Files
LibreMail-Bug-Report-Ingest/internal/storage/storage_test.go
T
JMR-devandClaude Opus 4.8 cb7d9c67a0 #10 Report lifecycle/status metadata (pending/removed/published)
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>
2026-07-02 15:25:11 -05:00

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)
}
}