oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package callstest
2
3import (
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
16var (
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
25func 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.
37func 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
46func 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.
67func 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
102func 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).
124func 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.
161func 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.
193func 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.
209func 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).
241func 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.
269func 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.
288func 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.
317func 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.
349func 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