| 1 | package 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 | |
| 32 | import ( |
| 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. |
| 45 | type 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 | |
| 53 | func (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. |
| 63 | const claimKeymailLoc = "<keymail>" |
| 64 | |
| 65 | // claimProbe is one valid request and the proof that it worked. |
| 66 | type 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. |
| 90 | func 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. |
| 97 | var 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. |
| 101 | func 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. |
| 109 | func 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. |
| 147 | func 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. |
| 187 | func 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. |
| 222 | var 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. |
| 228 | func 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. |
| 287 | func 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. |
| 308 | func 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. |
| 317 | func (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. |
| 323 | func (cx *claimCase) pageReply(t *testing.T, path string) claimReply { |
| 324 | t.Helper() |
| 325 | return cx.pageReplyAs(t, cx.browser, path) |
| 326 | } |
| 327 | |
| 328 | func (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. |
| 337 | func (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 | |
| 342 | func (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. |
| 355 | func (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 | |
| 360 | func (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. |
| 367 | func claimPageReply(path string) func(*testing.T, *claimCase) claimReply { |
| 368 | return func(t *testing.T, cx *claimCase) claimReply { return cx.pageReply(t, path) } |
| 369 | } |
| 370 | |
| 371 | func 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 | |
| 375 | func 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. |
| 380 | func 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. |
| 387 | func 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. |
| 399 | func (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. |
| 409 | func (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. |
| 417 | func 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. |
| 433 | func 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. |
| 468 | func 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. |
| 513 | func 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. |
| 558 | func 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 | |