oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package sibling
2
3import (
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
46func 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
178func 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.
324func 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
366func 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