package slidestest import ( "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" ) // bearerRequest builds the request an agent's HTTP client makes: an // Authorization header and nothing else. // // What it deliberately does NOT send is the point of most of this // file: no cookie, no Origin, no Sec-Fetch-Site. A non-browser caller // has none of those to send, so the API has to be reachable without // them โ€” and csrf.Protect refuses a POST carrying none of the three, // which is why /api/v1 is mounted outside it (api.go says so at // length). func bearerRequest(t *testing.T, method, target, token, body string) *http.Request { t.Helper() var r io.Reader if body != "" { r = strings.NewReader(body) } req := httptest.NewRequest(method, target, r) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if body != "" { req.Header.Set("Content-Type", "application/json") } return req } // send runs one built request against the whole app. func send(t *testing.T, a *testApp, req *http.Request) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() a.ServeHTTP(rec, req) return rec } // apiJSON makes one API call, insists on the status, and decodes the // JSON body. Every API answer with a body is JSON, including every // error, so one helper covers the whole surface. func apiJSON(t *testing.T, a *testApp, method, target, token, body string, want int) map[string]any { t.Helper() rec := send(t, a, bearerRequest(t, method, target, token, body)) if rec.Code != want { t.Fatalf("%s %s: status %d, want %d\n%s", method, target, rec.Code, want, rec.Body) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Fatalf("%s %s: Content-Type = %q, want application/json", method, target, ct) } var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("%s %s: body is not a JSON object: %v\n%s", method, target, err, rec.Body) } return out } // apiDeck creates a deck over the API and returns its sqid. func apiDeck(t *testing.T, a *testApp, token, title string) string { t.Helper() out := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token, `{"title":`+quote(title)+`}`, http.StatusCreated) sqid, _ := out["sqid"].(string) if len(sqid) != 10 { t.Fatalf("POST /api/v1/decks: sqid = %v, want ten characters", out["sqid"]) } return sqid } // quote is a JSON string literal, for building request bodies. func quote(s string) string { b, _ := json.Marshal(s) return string(b) } // TestApiNeverReadsCookie: the API is a bearer surface and only a // bearer surface. A request carrying a perfectly good session cookie // and no token is 401 โ€” otherwise every page in a signed-in person's // browser would be able to drive their decks cross-site. func TestApiNeverReadsCookie(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) req := bearerRequest(t, http.MethodGet, "/api/v1/decks", "", "") for _, c := range cookies { req.AddCookie(c) } rec := send(t, a, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("GET /api/v1/decks with a session cookie: status %d, want 401\n%s", rec.Code, rec.Body) } var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("the 401 body is not JSON: %v\n%s", err, rec.Body) } if out["error"] != "unauthorized" { t.Errorf("error = %v, want %q", out["error"], "unauthorized") } // The mirror of the same rule: an HTML route never reads a bearer. token := mintAgent(t, a, cookies, "claude") page := send(t, a, bearerRequest(t, http.MethodGet, "/", token, "")) if page.Code != http.StatusSeeOther { t.Errorf("GET / with a bearer and no cookie: status %d, want a redirect to sign-in", page.Code) } // A garbage bearer is the same 401 as none at all. bad := apiJSON(t, a, http.MethodGet, "/api/v1/decks", "sl_not-a-real-token", "", http.StatusUnauthorized) if bad["error"] != "unauthorized" { t.Errorf("a bad token: error = %v, want %q", bad["error"], "unauthorized") } } // TestApiCreateDeckRecordsAgentActor: a deck the API makes is the // operator's, and every write it carries names the AGENT โ€” never the // person whose token it is holding. func TestApiCreateDeckRecordsAgentActor(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Q3 review") var got struct { Actor string OwnerID int64 } err := a.db.G.Raw(`SELECT u.actor AS actor, d.owner_id AS owner_id FROM deck_updates u JOIN decks d ON d.id = u.deck_id WHERE d.sqid = ? ORDER BY u.seq DESC LIMIT 1`, sqid).Scan(&got).Error if err != nil { t.Fatalf("read deck_updates: %v", err) } var agentID int64 if err := a.db.G.Raw("SELECT id FROM agents").Scan(&agentID).Error; err != nil { t.Fatalf("read agents: %v", err) } if want := "agent:" + strconv.FormatInt(agentID, 10); got.Actor != want { t.Errorf("deck_updates.actor = %q, want %q", got.Actor, want) } var operator int64 if err := a.db.G.Raw("SELECT id FROM users WHERE email = ?", allowedAddress).Scan(&operator).Error; err != nil { t.Fatalf("read users: %v", err) } if got.OwnerID != operator { t.Errorf("decks.owner_id = %d, want the operator %d", got.OwnerID, operator) } // The deck the agent made is the operator's to open, and the row // says who last touched it: the robot mark plus the principal, // never a bare id. page := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() if !strings.Contains(page, "Q3 review") { t.Fatalf("the deck the agent made is not in the operator's list:\n%s", page) } if want := "๐Ÿค– claude~" + allowedAddress; !strings.Contains(page, want) { t.Errorf("the deck list does not badge the agent's write with %q:\n%s", want, page) } // And the sample deck the operator's own sign-in seeded is badged // the other way โ€” a person's address, with no robot mark. Matched // with the separator in front of it so this cannot pass on the // account menu, which shows the same address. if want := "ยท by " + allowedAddress; !strings.Contains(page, want) { t.Errorf("the deck list does not name the person behind a person's write with %q:\n%s", want, page) } } // TestApiOpsMintIdsAndReturnThem: an agent may leave the ids out, and // the answer tells it what they were โ€” otherwise it could never // address the slide it just made. func TestApiOpsMintIdsAndReturnThem(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Q3 review") before := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK) out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[ {"op":"add_slide","layout":"flow","template":"section"}, {"op":"set_meta","title":"Q3 review"} ]}`, http.StatusOK) ids, ok := out["ids"].(map[string]any) if !ok { t.Fatalf("ops answered no ids map: %v", out) } slide, _ := ids["0"].(string) if len(slide) != 12 { t.Fatalf(`ids["0"] = %v, want a twelve-character slide id`, ids["0"]) } if _, minted := ids["1"]; minted { t.Errorf("set_meta needs no id, but one was minted for it: %v", ids) } if out["seq"].(float64) <= before["seq"].(float64) { t.Errorf("seq did not move: %v then %v", before["seq"], out["seq"]) } // The id it was handed is the id the document holds, so the next // op an agent sends can address the slide it just made. after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK) if !strings.Contains(mustJSON(t, after["slides"]), slide) { t.Errorf("the minted slide id %q is not in the deck:\n%s", slide, mustJSON(t, after["slides"])) } // And a block goes on it the same way. blockOut := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[{"op":"add_block","slide":`+quote(slide)+`,"type":"heading","region":"heading","markdown":"Revenue"}]}`, http.StatusOK) blockIDs, _ := blockOut["ids"].(map[string]any) block, _ := blockIDs["0"].(string) if len(block) != 12 { t.Fatalf("add_block minted no id: %v", blockOut) } // The rendered section is how an agent looks at what it made. html := send(t, a, bearerRequest(t, http.MethodGet, "/api/v1/decks/"+sqid+"/slides/"+slide+".html?vp=phone", token, "")) if html.Code != http.StatusOK { t.Fatalf("GET the slide html: status %d\n%s", html.Code, html.Body) } if ct := html.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("slide html Content-Type = %q, want text/html", ct) } body := html.Body.String() if !strings.HasPrefix(strings.TrimSpace(body), "