| new file mode 100644 |
| index 0000000..e11f83c |
| --- /dev/null |
| +++ b/internal/memoria/sibling/feed_test.go |
| @@ -0,0 +1,247 @@ |
| +package sibling |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/suite" |
| +) |
| + |
| +// Gate 7's rule at the call boundary: **an empty feed and a failed poll |
| +// must never be the same value.** |
| +// |
| +// §5.1: "A timed-out, rate-limited, partially-paginated or credential-expired |
| +// enumeration returns *no information*, not *no documents*; treating the |
| +// second as the first purges the corpus silently." That is the worst |
| +// failure this codebase has a name for — the plausible empty list — and it |
| +// arrives here, on the one call whose result authorises deletion. |
| +// |
| +// The module refuses to decode a non-2xx for the same reason. What this |
| +// asserts is the half above it: a call that never reached a server has no |
| +// status for the module to refuse, and must still not read as "nothing |
| +// changed". |
| +// |
| +// TWO-SIDED. Every "this is an error" assertion is satisfied by a Feed that |
| +// always errors, so the last subtest requires a real page to decode and its |
| +// cursor to come back. Without it, `return suite.Feed{}, errors.New("no")` |
| +// passes this file. |
| +// |
| +// MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| +// |
| +// Feed swallows the transport error and returns suite.Feed{}, nil |
| +// -> unreachable case red |
| +// Feed ignores DecodeS1's error and returns the zero Feed |
| +// -> 500 case red, and the ambiguous-body case red |
| +// Feed sends no cursor at all |
| +// -> happy-path case red |
| +// Feed always errors |
| +// -> happy-path case red |
| +func TestAFailedPollIsNeverAnEmptyFeed(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("a sibling that cannot be reached is an error, not zero rows", func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + hj, ok := w.(http.Hijacker) |
| + if !ok { |
| + t.Skip("no hijacker") |
| + } |
| + conn, _, err := hj.Hijack() |
| + if err != nil { |
| + t.Fatalf("hijack: %v", err) |
| + } |
| + conn.Close() |
| + }) |
| + feed, err := newTestClient().Feed(ctx, s, "") |
| + if err == nil { |
| + t.Fatalf("got feed %+v and no error: an unreachable sibling read as "+ |
| + "'nothing changed', which is what authorises a purge", feed) |
| + } |
| + if len(feed.Rows) != 0 || feed.Cursor != "" { |
| + t.Errorf("a failed poll returned data: %+v", feed) |
| + } |
| + }) |
| + |
| + t.Run("a non-2xx is an error, not zero rows", func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusInternalServerError) |
| + _, _ = w.Write([]byte(`{"cursor":"c2","rows":[]}`)) |
| + }) |
| + // The body is a perfectly good empty feed. The status is not, and |
| + // the status wins: a sibling erroring while returning a plausible |
| + // body is exactly how a purge gets authorised by a failure. |
| + if _, err := newTestClient().Feed(ctx, s, ""); err == nil { |
| + t.Error("a 500 carrying a well-formed empty feed decoded as success") |
| + } |
| + }) |
| + |
| + t.Run("an ambiguous body is an error", func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + // A duplicated key: encoding/json resolves it silently, and a |
| + // duplicated `rows` would lose every tombstone in the first copy. |
| + _, _ = w.Write([]byte(`{"cursor":"c","rows":[],"rows":[{"item_id":"x","revision":1,"gone":true,"touched_at":"2026-01-01T00:00:00Z"}]}`)) |
| + }) |
| + if _, err := newTestClient().Feed(ctx, s, ""); err == nil { |
| + t.Error("an ambiguous body decoded as success") |
| + } |
| + }) |
| + |
| + t.Run("a real page decodes, or none of the above proves anything", func(t *testing.T) { |
| + var gotCursor string |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + gotCursor = r.URL.Query().Get("cursor") |
| + _ = json.NewEncoder(w).Encode(suite.Feed{ |
| + Cursor: "next-cursor", |
| + Rows: []suite.FeedRow{ |
| + {ItemID: "doc-a", Revision: 3, Gone: false, TouchedAt: time.Unix(0, 0).UTC()}, |
| + {ItemID: "doc-b", Revision: 9, Gone: true, TouchedAt: time.Unix(0, 0).UTC()}, |
| + }, |
| + }) |
| + }) |
| + feed, err := newTestClient().Feed(ctx, s, "stored-cursor") |
| + if err != nil { |
| + t.Fatalf("a good page failed to decode: %v — if Feed always errors, "+ |
| + "every assertion above passes for the wrong reason", err) |
| + } |
| + if gotCursor != "stored-cursor" { |
| + t.Errorf("cursor sent = %q, want the stored one: a poll that forgets "+ |
| + "its position re-reads the whole feed", gotCursor) |
| + } |
| + if feed.Cursor != "next-cursor" || len(feed.Rows) != 2 { |
| + t.Errorf("decoded %+v, want 2 rows and the sibling's next cursor", feed) |
| + } |
| + if !feed.Rows[1].Gone { |
| + t.Error("the tombstone did not survive decoding, which is the row " + |
| + "that carries a deletion") |
| + } |
| + }) |
| +} |
| + |
| +// The S3 fetch path, and what memoria owns on it. |
| +// |
| +// Not the indistinguishability itself: "404, never 403" is an obligation on |
| +// the *sibling*, and the module deliberately refuses to fold a 403 into |
| +// ErrNotAvailable — doing so would hide a leaking sibling from everyone |
| +// downstream, including the operator who could fix it. A first draft of |
| +// this test asserted the opposite, that memoria must not be able to tell |
| +// the two apart. That was wrong: the consumer noticing a breach is not the |
| +// leak, the breach is. |
| +// |
| +// What memoria owns is narrower, and all of it is here: |
| +// |
| +// a failure is never an empty body — `nil, nil` writes emptiness over a |
| +// real document, gate 7's shape one row down; |
| +// a breach stays a breach, not a shrug; |
| +// the call names an item and a revision and **nobody**. §6.1: the ingest |
| +// call is custody-scoped and person-less. |
| +// |
| +// TWO-SIDED: the last subtest needs a real body back, or a Content that |
| +// only ever errors satisfies every other assertion in this function. |
| +// |
| +// MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| +// |
| +// return raw, nil without consulting the status |
| +// -> 404 case red, breach case red |
| +// fold ErrContractBreach into ErrNotAvailable |
| +// -> breach case red |
| +// on error return []byte{} instead of nil |
| +// -> 404 case red |
| +// add &asker=... to the URL |
| +// -> closed-set case red |
| +// drop the revision from the URL |
| +// -> closed-set case red |
| +func TestContentFetch(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("a missing item is ErrNotAvailable and not an empty body", func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + }) |
| + body, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| + if !errors.Is(err, suite.ErrNotAvailable) { |
| + t.Fatalf("404 = %v, want ErrNotAvailable", err) |
| + } |
| + if body != nil { |
| + t.Errorf("got body %q alongside an error: a caller that ingests "+ |
| + "this writes emptiness over a document it still has", body) |
| + } |
| + }) |
| + |
| + t.Run("a sibling answering 403 is a breach, not a shrug", func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + }) |
| + _, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| + if !errors.Is(err, suite.ErrContractBreach) { |
| + t.Errorf("403 = %v, want ErrContractBreach — 403 means \"this exists "+ |
| + "and you may not have it\", the one fact custody hides, and "+ |
| + "quietly reading it as ErrNotAvailable keeps that leak invisible "+ |
| + "to the only person who could fix it", err) |
| + } |
| + }) |
| + |
| + t.Run("an unreachable sibling is an error, not an empty body", func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + hj, ok := w.(http.Hijacker) |
| + if !ok { |
| + t.Skip("no hijacker") |
| + } |
| + conn, _, err := hj.Hijack() |
| + if err != nil { |
| + t.Fatalf("hijack: %v", err) |
| + } |
| + conn.Close() |
| + }) |
| + body, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| + if err == nil { |
| + t.Fatalf("got %q and no error from a sibling that never answered", body) |
| + } |
| + if body != nil { |
| + t.Errorf("a failed fetch returned a body: %q", body) |
| + } |
| + }) |
| + |
| + t.Run("the call names an item and a revision and nobody", func(t *testing.T) { |
| + var query url.Values |
| + var headers http.Header |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + query, headers = r.URL.Query(), r.Header.Clone() |
| + _, _ = w.Write([]byte("the body")) |
| + }) |
| + body, err := newTestClient().Content(ctx, s, "doc-a", 7) |
| + if err != nil { |
| + t.Fatalf("a good fetch failed: %v — with this red, every assertion "+ |
| + "above passes for a Content that only ever errors", err) |
| + } |
| + if string(body) != "the body" { |
| + t.Errorf("body = %q, want %q", body, "the body") |
| + } |
| + |
| + // A closed set, not a list of forbidden names. §6.1 says the call |
| + // carries no person, and the way to assert that is to enumerate what |
| + // it may carry and fail on anything else — a check spelled |
| + // `query.Get("asker") == ""` passes for `user`, `on_behalf_of`, and |
| + // every other spelling of the same mistake. |
| + want := url.Values{"item_id": {"doc-a"}, "revision": {"7"}} |
| + if !reflect.DeepEqual(query, want) { |
| + t.Errorf("S3 query = %v, want exactly %v", query, want) |
| + } |
| + if got := headers.Get("Authorization"); got != "Bearer test-consumer-token" { |
| + t.Errorf("Authorization = %q, want the consumer token", got) |
| + } |
| + // The consumer token identifies the *app* (§4.3). A person would |
| + // have to arrive as some second header, so the headers get the same |
| + // treatment as the query: named ones checked, and the rest is what |
| + // the Go client adds on its own. |
| + for _, h := range []string{"From", "X-Asker", "X-User", "Cookie"} { |
| + if v := headers.Get(h); v != "" { |
| + t.Errorf("S3 carried %s: %q — the ingest call has nobody on it", h, v) |
| + } |
| + } |
| + }) |
| +} |