package console // claim_invariant_probes_test.go gives every entry the census classes as a // read or a nonApp mutator its own valid, authenticated request, and says what // counts as that request having worked. // // A probe is not a tripwire. The old census sent every such entry a generic // request ({} for a body, "claim-probe" for an id) and only asked that no app // effect appeared, so a request the handler turned away (401, 404, a 400 for // the empty body, a 303 back to a form with an error flash) passed exactly as // well as one that worked, and proved nothing about the handler. A probe // instead: // // 1. stages the world the request needs (a real fleet, a real invitation, a // published GeoIP database, a pending change) with setup; // 2. sends the request a client would send, as the case's signed-in owner or // its operator bearer, and returns what was answered, flash included; // 3. is accepted only when the answer is exactly the success status (2xx, or // the one named redirect), is not an auth redirect and carries no error // flash, the handler's own success text is in the body, and the mux says // the request matched the very entry being covered; // 4. for anything that changes state, proves the change from the WORLD (a // registry row, a bucket object, a SQL row, an account, a token), never // from the status, so a handler that answers 303 and did nothing fails; // 5. still runs under the app-effect tripwire: no raw app effect, no app // control write, no SQL change on the apps the case holds. // // Entries that a deployment intentionally does not support are not probes. // They are refused fixtures: an exact status and message from a closed list // (claimUnsupported), with no effect at all. import ( "context" "fmt" "net/http" "net/url" "slices" "sort" "strings" "testing" "time" ) // claimReply is what one request answered. type claimReply struct { Status int Loc string // the Location header of a redirect Body string Flash string // the flash kind the answer left in the browser: "ok", "err" or "" Note string // the flash text } func (r claimReply) String() string { body := r.Body if len(body) > 160 { body = body[:160] + "..." } return fmt.Sprintf("%d loc=%q flash=%s:%q body=%q", r.Status, r.Loc, r.Flash, r.Note, body) } // claimKeymailLoc stands for the fake Keymail's URL in a probe's loc: only the // sign-in start legitimately redirects off the console, and into Keymail. const claimKeymailLoc = "" // claimProbe is one valid request and the proof that it worked. type claimProbe struct { // setup stages what the request needs, with every app open. setup func(t *testing.T, cx *claimCase) // do sends the valid, authenticated request. do func(t *testing.T, cx *claimCase) claimReply // status is the one accepted status; zero means 200. status int // loc is the exact prefix of the Location an accepted redirect names. It // is required for a 3xx and is never "/" or a sign-in address. loc string // body lists substrings an accepted answer must carry: the handler's own // success text, or the name of the existing resource it was asked for. A // read must name at least one. body []string // effect proves from the world that the handler did its work, and returns // "" when it did. It is required for every nonApp entry. effect func(t *testing.T, cx *claimCase, r claimReply) string // serial, when set, says why this probe touches process-wide state (the // environment, the log output, a package variable). Such a probe runs // alone, before any parallel probe starts. serial string } // claimSerialProbe marks p serial, with the reason. func claimSerialProbe(reason string, p claimProbe) claimProbe { p.serial = reason return p } // claimWatched are the apps whose SQL and bucket state every probe must leave // alone. var claimWatched = []claimAppRef{{Account: "bab", App: "kass"}, {Account: "bab", App: "titogo"}} // claimAuthRedirect reports a redirect that is the console turning a request // away for want of a session or a passkey, not an answer. func claimAuthRedirect(loc, keymail string) bool { return loc == "/" || strings.HasPrefix(loc, "/auth/signin") || (keymail != "" && strings.HasPrefix(loc, keymail)) } // claimReplyProblems is the acceptance test, on the answer alone. Anything // answering 400 or above is a rejected probe, an auth redirect is a rejected // probe, an error flash behind a redirect is a rejected probe, and an answer // without the handler's own success text is a probe that found nothing. func claimReplyProblems(p claimProbe, r claimReply, keymail string) []string { var problems []string say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } want := p.status if want == 0 { want = http.StatusOK } switch { case r.Status == 0: say("the request got no answer") case r.Status >= http.StatusBadRequest: say("rejected probe: the request answered %s", r) case r.Status != want: say("the request answered %d, want exactly %d (%s)", r.Status, want, r) } if r.Status >= 300 && r.Status < 400 { wantLoc := strings.ReplaceAll(p.loc, claimKeymailLoc, keymail) switch { case wantLoc == "/" && r.Loc == "/": case wantLoc != "" && wantLoc != "/" && strings.HasPrefix(r.Loc, wantLoc): case claimAuthRedirect(r.Loc, keymail): say("auth redirect: the console sent the request to %q instead of answering it", r.Loc) default: say("redirected to %q, want %q", r.Loc, wantLoc) } } if r.Flash == "err" { say("the handler flashed an error behind a redirect: %q", r.Note) } for _, s := range p.body { if !strings.Contains(r.Body, s) { say("the answer is missing %q: %s", s, r) } } return problems } // runClaimProbe stages, sends and judges one probe on cx. func runClaimProbe(t *testing.T, cx *claimCase, key string, p claimProbe) []string { t.Helper() kind, pattern, _ := strings.Cut(key, " ") if p.setup != nil { p.setup(t, cx) } cx.quiesce() allBefore := claimProbeAllSQL(t, cx) before := map[claimAppRef]map[string]string{} for _, ref := range claimProbeApps(t, cx) { before[ref] = cx.sqlDigest(t, ref) } cx.srv.cli = newCLILimits() hm := cx.hits.mark() m := cx.rec.mark() r := p.do(t, cx) cx.quiesce() want := p want.body = make([]string, len(p.body)) for i, s := range p.body { want.body[i] = cx.expand(s) // a marker may name a value the setup minted } problems := claimReplyProblems(want, r, cx.fk.srv.URL) if kind == "route" { if seen := cx.hits.since(hm); !slices.Contains(seen, pattern) { problems = append(problems, fmt.Sprintf("the request never reached the entry: the mux matched %q, not %q", seen, pattern)) } } if p.effect != nil { if msg := p.effect(t, cx, r); msg != "" { problems = append(problems, "no effect: "+msg) } } if changed := claimChangedTables(allBefore, claimProbeAllSQL(t, cx)); len(changed) != 0 { problems = append(problems, fmt.Sprintf("app-scoped SQL tables changed: %v", changed)) } return append(problems, claimTripwireProblems(t, cx, m, before)...) } // claimTripwireProblems: whatever else a probe did, it reached no app. func claimTripwireProblems(t *testing.T, cx *claimCase, m int, before map[claimAppRef]map[string]string) []string { t.Helper() var problems []string for _, e := range cx.rec.since(m) { switch { case e.Control && len(e.Scopes) != 0: problems = append(problems, "wrote canonical control object "+e.Key) case e.Control: case e.Err != nil: problems = append(problems, fmt.Sprintf("raw %s reached a key the production classifier refuses (%v)", e, e.Err)) case e.Unscoped: problems = append(problems, fmt.Sprintf("raw %s reached a backend or a data bucket", e)) case len(e.Scopes) != 0: problems = append(problems, fmt.Sprintf("raw %s is app-scoped (%v)", e, e.Scopes)) } } watched := map[claimAppRef]bool{} for ref := range before { watched[ref] = true } for _, ref := range claimProbeApps(t, cx) { watched[ref] = true } for ref := range watched { if changed := claimChangedTables(before[ref], cx.sqlDigest(t, ref)); len(changed) != 0 { problems = append(problems, fmt.Sprintf("SQL rows of %s changed in %v", ref, changed)) } } return problems } // claimSessionEnd are the entries whose success IS the redirect to the front // page: ending a session sends the person there, and so does a request that // never had one. Their effect witness is the session itself, which is why a // probe of one of these may name "/" and no other probe may. var claimSessionEnd = map[string]bool{"route POST /auth/signout": true} // claimProbeCensus checks the probe table against the fixture table, exactly: // every read and nonApp entry has a probe, no other entry has one, a nonApp // probe proves an effect, a read probe names the resource it read, and no // probe accepts an error status or an auth redirect. func claimProbeCensus(fixtures map[string]claimFixture, probes map[string]claimProbe) []string { var problems []string for key, f := range fixtures { isRead, isNonApp := strings.TrimSpace(f.read) != "", strings.TrimSpace(f.nonApp) != "" _, ok := probes[key] if !isRead && !isNonApp { if ok { problems = append(problems, fmt.Sprintf("claim probe %s belongs to a door or a refusal, which drive their own requests", key)) } continue } if !ok { 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)) } } for key, p := range probes { f, ok := fixtures[key] isRead, isNonApp := ok && strings.TrimSpace(f.read) != "", ok && strings.TrimSpace(f.nonApp) != "" switch { case !ok: problems = append(problems, fmt.Sprintf("claim probe %s names no fixture", key)) continue case !isRead && !isNonApp: continue } if p.do == nil { problems = append(problems, fmt.Sprintf("claim probe %s sends no request", key)) } if isNonApp && p.effect == nil { 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)) } // A redirect read names its resource in where it sends the caller. if isRead && len(p.body) == 0 && (p.status < 300 || p.status >= 400) { 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)) } switch { case p.status >= http.StatusBadRequest: problems = append(problems, fmt.Sprintf("claim probe %s accepts status %d, an error", key, p.status)) case p.status >= 300 && p.status < 400: loc := strings.TrimPrefix(p.loc, claimKeymailLoc) switch { case p.loc == "": problems = append(problems, fmt.Sprintf("claim probe %s accepts a %d without naming where it must redirect", key, p.status)) case claimAuthRedirect(p.loc, "") && !claimSessionEnd[key]: problems = append(problems, fmt.Sprintf("claim probe %s accepts a redirect to %q, which is an auth redirect", key, p.loc)) case loc != p.loc && key != "route GET /auth/signin": problems = append(problems, fmt.Sprintf("claim probe %s redirects to Keymail, which only the sign-in start may do", key)) } case p.loc != "": problems = append(problems, fmt.Sprintf("claim probe %s names a redirect but accepts status %d", key, p.status)) } } sort.Strings(problems) return problems } // ---- requests ----------------------------------------------------------------- // claimFlashOf reads the flash a browser holds for the console at base. func claimFlashOf(c *http.Client, base string) (kind, msg string) { u, err := url.Parse(base) if err != nil { return "", "" } for _, ck := range c.Jar.Cookies(u) { if ck.Name != flashCookie || ck.Value == "" { continue } v, err := url.QueryUnescape(ck.Value) if err != nil { return "", "" } kind, msg, _ = strings.Cut(v, "|") return kind, msg } return "", "" } // claimClearFlash empties a browser's flash, so what a request leaves is the // request's own. func claimClearFlash(c *http.Client, base string) { u, err := url.Parse(base) if err != nil { return } c.Jar.SetCookies(u, []*http.Cookie{{Name: flashCookie, Value: "", Path: "/", MaxAge: -1}}) } // browserReply is the answer a browser got, with the flash it was left holding. func (cx *claimCase) browserReply(c *http.Client, status int, loc, body string) claimReply { kind, msg := claimFlashOf(c, cx.ts.URL) return claimReply{Status: status, Loc: loc, Body: body, Flash: kind, Note: msg} } // pageReply gets one page as the signed-in owner. func (cx *claimCase) pageReply(t *testing.T, path string) claimReply { t.Helper() return cx.pageReplyAs(t, cx.browser, path) } func (cx *claimCase) pageReplyAs(t *testing.T, c *http.Client, path string) claimReply { t.Helper() claimClearFlash(c, cx.ts.URL) status, loc, body := get(t, c, cx.ts.URL+cx.expand(path)) return cx.browserReply(c, status, loc, body) } // formReply posts one browser form as the signed-in owner, with the session's // CSRF token and the passkey step-up walked when the handler asks for one. func (cx *claimCase) formReply(t *testing.T, path string, kv ...string) claimReply { t.Helper() return cx.formReplyAs(t, cx.browser, cx.csrf, path, kv...) } func (cx *claimCase) formReplyAs(t *testing.T, c *http.Client, csrf, path string, kv ...string) claimReply { t.Helper() claimClearFlash(c, cx.ts.URL) v := url.Values{"csrf": {csrf}} for i := 0; i+1 < len(kv); i += 2 { v.Add(kv[i], cx.expand(kv[i+1])) } status, loc, body := postGated(t, cx.consoleFixture, c, cx.ts.URL+cx.expand(path), v) return cx.browserReply(c, status, loc, body) } // apiReply sends one CLI request as the case's owner (an operator, a member of // the ops account) bearer. func (cx *claimCase) apiReply(t *testing.T, method, path, body string) claimReply { t.Helper() return cx.apiReplyAs(t, cx.bearer, method, path, body) } func (cx *claimCase) apiReplyAs(t *testing.T, bearer, method, path, body string) claimReply { t.Helper() status, hdr, out := rawReq(t, method, cx.ts.URL+cx.expand(path), bearer, cx.expand(body)) return claimReply{Status: status, Loc: hdr.Get("Location"), Body: out} } // The request builders a probe table is written in. func claimPageReply(path string) func(*testing.T, *claimCase) claimReply { return func(t *testing.T, cx *claimCase) claimReply { return cx.pageReply(t, path) } } func claimFormReply(path string, kv ...string) func(*testing.T, *claimCase) claimReply { return func(t *testing.T, cx *claimCase) claimReply { return cx.formReply(t, path, kv...) } } func claimAPIReply(method, path, body string) func(*testing.T, *claimCase) claimReply { return func(t *testing.T, cx *claimCase) claimReply { return cx.apiReply(t, method, path, body) } } // claimGetAPI is the read the CLI sends. func claimGetAPI(path string) func(*testing.T, *claimCase) claimReply { return claimAPIReply(http.MethodGet, path, "") } // claimWorkerReply runs one recorded worker for a bounded time. The worker // answers nothing, so the reply is the success status and the proof is the // probe's effect. func claimWorkerReply(name string) func(*testing.T, *claimCase) claimReply { return func(t *testing.T, cx *claimCase) claimReply { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() cx.srv.RunClaimWorker(ctx, name) return claimReply{Status: http.StatusOK} } } // ---- world readers: each answers what is there, never stops the case ------------ // sqlCount counts rows of a query over the console's own tables. func (cx *claimCase) sqlCount(t *testing.T, query string, args ...any) int { t.Helper() var n int if err := cx.st.db.QueryRowContext(context.Background(), query, args...).Scan(&n); err != nil { t.Fatalf("%s: %v", query, err) } return n } // bucketHas reports whether the raw deployment bucket holds key. func (cx *claimCase) bucketHas(key string) bool { _, _, err := cx.rel.Get(context.Background(), key) return err == nil } // ---- the probe runs ------------------------------------------------------------ // claimProbeKeys are the read and nonApp entries, in table order. func claimProbeKeys(fixtures map[string]claimFixture) []string { var keys []string for key, f := range fixtures { if f.door == nil && f.refused == nil { keys = append(keys, key) } } sort.Strings(keys) return keys } // runClaimProbes runs every read and nonApp probe on its own console, so no // probe's world (a closed session, a deleted account) disturbs another's. // With parallel above zero, at most that many run at once; a serial probe // still runs alone, before the parallel ones start (they wait until this // function returns). The caller's t.Run returns when all of them are done. func runClaimProbes(t *testing.T, fixtures map[string]claimFixture, probes map[string]claimProbe, parallel int, opts ...func(*Config)) { t.Helper() run := func(t *testing.T, key string, p claimProbe) { cx := newClaimCaseWith(t, opts...) for _, problem := range runClaimProbe(t, cx, key, p) { t.Error(problem) } } var deferred []string for _, key := range claimProbeKeys(fixtures) { p, ok := probes[key] if !ok { continue // the census names it } if parallel > 0 && strings.TrimSpace(p.serial) == "" { deferred = append(deferred, key) continue } t.Run(key, func(t *testing.T) { run(t, key, p) }) } slots := make(chan struct{}, max(parallel, 1)) for _, key := range deferred { p := probes[key] t.Run(key, func(t *testing.T) { t.Parallel() claimParallelSlot(t, slots) run(t, key, p) }) } } // ---- the detectors ------------------------------------------------------------- // A rejected probe is not coverage: each answer below is one a hollow request // gets, and each must produce a problem. func TestClaimProbeRejectsWhatIsNotAnAcceptance(t *testing.T) { const keymail = "http://keymail.test" page := claimProbe{body: []string{"kass"}} redirect := claimProbe{status: http.StatusSeeOther, loc: "/a/bab/team"} for _, c := range []struct { name string p claimProbe r claimReply want string // a substring of a problem; "" means the answer is accepted }{ {"accepted page", page, claimReply{Status: 200, Body: "

kass

"}, ""}, {"accepted redirect", redirect, claimReply{Status: 303, Loc: "/a/bab/team", Flash: "ok", Note: "Invited"}, ""}, {"accepted 202", claimProbe{status: http.StatusAccepted}, claimReply{Status: 202}, ""}, {"no answer", page, claimReply{}, "no answer"}, {"401", page, claimReply{Status: 401, Body: `{"error":"not signed in"}`}, "rejected probe"}, {"403", page, claimReply{Status: 403, Body: "Refused"}, "rejected probe"}, {"404", page, claimReply{Status: 404, Body: "404 page not found"}, "rejected probe"}, {"409", page, claimReply{Status: 409}, "rejected probe"}, {"429", page, claimReply{Status: 429}, "rejected probe"}, {"500", page, claimReply{Status: 500}, "rejected probe"}, {"503", page, claimReply{Status: 503}, "rejected probe"}, {"a 200 for the wrong thing", page, claimReply{Status: 200, Body: "

Sign in

"}, `missing "kass"`}, {"a 200 where a redirect is the success", redirect, claimReply{Status: 200}, "want exactly 303"}, {"signed out", claimProbe{status: 200, body: []string{"x"}}, claimReply{Status: 302, Loc: "/"}, "auth redirect"}, {"sign-in redirect", redirect, claimReply{Status: 303, Loc: "/auth/signin"}, "auth redirect"}, {"unfinished step-up", redirect, claimReply{Status: 303, Loc: keymail + "/oauth/authorize?x=1"}, "auth redirect"}, {"a redirect somewhere else", redirect, claimReply{Status: 303, Loc: "/a/bab/settings"}, "redirected to"}, {"an error behind a redirect", redirect, claimReply{Status: 303, Loc: "/a/bab/team", Flash: "err", Note: copyText("team.bad_address")}, "flashed an error"}, } { t.Run(c.name, func(t *testing.T) { problems := claimReplyProblems(c.p, c.r, keymail) if c.want == "" { if len(problems) != 0 { t.Fatalf("an acceptable answer was rejected: %v", problems) } return } if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, c.want) }) { t.Fatalf("%s counted as coverage (%v), want a problem naming %q", c.name, problems, c.want) } }) } } // The probe census fails a table that would let a hollow request pass. func TestClaimProbeCensusRefusesAHollowTable(t *testing.T) { do := func(*testing.T, *claimCase) claimReply { return claimReply{Status: 200} } effect := func(*testing.T, *claimCase, claimReply) string { return "" } read := claimFixture{read: "reads"} mut := claimFixture{nonApp: "writes"} for _, c := range []struct { name string fixtures map[string]claimFixture probes map[string]claimProbe want string }{ {"a read with no probe", map[string]claimFixture{"route GET /x": read}, nil, "no probe"}, {"a mutator with no probe", map[string]claimFixture{"route POST /x": mut}, nil, "no probe"}, {"a probe for no entry", nil, map[string]claimProbe{"route GET /x": {do: do, body: []string{"x"}}}, "names no fixture"}, {"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"}, {"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"}, {"a mutator with no effect", map[string]claimFixture{"route POST /x": mut}, map[string]claimProbe{"route POST /x": {do: do}}, "no effect witness"}, {"a read that names nothing", map[string]claimFixture{"route GET /x": read}, map[string]claimProbe{"route GET /x": {do: do}}, "names no resource"}, {"a probe that sends nothing", map[string]claimFixture{"route GET /x": read}, map[string]claimProbe{"route GET /x": {body: []string{"x"}}}, "sends no request"}, {"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"}, {"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"}, {"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"}, {"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"}, {"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"}, {"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"}, } { t.Run(c.name, func(t *testing.T) { problems := claimProbeCensus(c.fixtures, c.probes) if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, c.want) }) { t.Fatalf("a hollow table passed the probe census: %v, want a problem naming %q", problems, c.want) } }) } good := map[string]claimFixture{"route GET /x": read, "route POST /y": mut} probes := map[string]claimProbe{ "route GET /x": {do: do, body: []string{"x"}}, "route POST /y": {do: do, effect: effect, status: 303, loc: "/z"}, } if problems := claimProbeCensus(good, probes); len(problems) != 0 { t.Fatalf("a complete table was refused: %v", problems) } } // The runner, against the real handlers: each probe below is the request a // careless fixture sends, and the runner must say it proves nothing. func TestClaimProbeRunnerDetectsAHollowRequest(t *testing.T) { has := func(problems []string, s string) bool { return slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, s) }) } invite := func(do func(t *testing.T, cx *claimCase) claimReply) claimProbe { return claimProbe{ do: do, status: http.StatusSeeOther, loc: "/a/bab/team", effect: func(t *testing.T, cx *claimCase, r claimReply) string { pending, err := cx.st.PendingInvites(OpsAccountID) if err != nil { return err.Error() } if len(pending) == 0 { return "no invitation exists" } return "" }, } } t.Run("an unauthenticated read", func(t *testing.T) { cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route GET /api/cli/apps", claimProbe{ do: func(t *testing.T, cx *claimCase) claimReply { return cx.apiReplyAs(t, "", http.MethodGet, "/api/cli/apps", "") }, body: []string{"kass"}, }) if !has(problems, "rejected probe") { t.Fatalf("a 401 counted as coverage: %v", problems) } }) t.Run("a signed-out browser", func(t *testing.T) { cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route GET /settings", claimProbe{ do: func(t *testing.T, cx *claimCase) claimReply { return cx.pageReplyAs(t, newBrowser(t), "/settings") }, body: []string{"settings"}, }) if !has(problems, "auth redirect") { t.Fatalf("a redirect to the front page counted as coverage: %v", problems) } }) t.Run("an id that names nothing", func(t *testing.T) { cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route GET /invite/{token}", claimProbe{ do: claimPageReply("/invite/claim-probe"), body: []string{"Join"}, }) if !has(problems, "rejected probe") { t.Fatalf("a made-up invitation id counted as coverage: %v", problems) } }) t.Run("an error flash below 400", func(t *testing.T) { cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route POST /a/{sqid}/invite", invite(claimFormReply("/a/bab/invite", "address", "not-an-address"))) if !has(problems, "flashed an error") || !has(problems, "no effect") { t.Fatalf("a refused invitation counted as coverage: %v", problems) } }) t.Run("a request for another entry", func(t *testing.T) { cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route GET /a/{sqid}/apps/{app}/logs", claimProbe{ do: claimPageReply("/a/bab/apps/titogo/logs/data"), body: []string{"{"}, }) if !has(problems, "never reached the entry") { t.Fatalf("a request that matched a different route counted as coverage: %v", problems) } }) t.Run("a redirect that did nothing", func(t *testing.T) { cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route POST /a/{sqid}/invite/revoke", claimProbe{ setup: func(t *testing.T, cx *claimCase) { tok, err := cx.st.CreateInvite(OpsAccountID, "kim@keymail.dev", claimFxOwner, time.Now()) if err != nil { t.Fatal(err) } cx.vars["invite"] = tok }, do: claimFormReply("/a/bab/invite/revoke", "token", "no-such-invitation"), status: http.StatusSeeOther, loc: "/a/bab/team", effect: func(t *testing.T, cx *claimCase, r claimReply) string { pending, err := cx.st.PendingInvites(OpsAccountID) if err != nil { return err.Error() } for _, inv := range pending { if inv.Token == cx.vars["invite"] { return "the invitation is still pending" } } return "" }, }) if !has(problems, "no effect") { t.Fatalf("a 303 that revoked nothing counted as coverage: %v", problems) } }) t.Run("the generic empty body", func(t *testing.T) { // The request the old census sent every mutation: {}. The handler // answers 400 for the missing fields, which is not an acceptance. cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route POST /api/cli/fleets/{account}/new", claimProbe{ do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/new", `{}`), effect: func(t *testing.T, cx *claimCase, r claimReply) string { if !cx.fleetNamed(t, "pi") { return "the registry has no fleet pi" } return "" }, }) if !has(problems, "rejected probe") || !has(problems, "no effect") { t.Fatalf("an empty-body create counted as coverage: %v", problems) } }) t.Run("a claim-probe id", func(t *testing.T) { // The old census named every resource "claim-probe", and the handler // 404s a name it does not know. cx := newClaimCase(t) problems := runClaimProbe(t, cx, "route GET /api/cli/apps/{account}/{app}/sidecars/{name}", claimProbe{ do: claimGetAPI("/api/cli/apps/bab/titogo/sidecars/claim-probe"), body: []string{"claim-probe"}, }) if !has(problems, "rejected probe") { t.Fatalf("a made-up sidecar name counted as coverage: %v", problems) } }) t.Run("an app token at the logout door", func(t *testing.T) { // A bearer the door does not admit is a 401, and a 401 never counts. cx := newClaimCase(t) raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", time.Now()) if err != nil { t.Fatal(err) } problems := runClaimProbe(t, cx, "route POST /auth/cli/revoke", claimProbe{ do: func(t *testing.T, cx *claimCase) claimReply { return cx.apiReplyAs(t, raw, http.MethodPost, "/auth/cli/revoke", `{}`) }, body: []string{`"revoked":true`}, effect: func(t *testing.T, cx *claimCase, r claimReply) string { if _, ok := cx.st.CLIPrincipal(raw, time.Now()); ok { return "" } return "the app token is gone" }, }) if !has(problems, "rejected probe") { t.Fatalf("an app token at the person's logout door counted as coverage: %v", problems) } if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok { t.Fatal("the logout door revoked an app token it must not admit") } }) }