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(), "