| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "amadan.net/oficina/calls/internal/calls" |
| 14 | ) |
| 15 | |
| 16 | var ( |
| 17 | agentTokenRe = regexp.MustCompile(`mt_[A-Za-z0-9_-]+`) |
| 18 | revokeHrefRe = regexp.MustCompile(`/settings/agents/(\d+)/revoke`) |
| 19 | // hex64Re is the shape of TokenHash (hex.EncodeToString of a |
| 20 | // SHA-256 sum): nothing rendered to a browser should ever match |
| 21 | // it, whether that would be the plaintext token or its own hash. |
| 22 | hex64Re = regexp.MustCompile(`\b[0-9a-f]{64}\b`) |
| 23 | ) |
| 24 | |
| 25 | func extractToken(t *testing.T, body string) string { |
| 26 | t.Helper() |
| 27 | m := agentTokenRe.FindString(body) |
| 28 | if m == "" { |
| 29 | t.Fatalf("no mt_ token on the page:\n%s", body) |
| 30 | } |
| 31 | return m |
| 32 | } |
| 33 | |
| 34 | // agentRevokeID reads the id off the first Revoke link the settings |
| 35 | // page renders — the same way a browser has no other route to it, |
| 36 | // since the id is never shown as a bare number anywhere on the page. |
| 37 | func agentRevokeID(t *testing.T, body string) string { |
| 38 | t.Helper() |
| 39 | m := revokeHrefRe.FindStringSubmatch(body) |
| 40 | if m == nil { |
| 41 | t.Fatalf("no revoke link on the settings page:\n%s", body) |
| 42 | } |
| 43 | return m[1] |
| 44 | } |
| 45 | |
| 46 | func TestSettingsPageIsEmptyForAMemberWithNoAgents(t *testing.T) { |
| 47 | x, _, _ := memberWithMeeting(t) |
| 48 | c := x.browser(t) |
| 49 | c.signIn("paul@example.com") |
| 50 | rec := c.get("/settings") |
| 51 | if rec.Code != http.StatusOK { |
| 52 | t.Fatalf("GET /settings: %d\n%s", rec.Code, rec.Body.String()) |
| 53 | } |
| 54 | body := rec.Body.String() |
| 55 | if !strings.Contains(body, "No agents yet.") { |
| 56 | t.Fatalf("empty settings page lacks its empty-state sentence:\n%s", body) |
| 57 | } |
| 58 | if strings.Contains(body, "mt_") { |
| 59 | t.Fatalf("empty settings page contains a token-shaped string:\n%s", body) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // Spec §8.1: the token exists in plaintext only on the page that |
| 64 | // mints it. A second GET of Settings must show the same agent by its |
| 65 | // principal — the identity a meeting's creator field would attribute |
| 66 | // to it — and never the token. |
| 67 | func TestCreatingAnAgentShowsTheTokenOnceWithThePrincipal(t *testing.T) { |
| 68 | x, _, _ := memberWithMeeting(t) |
| 69 | c := x.browser(t) |
| 70 | c.signIn("paul@example.com") |
| 71 | |
| 72 | rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 73 | if rec.Code != http.StatusOK { |
| 74 | t.Fatalf("POST /settings/agents: %d\n%s", rec.Code, rec.Body.String()) |
| 75 | } |
| 76 | mintBody := rec.Body.String() |
| 77 | extractToken(t, mintBody) |
| 78 | if !strings.Contains(mintBody, "scheduler~paul@example.com") { |
| 79 | t.Fatalf("mint page lacks the principal:\n%s", mintBody) |
| 80 | } |
| 81 | |
| 82 | token := extractToken(t, mintBody) |
| 83 | list := c.get("/settings") |
| 84 | if list.Code != http.StatusOK { |
| 85 | t.Fatalf("GET /settings: %d", list.Code) |
| 86 | } |
| 87 | listBody := list.Body.String() |
| 88 | if strings.Contains(listBody, token) { |
| 89 | t.Fatalf("the settings list shows the token:\n%s", listBody) |
| 90 | } |
| 91 | if hex64Re.MatchString(listBody) { |
| 92 | t.Fatalf("the settings list contains a 64-hex string (TokenHash):\n%s", listBody) |
| 93 | } |
| 94 | if !strings.Contains(listBody, "scheduler~paul@example.com") { |
| 95 | t.Fatalf("the settings list lacks the principal for a live agent:\n%s", listBody) |
| 96 | } |
| 97 | if !strings.Contains(listBody, "Revoke") { |
| 98 | t.Fatalf("a live agent has no Revoke link:\n%s", listBody) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestAnInvalidAgentNameIsRefusedWithASentenceAndMintsNothing(t *testing.T) { |
| 103 | x, _, _ := memberWithMeeting(t) |
| 104 | c := x.browser(t) |
| 105 | c.signIn("paul@example.com") |
| 106 | |
| 107 | rec := c.post("/settings/agents", url.Values{"name": {"has a space"}}) |
| 108 | if rec.Code != http.StatusUnprocessableEntity { |
| 109 | t.Fatalf("invalid name: %d\n%s", rec.Code, rec.Body.String()) |
| 110 | } |
| 111 | if strings.Contains(rec.Body.String(), "mt_") { |
| 112 | t.Fatalf("a refused create still minted a token:\n%s", rec.Body.String()) |
| 113 | } |
| 114 | if !strings.Contains(rec.Body.String(), "letters, digits, - or _") { |
| 115 | t.Fatalf("no name-rule sentence on the refusal:\n%s", rec.Body.String()) |
| 116 | } |
| 117 | if strings.Contains(c.get("/settings").Body.String(), "Revoke") { |
| 118 | t.Fatal("a refused create nonetheless left an agent behind") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // The confirm page and the POST both work for the operator: the same |
| 123 | // shape as replacing a link or ending a meeting (handlers_meetings.go). |
| 124 | func TestRevokeConfirmsAndRevokes(t *testing.T) { |
| 125 | x, _, _ := memberWithMeeting(t) |
| 126 | c := x.browser(t) |
| 127 | c.signIn("paul@example.com") |
| 128 | c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 129 | id := agentRevokeID(t, c.get("/settings").Body.String()) |
| 130 | |
| 131 | confirm := c.get("/settings/agents/" + id + "/revoke") |
| 132 | if confirm.Code != http.StatusOK { |
| 133 | t.Fatalf("GET confirm: %d\n%s", confirm.Code, confirm.Body.String()) |
| 134 | } |
| 135 | confirmBody := confirm.Body.String() |
| 136 | if !strings.Contains(confirmBody, "Revoke scheduler?") { |
| 137 | t.Fatalf("confirm page lacks its question:\n%s", confirmBody) |
| 138 | } |
| 139 | if !strings.Contains(confirmBody, "Anything using its token stops working at once.") { |
| 140 | t.Fatalf("confirm page lacks its consequence sentence:\n%s", confirmBody) |
| 141 | } |
| 142 | |
| 143 | post := c.post("/settings/agents/"+id+"/revoke", nil) |
| 144 | if post.Code != http.StatusSeeOther || post.Header().Get("Location") != "/settings" { |
| 145 | t.Fatalf("POST revoke: %d %s", post.Code, post.Header().Get("Location")) |
| 146 | } |
| 147 | |
| 148 | after := c.get("/settings").Body.String() |
| 149 | if !strings.Contains(after, "Revoked") { |
| 150 | t.Fatalf("settings does not show the agent as revoked:\n%s", after) |
| 151 | } |
| 152 | if strings.Contains(after, "/settings/agents/"+id+"/revoke") { |
| 153 | t.Fatalf("a revoked agent still carries a Revoke link:\n%s", after) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // Review round 1, item 1: the token and a meeting link both exist in |
| 158 | // plaintext on exactly one response each (spec §8.1; Sheets' ruling |
| 159 | // for a link). A cache must never become a second place either one |
| 160 | // survives. |
| 161 | func TestMintPagesAreNeverCached(t *testing.T) { |
| 162 | x, _, _ := memberWithMeeting(t) |
| 163 | c := x.browser(t) |
| 164 | c.signIn("paul@example.com") |
| 165 | |
| 166 | link := c.post("/meetings", url.Values{"title": {"Board meeting 2"}, "starts_at": {""}}) |
| 167 | if link.Code != http.StatusOK { |
| 168 | t.Fatalf("create meeting: %d\n%s", link.Code, link.Body.String()) |
| 169 | } |
| 170 | if got := link.Header().Get("Cache-Control"); got != "no-store" { |
| 171 | t.Fatalf("link_once Cache-Control: %q, want %q", got, "no-store") |
| 172 | } |
| 173 | |
| 174 | agent := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 175 | if agent.Code != http.StatusOK { |
| 176 | t.Fatalf("create agent: %d\n%s", agent.Code, agent.Body.String()) |
| 177 | } |
| 178 | if got := agent.Header().Get("Cache-Control"); got != "no-store" { |
| 179 | t.Fatalf("agent_once Cache-Control: %q, want %q", got, "no-store") |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // Review round 1, item 4: renderSettings is the only place that calls |
| 184 | // WriteHeader for either of its two callers' responses. Before that |
| 185 | // fix, a validation refusal (which wants 422) whose own renderSettings |
| 186 | // then hit a genuine store error still answered 422: a real |
| 187 | // ResponseWriter — and httptest's own recorder, which matches it here |
| 188 | // on purpose — keeps only the first WriteHeader call and silently |
| 189 | // drops the second. Dropping the agents table forces that second, |
| 190 | // genuine error while refusing a bad name, which itself never touches |
| 191 | // the database, so the only way this test can pass is if nothing wrote |
| 192 | // 422 before the store read had a chance to fail. |
| 193 | func TestCreateRefusalWithABrokenStoreAnswersFiveHundredNotTheFormsRefusal(t *testing.T) { |
| 194 | x, _, _ := memberWithMeeting(t) |
| 195 | c := x.browser(t) |
| 196 | c.signIn("paul@example.com") |
| 197 | |
| 198 | if err := x.db.G.Exec("DROP TABLE agents").Error; err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | rec := c.post("/settings/agents", url.Values{"name": {"has a space"}}) |
| 202 | if rec.Code != http.StatusInternalServerError { |
| 203 | t.Fatalf("name refusal with a broken store: %d, want %d", rec.Code, http.StatusInternalServerError) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // A foreign or unknown agent id 404s — never a 403 — for both the |
| 208 | // confirm page and the POST. |
| 209 | func TestForeignOrUnknownAgentIDIs404(t *testing.T) { |
| 210 | x := newHarness(t, "paul@example.com", "bea@rslw.com") |
| 211 | c := x.browser(t) |
| 212 | c.signIn("paul@example.com") |
| 213 | c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 214 | id := agentRevokeID(t, c.get("/settings").Body.String()) |
| 215 | |
| 216 | bea := x.browser(t) |
| 217 | bea.signIn("bea@rslw.com") |
| 218 | if rec := bea.get("/settings/agents/" + id + "/revoke"); rec.Code != http.StatusNotFound { |
| 219 | t.Fatalf("another member's confirm GET: %d", rec.Code) |
| 220 | } |
| 221 | if rec := bea.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusNotFound { |
| 222 | t.Fatalf("another member's revoke POST: %d", rec.Code) |
| 223 | } |
| 224 | // The agent the operator actually owns must be unaffected by the |
| 225 | // foreign attempt above. |
| 226 | if strings.Contains(bea.get("/settings").Body.String(), "scheduler") { |
| 227 | t.Fatal("bea's own settings page somehow shows paul's agent") |
| 228 | } |
| 229 | |
| 230 | if rec := c.get("/settings/agents/999999/revoke"); rec.Code != http.StatusNotFound { |
| 231 | t.Fatalf("unknown id confirm GET: %d", rec.Code) |
| 232 | } |
| 233 | if rec := c.post("/settings/agents/999999/revoke", nil); rec.Code != http.StatusNotFound { |
| 234 | t.Fatalf("unknown id revoke POST: %d", rec.Code) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // A guest — signed in through a live meeting link, never on the team — |
| 239 | // gets the same 404 from every settings route: requireMember's own |
| 240 | // refusal (spec §2.5). |
| 241 | func TestGuestCannotReachSettingsOrRevoke(t *testing.T) { |
| 242 | x, _, tok := memberWithMeeting(t) |
| 243 | c := x.browser(t) |
| 244 | c.signIn("paul@example.com") |
| 245 | c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 246 | id := agentRevokeID(t, c.get("/settings").Body.String()) |
| 247 | |
| 248 | g := x.browser(t) |
| 249 | g.get("/j/" + tok) |
| 250 | g.signIn("guest@example.com") |
| 251 | |
| 252 | if rec := g.get("/settings"); rec.Code != http.StatusNotFound { |
| 253 | t.Fatalf("guest GET /settings: %d", rec.Code) |
| 254 | } |
| 255 | if rec := g.post("/settings/agents", url.Values{"name": {"x"}}); rec.Code != http.StatusNotFound { |
| 256 | t.Fatalf("guest POST /settings/agents: %d", rec.Code) |
| 257 | } |
| 258 | if rec := g.get("/settings/agents/" + id + "/revoke"); rec.Code != http.StatusNotFound { |
| 259 | t.Fatalf("guest confirm GET: %d", rec.Code) |
| 260 | } |
| 261 | if rec := g.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusNotFound { |
| 262 | t.Fatalf("guest revoke POST: %d", rec.Code) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // A member never sees the Settings link before signing in as one, and a |
| 267 | // guest's shell (a live meeting they were let into) does not carry it |
| 268 | // either — the link belongs where sign-out lives, for members only. |
| 269 | func TestSettingsLinkIsShownOnlyToAMember(t *testing.T) { |
| 270 | x, sqid, tok := memberWithMeeting(t) |
| 271 | c := x.browser(t) |
| 272 | c.signIn("paul@example.com") |
| 273 | if rec := c.get("/"); !strings.Contains(rec.Body.String(), `href="/settings"`) { |
| 274 | t.Fatalf("a member's shell lacks the Settings link:\n%s", rec.Body.String()) |
| 275 | } |
| 276 | |
| 277 | g := x.browser(t) |
| 278 | g.get("/j/" + tok) |
| 279 | g.signIn("guest@example.com") |
| 280 | if rec := g.get("/m/" + sqid); strings.Contains(rec.Body.String(), `href="/settings"`) { |
| 281 | t.Fatalf("a guest's shell carries the Settings link:\n%s", rec.Body.String()) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // The reviewer's cap (Task 1 addendum): at most 20 live agents per |
| 286 | // operator. The 21st create is refused with a sentence and mints |
| 287 | // nothing; the 20 already made are untouched. |
| 288 | func TestCreateIsRefusedAtTwentyLiveAgents(t *testing.T) { |
| 289 | x, _, _ := memberWithMeeting(t) |
| 290 | c := x.browser(t) |
| 291 | c.signIn("paul@example.com") |
| 292 | for i := 0; i < 20; i++ { |
| 293 | name := fmt.Sprintf("agent%d", i) |
| 294 | rec := c.post("/settings/agents", url.Values{"name": {name}}) |
| 295 | if rec.Code != http.StatusOK { |
| 296 | t.Fatalf("create agent %d: %d\n%s", i, rec.Code, rec.Body.String()) |
| 297 | } |
| 298 | } |
| 299 | rec := c.post("/settings/agents", url.Values{"name": {"one-too-many"}}) |
| 300 | if rec.Code != http.StatusUnprocessableEntity { |
| 301 | t.Fatalf("the 21st create: %d\n%s", rec.Code, rec.Body.String()) |
| 302 | } |
| 303 | if !strings.Contains(rec.Body.String(), "20 live agents") { |
| 304 | t.Fatalf("no cap sentence on the refusal:\n%s", rec.Body.String()) |
| 305 | } |
| 306 | list := c.get("/settings").Body.String() |
| 307 | if strings.Contains(list, "one-too-many") { |
| 308 | t.Fatal("the refused 21st agent was created anyway") |
| 309 | } |
| 310 | if got := strings.Count(list, ">Revoke</a>"); got != 20 { |
| 311 | t.Fatalf("expected exactly 20 live agents, found %d Revoke links:\n%s", got, list) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // Fix round 1, item 5: a duplicate live name is refused with a |
| 316 | // sentence and mints nothing; revoking the original frees the name. |
| 317 | func TestCreateIsRefusedForADuplicateLiveNameButFreedByRevoke(t *testing.T) { |
| 318 | x, _, _ := memberWithMeeting(t) |
| 319 | c := x.browser(t) |
| 320 | c.signIn("paul@example.com") |
| 321 | |
| 322 | rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 323 | if rec.Code != http.StatusOK { |
| 324 | t.Fatalf("first create: %d\n%s", rec.Code, rec.Body.String()) |
| 325 | } |
| 326 | rec = c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 327 | if rec.Code != http.StatusUnprocessableEntity { |
| 328 | t.Fatalf("duplicate create: %d\n%s", rec.Code, rec.Body.String()) |
| 329 | } |
| 330 | if !strings.Contains(rec.Body.String(), "live agent with that name") { |
| 331 | t.Fatalf("no duplicate-name sentence on the refusal:\n%s", rec.Body.String()) |
| 332 | } |
| 333 | list := c.get("/settings").Body.String() |
| 334 | if got := strings.Count(list, ">Revoke</a>"); got != 1 { |
| 335 | t.Fatalf("expected exactly 1 live agent, found %d Revoke links:\n%s", got, list) |
| 336 | } |
| 337 | |
| 338 | id := agentRevokeID(t, list) |
| 339 | if rec := c.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusSeeOther { |
| 340 | t.Fatalf("revoke: %d", rec.Code) |
| 341 | } |
| 342 | if rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}); rec.Code != http.StatusOK { |
| 343 | t.Fatalf("recreate after revoke: %d\n%s", rec.Code, rec.Body.String()) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | // spec §0.1/§4.4's rule, applied to the mint token: nothing an app logs |
| 348 | // may ever carry it. |
| 349 | func TestMintedAgentTokenNeverAppearsInTheLog(t *testing.T) { |
| 350 | var buf bytes.Buffer |
| 351 | x := newHarnessWithLogger(t, calls.EnvDevelopment, |
| 352 | slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), |
| 353 | "paul@example.com") |
| 354 | c := x.browser(t) |
| 355 | c.signIn("paul@example.com") |
| 356 | |
| 357 | rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| 358 | if rec.Code != http.StatusOK { |
| 359 | t.Fatalf("create agent: %d\n%s", rec.Code, rec.Body.String()) |
| 360 | } |
| 361 | token := extractToken(t, rec.Body.String()) |
| 362 | if strings.Contains(buf.String(), token) { |
| 363 | t.Fatalf("the log contains the minted token:\n%s", buf.String()) |
| 364 | } |
| 365 | } |
| 366 | |