// Package conformance drives an app's server half against the doctrine's // golden fixtures. // // An app mounts its suite routes on a handler and hands that handler here. // The harness fires the fixture's requests at it and reports what the contract // says should have happened and did not. Doctrine §10: two specs are guesses, // two implementations are facts — this is where the second implementation // corrects the first. package conformance import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "amadan.net/oficina/suite" ) // Report is what a run found. An empty Failures is a pass. type Report struct { Checked int Failures []string } func (r *Report) fail(format string, args ...any) { r.Failures = append(r.Failures, fmt.Sprintf(format, args...)) } // Run drives the handler and fails the test with anything the contract // forbids. This is what an app calls from its own CI. func Run(t *testing.T, h http.Handler) { t.Helper() report := Check(h) for _, f := range report.Failures { t.Error(f) } if report.Checked == 0 { t.Error("conformance ran no checks; a gate that asserts nothing passes everything") } } // Check drives the handler and returns what it found, without touching a // *testing.T. It exists so the harness itself can be tested: a harness that // passes every server is worse than no harness, and the only way to know is to // point it at servers that should fail. func Check(h http.Handler) Report { var r Report fixture, err := suite.LoadCustodyFixture() if err != nil { r.fail("loading the custody fixture: %v", err) return r } checkS3(h, fixture, &r) checkS2(h, fixture, &r) checkS1(h, &r) return r } // Gate 3, the content half. Every rung but `team` answers 404, and it answers // the same 404 as a deleted item — a consumer that could tell "not // team-visible" from "does not exist" would learn that a private item exists. func checkS3(h http.Handler, f suite.CustodyFixture, r *Report) { // The refusal every non-team item must be indistinguishable from. Taken // from an id that is not in the fixture at all, so it is the server's // answer for "no such item" rather than anything the fixture arranged. baseline := do(h, "GET", suite.RouteS3+"/"+suite.NonexistentItemID+"?revision=1", nil) r.Checked++ if baseline.Code != 404 { r.fail("S3 for an item that does not exist: got %d, want 404", baseline.Code) } for _, c := range f.Cases { url := suite.RouteS3 + "/" + c.ItemID + "?revision=" + strconv.FormatInt(c.Revision, 10) rec := do(h, "GET", url, nil) r.Checked++ if rec.Code != c.S3Status { r.fail("S3 %s (%s, rung %s): got %d, want %d", c.ItemID, c.Name, c.Rung, rec.Code, c.S3Status) continue } if rec.Code == 403 { r.fail("S3 %s: answered 403, and the contract is 404 never 403 — a 403 says the item exists, which is the fact custody hides", c.ItemID) continue } body := rec.Body.String() if c.S3Status == 200 { // The item's own bytes, not merely some bytes. A 200 carrying a // different item is a leak that a status check cannot see. if body != c.S3Body { r.fail("S3 %s: body %q, want %q", c.ItemID, body, c.S3Body) } continue } // A refusal must be byte-identical to the refusal for an item that // never existed. Anything a server adds here — a reason, a name, a // revision — tells the caller the item is real. if body != baseline.Body.String() { r.fail("S3 %s: refusal body %q differs from the refusal for an item that does not exist (%q), so the answer reveals that this item exists", c.ItemID, body, baseline.Body.String()) } } } // Gate 3, the authorization half. The fixture's asker deliberately holds a // live grant on the private and granted items and a former grant on the // deleted one: the custody intersection (§4.3) has to beat the grant, or a // consumer holding an ingested id learns the item is still there. func checkS2(h http.Handler, f suite.CustodyFixture, r *Report) { // Three questions, not one. A single question at the current revision for // a single person lets a server answer from the rung alone and read // neither the asker nor the revision it was sent. current := make([]suite.ItemRevision, 0, len(f.Cases)) behind := make([]suite.ItemRevision, 0, len(f.Cases)) for _, c := range f.Cases { current = append(current, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision}) behind = append(behind, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision - 1}) } askS2(h, f, r, "the fixture's asker, at the current revision", f.Asker, current, func(c suite.CustodyCase) suite.Authorization { return c.S2Result }) askS2(h, f, r, "the fixture's asker, one revision behind", f.Asker, behind, func(c suite.CustodyCase) suite.Authorization { return c.S2ResultSuperseded }) askS2(h, f, r, "a person with no team standing", f.Stranger, current, func(c suite.CustodyCase) suite.Authorization { return c.S2ResultStranger }) } func askS2(h http.Handler, f suite.CustodyFixture, r *Report, what string, asker suite.Subject, pairs []suite.ItemRevision, want func(suite.CustodyCase) suite.Authorization) { if err := suite.CheckS2Batch(pairs); err != nil { r.fail("custody fixture is over the S2 cap: %v", err) return } body, err := json.Marshal(suite.S2Request{Asker: asker, Pairs: pairs}) if err != nil { r.fail("encoding the S2 question: %v", err) return } rec := do(h, "POST", suite.RouteS2, body) got := suite.DecodeS2(pairs, rec.Code, rec.Body.Bytes()) for i, c := range f.Cases { r.Checked++ if got[i] != want(c) { r.fail("S2 %s asked for %s: got %s, want %s", c.ItemID, what, got[i], want(c)) } } } // Gate 1 for the feed, and the leak memoria's draft 1 shipped. The decoder // refuses unknown fields, so a row carrying a title, a body or a visibility // flag fails here rather than in a store somebody greps a year later. func checkS1(h http.Handler, r *Report) { rec := do(h, "GET", suite.RouteS1+"?cursor=", nil) r.Checked++ if _, err := suite.DecodeS1(rec.Code, rec.Body.Bytes()); err != nil { r.fail("S1: %v", err) } } func do(h http.Handler, method, url string, body []byte) *httptest.ResponseRecorder { var rdr *bytes.Reader if body == nil { rdr = bytes.NewReader(nil) } else { rdr = bytes.NewReader(body) } req := httptest.NewRequest(method, url, rdr) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec }