Files
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

280 lines
9.9 KiB
Go

package lifecycle_test
import (
"bytes"
"context"
"errors"
"slices"
"testing"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/scrub"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
// mustKeyring builds a single-key keyring for sealing realistic ciphertext.
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
}
// seal returns an encrypted frame for plaintext (a realistic stored object).
func seal(t *testing.T, kr *crypto.Keyring, plaintext string) []byte {
t.Helper()
frame, err := crypto.Seal(kr, []byte(plaintext))
if err != nil {
t.Fatalf("Seal: %v", err)
}
return frame
}
// putStatus writes frame directly at the reports/<status>/<id> key, seeding a
// report in a chosen status without going through a transition.
func putStatus(t *testing.T, store storage.ObjectStore, status storage.Status, id string, frame []byte) {
t.Helper()
if err := store.Put(context.Background(), storage.ReportKey(status, id), frame); err != nil {
t.Fatalf("seed %s/%s: %v", status, id, err)
}
}
// TestListPendingExactness is the ticket's acceptance criterion: given a mix of
// reports in different states, ListPending returns exactly the pending ids.
func TestListPendingExactness(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
m := lifecycle.New(store)
// A deliberately interleaved mix across all three states.
putStatus(t, store, storage.StatusPending, "id-a", seal(t, kr, "a"))
putStatus(t, store, storage.StatusRemoved, "id-b", seal(t, kr, "b"))
putStatus(t, store, storage.StatusPending, "id-c", seal(t, kr, "c"))
putStatus(t, store, storage.StatusPublished, "id-d", seal(t, kr, "d"))
putStatus(t, store, storage.StatusPending, "id-e", seal(t, kr, "e"))
putStatus(t, store, storage.StatusRemoved, "id-f", seal(t, kr, "f"))
got, err := m.ListPending(ctx)
if err != nil {
t.Fatalf("ListPending: %v", err)
}
want := []string{"id-a", "id-c", "id-e"} // sorted; only the pending ones
if !slices.Equal(got, want) {
t.Errorf("ListPending = %v, want exactly %v", got, want)
}
}
// TestNewReportsAreListedAsPending proves the #9 ingest Sink now lands reports in
// the pending state, discoverable via ListPending, and readable back as the same
// ciphertext (which still decrypts to the scrubbed body).
func TestNewReportsAreListedAsPending(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
sink := storage.NewSink(store, kr) // default key func -> reports/pending/<id>
m := lifecycle.New(store)
raw := `{"appVersion":"1.0.0","platform":"android","report":"boom from 203.0.113.7"}`
if err := sink.Store(ctx, []byte(raw)); err != nil {
t.Fatalf("sink.Store: %v", err)
}
ids, err := m.ListPending(ctx)
if err != nil {
t.Fatalf("ListPending: %v", err)
}
if len(ids) != 1 {
t.Fatalf("ListPending returned %d ids, want 1 (the just-ingested report)", len(ids))
}
frame, err := m.GetPending(ctx, ids[0])
if err != nil {
t.Fatalf("GetPending(%q): %v", ids[0], err)
}
opened, err := crypto.Open(kr, frame)
if err != nil {
t.Fatalf("Open pending frame: %v", err)
}
if !bytes.Equal(opened, scrub.Scrub([]byte(raw))) {
t.Error("pending frame did not decrypt to the scrubbed body")
}
}
// TestMarkRemoved checks pending -> removed: the report leaves the pending set,
// appears under removed, and its bytes are the unchanged ciphertext.
func TestMarkRemoved(t *testing.T) {
transitionCase(t, "removed",
func(m *lifecycle.Manager, id string) error { return m.MarkRemoved(context.Background(), id) },
storage.StatusRemoved)
}
// TestMarkPublished checks pending -> published with the same guarantees.
func TestMarkPublished(t *testing.T) {
transitionCase(t, "published",
func(m *lifecycle.Manager, id string) error { return m.MarkPublished(context.Background(), id) },
storage.StatusPublished)
}
// transitionCase is the shared body for the two happy-path transitions.
func transitionCase(t *testing.T, name string, do func(*lifecycle.Manager, string) error, target storage.Status) {
t.Helper()
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
m := lifecycle.New(store)
const id = "id-x"
frame := seal(t, kr, "sensitive report body")
putStatus(t, store, storage.StatusPending, id, frame)
// A second pending report that must be unaffected by the transition.
putStatus(t, store, storage.StatusPending, "id-keep", seal(t, kr, "other"))
if err := do(m, id); err != nil {
t.Fatalf("%s transition: %v", name, err)
}
// It left the pending set (only the untouched report remains).
pending, _ := m.ListPending(ctx)
if !slices.Equal(pending, []string{"id-keep"}) {
t.Errorf("after %s, ListPending = %v, want [id-keep]", name, pending)
}
// The source key is gone.
if _, err := store.Get(ctx, storage.ReportKey(storage.StatusPending, id)); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("pending key still present after %s (err=%v)", name, err)
}
// It appears under the target status with byte-identical ciphertext...
moved, err := store.Get(ctx, storage.ReportKey(target, id))
if err != nil {
t.Fatalf("get %s object: %v", target, err)
}
if !bytes.Equal(moved, frame) {
t.Errorf("%s object bytes changed by the transition; must move ciphertext verbatim", target)
}
// ...and the frame is intact: it still decrypts to the original plaintext,
// proving the transition neither decrypted nor corrupted it.
opened, err := crypto.Open(kr, moved)
if err != nil {
t.Fatalf("Open moved frame: %v", err)
}
if string(opened) != "sensitive report body" {
t.Errorf("moved frame decrypted to %q, want original plaintext", opened)
}
}
// TestTransitionIdempotentRetry proves a transition can be retried safely: calling
// it twice ends in the same single-object state and the second call returns nil.
func TestTransitionIdempotentRetry(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
m := lifecycle.New(store)
const id = "id-1"
putStatus(t, store, storage.StatusPending, id, seal(t, kr, "body"))
if err := m.MarkPublished(ctx, id); err != nil {
t.Fatalf("first MarkPublished: %v", err)
}
if err := m.MarkPublished(ctx, id); err != nil {
t.Errorf("second MarkPublished (retry) = %v, want nil (idempotent)", err)
}
if pending, _ := m.ListPending(ctx); len(pending) != 0 {
t.Errorf("ListPending = %v after publish, want empty", pending)
}
if published, _ := store.List(ctx, storage.StatusPrefix(storage.StatusPublished)); len(published) != 1 {
t.Errorf("published objects = %d, want exactly 1 (no duplicate from retry)", len(published))
}
}
// TestTransitionConvergesFromInterruptedState simulates a crash after the copy
// (Put destination) but before the delete (source): the object exists under both
// statuses. Re-running the transition must converge to only the destination.
func TestTransitionConvergesFromInterruptedState(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
m := lifecycle.New(store)
const id = "id-2"
frame := seal(t, kr, "body")
putStatus(t, store, storage.StatusPending, id, frame) // source (leftover)
putStatus(t, store, storage.StatusPublished, id, frame) // destination (already copied)
// Mid-transition the report is visible in both sets.
if pending, _ := m.ListPending(ctx); !slices.Contains(pending, id) {
t.Fatalf("precondition: expected %q to appear pending mid-transition", id)
}
if err := m.MarkPublished(ctx, id); err != nil {
t.Fatalf("MarkPublished (convergence): %v", err)
}
if pending, _ := m.ListPending(ctx); slices.Contains(pending, id) {
t.Errorf("after convergence, %q still pending", id)
}
if _, err := store.Get(ctx, storage.ReportKey(storage.StatusPublished, id)); err != nil {
t.Errorf("published object missing after convergence: %v", err)
}
}
// TestUnknownIdErrors covers transitions of ids that are not pending.
func TestUnknownIdErrors(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
m := lifecycle.New(store)
// Never-seen id.
if err := m.MarkRemoved(ctx, "ghost"); !errors.Is(err, lifecycle.ErrUnknownReport) {
t.Errorf("MarkRemoved(unknown) err = %v, want ErrUnknownReport", err)
}
if err := m.MarkPublished(ctx, "ghost"); !errors.Is(err, lifecycle.ErrUnknownReport) {
t.Errorf("MarkPublished(unknown) err = %v, want ErrUnknownReport", err)
}
// An id in a terminal state cannot be transitioned again from pending: a
// removed report is not publishable, and a published report is not removable.
putStatus(t, store, storage.StatusRemoved, "was-removed", seal(t, kr, "x"))
if err := m.MarkPublished(ctx, "was-removed"); !errors.Is(err, lifecycle.ErrUnknownReport) {
t.Errorf("MarkPublished(removed id) err = %v, want ErrUnknownReport", err)
}
putStatus(t, store, storage.StatusPublished, "was-published", seal(t, kr, "y"))
if err := m.MarkRemoved(ctx, "was-published"); !errors.Is(err, lifecycle.ErrUnknownReport) {
t.Errorf("MarkRemoved(published id) err = %v, want ErrUnknownReport", err)
}
}
// TestGetPending covers the report-fetch helper the publish job relies on.
func TestGetPending(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
m := lifecycle.New(store)
frame := seal(t, kr, "body")
putStatus(t, store, storage.StatusPending, "id-1", frame)
got, err := m.GetPending(ctx, "id-1")
if err != nil {
t.Fatalf("GetPending: %v", err)
}
if !bytes.Equal(got, frame) {
t.Error("GetPending returned different bytes than were stored")
}
if _, err := m.GetPending(ctx, "missing"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetPending(missing) err = %v, want ErrNotFound", err)
}
}