Add a status layer over the ObjectStore so each stored report has a lifecycle state, encoded in its object key as reports/<status>/<id>: pending (new reports), removed (#11), published (#15). Encoding status in the key prefix means "list pending" is a single prefix listing with no secondary index to drift, so it returns exactly the pending reports by construction. Storage: - Extend ObjectStore with List(ctx, prefix) and Delete(ctx, key); implement in MemoryStore (host) and the js/wasm R2Store. R2Store.List drives the R2 binding's list() directly to page a prefix (the syumai helper takes no options), so a status with >1000 objects is still enumerated exactly. - The ingest Sink now writes new reports under reports/pending/<id>, so accepted reports enter the lifecycle as pending. The <id> is stable across transitions. lifecycle package: - Manager over an ObjectStore: ListPending, GetPending(id), MarkRemoved(id), MarkPublished(id). A transition copies the opaque ciphertext frame to the destination status key and deletes the source key — bytes are never decrypted or re-encrypted; no key is needed to change status. - Copy-then-delete is idempotent and retry-safe: Put(dest) before Delete(src) never loses a report, a retry converges (re-Put identical bytes, Delete the leftover source), and a transition of an id not in the source status returns ErrUnknownReport (unless it is already at the destination -> idempotent nil). Tests (host, MemoryStore, no TinyGo): - List-pending exactness across a mix of pending/removed/published. - pending->removed and pending->published leave the pending set, appear under the target, and move byte-identical ciphertext that still decrypts. - Idempotent retry and convergence from an interrupted (both-keys) state. - Unknown/terminal-state ids error sensibly; new Sink reports list as pending. Closes #10 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
260 lines
8.2 KiB
Go
260 lines
8.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
|
|
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/scrub"
|
|
)
|
|
|
|
// ghToken is a GitHub-token-shaped test value, assembled at runtime so the
|
|
// contiguous token pattern never appears as a literal in source (secret scanners
|
|
// flag test tokens on sight; internal/scrub/scrub_test.go uses the same trick for
|
|
// a GitLab PAT).
|
|
var ghToken = "ghp_" + "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
|
|
// rawReport is a realistic raw body carrying several kinds of PII plus some
|
|
// non-sensitive structure that must survive scrubbing.
|
|
var rawReport = `{"appVersion":"1.4.2","platform":"android",` +
|
|
`"report":"crash when syncing. contact alice@example.com. ` +
|
|
`token=` + ghToken + ` from 203.0.113.7"}`
|
|
|
|
// piiSubstrings are values that must never survive scrubbing, and must never be
|
|
// readable in the stored ciphertext.
|
|
var piiSubstrings = []string{
|
|
"alice@example.com",
|
|
ghToken,
|
|
"203.0.113.7",
|
|
}
|
|
|
|
func mustKeyring(t *testing.T) *crypto.Keyring {
|
|
t.Helper()
|
|
key, err := crypto.GenerateKey()
|
|
if err != nil {
|
|
t.Fatalf("GenerateKey: %v", err)
|
|
}
|
|
kr, err := crypto.NewKeyring(1, map[uint16][]byte{1: key})
|
|
if err != nil {
|
|
t.Fatalf("NewKeyring: %v", err)
|
|
}
|
|
return kr
|
|
}
|
|
|
|
func TestMemoryStorePutGet(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := NewMemoryStore()
|
|
|
|
if _, err := m.Get(ctx, "absent"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Get(absent) err = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
want := []byte("some bytes")
|
|
if err := m.Put(ctx, "k", want); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
got, err := m.Get(ctx, "k")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("Get = %q, want %q", got, want)
|
|
}
|
|
// Stored copy must be independent of the caller's slice.
|
|
want[0] = 'X'
|
|
if again, _ := m.Get(ctx, "k"); again[0] == 'X' {
|
|
t.Error("MemoryStore did not copy the value on Put")
|
|
}
|
|
if m.Len() != 1 {
|
|
t.Errorf("Len = %d, want 1", m.Len())
|
|
}
|
|
}
|
|
|
|
// TestSinkFullPath is the end-to-end acceptance: raw report -> scrubbed ->
|
|
// encrypted -> stored, and reading it back REQUIRES the key and yields the
|
|
// scrubbed (not raw) content.
|
|
func TestSinkFullPath(t *testing.T) {
|
|
ctx := context.Background()
|
|
kr := mustKeyring(t)
|
|
store := NewMemoryStore()
|
|
sink := NewSink(store, kr)
|
|
|
|
if err := sink.Store(ctx, []byte(rawReport)); err != nil {
|
|
t.Fatalf("Store: %v", err)
|
|
}
|
|
|
|
// Exactly one object, and new reports enter the lifecycle as pending, so the
|
|
// key lives under the pending status prefix (#10).
|
|
keys := store.Keys()
|
|
if len(keys) != 1 {
|
|
t.Fatalf("stored %d objects, want 1", len(keys))
|
|
}
|
|
if want := StatusPrefix(StatusPending); !strings.HasPrefix(keys[0], want) {
|
|
t.Errorf("object key %q lacks pending prefix %q", keys[0], want)
|
|
}
|
|
|
|
stored, err := store.Get(ctx, keys[0])
|
|
if err != nil {
|
|
t.Fatalf("Get stored: %v", err)
|
|
}
|
|
|
|
// The stored bytes are a sealed frame, not the raw or scrubbed plaintext.
|
|
scrubbed := scrub.Scrub([]byte(rawReport))
|
|
if bytes.Equal(stored, []byte(rawReport)) {
|
|
t.Error("stored object equals the raw body (not encrypted)")
|
|
}
|
|
if bytes.Equal(stored, scrubbed) {
|
|
t.Error("stored object equals the scrubbed plaintext (not encrypted)")
|
|
}
|
|
if string(stored[:4]) != crypto.Magic {
|
|
t.Errorf("stored object magic = %q, want %q", stored[:4], crypto.Magic)
|
|
}
|
|
|
|
// The ciphertext must not leak any PII substring in the clear.
|
|
for _, s := range piiSubstrings {
|
|
if bytes.Contains(stored, []byte(s)) {
|
|
t.Errorf("stored ciphertext leaks PII substring %q", s)
|
|
}
|
|
}
|
|
|
|
// The object is NOT decryptable without the key.
|
|
otherKR := mustKeyring(t) // different random key, same key_id 1
|
|
if _, err := crypto.Open(otherKR, stored); err == nil {
|
|
t.Error("stored object decrypted with the WRONG key; must require the correct key")
|
|
}
|
|
|
|
// With the correct key it yields exactly the scrubbed content.
|
|
opened, err := crypto.Open(kr, stored)
|
|
if err != nil {
|
|
t.Fatalf("Open with correct key: %v", err)
|
|
}
|
|
if !bytes.Equal(opened, scrubbed) {
|
|
t.Errorf("decrypted content != scrubbed content\n got = %q\n want = %q", opened, scrubbed)
|
|
}
|
|
|
|
// The decrypted (scrubbed) content has the PII removed (verify via #8's
|
|
// contract) and carries the redaction placeholders and surviving structure.
|
|
openedStr := string(opened)
|
|
for _, s := range piiSubstrings {
|
|
if strings.Contains(openedStr, s) {
|
|
t.Errorf("decrypted scrubbed content still leaks PII %q", s)
|
|
}
|
|
}
|
|
for _, p := range []string{scrub.PlaceholderEmail, scrub.PlaceholderToken, scrub.PlaceholderIP} {
|
|
if !strings.Contains(openedStr, p) {
|
|
t.Errorf("decrypted scrubbed content missing placeholder %q", p)
|
|
}
|
|
}
|
|
for _, keep := range []string{"crash when syncing", `"appVersion":"1.4.2"`} {
|
|
if !strings.Contains(openedStr, keep) {
|
|
t.Errorf("decrypted scrubbed content dropped non-PII text %q", keep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// failingStore always fails Put, to exercise the Sink's error propagation (which
|
|
// drives the ingest 503 path).
|
|
type failingStore struct{}
|
|
|
|
func (failingStore) Put(context.Context, string, []byte) error {
|
|
return errors.New("backend down")
|
|
}
|
|
func (failingStore) Get(context.Context, string) ([]byte, error) {
|
|
return nil, ErrNotFound
|
|
}
|
|
func (failingStore) List(context.Context, string) ([]string, error) {
|
|
return nil, errors.New("backend down")
|
|
}
|
|
func (failingStore) Delete(context.Context, string) error {
|
|
return errors.New("backend down")
|
|
}
|
|
|
|
func TestSinkStorePutError(t *testing.T) {
|
|
sink := NewSink(failingStore{}, mustKeyring(t))
|
|
if err := sink.Store(context.Background(), []byte(rawReport)); err == nil {
|
|
t.Error("Store returned nil despite a failing backend; want error (drives 503)")
|
|
}
|
|
}
|
|
|
|
func TestSinkUsesDistinctKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewMemoryStore()
|
|
sink := NewSink(store, mustKeyring(t))
|
|
for i := 0; i < 5; i++ {
|
|
if err := sink.Store(ctx, []byte(rawReport)); err != nil {
|
|
t.Fatalf("Store #%d: %v", i, err)
|
|
}
|
|
}
|
|
if store.Len() != 5 {
|
|
t.Errorf("stored %d objects, want 5 distinct keys", store.Len())
|
|
}
|
|
}
|
|
|
|
func TestSinkWithKeyFunc(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewMemoryStore()
|
|
sink := NewSink(store, mustKeyring(t), WithKeyFunc(func() string { return "reports/fixed" }))
|
|
if err := sink.Store(ctx, []byte(rawReport)); err != nil {
|
|
t.Fatalf("Store: %v", err)
|
|
}
|
|
if _, err := store.Get(ctx, "reports/fixed"); err != nil {
|
|
t.Errorf("expected object at fixed key: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStatusKeyLayout pins the status key scheme the lifecycle layer relies on:
|
|
// disjoint, trailing-slashed status prefixes so a prefix List of one status can
|
|
// never match another.
|
|
func TestStatusKeyLayout(t *testing.T) {
|
|
if got := ReportKey(StatusPending, "abc"); got != "reports/pending/abc" {
|
|
t.Errorf("ReportKey(pending, abc) = %q, want reports/pending/abc", got)
|
|
}
|
|
// "reports/pending/" must not be a prefix of another status's keys.
|
|
if strings.HasPrefix(ReportKey(StatusPublished, "x"), StatusPrefix(StatusPending)) {
|
|
t.Error("pending prefix wrongly matches a published key")
|
|
}
|
|
if strings.HasPrefix(ReportKey(StatusRemoved, "x"), StatusPrefix(StatusPending)) {
|
|
t.Error("pending prefix wrongly matches a removed key")
|
|
}
|
|
}
|
|
|
|
func TestMemoryStoreListAndDelete(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := NewMemoryStore()
|
|
seed := map[string][]byte{
|
|
"reports/pending/a": []byte("1"),
|
|
"reports/pending/b": []byte("2"),
|
|
"reports/published/c": []byte("3"),
|
|
"reports/removed/d": []byte("4"),
|
|
}
|
|
for k, v := range seed {
|
|
if err := m.Put(ctx, k, v); err != nil {
|
|
t.Fatalf("Put %s: %v", k, err)
|
|
}
|
|
}
|
|
|
|
got, err := m.List(ctx, "reports/pending/")
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
want := []string{"reports/pending/a", "reports/pending/b"}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("List(pending) = %v, want %v (must be exact and sorted)", got, want)
|
|
}
|
|
|
|
// Delete is idempotent: deleting a present then an absent key both succeed.
|
|
if err := m.Delete(ctx, "reports/pending/a"); err != nil {
|
|
t.Fatalf("Delete present: %v", err)
|
|
}
|
|
if err := m.Delete(ctx, "reports/pending/a"); err != nil {
|
|
t.Fatalf("Delete absent (must be a no-op): %v", err)
|
|
}
|
|
if got, _ := m.List(ctx, "reports/pending/"); !slices.Equal(got, []string{"reports/pending/b"}) {
|
|
t.Errorf("after delete, List(pending) = %v, want [reports/pending/b]", got)
|
|
}
|
|
}
|