| 1 | // Package conformance drives an app's server half against the doctrine's |
| 2 | // golden fixtures. |
| 3 | // |
| 4 | // An app mounts its suite routes on a handler and hands that handler here. |
| 5 | // The harness fires the fixture's requests at it and reports what the contract |
| 6 | // says should have happened and did not. Doctrine §10: two specs are guesses, |
| 7 | // two implementations are facts — this is where the second implementation |
| 8 | // corrects the first. |
| 9 | package conformance |
| 10 | |
| 11 | import ( |
| 12 | "bytes" |
| 13 | "encoding/json" |
| 14 | "fmt" |
| 15 | "net/http" |
| 16 | "net/http/httptest" |
| 17 | "strconv" |
| 18 | "testing" |
| 19 | |
| 20 | "amadan.net/oficina/suite" |
| 21 | ) |
| 22 | |
| 23 | // Report is what a run found. An empty Failures is a pass. |
| 24 | type Report struct { |
| 25 | Checked int |
| 26 | Failures []string |
| 27 | } |
| 28 | |
| 29 | func (r *Report) fail(format string, args ...any) { |
| 30 | r.Failures = append(r.Failures, fmt.Sprintf(format, args...)) |
| 31 | } |
| 32 | |
| 33 | // Run drives the handler and fails the test with anything the contract |
| 34 | // forbids. This is what an app calls from its own CI. |
| 35 | func Run(t *testing.T, h http.Handler) { |
| 36 | t.Helper() |
| 37 | report := Check(h) |
| 38 | for _, f := range report.Failures { |
| 39 | t.Error(f) |
| 40 | } |
| 41 | if report.Checked == 0 { |
| 42 | t.Error("conformance ran no checks; a gate that asserts nothing passes everything") |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // Check drives the handler and returns what it found, without touching a |
| 47 | // *testing.T. It exists so the harness itself can be tested: a harness that |
| 48 | // passes every server is worse than no harness, and the only way to know is to |
| 49 | // point it at servers that should fail. |
| 50 | func Check(h http.Handler) Report { |
| 51 | var r Report |
| 52 | |
| 53 | fixture, err := suite.LoadCustodyFixture() |
| 54 | if err != nil { |
| 55 | r.fail("loading the custody fixture: %v", err) |
| 56 | return r |
| 57 | } |
| 58 | |
| 59 | checkS3(h, fixture, &r) |
| 60 | checkS2(h, fixture, &r) |
| 61 | checkS1(h, &r) |
| 62 | return r |
| 63 | } |
| 64 | |
| 65 | // Gate 3, the content half. Every rung but `team` answers 404, and it answers |
| 66 | // the same 404 as a deleted item — a consumer that could tell "not |
| 67 | // team-visible" from "does not exist" would learn that a private item exists. |
| 68 | func checkS3(h http.Handler, f suite.CustodyFixture, r *Report) { |
| 69 | // The refusal every non-team item must be indistinguishable from. Taken |
| 70 | // from an id that is not in the fixture at all, so it is the server's |
| 71 | // answer for "no such item" rather than anything the fixture arranged. |
| 72 | baseline := do(h, "GET", suite.RouteS3+"/"+suite.NonexistentItemID+"?revision=1", nil) |
| 73 | r.Checked++ |
| 74 | if baseline.Code != 404 { |
| 75 | r.fail("S3 for an item that does not exist: got %d, want 404", baseline.Code) |
| 76 | } |
| 77 | |
| 78 | for _, c := range f.Cases { |
| 79 | url := suite.RouteS3 + "/" + c.ItemID + "?revision=" + strconv.FormatInt(c.Revision, 10) |
| 80 | rec := do(h, "GET", url, nil) |
| 81 | r.Checked++ |
| 82 | |
| 83 | if rec.Code != c.S3Status { |
| 84 | r.fail("S3 %s (%s, rung %s): got %d, want %d", c.ItemID, c.Name, c.Rung, rec.Code, c.S3Status) |
| 85 | continue |
| 86 | } |
| 87 | if rec.Code == 403 { |
| 88 | 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) |
| 89 | continue |
| 90 | } |
| 91 | |
| 92 | body := rec.Body.String() |
| 93 | if c.S3Status == 200 { |
| 94 | // The item's own bytes, not merely some bytes. A 200 carrying a |
| 95 | // different item is a leak that a status check cannot see. |
| 96 | if body != c.S3Body { |
| 97 | r.fail("S3 %s: body %q, want %q", c.ItemID, body, c.S3Body) |
| 98 | } |
| 99 | continue |
| 100 | } |
| 101 | |
| 102 | // A refusal must be byte-identical to the refusal for an item that |
| 103 | // never existed. Anything a server adds here — a reason, a name, a |
| 104 | // revision — tells the caller the item is real. |
| 105 | if body != baseline.Body.String() { |
| 106 | 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", |
| 107 | c.ItemID, body, baseline.Body.String()) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Gate 3, the authorization half. The fixture's asker deliberately holds a |
| 113 | // live grant on the private and granted items and a former grant on the |
| 114 | // deleted one: the custody intersection (§4.3) has to beat the grant, or a |
| 115 | // consumer holding an ingested id learns the item is still there. |
| 116 | func checkS2(h http.Handler, f suite.CustodyFixture, r *Report) { |
| 117 | // Three questions, not one. A single question at the current revision for |
| 118 | // a single person lets a server answer from the rung alone and read |
| 119 | // neither the asker nor the revision it was sent. |
| 120 | current := make([]suite.ItemRevision, 0, len(f.Cases)) |
| 121 | behind := make([]suite.ItemRevision, 0, len(f.Cases)) |
| 122 | for _, c := range f.Cases { |
| 123 | current = append(current, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision}) |
| 124 | behind = append(behind, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision - 1}) |
| 125 | } |
| 126 | |
| 127 | askS2(h, f, r, "the fixture's asker, at the current revision", f.Asker, current, |
| 128 | func(c suite.CustodyCase) suite.Authorization { return c.S2Result }) |
| 129 | |
| 130 | askS2(h, f, r, "the fixture's asker, one revision behind", f.Asker, behind, |
| 131 | func(c suite.CustodyCase) suite.Authorization { return c.S2ResultSuperseded }) |
| 132 | |
| 133 | askS2(h, f, r, "a person with no team standing", f.Stranger, current, |
| 134 | func(c suite.CustodyCase) suite.Authorization { return c.S2ResultStranger }) |
| 135 | } |
| 136 | |
| 137 | func askS2(h http.Handler, f suite.CustodyFixture, r *Report, what string, asker suite.Subject, pairs []suite.ItemRevision, want func(suite.CustodyCase) suite.Authorization) { |
| 138 | if err := suite.CheckS2Batch(pairs); err != nil { |
| 139 | r.fail("custody fixture is over the S2 cap: %v", err) |
| 140 | return |
| 141 | } |
| 142 | |
| 143 | body, err := json.Marshal(suite.S2Request{Asker: asker, Pairs: pairs}) |
| 144 | if err != nil { |
| 145 | r.fail("encoding the S2 question: %v", err) |
| 146 | return |
| 147 | } |
| 148 | |
| 149 | rec := do(h, "POST", suite.RouteS2, body) |
| 150 | got := suite.DecodeS2(pairs, rec.Code, rec.Body.Bytes()) |
| 151 | |
| 152 | for i, c := range f.Cases { |
| 153 | r.Checked++ |
| 154 | if got[i] != want(c) { |
| 155 | r.fail("S2 %s asked for %s: got %s, want %s", c.ItemID, what, got[i], want(c)) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Gate 1 for the feed, and the leak memoria's draft 1 shipped. The decoder |
| 161 | // refuses unknown fields, so a row carrying a title, a body or a visibility |
| 162 | // flag fails here rather than in a store somebody greps a year later. |
| 163 | func checkS1(h http.Handler, r *Report) { |
| 164 | rec := do(h, "GET", suite.RouteS1+"?cursor=", nil) |
| 165 | r.Checked++ |
| 166 | |
| 167 | if _, err := suite.DecodeS1(rec.Code, rec.Body.Bytes()); err != nil { |
| 168 | r.fail("S1: %v", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func do(h http.Handler, method, url string, body []byte) *httptest.ResponseRecorder { |
| 173 | var rdr *bytes.Reader |
| 174 | if body == nil { |
| 175 | rdr = bytes.NewReader(nil) |
| 176 | } else { |
| 177 | rdr = bytes.NewReader(body) |
| 178 | } |
| 179 | req := httptest.NewRequest(method, url, rdr) |
| 180 | rec := httptest.NewRecorder() |
| 181 | h.ServeHTTP(rec, req) |
| 182 | return rec |
| 183 | } |
| 184 | |