package callstest import ( "bytes" "fmt" "log/slog" "net/http" "net/url" "regexp" "strings" "testing" "amadan.net/oficina/calls/internal/calls" ) var ( agentTokenRe = regexp.MustCompile(`mt_[A-Za-z0-9_-]+`) revokeHrefRe = regexp.MustCompile(`/settings/agents/(\d+)/revoke`) // hex64Re is the shape of TokenHash (hex.EncodeToString of a // SHA-256 sum): nothing rendered to a browser should ever match // it, whether that would be the plaintext token or its own hash. hex64Re = regexp.MustCompile(`\b[0-9a-f]{64}\b`) ) func extractToken(t *testing.T, body string) string { t.Helper() m := agentTokenRe.FindString(body) if m == "" { t.Fatalf("no mt_ token on the page:\n%s", body) } return m } // agentRevokeID reads the id off the first Revoke link the settings // page renders — the same way a browser has no other route to it, // since the id is never shown as a bare number anywhere on the page. func agentRevokeID(t *testing.T, body string) string { t.Helper() m := revokeHrefRe.FindStringSubmatch(body) if m == nil { t.Fatalf("no revoke link on the settings page:\n%s", body) } return m[1] } func TestSettingsPageIsEmptyForAMemberWithNoAgents(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") rec := c.get("/settings") if rec.Code != http.StatusOK { t.Fatalf("GET /settings: %d\n%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "No agents yet.") { t.Fatalf("empty settings page lacks its empty-state sentence:\n%s", body) } if strings.Contains(body, "mt_") { t.Fatalf("empty settings page contains a token-shaped string:\n%s", body) } } // Spec §8.1: the token exists in plaintext only on the page that // mints it. A second GET of Settings must show the same agent by its // principal — the identity a meeting's creator field would attribute // to it — and never the token. func TestCreatingAnAgentShowsTheTokenOnceWithThePrincipal(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) if rec.Code != http.StatusOK { t.Fatalf("POST /settings/agents: %d\n%s", rec.Code, rec.Body.String()) } mintBody := rec.Body.String() extractToken(t, mintBody) if !strings.Contains(mintBody, "scheduler~paul@example.com") { t.Fatalf("mint page lacks the principal:\n%s", mintBody) } token := extractToken(t, mintBody) list := c.get("/settings") if list.Code != http.StatusOK { t.Fatalf("GET /settings: %d", list.Code) } listBody := list.Body.String() if strings.Contains(listBody, token) { t.Fatalf("the settings list shows the token:\n%s", listBody) } if hex64Re.MatchString(listBody) { t.Fatalf("the settings list contains a 64-hex string (TokenHash):\n%s", listBody) } if !strings.Contains(listBody, "scheduler~paul@example.com") { t.Fatalf("the settings list lacks the principal for a live agent:\n%s", listBody) } if !strings.Contains(listBody, "Revoke") { t.Fatalf("a live agent has no Revoke link:\n%s", listBody) } } func TestAnInvalidAgentNameIsRefusedWithASentenceAndMintsNothing(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") rec := c.post("/settings/agents", url.Values{"name": {"has a space"}}) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("invalid name: %d\n%s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "mt_") { t.Fatalf("a refused create still minted a token:\n%s", rec.Body.String()) } if !strings.Contains(rec.Body.String(), "letters, digits, - or _") { t.Fatalf("no name-rule sentence on the refusal:\n%s", rec.Body.String()) } if strings.Contains(c.get("/settings").Body.String(), "Revoke") { t.Fatal("a refused create nonetheless left an agent behind") } } // The confirm page and the POST both work for the operator: the same // shape as replacing a link or ending a meeting (handlers_meetings.go). func TestRevokeConfirmsAndRevokes(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") c.post("/settings/agents", url.Values{"name": {"scheduler"}}) id := agentRevokeID(t, c.get("/settings").Body.String()) confirm := c.get("/settings/agents/" + id + "/revoke") if confirm.Code != http.StatusOK { t.Fatalf("GET confirm: %d\n%s", confirm.Code, confirm.Body.String()) } confirmBody := confirm.Body.String() if !strings.Contains(confirmBody, "Revoke scheduler?") { t.Fatalf("confirm page lacks its question:\n%s", confirmBody) } if !strings.Contains(confirmBody, "Anything using its token stops working at once.") { t.Fatalf("confirm page lacks its consequence sentence:\n%s", confirmBody) } post := c.post("/settings/agents/"+id+"/revoke", nil) if post.Code != http.StatusSeeOther || post.Header().Get("Location") != "/settings" { t.Fatalf("POST revoke: %d %s", post.Code, post.Header().Get("Location")) } after := c.get("/settings").Body.String() if !strings.Contains(after, "Revoked") { t.Fatalf("settings does not show the agent as revoked:\n%s", after) } if strings.Contains(after, "/settings/agents/"+id+"/revoke") { t.Fatalf("a revoked agent still carries a Revoke link:\n%s", after) } } // Review round 1, item 1: the token and a meeting link both exist in // plaintext on exactly one response each (spec §8.1; Sheets' ruling // for a link). A cache must never become a second place either one // survives. func TestMintPagesAreNeverCached(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") link := c.post("/meetings", url.Values{"title": {"Board meeting 2"}, "starts_at": {""}}) if link.Code != http.StatusOK { t.Fatalf("create meeting: %d\n%s", link.Code, link.Body.String()) } if got := link.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("link_once Cache-Control: %q, want %q", got, "no-store") } agent := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) if agent.Code != http.StatusOK { t.Fatalf("create agent: %d\n%s", agent.Code, agent.Body.String()) } if got := agent.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("agent_once Cache-Control: %q, want %q", got, "no-store") } } // Review round 1, item 4: renderSettings is the only place that calls // WriteHeader for either of its two callers' responses. Before that // fix, a validation refusal (which wants 422) whose own renderSettings // then hit a genuine store error still answered 422: a real // ResponseWriter — and httptest's own recorder, which matches it here // on purpose — keeps only the first WriteHeader call and silently // drops the second. Dropping the agents table forces that second, // genuine error while refusing a bad name, which itself never touches // the database, so the only way this test can pass is if nothing wrote // 422 before the store read had a chance to fail. func TestCreateRefusalWithABrokenStoreAnswersFiveHundredNotTheFormsRefusal(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") if err := x.db.G.Exec("DROP TABLE agents").Error; err != nil { t.Fatal(err) } rec := c.post("/settings/agents", url.Values{"name": {"has a space"}}) if rec.Code != http.StatusInternalServerError { t.Fatalf("name refusal with a broken store: %d, want %d", rec.Code, http.StatusInternalServerError) } } // A foreign or unknown agent id 404s — never a 403 — for both the // confirm page and the POST. func TestForeignOrUnknownAgentIDIs404(t *testing.T) { x := newHarness(t, "paul@example.com", "bea@rslw.com") c := x.browser(t) c.signIn("paul@example.com") c.post("/settings/agents", url.Values{"name": {"scheduler"}}) id := agentRevokeID(t, c.get("/settings").Body.String()) bea := x.browser(t) bea.signIn("bea@rslw.com") if rec := bea.get("/settings/agents/" + id + "/revoke"); rec.Code != http.StatusNotFound { t.Fatalf("another member's confirm GET: %d", rec.Code) } if rec := bea.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusNotFound { t.Fatalf("another member's revoke POST: %d", rec.Code) } // The agent the operator actually owns must be unaffected by the // foreign attempt above. if strings.Contains(bea.get("/settings").Body.String(), "scheduler") { t.Fatal("bea's own settings page somehow shows paul's agent") } if rec := c.get("/settings/agents/999999/revoke"); rec.Code != http.StatusNotFound { t.Fatalf("unknown id confirm GET: %d", rec.Code) } if rec := c.post("/settings/agents/999999/revoke", nil); rec.Code != http.StatusNotFound { t.Fatalf("unknown id revoke POST: %d", rec.Code) } } // A guest — signed in through a live meeting link, never on the team — // gets the same 404 from every settings route: requireMember's own // refusal (spec §2.5). func TestGuestCannotReachSettingsOrRevoke(t *testing.T) { x, _, tok := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") c.post("/settings/agents", url.Values{"name": {"scheduler"}}) id := agentRevokeID(t, c.get("/settings").Body.String()) g := x.browser(t) g.get("/j/" + tok) g.signIn("guest@example.com") if rec := g.get("/settings"); rec.Code != http.StatusNotFound { t.Fatalf("guest GET /settings: %d", rec.Code) } if rec := g.post("/settings/agents", url.Values{"name": {"x"}}); rec.Code != http.StatusNotFound { t.Fatalf("guest POST /settings/agents: %d", rec.Code) } if rec := g.get("/settings/agents/" + id + "/revoke"); rec.Code != http.StatusNotFound { t.Fatalf("guest confirm GET: %d", rec.Code) } if rec := g.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusNotFound { t.Fatalf("guest revoke POST: %d", rec.Code) } } // A member never sees the Settings link before signing in as one, and a // guest's shell (a live meeting they were let into) does not carry it // either — the link belongs where sign-out lives, for members only. func TestSettingsLinkIsShownOnlyToAMember(t *testing.T) { x, sqid, tok := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") if rec := c.get("/"); !strings.Contains(rec.Body.String(), `href="/settings"`) { t.Fatalf("a member's shell lacks the Settings link:\n%s", rec.Body.String()) } g := x.browser(t) g.get("/j/" + tok) g.signIn("guest@example.com") if rec := g.get("/m/" + sqid); strings.Contains(rec.Body.String(), `href="/settings"`) { t.Fatalf("a guest's shell carries the Settings link:\n%s", rec.Body.String()) } } // The reviewer's cap (Task 1 addendum): at most 20 live agents per // operator. The 21st create is refused with a sentence and mints // nothing; the 20 already made are untouched. func TestCreateIsRefusedAtTwentyLiveAgents(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") for i := 0; i < 20; i++ { name := fmt.Sprintf("agent%d", i) rec := c.post("/settings/agents", url.Values{"name": {name}}) if rec.Code != http.StatusOK { t.Fatalf("create agent %d: %d\n%s", i, rec.Code, rec.Body.String()) } } rec := c.post("/settings/agents", url.Values{"name": {"one-too-many"}}) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("the 21st create: %d\n%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "20 live agents") { t.Fatalf("no cap sentence on the refusal:\n%s", rec.Body.String()) } list := c.get("/settings").Body.String() if strings.Contains(list, "one-too-many") { t.Fatal("the refused 21st agent was created anyway") } if got := strings.Count(list, ">Revoke"); got != 20 { t.Fatalf("expected exactly 20 live agents, found %d Revoke links:\n%s", got, list) } } // Fix round 1, item 5: a duplicate live name is refused with a // sentence and mints nothing; revoking the original frees the name. func TestCreateIsRefusedForADuplicateLiveNameButFreedByRevoke(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) if rec.Code != http.StatusOK { t.Fatalf("first create: %d\n%s", rec.Code, rec.Body.String()) } rec = c.post("/settings/agents", url.Values{"name": {"scheduler"}}) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("duplicate create: %d\n%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "live agent with that name") { t.Fatalf("no duplicate-name sentence on the refusal:\n%s", rec.Body.String()) } list := c.get("/settings").Body.String() if got := strings.Count(list, ">Revoke"); got != 1 { t.Fatalf("expected exactly 1 live agent, found %d Revoke links:\n%s", got, list) } id := agentRevokeID(t, list) if rec := c.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusSeeOther { t.Fatalf("revoke: %d", rec.Code) } if rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}); rec.Code != http.StatusOK { t.Fatalf("recreate after revoke: %d\n%s", rec.Code, rec.Body.String()) } } // spec §0.1/§4.4's rule, applied to the mint token: nothing an app logs // may ever carry it. func TestMintedAgentTokenNeverAppearsInTheLog(t *testing.T) { var buf bytes.Buffer x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") c := x.browser(t) c.signIn("paul@example.com") rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) if rec.Code != http.StatusOK { t.Fatalf("create agent: %d\n%s", rec.Code, rec.Body.String()) } token := extractToken(t, rec.Body.String()) if strings.Contains(buf.String(), token) { t.Fatalf("the log contains the minted token:\n%s", buf.String()) } }