| 1 | package sibling |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/oficina/suite" |
| 15 | ) |
| 16 | |
| 17 | // Gate 7's rule at the call boundary: **an empty feed and a failed poll |
| 18 | // must never be the same value.** |
| 19 | // |
| 20 | // §5.1: "A timed-out, rate-limited, partially-paginated or credential-expired |
| 21 | // enumeration returns *no information*, not *no documents*; treating the |
| 22 | // second as the first purges the corpus silently." That is the worst |
| 23 | // failure this codebase has a name for — the plausible empty list — and it |
| 24 | // arrives here, on the one call whose result authorises deletion. |
| 25 | // |
| 26 | // The module refuses to decode a non-2xx for the same reason. What this |
| 27 | // asserts is the half above it: a call that never reached a server has no |
| 28 | // status for the module to refuse, and must still not read as "nothing |
| 29 | // changed". |
| 30 | // |
| 31 | // TWO-SIDED. Every "this is an error" assertion is satisfied by a Feed that |
| 32 | // always errors, so the last subtest requires a real page to decode and its |
| 33 | // cursor to come back. Without it, `return suite.Feed{}, errors.New("no")` |
| 34 | // passes this file. |
| 35 | // |
| 36 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 37 | // |
| 38 | // Feed swallows the transport error and returns suite.Feed{}, nil |
| 39 | // -> unreachable case red |
| 40 | // Feed ignores DecodeS1's error and returns the zero Feed |
| 41 | // -> 500 case red, and the ambiguous-body case red |
| 42 | // Feed sends no cursor at all |
| 43 | // -> happy-path case red |
| 44 | // Feed always errors |
| 45 | // -> happy-path case red |
| 46 | func TestAFailedPollIsNeverAnEmptyFeed(t *testing.T) { |
| 47 | ctx := context.Background() |
| 48 | |
| 49 | t.Run("a sibling that cannot be reached is an error, not zero rows", func(t *testing.T) { |
| 50 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 51 | hj, ok := w.(http.Hijacker) |
| 52 | if !ok { |
| 53 | t.Skip("no hijacker") |
| 54 | } |
| 55 | conn, _, err := hj.Hijack() |
| 56 | if err != nil { |
| 57 | t.Fatalf("hijack: %v", err) |
| 58 | } |
| 59 | conn.Close() |
| 60 | }) |
| 61 | feed, err := newTestClient().Feed(ctx, s, "") |
| 62 | if err == nil { |
| 63 | t.Fatalf("got feed %+v and no error: an unreachable sibling read as "+ |
| 64 | "'nothing changed', which is what authorises a purge", feed) |
| 65 | } |
| 66 | if len(feed.Rows) != 0 || feed.Cursor != "" { |
| 67 | t.Errorf("a failed poll returned data: %+v", feed) |
| 68 | } |
| 69 | }) |
| 70 | |
| 71 | t.Run("a non-2xx is an error, not zero rows", func(t *testing.T) { |
| 72 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 73 | w.WriteHeader(http.StatusInternalServerError) |
| 74 | _, _ = w.Write([]byte(`{"cursor":"c2","rows":[]}`)) |
| 75 | }) |
| 76 | // The body is a perfectly good empty feed. The status is not, and |
| 77 | // the status wins: a sibling erroring while returning a plausible |
| 78 | // body is exactly how a purge gets authorised by a failure. |
| 79 | if _, err := newTestClient().Feed(ctx, s, ""); err == nil { |
| 80 | t.Error("a 500 carrying a well-formed empty feed decoded as success") |
| 81 | } |
| 82 | }) |
| 83 | |
| 84 | t.Run("an ambiguous body is an error", func(t *testing.T) { |
| 85 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 86 | // A duplicated key: encoding/json resolves it silently, and a |
| 87 | // duplicated `rows` would lose every tombstone in the first copy. |
| 88 | _, _ = w.Write([]byte(`{"cursor":"c","rows":[],"rows":[{"item_id":"x","revision":1,"gone":true,"touched_at":"2026-01-01T00:00:00Z"}]}`)) |
| 89 | }) |
| 90 | if _, err := newTestClient().Feed(ctx, s, ""); err == nil { |
| 91 | t.Error("an ambiguous body decoded as success") |
| 92 | } |
| 93 | }) |
| 94 | |
| 95 | t.Run("a real page decodes, or none of the above proves anything", func(t *testing.T) { |
| 96 | var gotCursor string |
| 97 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 98 | gotCursor = r.URL.Query().Get("cursor") |
| 99 | _ = json.NewEncoder(w).Encode(suite.Feed{ |
| 100 | Cursor: "next-cursor", |
| 101 | Rows: []suite.FeedRow{ |
| 102 | {ItemID: "doc-a", Revision: 3, Gone: false, TouchedAt: time.Unix(0, 0).UTC()}, |
| 103 | {ItemID: "doc-b", Revision: 9, Gone: true, TouchedAt: time.Unix(0, 0).UTC()}, |
| 104 | }, |
| 105 | }) |
| 106 | }) |
| 107 | // A cursor with reserved characters in it. Cursors are opaque and |
| 108 | // base64 more often than not, so "+/=&" is the ordinary case and |
| 109 | // not an exotic one — and with a plain alphanumeric fixture, |
| 110 | // deleting the escaping entirely stayed green. Found by a review. |
| 111 | feed, err := newTestClient().Feed(ctx, s, "a+b/c=d&e") |
| 112 | if err != nil { |
| 113 | t.Fatalf("a good page failed to decode: %v — if Feed always errors, "+ |
| 114 | "every assertion above passes for the wrong reason", err) |
| 115 | } |
| 116 | if gotCursor != "a+b/c=d&e" { |
| 117 | t.Errorf("cursor sent = %q, want the stored one: a poll that forgets "+ |
| 118 | "its position re-reads the whole feed", gotCursor) |
| 119 | } |
| 120 | if feed.Cursor != "next-cursor" || len(feed.Rows) != 2 { |
| 121 | t.Errorf("decoded %+v, want 2 rows and the sibling's next cursor", feed) |
| 122 | } |
| 123 | if !feed.Rows[1].Gone { |
| 124 | t.Error("the tombstone did not survive decoding, which is the row " + |
| 125 | "that carries a deletion") |
| 126 | } |
| 127 | }) |
| 128 | } |
| 129 | |
| 130 | // The S3 fetch path, and what memoria owns on it. |
| 131 | // |
| 132 | // Not the indistinguishability itself: "404, never 403" is an obligation on |
| 133 | // the *sibling*, and the module deliberately refuses to fold a 403 into |
| 134 | // ErrNotAvailable — doing so would hide a leaking sibling from everyone |
| 135 | // downstream, including the operator who could fix it. A first draft of |
| 136 | // this test asserted the opposite, that memoria must not be able to tell |
| 137 | // the two apart. That was wrong: the consumer noticing a breach is not the |
| 138 | // leak, the breach is. |
| 139 | // |
| 140 | // What memoria owns is narrower, and all of it is here: |
| 141 | // |
| 142 | // a failure is never an empty body — `nil, nil` writes emptiness over a |
| 143 | // real document, gate 7's shape one row down; |
| 144 | // a breach stays a breach, not a shrug; |
| 145 | // the call names an item and a revision and **nobody**. §6.1: the ingest |
| 146 | // call is custody-scoped and person-less. |
| 147 | // |
| 148 | // TWO-SIDED: the last subtest needs a real body back, or a Content that |
| 149 | // only ever errors satisfies every other assertion in this function. |
| 150 | // |
| 151 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 152 | // |
| 153 | // return raw, nil without consulting the status |
| 154 | // -> 404 case red, breach case red |
| 155 | // fold ErrContractBreach into ErrNotAvailable |
| 156 | // -> breach case red |
| 157 | // on error return []byte{} instead of nil |
| 158 | // -> 404 case red |
| 159 | // add &asker=... to the URL |
| 160 | // -> closed-set case red |
| 161 | // drop the revision from the URL |
| 162 | // -> closed-set case red |
| 163 | // send the item id as ?item_id= instead of a path segment |
| 164 | // -> path case red. **This was the shipped bug**, and the first |
| 165 | // version of this test asserted the query shape, so it pinned the |
| 166 | // bug rather than catching it. Found by a review that opened the |
| 167 | // module's route declaration. |
| 168 | // build the path without url.PathEscape |
| 169 | // -> path case red — but ONLY since the fixture id gained "?" and |
| 170 | // "#". Against `doc a/b` this mutation survived, because Go |
| 171 | // accepts a raw space and hands back the same decoded path. |
| 172 | // escape the path segment with QueryEscape |
| 173 | // -> path case red (a space becomes "+", which is a different item) |
| 174 | // return the body on a 5xx |
| 175 | // -> the 502 case red |
| 176 | // read exactly maxContentResponse instead of one byte more |
| 177 | // -> the oversized case red |
| 178 | func TestContentFetch(t *testing.T) { |
| 179 | ctx := context.Background() |
| 180 | |
| 181 | t.Run("a missing item is ErrNotAvailable and not an empty body", func(t *testing.T) { |
| 182 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 183 | w.WriteHeader(http.StatusNotFound) |
| 184 | }) |
| 185 | body, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| 186 | if !errors.Is(err, suite.ErrNotAvailable) { |
| 187 | t.Fatalf("404 = %v, want ErrNotAvailable", err) |
| 188 | } |
| 189 | if body != nil { |
| 190 | t.Errorf("got body %q alongside an error: a caller that ingests "+ |
| 191 | "this writes emptiness over a document it still has", body) |
| 192 | } |
| 193 | }) |
| 194 | |
| 195 | t.Run("a sibling answering 403 is a breach, not a shrug", func(t *testing.T) { |
| 196 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 197 | w.WriteHeader(http.StatusForbidden) |
| 198 | }) |
| 199 | _, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| 200 | if !errors.Is(err, suite.ErrContractBreach) { |
| 201 | t.Errorf("403 = %v, want ErrContractBreach — 403 means \"this exists "+ |
| 202 | "and you may not have it\", the one fact custody hides, and "+ |
| 203 | "quietly reading it as ErrNotAvailable keeps that leak invisible "+ |
| 204 | "to the only person who could fix it", err) |
| 205 | } |
| 206 | }) |
| 207 | |
| 208 | t.Run("an unreachable sibling is an error, not an empty body", func(t *testing.T) { |
| 209 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 210 | hj, ok := w.(http.Hijacker) |
| 211 | if !ok { |
| 212 | t.Skip("no hijacker") |
| 213 | } |
| 214 | conn, _, err := hj.Hijack() |
| 215 | if err != nil { |
| 216 | t.Fatalf("hijack: %v", err) |
| 217 | } |
| 218 | conn.Close() |
| 219 | }) |
| 220 | body, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| 221 | if err == nil { |
| 222 | t.Fatalf("got %q and no error from a sibling that never answered", body) |
| 223 | } |
| 224 | if body != nil { |
| 225 | t.Errorf("a failed fetch returned a body: %q", body) |
| 226 | } |
| 227 | }) |
| 228 | |
| 229 | t.Run("a 5xx is an error and not a body", func(t *testing.T) { |
| 230 | // An error page is a plausible document: it is text, it is UTF-8, |
| 231 | // and it indexes. Returning it would store a sibling's 502 as the |
| 232 | // content of a document memoria then cites. |
| 233 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 234 | w.WriteHeader(http.StatusBadGateway) |
| 235 | _, _ = w.Write([]byte("<html>502 Bad Gateway</html>")) |
| 236 | }) |
| 237 | body, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| 238 | if err == nil { |
| 239 | t.Fatalf("a 502 decoded as success, body %q", body) |
| 240 | } |
| 241 | if body != nil { |
| 242 | t.Errorf("a 502 returned a body: %q", body) |
| 243 | } |
| 244 | }) |
| 245 | |
| 246 | t.Run("an oversized body is an error, not a shorter document", func(t *testing.T) { |
| 247 | // **The cap has to be detectable, not merely applied.** S1 and S2 |
| 248 | // get this free — a truncated JSON does not parse — but a body is |
| 249 | // plaintext, and a prefix of a larger document indexes perfectly |
| 250 | // well as the whole thing, its tail unfindable and nothing said. |
| 251 | // A review found this by serving one byte over the cap and |
| 252 | // watching it come back as success. |
| 253 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 254 | _, _ = w.Write(make([]byte, maxContentResponse+1)) |
| 255 | }) |
| 256 | body, err := newTestClient().Content(ctx, s, "doc-a", 1) |
| 257 | if err == nil { |
| 258 | t.Fatalf("a body over the cap came back as a %d-byte document", len(body)) |
| 259 | } |
| 260 | if body != nil { |
| 261 | t.Errorf("an over-cap fetch returned a body: %d bytes", len(body)) |
| 262 | } |
| 263 | }) |
| 264 | |
| 265 | t.Run("the call names an item and a revision and nobody", func(t *testing.T) { |
| 266 | var path string |
| 267 | var query url.Values |
| 268 | var headers http.Header |
| 269 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 270 | path, query, headers = r.URL.Path, r.URL.Query(), r.Header.Clone() |
| 271 | _, _ = w.Write([]byte("the body")) |
| 272 | }) |
| 273 | // An id carrying the characters that CHANGE THE REQUEST when they |
| 274 | // are not escaped. A space alone does not: Go accepts it, the path |
| 275 | // arrives decoded, and a raw concatenation is indistinguishable |
| 276 | // from an escaped one — a review's mutation dropping the escaping |
| 277 | // entirely stayed green against `doc a/b`. With "?" and "#" in it |
| 278 | // the unescaped path truncates at the "?", the fragment eats the |
| 279 | // rest, and the revision never arrives at all. |
| 280 | body, err := newTestClient().Content(ctx, s, "doc a?b#c", 7) |
| 281 | if err != nil { |
| 282 | t.Fatalf("a good fetch failed: %v — with this red, every assertion "+ |
| 283 | "above passes for a Content that only ever errors", err) |
| 284 | } |
| 285 | if string(body) != "the body" { |
| 286 | t.Errorf("body = %q, want %q", body, "the body") |
| 287 | } |
| 288 | |
| 289 | // **The item id is a path segment.** The module declares the route |
| 290 | // as `/suite/s3/{item_id}?revision=` and its conformance harness |
| 291 | // fires exactly that, so a consumer sending `?item_id=` 404s |
| 292 | // against every conforming sibling. This test asserted the query |
| 293 | // shape and so pinned the bug in place; a review caught both. |
| 294 | // |
| 295 | // PathEscape rather than QueryEscape: a space is %20 in a path and |
| 296 | // "+" in a query, and a sibling reading "+" back as a space finds |
| 297 | // no such item. |
| 298 | if want := "/suite/s3/doc a?b#c"; path != want { |
| 299 | t.Errorf("S3 path = %q, want %q", path, want) |
| 300 | } |
| 301 | // A closed set, not a list of forbidden names. §6.1 says the call |
| 302 | // carries no person, and the way to assert that is to enumerate what |
| 303 | // it may carry and fail on anything else — a check spelled |
| 304 | // `query.Get("asker") == ""` passes for `user`, `on_behalf_of`, and |
| 305 | // every other spelling of the same mistake. |
| 306 | want := url.Values{"revision": {"7"}} |
| 307 | if !reflect.DeepEqual(query, want) { |
| 308 | t.Errorf("S3 query = %v, want exactly %v", query, want) |
| 309 | } |
| 310 | // **The headers get the closed-set treatment too**, and the first |
| 311 | // version of this did not: it named four headers a person might |
| 312 | // arrive in, which is the shape the paragraph above rejects. A |
| 313 | // review sent `X-On-Behalf-Of` straight through it. |
| 314 | assertNoPersonInHeaders(t, "S3", headers) |
| 315 | }) |
| 316 | } |
| 317 | |
| 318 | // assertNoPersonInHeaders pins the request's headers to a closed set. |
| 319 | // |
| 320 | // Only three may be present: the consumer token, which identifies the |
| 321 | // APP and not a person (§4.3), and the two the Go client adds on its own. |
| 322 | // Anything else is a finding whatever it is called — the point of a |
| 323 | // closed set is that it does not need to guess the name. |
| 324 | func assertNoPersonInHeaders(t *testing.T, call string, h http.Header) { |
| 325 | t.Helper() |
| 326 | if got := h.Get("Authorization"); got != "Bearer test-consumer-token" { |
| 327 | t.Errorf("%s Authorization = %q, want the consumer token", call, got) |
| 328 | } |
| 329 | allowed := map[string]bool{ |
| 330 | "Authorization": true, |
| 331 | "User-Agent": true, |
| 332 | "Accept-Encoding": true, |
| 333 | } |
| 334 | for name := range h { |
| 335 | if !allowed[name] { |
| 336 | t.Errorf("%s carried the header %q: the call is custody-scoped and "+ |
| 337 | "person-less (§6.1), and a closed set is the only way to say "+ |
| 338 | "that without guessing what a person would be called", call, name) |
| 339 | } |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | // **A transport error must not carry the URL, because the URL carries a |
| 344 | // coordinate.** |
| 345 | // |
| 346 | // `http.Client.Do` returns a `*url.Error` whose message is |
| 347 | // `Get "<the whole URL>": <cause>`. On S1 that URL holds the cursor; on S3 |
| 348 | // it holds the item id. Both are sealed at rest — the cursor under |
| 349 | // seal.PurposeCursor, the item id under seal.PurposeItemID — and |
| 350 | // AGENTS.md's rule about content covers error strings, not only log calls. |
| 351 | // |
| 352 | // Nothing logs these errors today, which is why it is worth fixing now: |
| 353 | // the next caller to log one puts a plaintext coordinate into `carlos |
| 354 | // logs`, which is storage nobody can delete, and has no reason to suspect |
| 355 | // it. Found by a review that unplugged a sibling and read the error. |
| 356 | // |
| 357 | // PRESENCE FIRST: the canaries really are in the request, so the absence |
| 358 | // check is an assertion. The cause must survive, because "connection |
| 359 | // refused" is the part an operator needs. |
| 360 | // |
| 361 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 362 | // |
| 363 | // drop bare() from the S1 path -> the cursor canary appears |
| 364 | // drop bare() from the S3 path -> the item canary appears |
| 365 | // bare() returns nil for a url.Error -> the cause assertion red |
| 366 | func TestATransportErrorCarriesNoCoordinate(t *testing.T) { |
| 367 | ctx := context.Background() |
| 368 | // Port 1 is reserved; nothing listens, so Do fails at the transport |
| 369 | // with the URL still in the error. |
| 370 | dead := Sibling{Name: "Docs", BaseURL: "http://127.0.0.1:1", Token: "t"} |
| 371 | |
| 372 | t.Run("S1 does not leak the cursor", func(t *testing.T) { |
| 373 | const canary = "CURSOR-CANARY-77" |
| 374 | _, err := newTestClient().Feed(ctx, dead, canary) |
| 375 | if err == nil { |
| 376 | t.Fatal("a dead sibling answered") |
| 377 | } |
| 378 | if strings.Contains(err.Error(), canary) { |
| 379 | t.Errorf("the cursor is in the error string: %v", err) |
| 380 | } |
| 381 | if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") { |
| 382 | t.Errorf("the cause did not survive: %v — an operator needs it, "+ |
| 383 | "and stripping the whole error would be the other mistake", err) |
| 384 | } |
| 385 | }) |
| 386 | |
| 387 | t.Run("S3 does not leak the item id", func(t *testing.T) { |
| 388 | const canary = "ITEM-CANARY-99" |
| 389 | _, err := newTestClient().Content(ctx, dead, canary, 1) |
| 390 | if err == nil { |
| 391 | t.Fatal("a dead sibling answered") |
| 392 | } |
| 393 | if strings.Contains(err.Error(), canary) { |
| 394 | t.Errorf("the item id is in the error string: %v", err) |
| 395 | } |
| 396 | }) |
| 397 | } |
| 398 | |