oficina / suite Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
9package conformance
10
11import (
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.
24type Report struct {
25 Checked int
26 Failures []string
27}
28
29func (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.
35func 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.
50func 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.
68func 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.
116func 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
137func 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.
163func 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
172func 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