| 1 | package sibling |
| 2 | |
| 3 | import ( |
| 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). |
| 20 | func 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. |
| 31 | var errRecorded = errors.New("memoriatest: dial recorded, not made") |
| 32 | |
| 33 | func 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. |
| 44 | func 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. |
| 57 | func 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. |
| 103 | func 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. |
| 274 | func 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. |
| 338 | func 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 | |