package callstest import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync" "testing" "time" "amadan.net/oficina/calls/internal/calls" "gorm.io/gorm" ) // mintAgent drives Settings as the signed-in member and hands back the // token that page shows exactly once — the only way an agent token // ever comes into existence, here as in production. func mintAgent(t *testing.T, x *harness, c *client, name string) string { t.Helper() rec := c.post("/settings/agents", url.Values{"name": {name}}) if rec.Code != http.StatusOK { t.Fatalf("POST /settings/agents: %d\n%s", rec.Code, rec.Body.String()) } return extractToken(t, rec.Body.String()) } // apiAuth is a request to the drive surface with a literal // Authorization header and no cookies, and — deliberately — no Origin, // Referer or Sec-Fetch-Site: an agent is not a browser and sends none // of the three, which is exactly why /api/v1 mounts outside // csrf.Protect (rastrillo's csrf.SameOrigin refuses a POST carrying // none of them). func apiAuth(t *testing.T, x *harness, method, target, authorization, body string) *httptest.ResponseRecorder { t.Helper() var rdr io.Reader if body != "" { rdr = strings.NewReader(body) } req := httptest.NewRequest(method, target, rdr) if authorization != "" { req.Header.Set("Authorization", authorization) } if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() x.h.ServeHTTP(rec, req) return rec } // api is apiAuth with a bearer token. func api(t *testing.T, x *harness, method, target, token, body string) *httptest.ResponseRecorder { t.Helper() return apiAuth(t, x, method, target, "Bearer "+token, body) } // apiBody asserts the status and the two headers every API answer // carries, and decodes the JSON. func apiBody(t *testing.T, rec *httptest.ResponseRecorder, want int) map[string]any { t.Helper() if rec.Code != want { t.Fatalf("status %d, want %d\n%s", rec.Code, want, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("Content-Type %q, want application/json", ct) } if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Fatalf("Cache-Control %q, want no-store", cc) } var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return out } func apiMeeting(t *testing.T, body map[string]any) map[string]any { t.Helper() m, ok := body["meeting"].(map[string]any) if !ok { t.Fatalf("no meeting object in %v", body) } return m } // apiMeetings is the list as the surface answers it, for the tests // that assert a refused request changed nothing: the list afterwards // is the state, read the same way an agent would read it. func apiMeetings(t *testing.T, x *harness, token string) []any { t.Helper() list := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) rows, ok := list["meetings"].([]any) if !ok { t.Fatalf("meetings %#v, want a list", list["meetings"]) } return rows } func apiString(t *testing.T, m map[string]any, key string) string { t.Helper() s, ok := m[key].(string) if !ok { t.Fatalf("%s is %#v, want a string", key, m[key]) } return s } // unknownSqid is the shape newSqid produces and a value it never has: // a valid-looking room id belonging to no meeting. const unknownSqid = "2345678999" // Spec §8.1 and the slice's ruling: a cookie is never honoured on the // API and the bearer is the only credential; a missing or malformed // one is 401, while an unknown token is the not-found body — the same // bytes an unknown meeting answers, so nothing about whether a token // exists is observable. func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") token := mintAgent(t, x, c, "scheduler") // The positive control first: the bearer alone, no cookie, works. apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) for _, tc := range []struct { name, header string }{ {"no Authorization header at all", ""}, {"a Basic credential", "Basic " + base64.StdEncoding.EncodeToString([]byte("paul@example.com:hunter2"))}, {"the scheme without a token", "Bearer "}, {"a token with no scheme", token}, } { t.Run(tc.name, func(t *testing.T) { rec := apiAuth(t, x, http.MethodGet, "/api/v1/meetings", tc.header, "") body := apiBody(t, rec, http.StatusUnauthorized) if body["error"] != "unauthorized" { t.Fatalf("%v, want error unauthorized", body) } }) } // A signed-in member's own cookie, carried exactly as the browser // carries it to every HTML screen, buys nothing here. t.Run("a session cookie alone", func(t *testing.T) { rec := c.get("/api/v1/meetings") body := apiBody(t, rec, http.StatusUnauthorized) if body["error"] != "unauthorized" { t.Fatalf("%v, want error unauthorized", body) } }) // An unknown token and an unknown meeting are one 404, byte for // byte. unknownToken := api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "") if unknownToken.Code != http.StatusNotFound { t.Fatalf("an unknown token: %d, want 404\n%s", unknownToken.Code, unknownToken.Body.String()) } unknownMeeting := api(t, x, http.MethodGet, "/api/v1/meetings/"+unknownSqid, token, "") if unknownMeeting.Code != http.StatusNotFound { t.Fatalf("an unknown meeting: %d, want 404\n%s", unknownMeeting.Code, unknownMeeting.Body.String()) } if unknownToken.Body.String() != unknownMeeting.Body.String() { t.Fatalf("the two 404s differ:\ntoken: %q\nmeeting: %q", unknownToken.Body.String(), unknownMeeting.Body.String()) } if got := apiBody(t, unknownMeeting, http.StatusNotFound)["error"]; got != "not-found" { t.Fatalf("not-found body carries error %v", got) } // A path this surface does not have answers the same not-found body // — and only to a caller that already holds a token, so the routes // cannot be mapped without one. if body := apiBody(t, api(t, x, http.MethodGet, "/api/v1/agents", token, ""), http.StatusNotFound); body["error"] != "not-found" { t.Fatalf("an unknown API path: %v, want the not-found body", body) } if body := apiBody(t, apiAuth(t, x, http.MethodGet, "/api/v1/agents", "", ""), http.StatusUnauthorized); body["error"] != "unauthorized" { t.Fatalf("an unknown API path without a bearer: %v, want unauthorized", body) } if body := apiBody(t, api(t, x, http.MethodDelete, "/api/v1/meetings", token, ""), http.StatusNotFound); body["error"] != "not-found" { t.Fatalf("a method this surface does not have: %v, want the not-found body", body) } } // The other half of the same ruling: an HTML route never honours a // bearer. A token alone, with no session cookie, is simply a signed-out // request. func TestABearerOnAnHTMLRouteIsIgnored(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") token := mintAgent(t, x, c, "scheduler") req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() x.h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" { t.Fatalf("GET / with a bearer: %d → %q, want 303 → /signin\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } // And the API's own not-found handler stays on the API: an unknown // HTML path is still the router's plain 404, not a JSON body. miss := get(t, x.h, "/nope") if miss.Code != http.StatusNotFound { t.Fatalf("GET /nope: %d, want 404", miss.Code) } if ct := miss.Header().Get("Content-Type"); strings.Contains(ct, "json") { t.Fatalf("an unknown HTML path answers %q\n%s", ct, miss.Body.String()) } } // Spec §8.1 and §13's slice-5 gate: reach is the operator's standing at // the moment of the request, not at the moment the token was minted. An // operator dropped from the team takes their agent's reach with them, // with no revocation step, and gets it back the same way. func TestAPIReachIsTheOperatorsStandingOnEveryRequest(t *testing.T) { x, _, _ := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") token := mintAgent(t, x, c, "scheduler") apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) x.ctl.SetAllowlist("bea@rslw.com") gone := api(t, x, http.MethodGet, "/api/v1/meetings", token, "") if body := apiBody(t, gone, http.StatusNotFound); body["error"] != "not-found" { t.Fatalf("an operator off the team: %v, want the not-found body", body) } unknown := api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "") if gone.Body.String() != unknown.Body.String() { t.Fatalf("a reach-less token is distinguishable from an unknown one:\nreach: %q\nunknown: %q", gone.Body.String(), unknown.Body.String()) } x.ctl.SetAllowlist("paul@example.com", "paul@rslw.com") apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) id := agentRevokeID(t, c.get("/settings").Body.String()) if rec := c.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusSeeOther { t.Fatalf("revoke: %d\n%s", rec.Code, rec.Body.String()) } revoked := api(t, x, http.MethodGet, "/api/v1/meetings", token, "") if body := apiBody(t, revoked, http.StatusNotFound); body["error"] != "not-found" { t.Fatalf("a revoked token: %v, want the not-found body", body) } if revoked.Body.String() != unknown.Body.String() { t.Fatalf("a revoked token is distinguishable from an unknown one:\nrevoked: %q\nunknown: %q", revoked.Body.String(), unknown.Body.String()) } } // Spec §8.2, the whole surface in one run: the five operations, the two // responses that carry a link token and no others, the admitted count // while the meeting runs, and the 409 an ended meeting answers. func TestAPIListsCreatesLinksAndEnds(t *testing.T) { x := newHarness(t, "paul@example.com") c := x.browser(t) c.signIn("paul@example.com") token := mintAgent(t, x, c, "claude") created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, `{"title":"Board meeting","starts_at":"2026-09-12T09:00:00Z"}`), http.StatusCreated) m := apiMeeting(t, created) sqid := apiString(t, m, "sqid") if apiString(t, m, "title") != "Board meeting" { t.Fatalf("title %v", m["title"]) } if apiString(t, m, "starts_at") != "2026-09-12T09:00:00Z" { t.Fatalf("starts_at %v, want RFC 3339 in UTC", m["starts_at"]) } if m["ended_at"] != nil { t.Fatalf("a new meeting has ended_at %v", m["ended_at"]) } if m["link_present"] != true { t.Fatalf("link_present %v, want true", m["link_present"]) } if apiString(t, m, "created_by") != "claude~paul@example.com" { t.Fatalf("created_by %v, want the agent's principal", m["created_by"]) } link := apiString(t, created, "link") if !strings.HasPrefix(link, testOrigin+"/j/") { t.Fatalf("link %q, want the configured origin's /j/ URL", link) } if strings.Contains(link, token) { t.Fatal("the link carries the agent token rather than a link token") } // The meeting, before anyone is in the room. GET never carries a // link — that only ever rides on create and replace. got := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) if got["admitted"] != float64(0) { t.Fatalf("admitted %#v, want 0", got["admitted"]) } if _, present := got["link"]; present { t.Fatalf("GET carries a link: %v", got) } // A member joins through the browser and is admitted on standing. s := c.stream("/m/" + sqid + "/stream") defer s.close() peer, _ := s.hello() c.signal(sqid, peer, `{"type":"join"}`) s.nextOf("roster", 2*time.Second) got = apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) if got["admitted"] != float64(1) { t.Fatalf("admitted %#v with one member in the room, want 1", got["admitted"]) } // Replacing the link hands back a new one and kills the old. replaced := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/links", token, ""), http.StatusOK) newLink := apiString(t, replaced, "link") if newLink == link { t.Fatal("replace handed back the same link") } oldToken := strings.TrimPrefix(link, testOrigin+"/j/") if rec := x.browser(t).get("/j/" + oldToken); rec.Code != http.StatusNotFound { t.Fatalf("the replaced link still works: %d", rec.Code) } // Ending: the meeting comes back ended, and without a live count or // a link — end never returns one. ended := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK) if _, present := ended["admitted"]; present { t.Fatalf("an ended meeting still reports admitted: %v", ended) } if _, present := ended["link"]; present { t.Fatalf("the end response carries a link: %v", ended) } em := apiMeeting(t, ended) if em["ended_at"] == nil { t.Fatalf("ended_at is null after end: %v", em) } if em["link_present"] != false { t.Fatalf("link_present %v after end, want false", em["link_present"]) } // GET on the now-ended meeting still has no admitted count and no // link (the guard is m.EndedAt == nil, checked again independently // of the end response above). afterEnd := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) if _, present := afterEnd["admitted"]; present { t.Fatalf("GET on an ended meeting still reports admitted: %v", afterEnd) } if _, present := afterEnd["link"]; present { t.Fatalf("GET on an ended meeting carries a link: %v", afterEnd) } for _, target := range []string{"/api/v1/meetings/" + sqid + "/end", "/api/v1/meetings/" + sqid + "/links"} { body := apiBody(t, api(t, x, http.MethodPost, target, token, ""), http.StatusConflict) if body["error"] != "ended" { t.Fatalf("POST %s on an ended meeting: %v, want error ended", target, body) } } // And the list, which is what a second agent run would read. list := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) rows, ok := list["meetings"].([]any) if !ok || len(rows) != 1 { t.Fatalf("meetings %#v, want one row", list["meetings"]) } row := rows[0].(map[string]any) if apiString(t, row, "sqid") != sqid || apiString(t, row, "created_by") != "claude~paul@example.com" { t.Fatalf("list row %v", row) } if row["ended_at"] == nil { t.Fatalf("the listed row is not ended: %v", row) } if _, present := row["link"]; present { t.Fatalf("a list row carries a link: %v", row) } // The meeting object is exactly these six keys — never more, never // a link riding along on the list. wantKeys := []string{"sqid", "title", "starts_at", "ended_at", "link_present", "created_by"} if len(row) != len(wantKeys) { t.Fatalf("list row has %d keys, want %d: %v", len(row), len(wantKeys), row) } for _, k := range wantKeys { if _, ok := row[k]; !ok { t.Fatalf("list row is missing %q: %v", k, row) } } if strings.Contains(list["meetings"].([]any)[0].(map[string]any)["title"].(string), "mt_") { t.Fatal("a token leaked into the title") } } // Spec's Global Constraints: the body is a typed request read under a // 16 KB cap with unknown fields refused, and a title is 1..200 // characters. Nothing here is ever bound onto a model. func TestAPIBodyRules(t *testing.T) { x := newHarness(t, "paul@example.com") c := x.browser(t) c.signIn("paul@example.com") token := mintAgent(t, x, c, "claude") for _, tc := range []struct { name, body string status int err string }{ {"an unknown field", `{"title":"Standup","meeting_id":7}`, http.StatusBadRequest, "bad-request"}, {"a title of 201 characters", `{"title":"` + strings.Repeat("a", 201) + `"}`, http.StatusBadRequest, "bad-request"}, {"an empty title", `{"title":""}`, http.StatusBadRequest, "bad-request"}, {"a whitespace title", `{"title":" "}`, http.StatusBadRequest, "bad-request"}, {"no title at all", `{}`, http.StatusBadRequest, "bad-request"}, {"a start that is not RFC 3339", `{"title":"Standup","starts_at":"tomorrow"}`, http.StatusBadRequest, "bad-request"}, {"a start in the form screen's own format", `{"title":"Standup","starts_at":"2026-09-12T09:00"}`, http.StatusBadRequest, "bad-request"}, {"a title that is not a string", `{"title":7}`, http.StatusBadRequest, "bad-request"}, {"trailing content after the object", `{"title":"Standup"} {}`, http.StatusBadRequest, "bad-request"}, // Review S5-1: dec.More() answers "is another array or object // element waiting", which these three are not — they got // through and created meetings. {"a trailing close bracket", `{"title":"Standup"}]`, http.StatusBadRequest, "bad-request"}, {"a trailing close brace", `{"title":"Standup"}}`, http.StatusBadRequest, "bad-request"}, {"a trailing scalar", `{"title":"Standup"} 7`, http.StatusBadRequest, "bad-request"}, {"no body at all", ``, http.StatusBadRequest, "bad-request"}, {"seventeen kilobytes", `{"title":"` + strings.Repeat("a", 17*1024) + `"}`, http.StatusRequestEntityTooLarge, "too-large"}, // And the size error survives a valid object: the excess is // whitespace the decoder would otherwise have skipped past // while swallowing the cap's own error (review S5-1). {"a valid object then seventeen kilobytes of space", `{"title":"Standup"}` + strings.Repeat(" ", 17*1024), http.StatusRequestEntityTooLarge, "too-large"}, } { t.Run(tc.name, func(t *testing.T) { rec := api(t, x, http.MethodPost, "/api/v1/meetings", token, tc.body) body := apiBody(t, rec, tc.status) if body["error"] != tc.err { t.Fatalf("%v, want error %q", body, tc.err) } // A refused body writes nothing: no meeting exists yet, and // the refusal did not make one (review S5-1 — the shapes // above used to create meetings and answer 201). if n := len(apiMeetings(t, x, token)); n != 0 { t.Fatalf("a refused request left %d meetings behind", n) } }) } // A title of exactly 200 characters is accepted: the boundary is // inclusive, and the refusals above are not simply "every title". edge := strings.Repeat("b", 200) created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, `{"title":"`+edge+`"}`), http.StatusCreated) if apiString(t, apiMeeting(t, created), "title") != edge { t.Fatal("a 200-character title came back changed") } if apiMeeting(t, created)["starts_at"] != nil { t.Fatalf("an absent starts_at came back as %v", apiMeeting(t, created)["starts_at"]) } // The 200-character boundary is runes, not bytes (matching the // create screen's own "characters" copy, handlers_meetings.go's // titleTooLong): 200 multibyte characters are accepted even though // they are 600 bytes, and 201 of them are refused exactly as 201 // ASCII characters are. wideOK := strings.Repeat("字", 200) createdWide := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, string(mustJSON(t, map[string]string{"title": wideOK}))), http.StatusCreated) if apiString(t, apiMeeting(t, createdWide), "title") != wideOK { t.Fatal("a 200-rune title came back changed") } wideRefused := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, string(mustJSON(t, map[string]string{"title": strings.Repeat("字", 201)}))), http.StatusBadRequest) if wideRefused["error"] != "bad-request" { t.Fatalf("a 201-rune title: %v, want bad-request", wideRefused) } // A title is data, not markup: it is stored and listed as a plain // JSON string, escaped by the encoder and never by an HTML escaper. const scriptish = `` apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, `{"title":`+string(mustJSON(t, scriptish))+`}`), http.StatusCreated) rec := api(t, x, http.MethodGet, "/api/v1/meetings", token, "") list := apiBody(t, rec, http.StatusOK) var found bool for _, row := range list["meetings"].([]any) { if row.(map[string]any)["title"] == scriptish { found = true } } if !found { t.Fatalf("the title did not survive the round trip:\n%s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "