carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// claim_invariant_probes_test.go gives every entry the census classes as a
4// read or a nonApp mutator its own valid, authenticated request, and says what
5// counts as that request having worked.
6//
7// A probe is not a tripwire. The old census sent every such entry a generic
8// request ({} for a body, "claim-probe" for an id) and only asked that no app
9// effect appeared, so a request the handler turned away (401, 404, a 400 for
10// the empty body, a 303 back to a form with an error flash) passed exactly as
11// well as one that worked, and proved nothing about the handler. A probe
12// instead:
13//
14// 1. stages the world the request needs (a real fleet, a real invitation, a
15// published GeoIP database, a pending change) with setup;
16// 2. sends the request a client would send, as the case's signed-in owner or
17// its operator bearer, and returns what was answered, flash included;
18// 3. is accepted only when the answer is exactly the success status (2xx, or
19// the one named redirect), is not an auth redirect and carries no error
20// flash, the handler's own success text is in the body, and the mux says
21// the request matched the very entry being covered;
22// 4. for anything that changes state, proves the change from the WORLD (a
23// registry row, a bucket object, a SQL row, an account, a token), never
24// from the status, so a handler that answers 303 and did nothing fails;
25// 5. still runs under the app-effect tripwire: no raw app effect, no app
26// control write, no SQL change on the apps the case holds.
27//
28// Entries that a deployment intentionally does not support are not probes.
29// They are refused fixtures: an exact status and message from a closed list
30// (claimUnsupported), with no effect at all.
31
32import (
33 "context"
34 "fmt"
35 "net/http"
36 "net/url"
37 "slices"
38 "sort"
39 "strings"
40 "testing"
41 "time"
42)
43
44// claimReply is what one request answered.
45type claimReply struct {
46 Status int
47 Loc string // the Location header of a redirect
48 Body string
49 Flash string // the flash kind the answer left in the browser: "ok", "err" or ""
50 Note string // the flash text
51}
52
53func (r claimReply) String() string {
54 body := r.Body
55 if len(body) > 160 {
56 body = body[:160] + "..."
57 }
58 return fmt.Sprintf("%d loc=%q flash=%s:%q body=%q", r.Status, r.Loc, r.Flash, r.Note, body)
59}
60
61// claimKeymailLoc stands for the fake Keymail's URL in a probe's loc: only the
62// sign-in start legitimately redirects off the console, and into Keymail.
63const claimKeymailLoc = "<keymail>"
64
65// claimProbe is one valid request and the proof that it worked.
66type claimProbe struct {
67 // setup stages what the request needs, with every app open.
68 setup func(t *testing.T, cx *claimCase)
69 // do sends the valid, authenticated request.
70 do func(t *testing.T, cx *claimCase) claimReply
71 // status is the one accepted status; zero means 200.
72 status int
73 // loc is the exact prefix of the Location an accepted redirect names. It
74 // is required for a 3xx and is never "/" or a sign-in address.
75 loc string
76 // body lists substrings an accepted answer must carry: the handler's own
77 // success text, or the name of the existing resource it was asked for. A
78 // read must name at least one.
79 body []string
80 // effect proves from the world that the handler did its work, and returns
81 // "" when it did. It is required for every nonApp entry.
82 effect func(t *testing.T, cx *claimCase, r claimReply) string
83 // serial, when set, says why this probe touches process-wide state (the
84 // environment, the log output, a package variable). Such a probe runs
85 // alone, before any parallel probe starts.
86 serial string
87}
88
89// claimSerialProbe marks p serial, with the reason.
90func claimSerialProbe(reason string, p claimProbe) claimProbe {
91 p.serial = reason
92 return p
93}
94
95// claimWatched are the apps whose SQL and bucket state every probe must leave
96// alone.
97var claimWatched = []claimAppRef{{Account: "bab", App: "kass"}, {Account: "bab", App: "titogo"}}
98
99// claimAuthRedirect reports a redirect that is the console turning a request
100// away for want of a session or a passkey, not an answer.
101func claimAuthRedirect(loc, keymail string) bool {
102 return loc == "/" || strings.HasPrefix(loc, "/auth/signin") || (keymail != "" && strings.HasPrefix(loc, keymail))
103}
104
105// claimReplyProblems is the acceptance test, on the answer alone. Anything
106// answering 400 or above is a rejected probe, an auth redirect is a rejected
107// probe, an error flash behind a redirect is a rejected probe, and an answer
108// without the handler's own success text is a probe that found nothing.
109func claimReplyProblems(p claimProbe, r claimReply, keymail string) []string {
110 var problems []string
111 say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
112 want := p.status
113 if want == 0 {
114 want = http.StatusOK
115 }
116 switch {
117 case r.Status == 0:
118 say("the request got no answer")
119 case r.Status >= http.StatusBadRequest:
120 say("rejected probe: the request answered %s", r)
121 case r.Status != want:
122 say("the request answered %d, want exactly %d (%s)", r.Status, want, r)
123 }
124 if r.Status >= 300 && r.Status < 400 {
125 wantLoc := strings.ReplaceAll(p.loc, claimKeymailLoc, keymail)
126 switch {
127 case wantLoc == "/" && r.Loc == "/":
128 case wantLoc != "" && wantLoc != "/" && strings.HasPrefix(r.Loc, wantLoc):
129 case claimAuthRedirect(r.Loc, keymail):
130 say("auth redirect: the console sent the request to %q instead of answering it", r.Loc)
131 default:
132 say("redirected to %q, want %q", r.Loc, wantLoc)
133 }
134 }
135 if r.Flash == "err" {
136 say("the handler flashed an error behind a redirect: %q", r.Note)
137 }
138 for _, s := range p.body {
139 if !strings.Contains(r.Body, s) {
140 say("the answer is missing %q: %s", s, r)
141 }
142 }
143 return problems
144}
145
146// runClaimProbe stages, sends and judges one probe on cx.
147func runClaimProbe(t *testing.T, cx *claimCase, key string, p claimProbe) []string {
148 t.Helper()
149 kind, pattern, _ := strings.Cut(key, " ")
150 if p.setup != nil {
151 p.setup(t, cx)
152 }
153 cx.quiesce()
154 allBefore := claimProbeAllSQL(t, cx)
155 before := map[claimAppRef]map[string]string{}
156 for _, ref := range claimProbeApps(t, cx) {
157 before[ref] = cx.sqlDigest(t, ref)
158 }
159 cx.srv.cli = newCLILimits()
160 hm := cx.hits.mark()
161 m := cx.rec.mark()
162 r := p.do(t, cx)
163 cx.quiesce()
164 want := p
165 want.body = make([]string, len(p.body))
166 for i, s := range p.body {
167 want.body[i] = cx.expand(s) // a marker may name a value the setup minted
168 }
169 problems := claimReplyProblems(want, r, cx.fk.srv.URL)
170 if kind == "route" {
171 if seen := cx.hits.since(hm); !slices.Contains(seen, pattern) {
172 problems = append(problems, fmt.Sprintf("the request never reached the entry: the mux matched %q, not %q", seen, pattern))
173 }
174 }
175 if p.effect != nil {
176 if msg := p.effect(t, cx, r); msg != "" {
177 problems = append(problems, "no effect: "+msg)
178 }
179 }
180 if changed := claimChangedTables(allBefore, claimProbeAllSQL(t, cx)); len(changed) != 0 {
181 problems = append(problems, fmt.Sprintf("app-scoped SQL tables changed: %v", changed))
182 }
183 return append(problems, claimTripwireProblems(t, cx, m, before)...)
184}
185
186// claimTripwireProblems: whatever else a probe did, it reached no app.
187func claimTripwireProblems(t *testing.T, cx *claimCase, m int, before map[claimAppRef]map[string]string) []string {
188 t.Helper()
189 var problems []string
190 for _, e := range cx.rec.since(m) {
191 switch {
192 case e.Control && len(e.Scopes) != 0:
193 problems = append(problems, "wrote canonical control object "+e.Key)
194 case e.Control:
195 case e.Err != nil:
196 problems = append(problems, fmt.Sprintf("raw %s reached a key the production classifier refuses (%v)", e, e.Err))
197 case e.Unscoped:
198 problems = append(problems, fmt.Sprintf("raw %s reached a backend or a data bucket", e))
199 case len(e.Scopes) != 0:
200 problems = append(problems, fmt.Sprintf("raw %s is app-scoped (%v)", e, e.Scopes))
201 }
202 }
203 watched := map[claimAppRef]bool{}
204 for ref := range before {
205 watched[ref] = true
206 }
207 for _, ref := range claimProbeApps(t, cx) {
208 watched[ref] = true
209 }
210 for ref := range watched {
211 if changed := claimChangedTables(before[ref], cx.sqlDigest(t, ref)); len(changed) != 0 {
212 problems = append(problems, fmt.Sprintf("SQL rows of %s changed in %v", ref, changed))
213 }
214 }
215 return problems
216}
217
218// claimSessionEnd are the entries whose success IS the redirect to the front
219// page: ending a session sends the person there, and so does a request that
220// never had one. Their effect witness is the session itself, which is why a
221// probe of one of these may name "/" and no other probe may.
222var claimSessionEnd = map[string]bool{"route POST /auth/signout": true}
223
224// claimProbeCensus checks the probe table against the fixture table, exactly:
225// every read and nonApp entry has a probe, no other entry has one, a nonApp
226// probe proves an effect, a read probe names the resource it read, and no
227// probe accepts an error status or an auth redirect.
228func claimProbeCensus(fixtures map[string]claimFixture, probes map[string]claimProbe) []string {
229 var problems []string
230 for key, f := range fixtures {
231 isRead, isNonApp := strings.TrimSpace(f.read) != "", strings.TrimSpace(f.nonApp) != ""
232 _, ok := probes[key]
233 if !isRead && !isNonApp {
234 if ok {
235 problems = append(problems, fmt.Sprintf("claim probe %s belongs to a door or a refusal, which drive their own requests", key))
236 }
237 continue
238 }
239 if !ok {
240 problems = append(problems, fmt.Sprintf("claim fixture %s is a read or nonApp entry with no probe: a rejected generic request is not coverage", key))
241 }
242 }
243 for key, p := range probes {
244 f, ok := fixtures[key]
245 isRead, isNonApp := ok && strings.TrimSpace(f.read) != "", ok && strings.TrimSpace(f.nonApp) != ""
246 switch {
247 case !ok:
248 problems = append(problems, fmt.Sprintf("claim probe %s names no fixture", key))
249 continue
250 case !isRead && !isNonApp:
251 continue
252 }
253 if p.do == nil {
254 problems = append(problems, fmt.Sprintf("claim probe %s sends no request", key))
255 }
256 if isNonApp && p.effect == nil {
257 problems = append(problems, fmt.Sprintf("claim probe %s is a mutator with no effect witness: a status alone cannot show the handler's mutation path ran", key))
258 }
259 // A redirect read names its resource in where it sends the caller.
260 if isRead && len(p.body) == 0 && (p.status < 300 || p.status >= 400) {
261 problems = append(problems, fmt.Sprintf("claim probe %s is a read that names no resource in its answer: a 200 for a made-up id proves nothing", key))
262 }
263 switch {
264 case p.status >= http.StatusBadRequest:
265 problems = append(problems, fmt.Sprintf("claim probe %s accepts status %d, an error", key, p.status))
266 case p.status >= 300 && p.status < 400:
267 loc := strings.TrimPrefix(p.loc, claimKeymailLoc)
268 switch {
269 case p.loc == "":
270 problems = append(problems, fmt.Sprintf("claim probe %s accepts a %d without naming where it must redirect", key, p.status))
271 case claimAuthRedirect(p.loc, "") && !claimSessionEnd[key]:
272 problems = append(problems, fmt.Sprintf("claim probe %s accepts a redirect to %q, which is an auth redirect", key, p.loc))
273 case loc != p.loc && key != "route GET /auth/signin":
274 problems = append(problems, fmt.Sprintf("claim probe %s redirects to Keymail, which only the sign-in start may do", key))
275 }
276 case p.loc != "":
277 problems = append(problems, fmt.Sprintf("claim probe %s names a redirect but accepts status %d", key, p.status))
278 }
279 }
280 sort.Strings(problems)
281 return problems
282}
283
284// ---- requests -----------------------------------------------------------------
285
286// claimFlashOf reads the flash a browser holds for the console at base.
287func claimFlashOf(c *http.Client, base string) (kind, msg string) {
288 u, err := url.Parse(base)
289 if err != nil {
290 return "", ""
291 }
292 for _, ck := range c.Jar.Cookies(u) {
293 if ck.Name != flashCookie || ck.Value == "" {
294 continue
295 }
296 v, err := url.QueryUnescape(ck.Value)
297 if err != nil {
298 return "", ""
299 }
300 kind, msg, _ = strings.Cut(v, "|")
301 return kind, msg
302 }
303 return "", ""
304}
305
306// claimClearFlash empties a browser's flash, so what a request leaves is the
307// request's own.
308func claimClearFlash(c *http.Client, base string) {
309 u, err := url.Parse(base)
310 if err != nil {
311 return
312 }
313 c.Jar.SetCookies(u, []*http.Cookie{{Name: flashCookie, Value: "", Path: "/", MaxAge: -1}})
314}
315
316// browserReply is the answer a browser got, with the flash it was left holding.
317func (cx *claimCase) browserReply(c *http.Client, status int, loc, body string) claimReply {
318 kind, msg := claimFlashOf(c, cx.ts.URL)
319 return claimReply{Status: status, Loc: loc, Body: body, Flash: kind, Note: msg}
320}
321
322// pageReply gets one page as the signed-in owner.
323func (cx *claimCase) pageReply(t *testing.T, path string) claimReply {
324 t.Helper()
325 return cx.pageReplyAs(t, cx.browser, path)
326}
327
328func (cx *claimCase) pageReplyAs(t *testing.T, c *http.Client, path string) claimReply {
329 t.Helper()
330 claimClearFlash(c, cx.ts.URL)
331 status, loc, body := get(t, c, cx.ts.URL+cx.expand(path))
332 return cx.browserReply(c, status, loc, body)
333}
334
335// formReply posts one browser form as the signed-in owner, with the session's
336// CSRF token and the passkey step-up walked when the handler asks for one.
337func (cx *claimCase) formReply(t *testing.T, path string, kv ...string) claimReply {
338 t.Helper()
339 return cx.formReplyAs(t, cx.browser, cx.csrf, path, kv...)
340}
341
342func (cx *claimCase) formReplyAs(t *testing.T, c *http.Client, csrf, path string, kv ...string) claimReply {
343 t.Helper()
344 claimClearFlash(c, cx.ts.URL)
345 v := url.Values{"csrf": {csrf}}
346 for i := 0; i+1 < len(kv); i += 2 {
347 v.Add(kv[i], cx.expand(kv[i+1]))
348 }
349 status, loc, body := postGated(t, cx.consoleFixture, c, cx.ts.URL+cx.expand(path), v)
350 return cx.browserReply(c, status, loc, body)
351}
352
353// apiReply sends one CLI request as the case's owner (an operator, a member of
354// the ops account) bearer.
355func (cx *claimCase) apiReply(t *testing.T, method, path, body string) claimReply {
356 t.Helper()
357 return cx.apiReplyAs(t, cx.bearer, method, path, body)
358}
359
360func (cx *claimCase) apiReplyAs(t *testing.T, bearer, method, path, body string) claimReply {
361 t.Helper()
362 status, hdr, out := rawReq(t, method, cx.ts.URL+cx.expand(path), bearer, cx.expand(body))
363 return claimReply{Status: status, Loc: hdr.Get("Location"), Body: out}
364}
365
366// The request builders a probe table is written in.
367func claimPageReply(path string) func(*testing.T, *claimCase) claimReply {
368 return func(t *testing.T, cx *claimCase) claimReply { return cx.pageReply(t, path) }
369}
370
371func claimFormReply(path string, kv ...string) func(*testing.T, *claimCase) claimReply {
372 return func(t *testing.T, cx *claimCase) claimReply { return cx.formReply(t, path, kv...) }
373}
374
375func claimAPIReply(method, path, body string) func(*testing.T, *claimCase) claimReply {
376 return func(t *testing.T, cx *claimCase) claimReply { return cx.apiReply(t, method, path, body) }
377}
378
379// claimGetAPI is the read the CLI sends.
380func claimGetAPI(path string) func(*testing.T, *claimCase) claimReply {
381 return claimAPIReply(http.MethodGet, path, "")
382}
383
384// claimWorkerReply runs one recorded worker for a bounded time. The worker
385// answers nothing, so the reply is the success status and the proof is the
386// probe's effect.
387func claimWorkerReply(name string) func(*testing.T, *claimCase) claimReply {
388 return func(t *testing.T, cx *claimCase) claimReply {
389 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
390 defer cancel()
391 cx.srv.RunClaimWorker(ctx, name)
392 return claimReply{Status: http.StatusOK}
393 }
394}
395
396// ---- world readers: each answers what is there, never stops the case ------------
397
398// sqlCount counts rows of a query over the console's own tables.
399func (cx *claimCase) sqlCount(t *testing.T, query string, args ...any) int {
400 t.Helper()
401 var n int
402 if err := cx.st.db.QueryRowContext(context.Background(), query, args...).Scan(&n); err != nil {
403 t.Fatalf("%s: %v", query, err)
404 }
405 return n
406}
407
408// bucketHas reports whether the raw deployment bucket holds key.
409func (cx *claimCase) bucketHas(key string) bool {
410 _, _, err := cx.rel.Get(context.Background(), key)
411 return err == nil
412}
413
414// ---- the probe runs ------------------------------------------------------------
415
416// claimProbeKeys are the read and nonApp entries, in table order.
417func claimProbeKeys(fixtures map[string]claimFixture) []string {
418 var keys []string
419 for key, f := range fixtures {
420 if f.door == nil && f.refused == nil {
421 keys = append(keys, key)
422 }
423 }
424 sort.Strings(keys)
425 return keys
426}
427
428// runClaimProbes runs every read and nonApp probe on its own console, so no
429// probe's world (a closed session, a deleted account) disturbs another's.
430// With parallel above zero, at most that many run at once; a serial probe
431// still runs alone, before the parallel ones start (they wait until this
432// function returns). The caller's t.Run returns when all of them are done.
433func runClaimProbes(t *testing.T, fixtures map[string]claimFixture, probes map[string]claimProbe, parallel int, opts ...func(*Config)) {
434 t.Helper()
435 run := func(t *testing.T, key string, p claimProbe) {
436 cx := newClaimCaseWith(t, opts...)
437 for _, problem := range runClaimProbe(t, cx, key, p) {
438 t.Error(problem)
439 }
440 }
441 var deferred []string
442 for _, key := range claimProbeKeys(fixtures) {
443 p, ok := probes[key]
444 if !ok {
445 continue // the census names it
446 }
447 if parallel > 0 && strings.TrimSpace(p.serial) == "" {
448 deferred = append(deferred, key)
449 continue
450 }
451 t.Run(key, func(t *testing.T) { run(t, key, p) })
452 }
453 slots := make(chan struct{}, max(parallel, 1))
454 for _, key := range deferred {
455 p := probes[key]
456 t.Run(key, func(t *testing.T) {
457 t.Parallel()
458 claimParallelSlot(t, slots)
459 run(t, key, p)
460 })
461 }
462}
463
464// ---- the detectors -------------------------------------------------------------
465
466// A rejected probe is not coverage: each answer below is one a hollow request
467// gets, and each must produce a problem.
468func TestClaimProbeRejectsWhatIsNotAnAcceptance(t *testing.T) {
469 const keymail = "http://keymail.test"
470 page := claimProbe{body: []string{"kass"}}
471 redirect := claimProbe{status: http.StatusSeeOther, loc: "/a/bab/team"}
472 for _, c := range []struct {
473 name string
474 p claimProbe
475 r claimReply
476 want string // a substring of a problem; "" means the answer is accepted
477 }{
478 {"accepted page", page, claimReply{Status: 200, Body: "<h1>kass</h1>"}, ""},
479 {"accepted redirect", redirect, claimReply{Status: 303, Loc: "/a/bab/team", Flash: "ok", Note: "Invited"}, ""},
480 {"accepted 202", claimProbe{status: http.StatusAccepted}, claimReply{Status: 202}, ""},
481 {"no answer", page, claimReply{}, "no answer"},
482 {"401", page, claimReply{Status: 401, Body: `{"error":"not signed in"}`}, "rejected probe"},
483 {"403", page, claimReply{Status: 403, Body: "Refused"}, "rejected probe"},
484 {"404", page, claimReply{Status: 404, Body: "404 page not found"}, "rejected probe"},
485 {"409", page, claimReply{Status: 409}, "rejected probe"},
486 {"429", page, claimReply{Status: 429}, "rejected probe"},
487 {"500", page, claimReply{Status: 500}, "rejected probe"},
488 {"503", page, claimReply{Status: 503}, "rejected probe"},
489 {"a 200 for the wrong thing", page, claimReply{Status: 200, Body: "<h1>Sign in</h1>"}, `missing "kass"`},
490 {"a 200 where a redirect is the success", redirect, claimReply{Status: 200}, "want exactly 303"},
491 {"signed out", claimProbe{status: 200, body: []string{"x"}}, claimReply{Status: 302, Loc: "/"}, "auth redirect"},
492 {"sign-in redirect", redirect, claimReply{Status: 303, Loc: "/auth/signin"}, "auth redirect"},
493 {"unfinished step-up", redirect, claimReply{Status: 303, Loc: keymail + "/oauth/authorize?x=1"}, "auth redirect"},
494 {"a redirect somewhere else", redirect, claimReply{Status: 303, Loc: "/a/bab/settings"}, "redirected to"},
495 {"an error behind a redirect", redirect, claimReply{Status: 303, Loc: "/a/bab/team", Flash: "err", Note: copyText("team.bad_address")}, "flashed an error"},
496 } {
497 t.Run(c.name, func(t *testing.T) {
498 problems := claimReplyProblems(c.p, c.r, keymail)
499 if c.want == "" {
500 if len(problems) != 0 {
501 t.Fatalf("an acceptable answer was rejected: %v", problems)
502 }
503 return
504 }
505 if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, c.want) }) {
506 t.Fatalf("%s counted as coverage (%v), want a problem naming %q", c.name, problems, c.want)
507 }
508 })
509 }
510}
511
512// The probe census fails a table that would let a hollow request pass.
513func TestClaimProbeCensusRefusesAHollowTable(t *testing.T) {
514 do := func(*testing.T, *claimCase) claimReply { return claimReply{Status: 200} }
515 effect := func(*testing.T, *claimCase, claimReply) string { return "" }
516 read := claimFixture{read: "reads"}
517 mut := claimFixture{nonApp: "writes"}
518 for _, c := range []struct {
519 name string
520 fixtures map[string]claimFixture
521 probes map[string]claimProbe
522 want string
523 }{
524 {"a read with no probe", map[string]claimFixture{"route GET /x": read}, nil, "no probe"},
525 {"a mutator with no probe", map[string]claimFixture{"route POST /x": mut}, nil, "no probe"},
526 {"a probe for no entry", nil, map[string]claimProbe{"route GET /x": {do: do, body: []string{"x"}}}, "names no fixture"},
527 {"a probe for a door", map[string]claimFixture{"route POST /x": {door: &claimDoor{}}}, map[string]claimProbe{"route POST /x": {do: do, effect: effect}}, "door or a refusal"},
528 {"a probe for a refusal", map[string]claimFixture{"route POST /x": {refused: &claimRefusalFixture{}}}, map[string]claimProbe{"route POST /x": {do: do, effect: effect}}, "door or a refusal"},
529 {"a mutator with no effect", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do}}, "no effect witness"},
530 {"a read that names nothing", map[string]claimFixture{"route GET /x": read}, map[string]claimProbe{"route GET /x": {do: do}}, "names no resource"},
531 {"a probe that sends nothing", map[string]claimFixture{"route GET /x": read}, map[string]claimProbe{"route GET /x": {body: []string{"x"}}}, "sends no request"},
532 {"an accepted error", map[string]claimFixture{"route GET /x": read}, map[string]claimProbe{"route GET /x": {do: do, body: []string{"x"}, status: 404}}, "an error"},
533 {"a redirect with no target", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do, effect: effect, status: 303}}, "without naming where"},
534 {"a redirect to the front page", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do, effect: effect, status: 303, loc: "/"}}, "auth redirect"},
535 {"a redirect to sign-in", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do, effect: effect, status: 303, loc: "/auth/signin"}}, "auth redirect"},
536 {"a redirect into keymail", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do, effect: effect, status: 303, loc: claimKeymailLoc + "/oauth"}}, "only the sign-in start"},
537 {"a location with no redirect", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do, effect: effect, loc: "/y"}}, "names a redirect but accepts"},
538 } {
539 t.Run(c.name, func(t *testing.T) {
540 problems := claimProbeCensus(c.fixtures, c.probes)
541 if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, c.want) }) {
542 t.Fatalf("a hollow table passed the probe census: %v, want a problem naming %q", problems, c.want)
543 }
544 })
545 }
546 good := map[string]claimFixture{"route GET /x": read, "route POST /y": mut}
547 probes := map[string]claimProbe{
548 "route GET /x": {do: do, body: []string{"x"}},
549 "route POST /y": {do: do, effect: effect, status: 303, loc: "/z"},
550 }
551 if problems := claimProbeCensus(good, probes); len(problems) != 0 {
552 t.Fatalf("a complete table was refused: %v", problems)
553 }
554}
555
556// The runner, against the real handlers: each probe below is the request a
557// careless fixture sends, and the runner must say it proves nothing.
558func TestClaimProbeRunnerDetectsAHollowRequest(t *testing.T) {
559 has := func(problems []string, s string) bool {
560 return slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, s) })
561 }
562 invite := func(do func(t *testing.T, cx *claimCase) claimReply) claimProbe {
563 return claimProbe{
564 do: do, status: http.StatusSeeOther, loc: "/a/bab/team",
565 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
566 pending, err := cx.st.PendingInvites(OpsAccountID)
567 if err != nil {
568 return err.Error()
569 }
570 if len(pending) == 0 {
571 return "no invitation exists"
572 }
573 return ""
574 },
575 }
576 }
577 t.Run("an unauthenticated read", func(t *testing.T) {
578 cx := newClaimCase(t)
579 problems := runClaimProbe(t, cx, "route GET /api/cli/apps", claimProbe{
580 do: func(t *testing.T, cx *claimCase) claimReply {
581 return cx.apiReplyAs(t, "", http.MethodGet, "/api/cli/apps", "")
582 },
583 body: []string{"kass"},
584 })
585 if !has(problems, "rejected probe") {
586 t.Fatalf("a 401 counted as coverage: %v", problems)
587 }
588 })
589 t.Run("a signed-out browser", func(t *testing.T) {
590 cx := newClaimCase(t)
591 problems := runClaimProbe(t, cx, "route GET /settings", claimProbe{
592 do: func(t *testing.T, cx *claimCase) claimReply {
593 return cx.pageReplyAs(t, newBrowser(t), "/settings")
594 },
595 body: []string{"settings"},
596 })
597 if !has(problems, "auth redirect") {
598 t.Fatalf("a redirect to the front page counted as coverage: %v", problems)
599 }
600 })
601 t.Run("an id that names nothing", func(t *testing.T) {
602 cx := newClaimCase(t)
603 problems := runClaimProbe(t, cx, "route GET /invite/{token}", claimProbe{
604 do: claimPageReply("/invite/claim-probe"),
605 body: []string{"Join"},
606 })
607 if !has(problems, "rejected probe") {
608 t.Fatalf("a made-up invitation id counted as coverage: %v", problems)
609 }
610 })
611 t.Run("an error flash below 400", func(t *testing.T) {
612 cx := newClaimCase(t)
613 problems := runClaimProbe(t, cx, "route POST /a/{sqid}/invite",
614 invite(claimFormReply("/a/bab/invite", "address", "not-an-address")))
615 if !has(problems, "flashed an error") || !has(problems, "no effect") {
616 t.Fatalf("a refused invitation counted as coverage: %v", problems)
617 }
618 })
619 t.Run("a request for another entry", func(t *testing.T) {
620 cx := newClaimCase(t)
621 problems := runClaimProbe(t, cx, "route GET /a/{sqid}/apps/{app}/logs", claimProbe{
622 do: claimPageReply("/a/bab/apps/titogo/logs/data"),
623 body: []string{"{"},
624 })
625 if !has(problems, "never reached the entry") {
626 t.Fatalf("a request that matched a different route counted as coverage: %v", problems)
627 }
628 })
629 t.Run("a redirect that did nothing", func(t *testing.T) {
630 cx := newClaimCase(t)
631 problems := runClaimProbe(t, cx, "route POST /a/{sqid}/invite/revoke", claimProbe{
632 setup: func(t *testing.T, cx *claimCase) {
633 tok, err := cx.st.CreateInvite(OpsAccountID, "kim@keymail.dev", claimFxOwner, time.Now())
634 if err != nil {
635 t.Fatal(err)
636 }
637 cx.vars["invite"] = tok
638 },
639 do: claimFormReply("/a/bab/invite/revoke", "token", "no-such-invitation"),
640 status: http.StatusSeeOther, loc: "/a/bab/team",
641 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
642 pending, err := cx.st.PendingInvites(OpsAccountID)
643 if err != nil {
644 return err.Error()
645 }
646 for _, inv := range pending {
647 if inv.Token == cx.vars["invite"] {
648 return "the invitation is still pending"
649 }
650 }
651 return ""
652 },
653 })
654 if !has(problems, "no effect") {
655 t.Fatalf("a 303 that revoked nothing counted as coverage: %v", problems)
656 }
657 })
658 t.Run("the generic empty body", func(t *testing.T) {
659 // The request the old census sent every mutation: {}. The handler
660 // answers 400 for the missing fields, which is not an acceptance.
661 cx := newClaimCase(t)
662 problems := runClaimProbe(t, cx, "route POST /api/cli/fleets/{account}/new", claimProbe{
663 do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/new", `{}`),
664 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
665 if !cx.fleetNamed(t, "pi") {
666 return "the registry has no fleet pi"
667 }
668 return ""
669 },
670 })
671 if !has(problems, "rejected probe") || !has(problems, "no effect") {
672 t.Fatalf("an empty-body create counted as coverage: %v", problems)
673 }
674 })
675 t.Run("a claim-probe id", func(t *testing.T) {
676 // The old census named every resource "claim-probe", and the handler
677 // 404s a name it does not know.
678 cx := newClaimCase(t)
679 problems := runClaimProbe(t, cx, "route GET /api/cli/apps/{account}/{app}/sidecars/{name}", claimProbe{
680 do: claimGetAPI("/api/cli/apps/bab/titogo/sidecars/claim-probe"),
681 body: []string{"claim-probe"},
682 })
683 if !has(problems, "rejected probe") {
684 t.Fatalf("a made-up sidecar name counted as coverage: %v", problems)
685 }
686 })
687 t.Run("an app token at the logout door", func(t *testing.T) {
688 // A bearer the door does not admit is a 401, and a 401 never counts.
689 cx := newClaimCase(t)
690 raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", time.Now())
691 if err != nil {
692 t.Fatal(err)
693 }
694 problems := runClaimProbe(t, cx, "route POST /auth/cli/revoke", claimProbe{
695 do: func(t *testing.T, cx *claimCase) claimReply {
696 return cx.apiReplyAs(t, raw, http.MethodPost, "/auth/cli/revoke", `{}`)
697 },
698 body: []string{`"revoked":true`},
699 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
700 if _, ok := cx.st.CLIPrincipal(raw, time.Now()); ok {
701 return ""
702 }
703 return "the app token is gone"
704 },
705 })
706 if !has(problems, "rejected probe") {
707 t.Fatalf("an app token at the person's logout door counted as coverage: %v", problems)
708 }
709 if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok {
710 t.Fatal("the logout door revoked an app token it must not admit")
711 }
712 })
713}
714