| 1 | package slidestest |
| 2 | |
| 3 | // Who may read or write what, asked of the six holes the GPT-6 (codex) |
| 4 | // review of 2026-09-08 found in it. Each test names the finding it |
| 5 | // pins, and each one failed before the fix in the same commit. |
| 6 | // |
| 7 | // They live together rather than beside their subjects because they are |
| 8 | // one subject: a capability in this app is supposed to be a thing you |
| 9 | // were given, not a string you can name. Every finding here was a place |
| 10 | // where naming was enough. |
| 11 | |
| 12 | import ( |
| 13 | "bufio" |
| 14 | "bytes" |
| 15 | "context" |
| 16 | "fmt" |
| 17 | "net/http" |
| 18 | "net/http/httptest" |
| 19 | "net/url" |
| 20 | "regexp" |
| 21 | "strings" |
| 22 | "testing" |
| 23 | "time" |
| 24 | |
| 25 | "amadan.net/rastrillo/rastrillo/db" |
| 26 | |
| 27 | slides "amadan.net/oficina/slides/internal/slides" |
| 28 | ) |
| 29 | |
| 30 | // --- codex 1: an uploaded asset stops being writable once it is verified --- |
| 31 | |
| 32 | // TestVerifiedAssetCannotBeOverwritten is the finding itself: a member |
| 33 | // asked for a PUT URL naming an address that was already registered and |
| 34 | // verified, and the store signed an unconditional PUT for it — so |
| 35 | // replacement bytes changed what every surface served, in this deck and |
| 36 | // in any other holding the same ref, with no document update anywhere. |
| 37 | // |
| 38 | // Two halves, because the two stores take different code: the |
| 39 | // development PUT comes through this app, and a real bucket's does not. |
| 40 | func TestVerifiedAssetCannotBeOverwritten(t *testing.T) { |
| 41 | a := newAppWithStore(t, dirStore(t)) |
| 42 | cookies := signedIn(t, a, allowedAddress) |
| 43 | token := mintAgent(t, a, cookies, "claude") |
| 44 | sqid := apiDeck(t, a, token, "Immutable") |
| 45 | deck := "/d/" + sqid |
| 46 | |
| 47 | record := upload(t, a, deck, cookies, "chart.png", "image/png", onePixelPNG, "A chart") |
| 48 | registerInDeck(t, a, token, sqid, record) |
| 49 | ref := record["ref"].(string) |
| 50 | mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies) |
| 51 | |
| 52 | // Asking for a URL to the verified address does not get one: what |
| 53 | // comes back is a staging address of the app's own, and it is not |
| 54 | // the ref. |
| 55 | signed := postJSON(t, a, deck+"/uploads", |
| 56 | fmt.Sprintf(`{"name":"chart.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), ref), cookies) |
| 57 | if signed.Code != http.StatusOK { |
| 58 | t.Fatalf("sign: status %d\n%s", signed.Code, signed.Body) |
| 59 | } |
| 60 | if got, _ := jsonBody(t, signed)["ref"].(string); got == ref { |
| 61 | t.Fatalf("signing answered the verified address %s", ref) |
| 62 | } |
| 63 | |
| 64 | // And the PUT this app receives in development refuses an address |
| 65 | // no receipt of its own covers, which the verified one never can be. |
| 66 | evil := []byte("\x89PNG\r\n\x1a\nnot the chart you registered") |
| 67 | if rec := putBytes(t, a, deck+"/uploads/"+ref, "image/png", evil, cookies); rec.Code != http.StatusNotFound { |
| 68 | t.Errorf("PUT to a verified address: status %d, want 404\n%s", rec.Code, rec.Body) |
| 69 | } |
| 70 | |
| 71 | // The picture is still the picture. |
| 72 | rec := mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies) |
| 73 | if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) { |
| 74 | t.Errorf("the verified asset changed under it: %d bytes, want the %d that went up", |
| 75 | rec.Body.Len(), len(onePixelPNG)) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // TestStagingAddressIsNeverTheClientsChoice: two signings of the same |
| 80 | // file get two different addresses, and neither is the content hash the |
| 81 | // browser sent. That is what makes "an address a browser can name is an |
| 82 | // address a browser can overwrite" stop being a sentence about this app. |
| 83 | func TestStagingAddressIsNeverTheClientsChoice(t *testing.T) { |
| 84 | a := newAppWithStore(t, dirStore(t)) |
| 85 | cookies := signedIn(t, a, allowedAddress) |
| 86 | deck := createDeck(t, a, cookies, "Staging") |
| 87 | hash := hashOf(onePixelPNG) |
| 88 | |
| 89 | seen := map[string]bool{} |
| 90 | for i := 0; i < 2; i++ { |
| 91 | signed := postJSON(t, a, deck+"/uploads", |
| 92 | fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), hash), cookies) |
| 93 | if signed.Code != http.StatusOK { |
| 94 | t.Fatalf("sign %d: status %d\n%s", i, signed.Code, signed.Body) |
| 95 | } |
| 96 | key, _ := jsonBody(t, signed)["ref"].(string) |
| 97 | if key == hash { |
| 98 | t.Fatalf("sign %d answered the address the browser named", i) |
| 99 | } |
| 100 | if seen[key] { |
| 101 | t.Fatalf("two signings answered the same staging address %s", key) |
| 102 | } |
| 103 | seen[key] = true |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // --- codex 2: knowing a ref is not permission to read it --- |
| 108 | |
| 109 | // TestRegisteringAnotherDecksRefIsRefused is the finding: registration |
| 110 | // checked access to the DESTINATION deck and then fetched whatever ref |
| 111 | // it was handed, writing that deck's verification row. So a member who |
| 112 | // knew another deck's content address — and a ref is a content address, |
| 113 | // which is the whole reason serveAsset checks two things — could |
| 114 | // register it into a deck of their own and pass both checks. |
| 115 | // |
| 116 | // Now registration only ever looks at a staged upload of this |
| 117 | // principal's, for this deck. A ref alone reaches nothing. |
| 118 | func TestRegisteringAnotherDecksRefIsRefused(t *testing.T) { |
| 119 | a := newAppWithStore(t, dirStore(t)) |
| 120 | cookies := signedIn(t, a, allowedAddress) |
| 121 | token := mintAgent(t, a, cookies, "claude") |
| 122 | |
| 123 | theirs := apiDeck(t, a, token, "Theirs") |
| 124 | mine := apiDeck(t, a, token, "Mine") |
| 125 | |
| 126 | record := upload(t, a, "/d/"+theirs, cookies, "secret.png", "image/png", onePixelPNG, "A chart") |
| 127 | registerInDeck(t, a, token, theirs, record) |
| 128 | ref := record["ref"].(string) |
| 129 | mustGet(t, a, "/d/"+theirs+"/assets/"+ref, http.StatusOK, cookies) |
| 130 | |
| 131 | // The other deck's ref, named at the other deck's owner's own |
| 132 | // second deck. Not a 500, and not a verification: nothing was |
| 133 | // staged here. |
| 134 | reg := postJSON(t, a, "/d/"+mine+"/assets", |
| 135 | fmt.Sprintf(`{"ref":%q,"name":"secret.png","width":1,"height":1,"alt":"a"}`, ref), cookies) |
| 136 | if reg.Code != http.StatusNotFound { |
| 137 | t.Fatalf("registering another deck's ref: status %d, want 404\n%s", reg.Code, reg.Body) |
| 138 | } |
| 139 | |
| 140 | // Even with the document made to refer to it, which is an op |
| 141 | // nothing checks, the bytes stay out of reach. |
| 142 | apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+mine+"/ops", token, |
| 143 | fmt.Sprintf(`{"ops":[{"op":"register_asset","id":"borrowed1234","ref":%q,"name":"x.png","width":1,"height":1,"alt":"x"}]}`, ref), |
| 144 | http.StatusOK) |
| 145 | if rec := get(t, a, "/d/"+mine+"/assets/"+ref, cookies...); rec.Code != http.StatusNotFound { |
| 146 | t.Errorf("a borrowed ref was served: status %d, want 404", rec.Code) |
| 147 | } |
| 148 | share := mintShareLink(t, a, "/d/"+mine, cookies) |
| 149 | if rec := get(t, a, "/s/"+share+"/assets/"+ref); rec.Code != http.StatusNotFound { |
| 150 | t.Errorf("a borrowed ref was served over a share link: status %d, want 404", rec.Code) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // TestAStagedUploadBelongsToOneDeck: a receipt is not transferable |
| 155 | // either. An upload staged for one deck cannot be registered into |
| 156 | // another, however firmly the person holds both. |
| 157 | func TestAStagedUploadBelongsToOneDeck(t *testing.T) { |
| 158 | a := newAppWithStore(t, dirStore(t)) |
| 159 | cookies := signedIn(t, a, allowedAddress) |
| 160 | one := createDeck(t, a, cookies, "One") |
| 161 | two := createDeck(t, a, cookies, "Two") |
| 162 | |
| 163 | staged := stageUpload(t, a, one, cookies, "x.png", "image/png", onePixelPNG) |
| 164 | reg := postJSON(t, a, two+"/assets", |
| 165 | fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| 166 | if reg.Code != http.StatusNotFound { |
| 167 | t.Errorf("registering deck one's staged upload into deck two: status %d, want 404\n%s", reg.Code, reg.Body) |
| 168 | } |
| 169 | // And it is single use: registered once in its own deck, the same |
| 170 | // receipt is spent. |
| 171 | first := postJSON(t, a, one+"/assets", |
| 172 | fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| 173 | if first.Code != http.StatusOK { |
| 174 | t.Fatalf("registering in its own deck: status %d, want 200\n%s", first.Code, first.Body) |
| 175 | } |
| 176 | again := postJSON(t, a, one+"/assets", |
| 177 | fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| 178 | if again.Code != http.StatusNotFound { |
| 179 | t.Errorf("registering a spent receipt: status %d, want 404\n%s", again.Code, again.Body) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // --- codex 5: following a presentation stays behind the share link --- |
| 184 | |
| 185 | // TestFollowStaysBehindTheShareLink is the finding: following handed |
| 186 | // the audience the presentation session sqid, and every later read went |
| 187 | // to /p/{session}, which asks only for a live presentation. So revoking |
| 188 | // the share link left the recipient reading fresh deck content until |
| 189 | // the talk ended — a capability the person who granted it could not |
| 190 | // take back. |
| 191 | func TestFollowStaysBehindTheShareLink(t *testing.T) { |
| 192 | a := newApp(t) |
| 193 | cookies := signedIn(t, a, allowedAddress) |
| 194 | path := sampleDeck(t, a, cookies) |
| 195 | link := mintShareLink(t, a, path, cookies) |
| 196 | share := "/s/" + link |
| 197 | |
| 198 | session, remote := startPresenting(t, a, path, cookies) |
| 199 | command(t, a, session, "next", remote, "", nil) |
| 200 | |
| 201 | follow := mustGet(t, a, share+"?follow=1", http.StatusOK, nil).Body.String() |
| 202 | if strings.Contains(follow, session) { |
| 203 | t.Errorf("the follow surface carries the session sqid %s:\n%s", session, follow) |
| 204 | } |
| 205 | if strings.Contains(follow, "/p/") { |
| 206 | t.Errorf("the follow surface carries a /p/ URL:\n%s", follow) |
| 207 | } |
| 208 | // The two URLs its script uses, both the share link's own. |
| 209 | if !strings.Contains(follow, `data-stream="`+share+`/stream"`) { |
| 210 | t.Errorf("the follow surface does not stream from the share link:\n%s", follow) |
| 211 | } |
| 212 | if !strings.Contains(follow, `data-fragment="`+share+`?follow=1&slide={n}&fragment=1"`) { |
| 213 | t.Errorf("the follow surface does not fetch fragments from the share link:\n%s", follow) |
| 214 | } |
| 215 | |
| 216 | // And they work: the fragment is a bare slide, the stream speaks. |
| 217 | fragment := mustGet(t, a, share+"?follow=1&slide=3&fragment=1", http.StatusOK, nil).Body.String() |
| 218 | if !strings.HasPrefix(strings.TrimSpace(fragment), "<section slides-slide") { |
| 219 | t.Errorf("the follow fragment is not a bare slide:\n%s", fragment) |
| 220 | } |
| 221 | if strings.Contains(fragment, "<html") { |
| 222 | t.Errorf("the follow fragment carries the whole page:\n%s", fragment) |
| 223 | } |
| 224 | |
| 225 | srv := httptest.NewServer(a) |
| 226 | defer srv.Close() |
| 227 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 228 | defer cancel() |
| 229 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+share+"/stream", nil) |
| 230 | if err != nil { |
| 231 | t.Fatalf("build the stream request: %v", err) |
| 232 | } |
| 233 | resp, err := http.DefaultClient.Do(req) |
| 234 | if err != nil { |
| 235 | t.Fatalf("open the share link's stream: %v", err) |
| 236 | } |
| 237 | defer resp.Body.Close() |
| 238 | if resp.StatusCode != http.StatusOK { |
| 239 | t.Fatalf("GET %s/stream: status %d, want 200", share, resp.StatusCode) |
| 240 | } |
| 241 | if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { |
| 242 | t.Fatalf("the share link's stream is %q, want text/event-stream", got) |
| 243 | } |
| 244 | if data := nextSlideEvent(t, bufio.NewReader(resp.Body)); !strings.Contains(data, `"slide":2`) { |
| 245 | t.Errorf("the connect event is %q, want the presenter's slide 2", data) |
| 246 | } |
| 247 | resp.Body.Close() |
| 248 | |
| 249 | // Revoked, mid-talk, and every one of those doors shuts — which is |
| 250 | // the whole point of the finding. |
| 251 | revokeShareLink(t, a, path, cookies) |
| 252 | for _, target := range []string{ |
| 253 | share, |
| 254 | share + "?follow=1", |
| 255 | share + "?follow=1&slide=3&fragment=1", |
| 256 | share + "/stream", |
| 257 | } { |
| 258 | if rec := get(t, a, target); rec.Code != http.StatusNotFound { |
| 259 | t.Errorf("GET %s after revoking: status %d, want 404", target, rec.Code) |
| 260 | } |
| 261 | } |
| 262 | // The talk is still live for the projector, which is the point of |
| 263 | // the projector's own capability being a different thing. |
| 264 | mustGet(t, a, "/p/"+session, http.StatusOK, nil) |
| 265 | } |
| 266 | |
| 267 | // TestFollowingWithNothingLiveEndsTheStream: the share link's stream |
| 268 | // answers even when there is no talk, with the one event that tells a |
| 269 | // following page to go back to reading the deck. Nothing to follow is |
| 270 | // the ordinary end of a session, not an error. |
| 271 | func TestFollowingWithNothingLiveEndsTheStream(t *testing.T) { |
| 272 | a := newApp(t) |
| 273 | cookies := signedIn(t, a, allowedAddress) |
| 274 | path := sampleDeck(t, a, cookies) |
| 275 | link := mintShareLink(t, a, path, cookies) |
| 276 | |
| 277 | rec := mustGet(t, a, "/s/"+link+"/stream", http.StatusOK, nil) |
| 278 | if got := rec.Header().Get("Content-Type"); got != "text/event-stream" { |
| 279 | t.Fatalf("Content-Type = %q, want text/event-stream", got) |
| 280 | } |
| 281 | if !strings.Contains(rec.Body.String(), `"ended":true`) { |
| 282 | t.Errorf("the stream with nothing live said %q, want an ended event", rec.Body) |
| 283 | } |
| 284 | // An unknown token is a 404 here like everywhere else. |
| 285 | if rec := get(t, a, "/s/nosuchtoken/stream"); rec.Code != http.StatusNotFound { |
| 286 | t.Errorf("an unknown share token's stream: status %d, want 404", rec.Code) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // revokeShareLink turns off the newest link on a deck, through the |
| 291 | // screen a person uses. share_test.go finds the form the same way; this |
| 292 | // is that walk as a helper, because two tests here need it. |
| 293 | func revokeShareLink(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) { |
| 294 | t.Helper() |
| 295 | page := mustGet(t, a, deckPath+"/share", http.StatusOK, cookies).Body.String() |
| 296 | m := regexp.MustCompile(`/share/(\d+)/revoke`).FindStringSubmatch(page) |
| 297 | if m == nil { |
| 298 | t.Fatalf("the share page carries no revoke form:\n%s", page) |
| 299 | } |
| 300 | if rec := post(t, a, deckPath+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther { |
| 301 | t.Fatalf("POST revoke: status %d, want 303\n%s", rec.Code, rec.Body) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // --- codex 6: removing an address ends the authority it had --- |
| 306 | |
| 307 | // TestRemovingAnAddressEndsItsSessionsAndItsAgents is the finding: the |
| 308 | // allowlist gated the minting of a session and nothing else. An |
| 309 | // existing session resolved its user row without asking whether the |
| 310 | // address was still admitted, rastrillo's RequireSession asks nothing, |
| 311 | // and an agent needed only an unrevoked token and an operator row. So |
| 312 | // taking somebody off SLIDES_ALLOWED_ADDRESSES and restarting removed |
| 313 | // nothing they already had, including an agent's writes to team decks. |
| 314 | // |
| 315 | // The restart is real: a second app is built over the SAME database |
| 316 | // file with the address gone from the list, which is exactly what |
| 317 | // `carlos env` plus a recycle does. |
| 318 | func TestRemovingAnAddressEndsItsSessionsAndItsAgents(t *testing.T) { |
| 319 | cfg, mailer := newConfig(t) |
| 320 | d := openTestDB(t, cfg) |
| 321 | |
| 322 | before := newAppOver(t, d, cfg, mailer) |
| 323 | cookies := signedIn(t, before, allowedAddress) |
| 324 | token := mintAgent(t, before, cookies, "claude") |
| 325 | sqid := apiDeck(t, before, token, "Leaving") |
| 326 | deck := "/d/" + sqid |
| 327 | // Both credentials work while the address is on the list. |
| 328 | mustGet(t, before, deck, http.StatusOK, cookies) |
| 329 | apiJSON(t, before, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK) |
| 330 | |
| 331 | // Somebody presenting on the way out, so the lifecycle question the |
| 332 | // finding asks — what happens to outstanding capabilities — has an |
| 333 | // answer under test rather than only in the spec. |
| 334 | session, _ := startPresenting(t, before, deck, cookies) |
| 335 | link := mintShareLink(t, before, deck, cookies) |
| 336 | mustGet(t, before, "/p/"+session, http.StatusOK, nil) |
| 337 | |
| 338 | // The offboarding: the address comes off the list, and the app |
| 339 | // restarts over the same data. |
| 340 | gone, _ := newConfig(t) |
| 341 | gone.AllowedAddresses = []string{"someone.else@example.test"} |
| 342 | after := newAppOver(t, d, gone, mailer) |
| 343 | |
| 344 | // The session cookie is the same cookie, and it reaches nothing. |
| 345 | rec := get(t, after, deck, cookies...) |
| 346 | if rec.Code != http.StatusSeeOther { |
| 347 | t.Fatalf("a removed address's session: status %d, want 303 to the sign-in page\n%s", rec.Code, rec.Body) |
| 348 | } |
| 349 | if loc := rec.Header().Get("Location"); loc != "/signin?err=removed" { |
| 350 | t.Errorf("Location = %q, want /signin?err=removed", loc) |
| 351 | } |
| 352 | // And it is told so in words from the catalog, not left to guess. |
| 353 | page := mustGet(t, after, "/signin?err=removed", http.StatusOK, nil).Body.String() |
| 354 | if !strings.Contains(page, "has been removed") { |
| 355 | t.Errorf("the sign-in page does not say the access was removed:\n%s", page) |
| 356 | } |
| 357 | // A write is a 403 rather than a redirect: there is no page to send |
| 358 | // a fetch or a form POST to. |
| 359 | if rec := post(t, after, deck+"/settings", url.Values{"title": {"Renamed"}}, cookies...); rec.Code != http.StatusForbidden { |
| 360 | t.Errorf("a removed address's POST: status %d, want 403", rec.Code) |
| 361 | } |
| 362 | // Really revoked, not merely un-cookied: signing in again is the |
| 363 | // only way back, and the allowlist refuses that too. |
| 364 | if rec := get(t, after, "/", cookies...); rec.Code != http.StatusSeeOther { |
| 365 | t.Errorf("a removed address's home: status %d, want 303", rec.Code) |
| 366 | } |
| 367 | |
| 368 | // The agent token, which is the path that survives a browser being |
| 369 | // signed out, fails closed on the operator rather than on itself. |
| 370 | apiJSON(t, after, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusUnauthorized) |
| 371 | apiJSON(t, after, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| 372 | `{"ops":[{"op":"set_meta","title":"Written by a removed operator"}]}`, http.StatusUnauthorized) |
| 373 | |
| 374 | // Their live talk is over: a presentation is a public URL that |
| 375 | // reads the deck out, and a removed person does not get to leave |
| 376 | // one running. |
| 377 | if rec := get(t, after, "/p/"+session); rec.Code != http.StatusGone { |
| 378 | t.Errorf("a removed presenter's session: status %d, want 410", rec.Code) |
| 379 | } |
| 380 | // Their share links are NOT revoked: a link belongs to the deck, |
| 381 | // which belongs to the team, and one member leaving is not an |
| 382 | // outage for everybody holding a link to a team deck. Spec §6.1 |
| 383 | // says so out loud. |
| 384 | mustGet(t, after, "/s/"+link, http.StatusOK, nil) |
| 385 | } |
| 386 | |
| 387 | // newAppOver is newAppWith over a database that already exists — the |
| 388 | // closest thing to a restart there is. The harness's own newAppWith |
| 389 | // opens a fresh file per app, which is what every other test wants and |
| 390 | // exactly what an offboarding test cannot have. |
| 391 | func newAppOver(t *testing.T, d *db.DB, cfg slides.Config, mailer *fakeMailer) *testApp { |
| 392 | t.Helper() |
| 393 | mux, a, err := slides.New(d, cfg) |
| 394 | if err != nil { |
| 395 | t.Fatalf("slides.New: %v", err) |
| 396 | } |
| 397 | t.Cleanup(func() { a.Close() }) |
| 398 | mailer.flush = a.FlushMail |
| 399 | return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, app: a} |
| 400 | } |
| 401 | |