oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package 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
12import (
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.
40func 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.
83func 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.
118func 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.
157func 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.
191func 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&amp;slide={n}&amp;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.
271func 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.
293func 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.
318func 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.
391func 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