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 "io"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "testing"
13 "time"
14
15 "amadan.net/oficina/suite"
16)
17
18// asker is a lone-shape subject: the address is the subject memoria's
19// siblings key their members on when there is no home (suite §3).
20func asker(t *testing.T) suite.Subject {
21 t.Helper()
22 s, err := suite.NewSubject(suite.SubjectAddress, "person@example.test")
23 if err != nil {
24 t.Fatalf("NewSubject: %v", err)
25 }
26 return s
27}
28
29// errRecorded stands in for a connection. Being called at all is the
30// finding; the value never matters.
31var errRecorded = errors.New("memoriatest: dial recorded, not made")
32
33func pairs(n int) []suite.ItemRevision {
34 out := make([]suite.ItemRevision, n)
35 for i := range out {
36 out[i] = suite.ItemRevision{ItemID: "doc-" + string(rune('a'+i%26)), Revision: int64(i + 1)}
37 }
38 return out
39}
40
41// serve stands a sibling up on localhost and returns a Sibling pointing at
42// it. The team label matches the module's SuiteDomain check via the URL the
43// test server hands back.
44func serve(t *testing.T, h http.HandlerFunc) Sibling {
45 t.Helper()
46 srv := httptest.NewServer(h)
47 t.Cleanup(srv.Close)
48 return Sibling{Name: "Sheets", BaseURL: srv.URL, Token: "test-consumer-token"}
49}
50
51// newTestClient is New with the team-bound transport replaced, because that
52// transport refuses any host outside the team's own <team>.oficina.works
53// label — correctly — and a httptest server is on 127.0.0.1. The refusal
54// itself is the module's to test (its hostile-hosts fixture); what this
55// package must prove is what memoria does with an answer, or the absence
56// of one.
57func newTestClient() *Client {
58 return &Client{http: &http.Client{}}
59}
60
61// Gate: every pair asked gets exactly one answer, and a call that never
62// reached a server is UNKNOWN — never absent, never silently dropped.
63//
64// §4.1: "UNKNOWN is synthesised by the caller, not only returned by the
65// sibling. A sibling that never answers cannot return anything." The
66// module's DecodeS2 handles a bad status, an unparseable body, a duplicate
67// row and a missing pair. It cannot handle a call with no status at all,
68// because it is never invoked — that gap is this package's, and it is the
69// one a reviewer would find by unplugging the network.
70//
71// TWO-SIDED. Every assertion of the form "this comes back UNKNOWN" is
72// satisfied by a client that returns UNKNOWN unconditionally, so the
73// happy-path subtest asserts the real values arrive and in order. Without
74// it, deleting the whole HTTP call and returning allUnknown(len(pairs))
75// would pass this file — which is M4 below, and it is caught only by that
76// half.
77//
78// MUTATION-TESTED 2026-09-06, five, each verified red and reverted:
79//
80// M1 transport failure returns nil instead of UNKNOWN -> not-total red
81// M2 oversized batch sent instead of refused -> cap case red
82// M3 the deadline is dropped -> slow case red
83// M4 always UNKNOWN, never asks the sibling -> happy path red
84// M5 the consumer token is not sent -> happy path red
85//
86// Those five were author-chosen, and a review then found FOUR more that
87// stayed green — which is §1.9's rule earning itself again:
88//
89// M6 Deadline widened to 30s -> the deadline case, now pinned
90// M7 caller's context dropped -> the cancellation case
91// M8 Content-Type not sent -> the happy path
92// M9 NOT_VISIBLE renders a count -> the withholding case
93//
94// M6 is the instructive one. The slow-sibling case bounded elapsed time
95// against `Deadline` itself, so it passed for ANY value — a page could
96// hang for thirty seconds with the suite green. A test that measures a
97// constant against itself measures nothing; §6.5 states 800 ms and the
98// number is now asserted.
99//
100// M9 is the one that mattered most: `Withheld` rendered "Sheets: 7 results
101// withheld." for `NOT_VISIBLE`, a count of what a reader is not allowed to
102// see — from the one value §4.1 and §8.1 say must be silent.
103func TestEveryPairGetsExactlyOneAnswer(t *testing.T) {
104 ctx := context.Background()
105
106 t.Run("a sibling that never answers is UNKNOWN, not missing", func(t *testing.T) {
107 // A server that accepts the connection and hangs up without a
108 // response: no status, no body, nothing to decode.
109 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
110 hj, ok := w.(http.Hijacker)
111 if !ok {
112 t.Skip("no hijacker")
113 }
114 conn, _, err := hj.Hijack()
115 if err != nil {
116 t.Fatalf("hijack: %v", err)
117 }
118 conn.Close()
119 })
120 asked := pairs(3)
121 got := newTestClient().Authorize(ctx, s, asker(t), asked)
122 if len(got) != len(asked) {
123 t.Fatalf("got %d answers for %d pairs — the decoder is not total",
124 len(got), len(asked))
125 }
126 for i, a := range got {
127 if a != suite.UNKNOWN {
128 t.Errorf("pair %d = %q, want UNKNOWN: a call that never got a "+
129 "status is not an authorization", i, a)
130 }
131 }
132 })
133
134 t.Run("a cancelled request does not wait out the deadline", func(t *testing.T) {
135 // Authorize wraps the caller's context; replacing it with
136 // context.Background() would leave a reader who closed the tab
137 // costing a sibling 800ms of a hibernating instance's time, and
138 // every other case here would stay green.
139 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
140 time.Sleep(Deadline * 2)
141 })
142 cancelled, cancel := context.WithCancel(context.Background())
143 cancel()
144 start := time.Now()
145 got := newTestClient().Authorize(cancelled, s, asker(t), pairs(2))
146 if elapsed := time.Since(start); elapsed > Deadline/2 {
147 t.Errorf("waited %v on an already-cancelled context: the caller's "+
148 "cancellation is not reaching the request", elapsed)
149 }
150 for i, a := range got {
151 if a != suite.UNKNOWN {
152 t.Errorf("pair %d = %q, want UNKNOWN", i, a)
153 }
154 }
155 })
156
157 t.Run("a batch over the cap is refused, never truncated", func(t *testing.T) {
158 var asked int
159 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
160 asked++
161 w.WriteHeader(http.StatusOK)
162 })
163 over := pairs(suite.MaxS2Pairs + 1)
164 got := newTestClient().Authorize(ctx, s, asker(t), over)
165 if len(got) != len(over) {
166 t.Fatalf("got %d answers for %d pairs", len(got), len(over))
167 }
168 for i, a := range got {
169 if a != suite.UNKNOWN {
170 t.Errorf("pair %d = %q, want UNKNOWN", i, a)
171 }
172 }
173 if asked != 0 {
174 t.Error("an oversized batch was sent: the pairs past the cap would " +
175 "come back unanswered, which withholds and looks like the " +
176 "sibling's fault when it is ours")
177 }
178 })
179
180 t.Run("the real values arrive, in order, or the rest proves nothing", func(t *testing.T) {
181 asked := pairs(4)
182 want := []suite.Authorization{
183 suite.VISIBLE, suite.NOT_VISIBLE, suite.STALE, suite.VISIBLE,
184 }
185 var gotToken, gotType string
186 var gotBody suite.S2Request
187 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
188 gotToken = r.Header.Get("Authorization")
189 gotType = r.Header.Get("Content-Type")
190 raw, _ := io.ReadAll(r.Body)
191 _ = json.Unmarshal(raw, &gotBody)
192 type row struct {
193 ItemID string `json:"item_id"`
194 Revision int64 `json:"revision"`
195 Result suite.Authorization `json:"result"`
196 }
197 rows := make([]row, len(asked))
198 for i, p := range asked {
199 rows[i] = row{p.ItemID, p.Revision, want[i]}
200 }
201 _ = json.NewEncoder(w).Encode(map[string]any{"results": rows})
202 })
203
204 got := newTestClient().Authorize(ctx, s, asker(t), asked)
205 for i := range want {
206 if got[i] != want[i] {
207 t.Errorf("pair %d = %q, want %q — if every answer is UNKNOWN "+
208 "this file is measuring nothing", i, got[i], want[i])
209 }
210 }
211 if gotType != "application/json" {
212 t.Errorf("Content-Type = %q, want application/json: a sibling that "+
213 "guesses at the body is a sibling that can guess wrong", gotType)
214 }
215 // §6.2: the consumer token rides the transport, never the body.
216 if gotToken != "Bearer test-consumer-token" {
217 t.Errorf("Authorization header = %q, want the consumer token", gotToken)
218 }
219 if gotBody.Asker.Value != "person@example.test" {
220 t.Errorf("asker did not reach the sibling: %+v", gotBody.Asker)
221 }
222 if gotBody.Asker.Kind != suite.SubjectAddress {
223 t.Errorf("asker kind = %q, want %q: the kind travels on the wire "+
224 "so a receiver keying on the other one answers UNKNOWN rather "+
225 "than guessing (suite §3)", gotBody.Asker.Kind, suite.SubjectAddress)
226 }
227 })
228
229 t.Run("the deadline is §6.5's number, not whatever the constant says", func(t *testing.T) {
230 // The slow case below bounds elapsed time against Deadline itself,
231 // so it passes for ANY value — a review set it to 30s and the test
232 // stayed green while a page hung for half a minute. §6.5 states
233 // 800 ms; assert the number, not the shape.
234 if Deadline != 800*time.Millisecond {
235 t.Errorf("Deadline = %v, want 800ms: §6.5 sets it and §13 owes the "+
236 "measurement that would change it — moving it here changes the "+
237 "product without changing the spec", Deadline)
238 }
239 })
240
241 t.Run("a slow sibling is UNKNOWN at the deadline, not a hung page", func(t *testing.T) {
242 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
243 time.Sleep(Deadline + 500*time.Millisecond)
244 w.WriteHeader(http.StatusOK)
245 })
246 asked := pairs(2)
247 start := time.Now()
248 got := newTestClient().Authorize(ctx, s, asker(t), asked)
249 elapsed := time.Since(start)
250 if elapsed > Deadline+400*time.Millisecond {
251 t.Errorf("waited %v for a sibling past a %v deadline: §6.5 makes a "+
252 "page one round, and the floor is the slowest sibling — not "+
253 "the slowest sibling plus however long it feels like taking",
254 elapsed, Deadline)
255 }
256 for i, a := range got {
257 if a != suite.UNKNOWN {
258 t.Errorf("pair %d = %q, want UNKNOWN past the deadline", i, a)
259 }
260 }
261 })
262}
263
264// Withholding names the sibling and the shape of the loss, because a
265// notice that says only "some results hidden" is one a reader stops
266// reading — and a reader who stops reading it is in the same position as
267// one who was told nothing (§6.5).
268//
269// Asserted on `Outcome`, which is now the only renderer of these lines.
270// It used to be asserted on a second type, `Withheld`, which rendered the
271// same notices in different words — so what the page said depended on
272// which renderer a caller reached. A review found the pair; the type is
273// gone and its claim is here.
274func TestWithholdingNamesTheSiblingAndTheReason(t *testing.T) {
275 for _, c := range []struct {
276 reason suite.Authorization
277 want string
278 }{
279 {suite.UNKNOWN, "Sheets did not answer; 7 results withheld."},
280 {suite.STALE, "Sheets has moved on from 7 results; refetching."},
281 // NOT_VISIBLE is the one value that may be silent, so it produces
282 // no line at all — and now by never being recorded rather than by
283 // rendering as "". A count here would tell a reader how many
284 // things they are not allowed to see, which is the number this
285 // whole vocabulary exists to avoid saying.
286 {suite.NOT_VISIBLE, ""},
287 } {
288 var o Outcome
289 for i := 0; i < 7; i++ {
290 o.Record("Sheets", Decide(c.reason))
291 }
292 lines := o.Lines()
293 switch {
294 case c.want == "":
295 if len(lines) != 0 {
296 t.Errorf("%s produced %v, want silence", c.reason, lines)
297 }
298 case len(lines) != 1 || lines[0] != c.want:
299 t.Errorf("%s produced %v, want [%q]", c.reason, lines, c.want)
300 default:
301 if !strings.Contains(lines[0], "Sheets") {
302 t.Errorf("withholding notice does not name the app: %q", lines[0])
303 }
304 }
305 }
306}
307
308// This is the test gate 15 names as the price of this package's egress
309// exemption, and until 2026-09-06 it did not exist — the gate's comment
310// cited it by name while `sibling_test.go` contained nothing of the kind,
311// and every other test here built a plain `&http.Client{}` that exercised
312// none of the guard. AGENTS.md's second named failure, in the comment
313// claiming the payment.
314//
315// It goes through the REAL constructor, because that is the whole point: a
316// gate that exempts a package from the egress grep buys the exemption with
317// a runtime guard the grep cannot perform, and a test that builds its own
318// client performs it on something production never uses.
319//
320// TWO-SIDED. "A foreign host is refused" is satisfied by a client that
321// refuses everything, by a constructor that returns a broken client, and
322// by a dial hook that is never installed. So the same-team case asserts
323// the dial DOES happen and reaches the host asked for.
324//
325// MUTATION-TESTED 2026-09-06, each reverted — and all three are caught by
326// the SAME-TEAM half, which is the argument for having it:
327//
328// New returns &Client{http: &http.Client{}} -> same-team case red
329// New passes "" as the team -> same-team case red
330// the dial hook is dropped (nil passed through) -> same-team case red
331//
332// The foreign half passes under all three, and vacuously: with no dial
333// hook installed the recorder sees nothing, so "it was not dialled" is
334// true because nothing was dialled at all. A one-sided version of this
335// test — assert only that a foreign host is refused — would have gone
336// green against a constructor that returns a client with no guard in it,
337// which is precisely the mutation this gate exists to catch.
338func TestClientRefusesHostsOutsideTheTeam(t *testing.T) {
339 const team = "acme"
340 var dialed []string
341 rec := func(ctx context.Context, network, addr string) (net.Conn, error) {
342 dialed = append(dialed, addr)
343 return nil, errRecorded
344 }
345 c := New(team, rec)
346
347 ask := func(host string) []suite.Authorization {
348 s := Sibling{Name: "Sheets", BaseURL: "https://" + host, Token: "t"}
349 return c.Authorize(context.Background(), s, asker(t), pairs(2))
350 }
351
352 t.Run("a host in another team is refused before it is dialled", func(t *testing.T) {
353 dialed = nil
354 got := ask("evil.sheets." + suite.SuiteDomain)
355 for i, a := range got {
356 if a != suite.UNKNOWN {
357 t.Errorf("pair %d = %q, want UNKNOWN", i, a)
358 }
359 }
360 if len(dialed) != 0 {
361 t.Errorf("a foreign team's host was dialled: %v — the check must run "+
362 "before the transport, so a refused host costs no connection", dialed)
363 }
364 })
365
366 t.Run("this team's host is reached, or the refusal proves nothing", func(t *testing.T) {
367 dialed = nil
368 ask(team + ".sheets." + suite.SuiteDomain)
369 if len(dialed) == 0 {
370 t.Error("this team's own sibling was never dialled: the client refuses " +
371 "everything, or the dial hook is not installed, and in both cases " +
372 "the other half of this gate is measuring nothing")
373 }
374 })
375}
376