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 "context"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "io"
11 "log/slog"
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "strconv"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "amadan.net/oficina/calls/internal/calls"
22 "gorm.io/gorm"
23)
24
25// mintAgent drives Settings as the signed-in member and hands back the
26// token that page shows exactly once — the only way an agent token
27// ever comes into existence, here as in production.
28func mintAgent(t *testing.T, x *harness, c *client, name string) string {
29 t.Helper()
30 rec := c.post("/settings/agents", url.Values{"name": {name}})
31 if rec.Code != http.StatusOK {
32 t.Fatalf("POST /settings/agents: %d\n%s", rec.Code, rec.Body.String())
33 }
34 return extractToken(t, rec.Body.String())
35}
36
37// apiAuth is a request to the drive surface with a literal
38// Authorization header and no cookies, and — deliberately — no Origin,
39// Referer or Sec-Fetch-Site: an agent is not a browser and sends none
40// of the three, which is exactly why /api/v1 mounts outside
41// csrf.Protect (rastrillo's csrf.SameOrigin refuses a POST carrying
42// none of them).
43func apiAuth(t *testing.T, x *harness, method, target, authorization, body string) *httptest.ResponseRecorder {
44 t.Helper()
45 var rdr io.Reader
46 if body != "" {
47 rdr = strings.NewReader(body)
48 }
49 req := httptest.NewRequest(method, target, rdr)
50 if authorization != "" {
51 req.Header.Set("Authorization", authorization)
52 }
53 if body != "" {
54 req.Header.Set("Content-Type", "application/json")
55 }
56 rec := httptest.NewRecorder()
57 x.h.ServeHTTP(rec, req)
58 return rec
59}
60
61// api is apiAuth with a bearer token.
62func api(t *testing.T, x *harness, method, target, token, body string) *httptest.ResponseRecorder {
63 t.Helper()
64 return apiAuth(t, x, method, target, "Bearer "+token, body)
65}
66
67// apiBody asserts the status and the two headers every API answer
68// carries, and decodes the JSON.
69func apiBody(t *testing.T, rec *httptest.ResponseRecorder, want int) map[string]any {
70 t.Helper()
71 if rec.Code != want {
72 t.Fatalf("status %d, want %d\n%s", rec.Code, want, rec.Body.String())
73 }
74 if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
75 t.Fatalf("Content-Type %q, want application/json", ct)
76 }
77 if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
78 t.Fatalf("Cache-Control %q, want no-store", cc)
79 }
80 var out map[string]any
81 if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
82 t.Fatalf("decode %q: %v", rec.Body.String(), err)
83 }
84 return out
85}
86
87func apiMeeting(t *testing.T, body map[string]any) map[string]any {
88 t.Helper()
89 m, ok := body["meeting"].(map[string]any)
90 if !ok {
91 t.Fatalf("no meeting object in %v", body)
92 }
93 return m
94}
95
96// apiMeetings is the list as the surface answers it, for the tests
97// that assert a refused request changed nothing: the list afterwards
98// is the state, read the same way an agent would read it.
99func apiMeetings(t *testing.T, x *harness, token string) []any {
100 t.Helper()
101 list := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK)
102 rows, ok := list["meetings"].([]any)
103 if !ok {
104 t.Fatalf("meetings %#v, want a list", list["meetings"])
105 }
106 return rows
107}
108
109func apiString(t *testing.T, m map[string]any, key string) string {
110 t.Helper()
111 s, ok := m[key].(string)
112 if !ok {
113 t.Fatalf("%s is %#v, want a string", key, m[key])
114 }
115 return s
116}
117
118// unknownSqid is the shape newSqid produces and a value it never has:
119// a valid-looking room id belonging to no meeting.
120const unknownSqid = "2345678999"
121
122// Spec §8.1 and the slice's ruling: a cookie is never honoured on the
123// API and the bearer is the only credential; a missing or malformed
124// one is 401, while an unknown token is the not-found body — the same
125// bytes an unknown meeting answers, so nothing about whether a token
126// exists is observable.
127func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) {
128 x, _, _ := memberWithMeeting(t)
129 c := x.browser(t)
130 c.signIn("paul@example.com")
131 token := mintAgent(t, x, c, "scheduler")
132
133 // The positive control first: the bearer alone, no cookie, works.
134 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK)
135
136 for _, tc := range []struct {
137 name, header string
138 }{
139 {"no Authorization header at all", ""},
140 {"a Basic credential", "Basic " + base64.StdEncoding.EncodeToString([]byte("paul@example.com:hunter2"))},
141 {"the scheme without a token", "Bearer "},
142 {"a token with no scheme", token},
143 } {
144 t.Run(tc.name, func(t *testing.T) {
145 rec := apiAuth(t, x, http.MethodGet, "/api/v1/meetings", tc.header, "")
146 body := apiBody(t, rec, http.StatusUnauthorized)
147 if body["error"] != "unauthorized" {
148 t.Fatalf("%v, want error unauthorized", body)
149 }
150 })
151 }
152
153 // A signed-in member's own cookie, carried exactly as the browser
154 // carries it to every HTML screen, buys nothing here.
155 t.Run("a session cookie alone", func(t *testing.T) {
156 rec := c.get("/api/v1/meetings")
157 body := apiBody(t, rec, http.StatusUnauthorized)
158 if body["error"] != "unauthorized" {
159 t.Fatalf("%v, want error unauthorized", body)
160 }
161 })
162
163 // An unknown token and an unknown meeting are one 404, byte for
164 // byte.
165 unknownToken := api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "")
166 if unknownToken.Code != http.StatusNotFound {
167 t.Fatalf("an unknown token: %d, want 404\n%s", unknownToken.Code, unknownToken.Body.String())
168 }
169 unknownMeeting := api(t, x, http.MethodGet, "/api/v1/meetings/"+unknownSqid, token, "")
170 if unknownMeeting.Code != http.StatusNotFound {
171 t.Fatalf("an unknown meeting: %d, want 404\n%s", unknownMeeting.Code, unknownMeeting.Body.String())
172 }
173 if unknownToken.Body.String() != unknownMeeting.Body.String() {
174 t.Fatalf("the two 404s differ:\ntoken: %q\nmeeting: %q",
175 unknownToken.Body.String(), unknownMeeting.Body.String())
176 }
177 if got := apiBody(t, unknownMeeting, http.StatusNotFound)["error"]; got != "not-found" {
178 t.Fatalf("not-found body carries error %v", got)
179 }
180
181 // A path this surface does not have answers the same not-found body
182 // — and only to a caller that already holds a token, so the routes
183 // cannot be mapped without one.
184 if body := apiBody(t, api(t, x, http.MethodGet, "/api/v1/agents", token, ""), http.StatusNotFound); body["error"] != "not-found" {
185 t.Fatalf("an unknown API path: %v, want the not-found body", body)
186 }
187 if body := apiBody(t, apiAuth(t, x, http.MethodGet, "/api/v1/agents", "", ""), http.StatusUnauthorized); body["error"] != "unauthorized" {
188 t.Fatalf("an unknown API path without a bearer: %v, want unauthorized", body)
189 }
190 if body := apiBody(t, api(t, x, http.MethodDelete, "/api/v1/meetings", token, ""), http.StatusNotFound); body["error"] != "not-found" {
191 t.Fatalf("a method this surface does not have: %v, want the not-found body", body)
192 }
193}
194
195// The other half of the same ruling: an HTML route never honours a
196// bearer. A token alone, with no session cookie, is simply a signed-out
197// request.
198func TestABearerOnAnHTMLRouteIsIgnored(t *testing.T) {
199 x, _, _ := memberWithMeeting(t)
200 c := x.browser(t)
201 c.signIn("paul@example.com")
202 token := mintAgent(t, x, c, "scheduler")
203
204 req := httptest.NewRequest(http.MethodGet, "/", nil)
205 req.Header.Set("Authorization", "Bearer "+token)
206 rec := httptest.NewRecorder()
207 x.h.ServeHTTP(rec, req)
208 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" {
209 t.Fatalf("GET / with a bearer: %d → %q, want 303 → /signin\n%s",
210 rec.Code, rec.Header().Get("Location"), rec.Body.String())
211 }
212
213 // And the API's own not-found handler stays on the API: an unknown
214 // HTML path is still the router's plain 404, not a JSON body.
215 miss := get(t, x.h, "/nope")
216 if miss.Code != http.StatusNotFound {
217 t.Fatalf("GET /nope: %d, want 404", miss.Code)
218 }
219 if ct := miss.Header().Get("Content-Type"); strings.Contains(ct, "json") {
220 t.Fatalf("an unknown HTML path answers %q\n%s", ct, miss.Body.String())
221 }
222}
223
224// Spec §8.1 and §13's slice-5 gate: reach is the operator's standing at
225// the moment of the request, not at the moment the token was minted. An
226// operator dropped from the team takes their agent's reach with them,
227// with no revocation step, and gets it back the same way.
228func TestAPIReachIsTheOperatorsStandingOnEveryRequest(t *testing.T) {
229 x, _, _ := memberWithMeeting(t)
230 c := x.browser(t)
231 c.signIn("paul@example.com")
232 token := mintAgent(t, x, c, "scheduler")
233
234 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK)
235
236 x.ctl.SetAllowlist("bea@rslw.com")
237 gone := api(t, x, http.MethodGet, "/api/v1/meetings", token, "")
238 if body := apiBody(t, gone, http.StatusNotFound); body["error"] != "not-found" {
239 t.Fatalf("an operator off the team: %v, want the not-found body", body)
240 }
241 unknown := api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "")
242 if gone.Body.String() != unknown.Body.String() {
243 t.Fatalf("a reach-less token is distinguishable from an unknown one:\nreach: %q\nunknown: %q",
244 gone.Body.String(), unknown.Body.String())
245 }
246
247 x.ctl.SetAllowlist("paul@example.com", "paul@rslw.com")
248 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK)
249
250 id := agentRevokeID(t, c.get("/settings").Body.String())
251 if rec := c.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusSeeOther {
252 t.Fatalf("revoke: %d\n%s", rec.Code, rec.Body.String())
253 }
254 revoked := api(t, x, http.MethodGet, "/api/v1/meetings", token, "")
255 if body := apiBody(t, revoked, http.StatusNotFound); body["error"] != "not-found" {
256 t.Fatalf("a revoked token: %v, want the not-found body", body)
257 }
258 if revoked.Body.String() != unknown.Body.String() {
259 t.Fatalf("a revoked token is distinguishable from an unknown one:\nrevoked: %q\nunknown: %q",
260 revoked.Body.String(), unknown.Body.String())
261 }
262}
263
264// Spec §8.2, the whole surface in one run: the five operations, the two
265// responses that carry a link token and no others, the admitted count
266// while the meeting runs, and the 409 an ended meeting answers.
267func TestAPIListsCreatesLinksAndEnds(t *testing.T) {
268 x := newHarness(t, "paul@example.com")
269 c := x.browser(t)
270 c.signIn("paul@example.com")
271 token := mintAgent(t, x, c, "claude")
272
273 created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
274 `{"title":"Board meeting","starts_at":"2026-09-12T09:00:00Z"}`), http.StatusCreated)
275 m := apiMeeting(t, created)
276 sqid := apiString(t, m, "sqid")
277 if apiString(t, m, "title") != "Board meeting" {
278 t.Fatalf("title %v", m["title"])
279 }
280 if apiString(t, m, "starts_at") != "2026-09-12T09:00:00Z" {
281 t.Fatalf("starts_at %v, want RFC 3339 in UTC", m["starts_at"])
282 }
283 if m["ended_at"] != nil {
284 t.Fatalf("a new meeting has ended_at %v", m["ended_at"])
285 }
286 if m["link_present"] != true {
287 t.Fatalf("link_present %v, want true", m["link_present"])
288 }
289 if apiString(t, m, "created_by") != "claude~paul@example.com" {
290 t.Fatalf("created_by %v, want the agent's principal", m["created_by"])
291 }
292 link := apiString(t, created, "link")
293 if !strings.HasPrefix(link, testOrigin+"/j/") {
294 t.Fatalf("link %q, want the configured origin's /j/ URL", link)
295 }
296 if strings.Contains(link, token) {
297 t.Fatal("the link carries the agent token rather than a link token")
298 }
299
300 // The meeting, before anyone is in the room. GET never carries a
301 // link — that only ever rides on create and replace.
302 got := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK)
303 if got["admitted"] != float64(0) {
304 t.Fatalf("admitted %#v, want 0", got["admitted"])
305 }
306 if _, present := got["link"]; present {
307 t.Fatalf("GET carries a link: %v", got)
308 }
309
310 // A member joins through the browser and is admitted on standing.
311 s := c.stream("/m/" + sqid + "/stream")
312 defer s.close()
313 peer, _ := s.hello()
314 c.signal(sqid, peer, `{"type":"join"}`)
315 s.nextOf("roster", 2*time.Second)
316 got = apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK)
317 if got["admitted"] != float64(1) {
318 t.Fatalf("admitted %#v with one member in the room, want 1", got["admitted"])
319 }
320
321 // Replacing the link hands back a new one and kills the old.
322 replaced := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/links", token, ""), http.StatusOK)
323 newLink := apiString(t, replaced, "link")
324 if newLink == link {
325 t.Fatal("replace handed back the same link")
326 }
327 oldToken := strings.TrimPrefix(link, testOrigin+"/j/")
328 if rec := x.browser(t).get("/j/" + oldToken); rec.Code != http.StatusNotFound {
329 t.Fatalf("the replaced link still works: %d", rec.Code)
330 }
331
332 // Ending: the meeting comes back ended, and without a live count or
333 // a link — end never returns one.
334 ended := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK)
335 if _, present := ended["admitted"]; present {
336 t.Fatalf("an ended meeting still reports admitted: %v", ended)
337 }
338 if _, present := ended["link"]; present {
339 t.Fatalf("the end response carries a link: %v", ended)
340 }
341 em := apiMeeting(t, ended)
342 if em["ended_at"] == nil {
343 t.Fatalf("ended_at is null after end: %v", em)
344 }
345 if em["link_present"] != false {
346 t.Fatalf("link_present %v after end, want false", em["link_present"])
347 }
348
349 // GET on the now-ended meeting still has no admitted count and no
350 // link (the guard is m.EndedAt == nil, checked again independently
351 // of the end response above).
352 afterEnd := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK)
353 if _, present := afterEnd["admitted"]; present {
354 t.Fatalf("GET on an ended meeting still reports admitted: %v", afterEnd)
355 }
356 if _, present := afterEnd["link"]; present {
357 t.Fatalf("GET on an ended meeting carries a link: %v", afterEnd)
358 }
359
360 for _, target := range []string{"/api/v1/meetings/" + sqid + "/end", "/api/v1/meetings/" + sqid + "/links"} {
361 body := apiBody(t, api(t, x, http.MethodPost, target, token, ""), http.StatusConflict)
362 if body["error"] != "ended" {
363 t.Fatalf("POST %s on an ended meeting: %v, want error ended", target, body)
364 }
365 }
366
367 // And the list, which is what a second agent run would read.
368 list := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK)
369 rows, ok := list["meetings"].([]any)
370 if !ok || len(rows) != 1 {
371 t.Fatalf("meetings %#v, want one row", list["meetings"])
372 }
373 row := rows[0].(map[string]any)
374 if apiString(t, row, "sqid") != sqid || apiString(t, row, "created_by") != "claude~paul@example.com" {
375 t.Fatalf("list row %v", row)
376 }
377 if row["ended_at"] == nil {
378 t.Fatalf("the listed row is not ended: %v", row)
379 }
380 if _, present := row["link"]; present {
381 t.Fatalf("a list row carries a link: %v", row)
382 }
383 // The meeting object is exactly these six keys — never more, never
384 // a link riding along on the list.
385 wantKeys := []string{"sqid", "title", "starts_at", "ended_at", "link_present", "created_by"}
386 if len(row) != len(wantKeys) {
387 t.Fatalf("list row has %d keys, want %d: %v", len(row), len(wantKeys), row)
388 }
389 for _, k := range wantKeys {
390 if _, ok := row[k]; !ok {
391 t.Fatalf("list row is missing %q: %v", k, row)
392 }
393 }
394 if strings.Contains(list["meetings"].([]any)[0].(map[string]any)["title"].(string), "mt_") {
395 t.Fatal("a token leaked into the title")
396 }
397}
398
399// Spec's Global Constraints: the body is a typed request read under a
400// 16 KB cap with unknown fields refused, and a title is 1..200
401// characters. Nothing here is ever bound onto a model.
402func TestAPIBodyRules(t *testing.T) {
403 x := newHarness(t, "paul@example.com")
404 c := x.browser(t)
405 c.signIn("paul@example.com")
406 token := mintAgent(t, x, c, "claude")
407
408 for _, tc := range []struct {
409 name, body string
410 status int
411 err string
412 }{
413 {"an unknown field", `{"title":"Standup","meeting_id":7}`, http.StatusBadRequest, "bad-request"},
414 {"a title of 201 characters", `{"title":"` + strings.Repeat("a", 201) + `"}`, http.StatusBadRequest, "bad-request"},
415 {"an empty title", `{"title":""}`, http.StatusBadRequest, "bad-request"},
416 {"a whitespace title", `{"title":" "}`, http.StatusBadRequest, "bad-request"},
417 {"no title at all", `{}`, http.StatusBadRequest, "bad-request"},
418 {"a start that is not RFC 3339", `{"title":"Standup","starts_at":"tomorrow"}`, http.StatusBadRequest, "bad-request"},
419 {"a start in the form screen's own format", `{"title":"Standup","starts_at":"2026-09-12T09:00"}`, http.StatusBadRequest, "bad-request"},
420 {"a title that is not a string", `{"title":7}`, http.StatusBadRequest, "bad-request"},
421 {"trailing content after the object", `{"title":"Standup"} {}`, http.StatusBadRequest, "bad-request"},
422 // Review S5-1: dec.More() answers "is another array or object
423 // element waiting", which these three are not — they got
424 // through and created meetings.
425 {"a trailing close bracket", `{"title":"Standup"}]`, http.StatusBadRequest, "bad-request"},
426 {"a trailing close brace", `{"title":"Standup"}}`, http.StatusBadRequest, "bad-request"},
427 {"a trailing scalar", `{"title":"Standup"} 7`, http.StatusBadRequest, "bad-request"},
428 {"no body at all", ``, http.StatusBadRequest, "bad-request"},
429 {"seventeen kilobytes", `{"title":"` + strings.Repeat("a", 17*1024) + `"}`, http.StatusRequestEntityTooLarge, "too-large"},
430 // And the size error survives a valid object: the excess is
431 // whitespace the decoder would otherwise have skipped past
432 // while swallowing the cap's own error (review S5-1).
433 {"a valid object then seventeen kilobytes of space", `{"title":"Standup"}` + strings.Repeat(" ", 17*1024),
434 http.StatusRequestEntityTooLarge, "too-large"},
435 } {
436 t.Run(tc.name, func(t *testing.T) {
437 rec := api(t, x, http.MethodPost, "/api/v1/meetings", token, tc.body)
438 body := apiBody(t, rec, tc.status)
439 if body["error"] != tc.err {
440 t.Fatalf("%v, want error %q", body, tc.err)
441 }
442 // A refused body writes nothing: no meeting exists yet, and
443 // the refusal did not make one (review S5-1 — the shapes
444 // above used to create meetings and answer 201).
445 if n := len(apiMeetings(t, x, token)); n != 0 {
446 t.Fatalf("a refused request left %d meetings behind", n)
447 }
448 })
449 }
450
451 // A title of exactly 200 characters is accepted: the boundary is
452 // inclusive, and the refusals above are not simply "every title".
453 edge := strings.Repeat("b", 200)
454 created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
455 `{"title":"`+edge+`"}`), http.StatusCreated)
456 if apiString(t, apiMeeting(t, created), "title") != edge {
457 t.Fatal("a 200-character title came back changed")
458 }
459 if apiMeeting(t, created)["starts_at"] != nil {
460 t.Fatalf("an absent starts_at came back as %v", apiMeeting(t, created)["starts_at"])
461 }
462
463 // The 200-character boundary is runes, not bytes (matching the
464 // create screen's own "characters" copy, handlers_meetings.go's
465 // titleTooLong): 200 multibyte characters are accepted even though
466 // they are 600 bytes, and 201 of them are refused exactly as 201
467 // ASCII characters are.
468 wideOK := strings.Repeat("字", 200)
469 createdWide := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
470 string(mustJSON(t, map[string]string{"title": wideOK}))), http.StatusCreated)
471 if apiString(t, apiMeeting(t, createdWide), "title") != wideOK {
472 t.Fatal("a 200-rune title came back changed")
473 }
474 wideRefused := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
475 string(mustJSON(t, map[string]string{"title": strings.Repeat("字", 201)}))), http.StatusBadRequest)
476 if wideRefused["error"] != "bad-request" {
477 t.Fatalf("a 201-rune title: %v, want bad-request", wideRefused)
478 }
479
480 // A title is data, not markup: it is stored and listed as a plain
481 // JSON string, escaped by the encoder and never by an HTML escaper.
482 const scriptish = `<script>alert("x")</script>`
483 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
484 `{"title":`+string(mustJSON(t, scriptish))+`}`), http.StatusCreated)
485 rec := api(t, x, http.MethodGet, "/api/v1/meetings", token, "")
486 list := apiBody(t, rec, http.StatusOK)
487 var found bool
488 for _, row := range list["meetings"].([]any) {
489 if row.(map[string]any)["title"] == scriptish {
490 found = true
491 }
492 }
493 if !found {
494 t.Fatalf("the title did not survive the round trip:\n%s", rec.Body.String())
495 }
496 if strings.Contains(rec.Body.String(), "<script>") {
497 t.Fatalf("the raw JSON carries an unescaped tag:\n%s", rec.Body.String())
498 }
499}
500
501// Review S5-9: the cap is 16,384 bytes and the boundary is inclusive —
502// http.MaxBytesReader refuses only when reading encounters a byte
503// *beyond* the limit, so a body of exactly 16,384 is within it and
504// 16,385 is not. Nothing asserted this before: the other body tests all
505// use 17 KB, which is comfortably past the edge and says nothing about
506// where the edge is. Both surfaces that read a body are pinned here,
507// and the two answer differently on the inside of the boundary: a
508// create of exactly the cap is a 201 (its padding is JSON whitespace),
509// a bodyless write of exactly the cap is a 400 (it takes no body at
510// all) — and one byte more is 413 on both.
511func TestAPIBodyCapBoundary(t *testing.T) {
512 x := newHarness(t, "paul@example.com")
513 c := x.browser(t)
514 c.signIn("paul@example.com")
515 token := mintAgent(t, x, c, "claude")
516
517 const limit = 16 * 1024
518 create := `{"title":"Standup"}`
519 atCap := create + strings.Repeat(" ", limit-len(create))
520 overCap := atCap + " "
521 if len(atCap) != limit || len(overCap) != limit+1 {
522 t.Fatalf("the bodies are %d and %d bytes, want %d and %d", len(atCap), len(overCap), limit, limit+1)
523 }
524
525 created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, atCap), http.StatusCreated)
526 if got := apiString(t, apiMeeting(t, created), "title"); got != "Standup" {
527 t.Fatalf("title %q, want Standup", got)
528 }
529 sqid := apiString(t, apiMeeting(t, created), "sqid")
530 if body := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, overCap), http.StatusRequestEntityTooLarge); body["error"] != "too-large" {
531 t.Fatalf("one byte over the cap: %v, want too-large", body)
532 }
533
534 // The bodyless writes measure the same cap, and refuse a body of
535 // any size — so at the cap they answer 400 for its content and over
536 // it 413 for its size.
537 for _, op := range []string{"links", "end"} {
538 if body := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/"+op, token, atCap), http.StatusBadRequest); body["error"] != "bad-request" {
539 t.Fatalf("%s at exactly the cap: %v, want bad-request", op, body)
540 }
541 if body := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/"+op, token, overCap), http.StatusRequestEntityTooLarge); body["error"] != "too-large" {
542 t.Fatalf("%s one byte over the cap: %v, want too-large", op, body)
543 }
544 }
545
546 // And the refusals wrote nothing: the meeting made by the create at
547 // the cap is still running, with its first link.
548 m := apiMeeting(t, apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK))
549 if m["ended_at"] != nil || m["link_present"] != true {
550 t.Fatalf("a refused body changed the meeting: %v", m)
551 }
552}
553
554// Review S5-7: requiring EOF after the object is not requiring the
555// object to be the last byte. Whitespace is not content — a trailing
556// newline is what most tools put on the end of a file, and curl
557// --data-binary @file sends it — so a create that ends in one is a 201,
558// and so is one padded with spaces and tabs. This is deliberately
559// *not* the bodyless rule: `links` and `end` take no body at all, and a
560// single space there is a 400 (TestAPIBodylessWritesRefuseABody). The
561// asymmetry is that a create's body is parsed as JSON, where trailing
562// whitespace is part of the grammar, and the other two are not parsed
563// at all.
564func TestAPICreateAcceptsTrailingWhitespace(t *testing.T) {
565 x := newHarness(t, "paul@example.com")
566 c := x.browser(t)
567 c.signIn("paul@example.com")
568 token := mintAgent(t, x, c, "claude")
569
570 for _, tc := range []struct{ name, body string }{
571 {"a trailing newline", `{"title":"Standup"}` + "\n"},
572 {"trailing whitespace", `{"title":"Standup"}` + " \t\r\n "},
573 {"whitespace on both sides", "\n " + `{"title":"Standup"}` + "\n"},
574 } {
575 t.Run(tc.name, func(t *testing.T) {
576 created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, tc.body), http.StatusCreated)
577 if got := apiString(t, apiMeeting(t, created), "title"); got != "Standup" {
578 t.Fatalf("title %q, want Standup", got)
579 }
580 })
581 }
582}
583
584// Review S5-1: `/links` and `/end` take no body, and until this fix
585// they read none either — so the 16 KB cap did not apply to them and a
586// megabyte posted at `/end` performed the write. Now every request on
587// the surface is capped by the group's own middleware, and the two
588// bodyless writes refuse anything they are sent: over the cap is 413,
589// any other content is 400, and both answer before the write.
590func TestAPIBodylessWritesRefuseABody(t *testing.T) {
591 x := newHarness(t, "paul@example.com")
592 c := x.browser(t)
593 c.signIn("paul@example.com")
594 token := mintAgent(t, x, c, "claude")
595
596 created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
597 `{"title":"Standup"}`), http.StatusCreated)
598 sqid := apiString(t, apiMeeting(t, created), "sqid")
599 link := apiString(t, created, "link")
600
601 oversize := strings.Repeat("a", 17*1024)
602 for _, tc := range []struct {
603 name, op, body string
604 status int
605 err string
606 }{
607 {"a JSON object at links", "links", `{"title":"x"}`, http.StatusBadRequest, "bad-request"},
608 {"a JSON object at end", "end", `{}`, http.StatusBadRequest, "bad-request"},
609 {"a single space at links", "links", ` `, http.StatusBadRequest, "bad-request"},
610 {"a single space at end", "end", ` `, http.StatusBadRequest, "bad-request"},
611 {"seventeen kilobytes at links", "links", oversize, http.StatusRequestEntityTooLarge, "too-large"},
612 {"seventeen kilobytes at end", "end", oversize, http.StatusRequestEntityTooLarge, "too-large"},
613 } {
614 t.Run(tc.name, func(t *testing.T) {
615 body := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/"+tc.op, token, tc.body), tc.status)
616 if body["error"] != tc.err {
617 t.Fatalf("%v, want error %q", body, tc.err)
618 }
619 // Nothing was written: the meeting still runs and still has
620 // exactly the link it was created with.
621 m := apiMeeting(t, apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK))
622 if m["ended_at"] != nil {
623 t.Fatalf("a refused %s ended the meeting: %v", tc.op, m)
624 }
625 if m["link_present"] != true {
626 t.Fatalf("a refused %s left no live link: %v", tc.op, m)
627 }
628 })
629 }
630
631 // The link the meeting was created with is still the live one: a
632 // refused replace minted nothing that could have taken its place.
633 if rec := x.browser(t).get("/j/" + strings.TrimPrefix(link, testOrigin+"/j/")); rec.Code == http.StatusNotFound {
634 t.Fatal("the original link stopped working after the refusals")
635 }
636
637 // The positive control: with no body at all, both writes work.
638 replaced := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/links", token, ""), http.StatusOK)
639 if apiString(t, replaced, "link") == link {
640 t.Fatal("replace handed back the same link")
641 }
642 ended := apiMeeting(t, apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK))
643 if ended["ended_at"] == nil {
644 t.Fatalf("a bodyless end did not end the meeting: %v", ended)
645 }
646}
647
648// Review S5-2: both write handlers used to decide "has this meeting
649// ended?" from a read taken before the store's transaction, so two
650// requests could each read a running meeting and each go on to write.
651// The decision now lives inside the write — a conditional UPDATE for
652// end, a re-read of ended_at inside the transaction for replace.
653//
654// This is a smoke test and not a proof (review S5-6). Closing one
655// channel makes both goroutines runnable; it does not make either read
656// the running meeting before the other writes, and the schedule where
657// the first request finishes before the second reads at all satisfies
658// every assertion below under the old code too. What it does check is
659// that two requests arriving together answer sanely and leave the row
660// consistent — worth having, and worth being honest about. The
661// interleaving that actually exposes the defect is forced, one read at
662// a time, in TestAPIAStaleReadCannotWriteOverAnEnd.
663func TestAPIConcurrentEndsAndReplacesSeeTheEndedRow(t *testing.T) {
664 x := newHarness(t, "paul@example.com")
665 c := x.browser(t)
666 c.signIn("paul@example.com")
667 token := mintAgent(t, x, c, "claude")
668
669 // Two ends at once: one 200, one 409, never two of either.
670 first := apiString(t, apiMeeting(t, apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
671 `{"title":"Standup"}`), http.StatusCreated)), "sqid")
672 ends := race(t, x, token,
673 http.MethodPost, "/api/v1/meetings/"+first+"/end",
674 http.MethodPost, "/api/v1/meetings/"+first+"/end")
675 codes := []int{ends[0].Code, ends[1].Code}
676 if !(codes[0] == http.StatusOK && codes[1] == http.StatusConflict) &&
677 !(codes[1] == http.StatusOK && codes[0] == http.StatusConflict) {
678 t.Fatalf("two racing ends answered %d and %d, want one 200 and one 409\n%s\n%s",
679 codes[0], codes[1], ends[0].Body.String(), ends[1].Body.String())
680 }
681 for _, rec := range ends {
682 if rec.Code == http.StatusConflict {
683 if body := apiBody(t, rec, http.StatusConflict); body["error"] != "ended" {
684 t.Fatalf("the losing end: %v, want error ended", body)
685 }
686 }
687 }
688
689 // An end and a replacement at once: the replacement either wins
690 // outright or is refused as ended, and either way the meeting ends
691 // with no live link.
692 second := apiString(t, apiMeeting(t, apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
693 `{"title":"Retro"}`), http.StatusCreated)), "sqid")
694 both := race(t, x, token,
695 http.MethodPost, "/api/v1/meetings/"+second+"/end",
696 http.MethodPost, "/api/v1/meetings/"+second+"/links")
697 if both[0].Code != http.StatusOK {
698 t.Fatalf("the end lost to the replacement: %d\n%s", both[0].Code, both[0].Body.String())
699 }
700 if both[1].Code != http.StatusOK && both[1].Code != http.StatusConflict {
701 t.Fatalf("the racing replacement: %d, want 200 or 409\n%s", both[1].Code, both[1].Body.String())
702 }
703 m := apiMeeting(t, apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+second, token, ""), http.StatusOK))
704 if m["ended_at"] == nil {
705 t.Fatalf("the meeting is not ended after the race: %v", m)
706 }
707 if m["link_present"] != false {
708 t.Fatalf("an ended meeting still has a live link: %v — the replacement landed after the end", m)
709 }
710}
711
712// Review S5-6: the interleaving that broke both writes, forced rather
713// than hoped for. One request is held at the exact point the old code
714// went wrong — just after its handler has read the meeting and found it
715// running — while a second request ends the meeting and commits. Only
716// then is the held request let go, so it reaches its write with a
717// snapshot that is now stale, which is the state the old handlers
718// trusted.
719//
720// Under the shipped code both refusals come from inside the write:
721// end's conditional UPDATE changes no row, replace's re-read of
722// ended_at inside its transaction answers errMeetingEnded, and both
723// are 409. Under the old code the held end wrote a second time and
724// answered 200, and the held replacement minted a link on a meeting
725// that had already ended — both of which this test fails on, every
726// run. Proved by mutation: see the round-2 report.
727func TestAPIAStaleReadCannotWriteOverAnEnd(t *testing.T) {
728 x := newHarness(t, "paul@example.com")
729 c := x.browser(t)
730 c.signIn("paul@example.com")
731 token := mintAgent(t, x, c, "claude")
732 installMeetingReadStall(t, x)
733
734 for _, op := range []string{"end", "links"} {
735 t.Run("a stalled "+op, func(t *testing.T) {
736 sqid := apiString(t, apiMeeting(t, apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
737 `{"title":"Standup"}`), http.StatusCreated)), "sqid")
738
739 // The held request reads the running meeting, then waits.
740 // goStalledAPI registers the cleanup that lets it go before
741 // it ever starts, so a failure in any assertion below leaves
742 // nothing blocked (review S5-8).
743 held := goStalledAPI(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/"+op, token)
744 held.waitReached(t)
745
746 // With that read held, the meeting ends and commits.
747 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK)
748
749 // And now the held request runs on into its write.
750 body := apiBody(t, held.response(t), http.StatusConflict)
751 if body["error"] != "ended" {
752 t.Fatalf("the stalled %s: %v, want error ended", op, body)
753 }
754
755 m := apiMeeting(t, apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK))
756 if m["ended_at"] == nil {
757 t.Fatalf("the meeting is not ended: %v", m)
758 }
759 if m["link_present"] != false {
760 t.Fatalf("an ended meeting has a live link: %v — the stalled %s wrote over the end", m, op)
761 }
762 })
763 }
764}
765
766// stallKey marks a request whose first `meetings` read should block.
767// The value is the request's own stall, so the callback stalls only the
768// request the test built, and only once.
769type stallKey struct{}
770
771type stall struct {
772 reached chan struct{}
773 releasing chan struct{}
774 holding sync.Once
775 releasing1 sync.Once
776}
777
778func newStall() *stall {
779 return &stall{reached: make(chan struct{}), releasing: make(chan struct{})}
780}
781
782// release lets the held request go, and may be called any number of
783// times (review S5-8): the test calls it on its way to the response,
784// and the cleanup calls it again in case the test never got that far.
785func (s *stall) release() { s.releasing1.Do(func() { close(s.releasing) }) }
786
787// stallWait bounds every wait in this test. Long enough that a loaded
788// machine does not trip it, short enough that a regression is a named
789// failure here rather than the package timeout killing the whole run
790// with a goroutine dump (review S5-8).
791const stallWait = 5 * time.Second
792
793// installMeetingReadStall registers a test-only GORM callback on the
794// harness's own *gorm.DB — the seam the round-1 report wrongly said did
795// not exist (review S5-3's note, review S5-6's correction) — and takes
796// it off again when the test ends.
797//
798// It runs after gorm:query, so the rows are already scanned and closed
799// and the held request is holding no connection: the request that ends
800// the meeting behind it can use the same pool. It fires only for a
801// request carrying a stall in its context and only for the `meetings`
802// table, which on these paths is the handler's own meetingBySqid — the
803// read the old code went on to trust. sync.Once keeps it to that first
804// read, so the meeting read inside replaceLink's transaction, later in
805// the same request, runs unimpeded.
806func installMeetingReadStall(t *testing.T, x *harness) {
807 t.Helper()
808 const name = "callstest:stall_meeting_read"
809 err := x.db.G.Callback().Query().After("gorm:query").Register(name, func(tx *gorm.DB) {
810 if tx.Statement.Context == nil || tx.Statement.Table != "meetings" {
811 return
812 }
813 s, ok := tx.Statement.Context.Value(stallKey{}).(*stall)
814 if !ok {
815 return
816 }
817 s.holding.Do(func() {
818 close(s.reached)
819 <-s.releasing
820 })
821 })
822 if err != nil {
823 t.Fatal(err)
824 }
825 t.Cleanup(func() { _ = x.db.G.Callback().Query().Remove(name) })
826}
827
828// heldRequest is one bodyless API request running on its own goroutine
829// and stalled at its meeting read. Nothing is asserted on that
830// goroutine — t.Fatalf from one is not allowed — so the recorder comes
831// back to the test's own goroutine through done.
832type heldRequest struct {
833 stall *stall
834 done chan *httptest.ResponseRecorder
835
836 joined sync.Once
837 rec *httptest.ResponseRecorder
838 stuck bool
839 reported bool
840}
841
842// goStalledAPI starts the request, having first registered the cleanup
843// that releases and joins it (review S5-8). Order matters: a t.Fatal in
844// any assertion between here and the response would otherwise skip the
845// release and leave the request parked on the stall channel for the
846// rest of the package run, since removing the callback does not unblock
847// one already inside it.
848func goStalledAPI(t *testing.T, x *harness, method, target, token string) *heldRequest {
849 t.Helper()
850 h := &heldRequest{stall: newStall(), done: make(chan *httptest.ResponseRecorder, 1)}
851 t.Cleanup(func() {
852 h.join()
853 if h.stuck && !h.reported {
854 h.reported = true
855 t.Errorf("the held %s %s never finished", method, target)
856 }
857 })
858 req := httptest.NewRequest(method, target, nil)
859 req.Header.Set("Authorization", "Bearer "+token)
860 req = req.WithContext(context.WithValue(req.Context(), stallKey{}, h.stall))
861 go func() {
862 rec := httptest.NewRecorder()
863 x.h.ServeHTTP(rec, req)
864 h.done <- rec
865 }()
866 return h
867}
868
869// waitReached blocks until the request has made its meeting read and is
870// being held. A request that never makes one would hang the test, so
871// this gives up and says so instead.
872func (h *heldRequest) waitReached(t *testing.T) {
873 t.Helper()
874 select {
875 case <-h.stall.reached:
876 case <-time.After(stallWait):
877 t.Fatal("the request never reached its meeting read")
878 }
879}
880
881// join releases the held request and waits for it, at most once.
882func (h *heldRequest) join() {
883 h.joined.Do(func() {
884 h.stall.release()
885 select {
886 case h.rec = <-h.done:
887 case <-time.After(stallWait):
888 h.stuck = true
889 }
890 })
891}
892
893// response is join for a caller that wants the answer: the same wait,
894// bounded the same way, failing here rather than in the cleanup.
895func (h *heldRequest) response(t *testing.T) *httptest.ResponseRecorder {
896 t.Helper()
897 h.join()
898 if h.stuck {
899 h.reported = true
900 t.Fatal("the held request never finished")
901 }
902 return h.rec
903}
904
905// race fires two API requests from two goroutines released together,
906// and hands back both recorders. Nothing is asserted in the goroutines
907// — t.Fatalf from one is not allowed — so every check happens on the
908// test's own goroutine afterwards.
909func race(t *testing.T, x *harness, token, m1, t1, m2, t2 string) [2]*httptest.ResponseRecorder {
910 t.Helper()
911 var out [2]*httptest.ResponseRecorder
912 start := make(chan struct{})
913 var wg sync.WaitGroup
914 for i, call := range [2][2]string{{m1, t1}, {m2, t2}} {
915 wg.Add(1)
916 go func() {
917 defer wg.Done()
918 req := httptest.NewRequest(call[0], call[1], nil)
919 req.Header.Set("Authorization", "Bearer "+token)
920 rec := httptest.NewRecorder()
921 <-start
922 x.h.ServeHTTP(rec, req)
923 out[i] = rec
924 }()
925 }
926 close(start)
927 wg.Wait()
928 return out
929}
930
931func mustJSON(t *testing.T, v any) []byte {
932 t.Helper()
933 b, err := json.Marshal(v)
934 if err != nil {
935 t.Fatal(err)
936 }
937 return b
938}
939
940// Spec §0.1 and §8.1: the API's own log lines name the agent and the
941// outcome, and nothing else. No token, no hash, no link, no title.
942func TestAPILogsCarryNoToken(t *testing.T) {
943 var buf bytes.Buffer
944 x := newHarnessWithLogger(t, calls.EnvDevelopment,
945 slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})),
946 "paul@example.com")
947 c := x.browser(t)
948 c.signIn("paul@example.com")
949 token := mintAgent(t, x, c, "claude")
950
951 // Success is silent, measured around each successful operation
952 // rather than before the first one (review S5-3: the mark used to
953 // be taken *after* the create, so a line logged by every successful
954 // create would have gone unnoticed). silent runs the operation and
955 // asserts nothing at all was logged by it.
956 silent := func(what string, op func()) {
957 t.Helper()
958 mark := buf.Len()
959 op()
960 if n := strings.Count(buf.String()[mark:], "calls: api"); n != 0 {
961 t.Fatalf("%s logged %d api lines, want none:\n%s", what, n, buf.String()[mark:])
962 }
963 }
964
965 var created map[string]any
966 silent("a successful create", func() {
967 created = apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
968 `{"title":"Quarterly review"}`), http.StatusCreated)
969 })
970 link := apiString(t, created, "link")
971 linkToken := strings.TrimPrefix(link, testOrigin+"/j/")
972 sqid := apiString(t, apiMeeting(t, created), "sqid")
973
974 silent("a successful list", func() {
975 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK)
976 })
977 silent("a successful read", func() {
978 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK)
979 })
980 var replaced map[string]any
981 silent("a successful link replacement", func() {
982 replaced = apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/links", token, ""), http.StatusOK)
983 })
984 newLinkToken := strings.TrimPrefix(apiString(t, replaced, "link"), testOrigin+"/j/")
985 silent("a successful end", func() {
986 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK)
987 })
988
989 before := buf.Len()
990 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+unknownSqid, token, ""), http.StatusNotFound)
991 apiAuth(t, x, http.MethodGet, "/api/v1/meetings", "", "")
992 api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "")
993
994 log := buf.String()
995 // The operator's own address is a secret this log has no business
996 // carrying either (review S5-3): the agent is named by id, and the
997 // principal that spells out an operator's address is a rendering
998 // for a member's screen, not a log field.
999 for _, secret := range []string{token, meetHash(token), linkToken, newLinkToken, "Quarterly review", "paul@example.com"} {
1000 if secret != "" && strings.Contains(log, secret) {
1001 t.Fatalf("the log carries a secret (%q):\n%s", secret, log)
1002 }
1003 }
1004 if !strings.Contains(log[before:], "outcome") {
1005 t.Fatalf("no refusal was logged at all:\n%s", log[before:])
1006 }
1007 if !strings.Contains(log[before:], "agent_id") {
1008 t.Fatalf("a resolved agent's refusal carries no agent_id:\n%s", log[before:])
1009 }
1010 // And the refusals log one line each, no more.
1011 if n := strings.Count(log[before:], "calls: api"); n != 3 {
1012 t.Fatalf("want exactly three api log lines (one per refusal), got %d:\n%s", n, log[before:])
1013 }
1014}
1015
1016// meetHash is the token hash exactly as the store stores it — hex of
1017// the SHA-256 of the token, mt_ prefix included — recomputed here
1018// because callstest is an external test package and cannot call
1019// hashAgentToken. If the two ever disagree this assertion goes quiet
1020// rather than wrong, which is why the plaintext token is checked
1021// alongside it.
1022func meetHash(token string) string {
1023 sum := sha256.Sum256([]byte(token))
1024 return hex.EncodeToString(sum[:])
1025}
1026
1027// Spec's Global Constraints: sixty requests a minute per agent, burst
1028// sixty, and the budget is the agent's own — a second agent minted by
1029// the same operator is untouched by the first's exhaustion.
1030func TestAPIRateLimitsPerAgent(t *testing.T) {
1031 x := newHarness(t, "paul@example.com")
1032 c := x.browser(t)
1033 c.signIn("paul@example.com")
1034 first := mintAgent(t, x, c, "claude")
1035 second := mintAgent(t, x, c, "scheduler")
1036
1037 for i := range 60 {
1038 rec := api(t, x, http.MethodGet, "/api/v1/meetings", first, "")
1039 if rec.Code != http.StatusOK {
1040 t.Fatalf("request %d: %d\n%s", i+1, rec.Code, rec.Body.String())
1041 }
1042 }
1043 rec := api(t, x, http.MethodGet, "/api/v1/meetings", first, "")
1044 body := apiBody(t, rec, http.StatusTooManyRequests)
1045 if body["error"] != "rate" {
1046 t.Fatalf("%v, want error rate", body)
1047 }
1048 ra := rec.Header().Get("Retry-After")
1049 if n, err := strconv.Atoi(ra); err != nil || n < 1 {
1050 t.Fatalf("Retry-After %q, want a positive number of seconds", ra)
1051 }
1052
1053 apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", second, ""), http.StatusOK)
1054}
1055
1056// Spec §8.1: every write an agent makes is marked as the agent's on the
1057// list the member reads, with the robot badge and the principal. The
1058// assertion belongs to this file; the badge itself is Task 4's.
1059func TestAPIWritesAreMarkedAsTheAgents(t *testing.T) {
1060 x := newHarness(t, "paul@example.com")
1061 c := x.browser(t)
1062 c.signIn("paul@example.com")
1063 token := mintAgent(t, x, c, "claude")
1064
1065 // Neutral titles on both rows (review S5-4): the old agent row was
1066 // called "Agent meeting", so searching the row for "Agent" passed
1067 // on the title alone and an empty badge would have gone unnoticed.
1068 created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token,
1069 `{"title":"Quarterly review"}`), http.StatusCreated)
1070 sqid := apiString(t, apiMeeting(t, created), "sqid")
1071 createMeeting(t, c, "Standup", "")
1072
1073 body := c.get("/").Body.String()
1074 agentRow := rowFor(t, body, "Quarterly review")
1075 memberRow := rowFor(t, body, "Standup")
1076 if !strings.Contains(agentRow, "claude~paul@example.com") {
1077 t.Fatalf("the agent's meeting shows no principal:\n%s", agentRow)
1078 }
1079 if !strings.Contains(agentRow, "lucide-bot") {
1080 t.Fatalf("the agent's meeting shows no robot badge:\n%s", agentRow)
1081 }
1082 // The icon is decorative (aria-hidden); the badge's own word is
1083 // what a screen reader actually gets — so the assertion is on the
1084 // badge element's own text, not on the row containing the word
1085 // somewhere.
1086 if got := badgeText(t, agentRow); got != "Agent" {
1087 t.Fatalf("the badge reads %q, want \"Agent\":\n%s", got, agentRow)
1088 }
1089 if strings.Contains(memberRow, "lucide-bot") || strings.Contains(memberRow, "~") {
1090 t.Fatalf("a member's own meeting is badged as an agent's:\n%s", memberRow)
1091 }
1092 if strings.Contains(memberRow, "rst-badge") || strings.Contains(memberRow, "Agent") {
1093 t.Fatalf("a member's own meeting carries an agent badge:\n%s", memberRow)
1094 }
1095
1096 // A create is not the only write an agent makes (review S5-4): the
1097 // replacement link and the end record the acting agent too, in the
1098 // same actor shape, read straight out of the rows.
1099 agentID := agentRevokeID(t, c.get("/settings").Body.String())
1100 wantActor := "agent:" + agentID
1101
1102 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/links", token, ""), http.StatusOK)
1103 var linkActor string
1104 if err := x.sqlDB().QueryRow(
1105 `SELECT created_by FROM meeting_links WHERE meeting_id = (SELECT id FROM meetings WHERE sqid = ?) AND revoked_at IS NULL`,
1106 sqid).Scan(&linkActor); err != nil {
1107 t.Fatal(err)
1108 }
1109 if linkActor != wantActor {
1110 t.Fatalf("the replacement link's created_by is %q, want %q", linkActor, wantActor)
1111 }
1112
1113 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK)
1114 var endActor string
1115 if err := x.sqlDB().QueryRow(`SELECT ended_by FROM meetings WHERE sqid = ?`, sqid).Scan(&endActor); err != nil {
1116 t.Fatal(err)
1117 }
1118 if endActor != wantActor {
1119 t.Fatalf("the meeting's ended_by is %q, want %q", endActor, wantActor)
1120 }
1121
1122 // And the member's own meeting records a person, not an agent — so
1123 // the two assertions above are about the actor and not about every
1124 // write recording the same string.
1125 var memberActor string
1126 if err := x.sqlDB().QueryRow(`SELECT created_by FROM meetings WHERE sqid <> ?`, sqid).Scan(&memberActor); err != nil {
1127 t.Fatal(err)
1128 }
1129 if !strings.HasPrefix(memberActor, "person:") {
1130 t.Fatalf("the member's own meeting records %q, want a person actor", memberActor)
1131 }
1132}
1133
1134// badgeText is the text inside the first ui/badge span of a fragment —
1135// <span rst-badge>Agent</span> — so a badge that renders empty fails
1136// the assertion the way a missing one does (review S5-4).
1137func badgeText(t *testing.T, fragment string) string {
1138 t.Helper()
1139 i := strings.Index(fragment, "<span rst-badge")
1140 if i < 0 {
1141 t.Fatalf("no badge element:\n%s", fragment)
1142 }
1143 rest := fragment[i:]
1144 open := strings.Index(rest, ">")
1145 end := strings.Index(rest, "</span>")
1146 if open < 0 || end < 0 || end < open {
1147 t.Fatalf("the badge element is not closed:\n%s", fragment)
1148 }
1149 return rest[open+1 : end]
1150}
1151
1152// Spec §8.1's principal is per agent, not per operator: two agents
1153// minted by the same member must not share a label on the list. This
1154// pins meetingViewOfCached's label cache key at m.CreatedBy — keying
1155// it on anything coarser (a constant, the operator) would let the
1156// first-resolved agent's principal leak onto the second's row.
1157func TestListShowsEachAgentsOwnPrincipal(t *testing.T) {
1158 x := newHarness(t, "paul@example.com")
1159 c := x.browser(t)
1160 c.signIn("paul@example.com")
1161 tokenA := mintAgent(t, x, c, "agent-a")
1162 tokenB := mintAgent(t, x, c, "agent-b")
1163
1164 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", tokenA,
1165 `{"title":"Meeting A"}`), http.StatusCreated)
1166 apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", tokenB,
1167 `{"title":"Meeting B"}`), http.StatusCreated)
1168
1169 body := c.get("/").Body.String()
1170 rowA := rowFor(t, body, "Meeting A")
1171 rowB := rowFor(t, body, "Meeting B")
1172 if !strings.Contains(rowA, "agent-a~paul@example.com") {
1173 t.Fatalf("agent-a's meeting shows the wrong principal:\n%s", rowA)
1174 }
1175 if strings.Contains(rowA, "agent-b~paul@example.com") {
1176 t.Fatalf("agent-a's meeting also shows agent-b's principal:\n%s", rowA)
1177 }
1178 if !strings.Contains(rowB, "agent-b~paul@example.com") {
1179 t.Fatalf("agent-b's meeting shows the wrong principal:\n%s", rowB)
1180 }
1181 if strings.Contains(rowB, "agent-a~paul@example.com") {
1182 t.Fatalf("agent-b's meeting also shows agent-a's principal:\n%s", rowB)
1183 }
1184}
1185
1186// rowFor is one meeting's list row (rst-row) on the list page, so a
1187// badge can be attributed to the meeting it is actually on rather than
1188// to the page.
1189func rowFor(t *testing.T, body, title string) string {
1190 t.Helper()
1191 i := strings.Index(body, title)
1192 if i < 0 {
1193 t.Fatalf("%q is not on the list:\n%s", title, body)
1194 }
1195 start := strings.LastIndex(body[:i], "<div rst-row>")
1196 end := strings.Index(body[i:], "<div rst-row>")
1197 if end < 0 {
1198 end = strings.Index(body[i:], "</section>")
1199 }
1200 if start < 0 || end < 0 {
1201 t.Fatalf("%q is not inside a list row:\n%s", title, body)
1202 }
1203 return body[start : i+end]
1204}
1205