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// The three image endpoints, end to end (spec §2.7, internal/slides/
4// assets.go): signing an upload, putting the bytes, registering what
5// landed, and serving it back to each of the surfaces that renders a
6// slide.
7//
8// Two stores are exercised, because production and development take
9// different halves of the same code. blobs.Dir is what development
10// binds and has nothing to presign, so the app receives the PUT and
11// serves the GET itself; presigningStore below is the shape a real
12// bucket has, and the same requests then become URLs the browser uses
13// directly.
14
15import (
16 "bytes"
17 "context"
18 "crypto/sha256"
19 "encoding/hex"
20 "encoding/json"
21 "fmt"
22 "io"
23 "net/http"
24 "net/http/httptest"
25 "strings"
26 "sync"
27 "testing"
28 "time"
29
30 "amadan.net/rastrillo/rastrillo/blobs"
31
32 slides "amadan.net/oficina/slides/internal/slides"
33)
34
35// onePixelPNG is the smallest real PNG: eight bytes of signature and
36// the chunks a decoder needs. Real bytes rather than a made-up magic
37// number, because registration sniffs what actually landed.
38var onePixelPNG = []byte{
39 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a,
40 0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R',
41 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
42 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89,
43 0x00, 0x00, 0x00, 0x0a, 'I', 'D', 'A', 'T',
44 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01,
45 0x0d, 0x0a, 0x2d, 0xb4,
46 0x00, 0x00, 0x00, 0x00, 'I', 'E', 'N', 'D', 0xae, 0x42, 0x60, 0x82,
47}
48
49func hashOf(b []byte) string {
50 sum := sha256.Sum256(b)
51 return hex.EncodeToString(sum[:])
52}
53
54// presigningStore is blobs.Dir with the two methods a real bucket has,
55// plus the one thing blobs.Dir cannot do: hold bytes at an address it
56// did not choose. The URLs it mints are not real — nothing fetches
57// them — which is exactly the point: what is under test is that the app
58// hands the browser a URL instead of the bytes.
59//
60// putDirect is what a browser does with a presigned PUT, and the tests
61// need it because a staged upload lands at the staging key the app
62// minted rather than at its own content hash (assets.go, codex 1).
63type presigningStore struct {
64 blobs.Store
65 mu sync.Mutex
66 minted []string
67 direct map[string][]byte
68}
69
70func (s *presigningStore) PresignPut(hash string, _ time.Duration) (string, error) {
71 s.mu.Lock()
72 defer s.mu.Unlock()
73 s.minted = append(s.minted, "PUT "+hash)
74 return "https://bucket.test/" + hash + "?X-Amz-Signature=put", nil
75}
76
77func (s *presigningStore) PresignGet(hash string, _ time.Duration) (string, error) {
78 s.mu.Lock()
79 defer s.mu.Unlock()
80 s.minted = append(s.minted, "GET "+hash)
81 return "https://bucket.test/" + hash + "?X-Amz-Signature=get", nil
82}
83
84// putDirect writes body at key, the way a presigned PUT does.
85func (s *presigningStore) putDirect(key string, body []byte) {
86 s.mu.Lock()
87 defer s.mu.Unlock()
88 if s.direct == nil {
89 s.direct = map[string][]byte{}
90 }
91 s.direct[key] = body
92}
93
94func (s *presigningStore) Get(ctx context.Context, hash string) (io.ReadCloser, error) {
95 s.mu.Lock()
96 body, ok := s.direct[hash]
97 s.mu.Unlock()
98 if ok {
99 return io.NopCloser(bytes.NewReader(body)), nil
100 }
101 return s.Store.Get(ctx, hash)
102}
103
104func (s *presigningStore) Delete(ctx context.Context, hash string) error {
105 s.mu.Lock()
106 delete(s.direct, hash)
107 s.mu.Unlock()
108 return s.Store.Delete(ctx, hash)
109}
110
111// mintedPuts is every address this store was asked to sign a PUT for.
112func (s *presigningStore) mintedPuts() []string {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115 out := []string{}
116 for _, m := range s.minted {
117 if strings.HasPrefix(m, "PUT ") {
118 out = append(out, strings.TrimPrefix(m, "PUT "))
119 }
120 }
121 return out
122}
123
124// newAppWithStore is newApp with an object store bound — the shape
125// cmd/slides/main.go builds in development, or on an instance the
126// platform granted a bucket.
127func newAppWithStore(t *testing.T, store blobs.Store) *testApp {
128 t.Helper()
129 cfg, mailer := newConfig(t)
130 cfg.Blobs = store
131 return newAppWith(t, cfg, mailer)
132}
133
134func dirStore(t *testing.T) blobs.Store {
135 t.Helper()
136 store, err := blobs.Dir(t.TempDir())
137 if err != nil {
138 t.Fatalf("blobs.Dir: %v", err)
139 }
140 return store
141}
142
143// postJSON is a browser's JSON POST: the body, and the same-origin
144// evidence csrf.Protect looks for.
145func postJSON(t *testing.T, a *testApp, target, body string, cookies []*http.Cookie) *httptest.ResponseRecorder {
146 t.Helper()
147 return postBody(t, a, target, "application/json", []byte(body), cookies)
148}
149
150// putBytes is the development upload: the same-origin PUT signUpload
151// hands back when the bound store cannot presign.
152func putBytes(t *testing.T, a *testApp, target, contentType string, body []byte, cookies []*http.Cookie) *httptest.ResponseRecorder {
153 t.Helper()
154 req := httptest.NewRequest(http.MethodPut, target, bytes.NewReader(body))
155 req.Header.Set("Content-Type", contentType)
156 req.Header.Set("Origin", testOrigin)
157 req.Header.Set("Sec-Fetch-Site", "same-origin")
158 for _, c := range cookies {
159 req.AddCookie(c)
160 }
161 rec := httptest.NewRecorder()
162 a.ServeHTTP(rec, req)
163 return rec
164}
165
166func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
167 t.Helper()
168 var out map[string]any
169 if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
170 t.Fatalf("body is not JSON: %v\n%s", err, rec.Body)
171 }
172 return out
173}
174
175// stageUpload is the first half of the browser flow: sign, then PUT.
176// It returns the staging key the app minted, which is what registration
177// takes — never a hash the caller chose (assets.go, codex 1).
178func stageUpload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte) string {
179 t.Helper()
180 signed := postJSON(t, a, deck+"/uploads",
181 fmt.Sprintf(`{"name":%q,"size":%d,"type":%q,"hash":%q}`, name, len(body), kind, hashOf(body)), cookies)
182 if signed.Code != http.StatusOK {
183 t.Fatalf("POST %s/uploads: status %d, want 200\n%s", deck, signed.Code, signed.Body)
184 }
185 out := jsonBody(t, signed)
186 key, _ := out["ref"].(string)
187 if key == "" {
188 t.Fatalf("POST %s/uploads answered no staging address: %v", deck, out)
189 }
190 target, _ := out["url"].(string)
191 if put := putBytes(t, a, target, kind, body, cookies); put.Code != http.StatusNoContent {
192 t.Fatalf("PUT %s: status %d, want 204\n%s", target, put.Code, put.Body)
193 }
194 return key
195}
196
197// upload runs the whole browser flow for one file and returns the
198// record the server validated: sign, PUT, register.
199func upload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte, alt string) map[string]any {
200 t.Helper()
201 key := stageUpload(t, a, deck, cookies, name, kind, body)
202 reg := postJSON(t, a, deck+"/assets",
203 fmt.Sprintf(`{"ref":%q,"name":%q,"width":10,"height":20,"alt":%q}`, key, name, alt), cookies)
204 if reg.Code != http.StatusOK {
205 t.Fatalf("POST %s/assets: status %d, want 200\n%s", deck, reg.Code, reg.Body)
206 }
207 return jsonBody(t, reg)
208}
209
210// registerInDeck writes the asset into the document over the drive API,
211// which is what the browser's own register_asset op does — the server
212// deliberately does not write it (assets.go). Only a deck that REFERS
213// to a ref will serve its bytes, so this is what makes the asset
214// fetchable.
215func registerInDeck(t *testing.T, a *testApp, token, sqid string, record map[string]any) {
216 t.Helper()
217 body := fmt.Sprintf(`{"ops":[{"op":"register_asset","id":%q,"ref":%q,"name":%q,"width":%d,"height":%d,"alt":%q}]}`,
218 record["id"], record["ref"], record["name"],
219 int(record["width"].(float64)), int(record["height"].(float64)), record["alt"])
220 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, body, http.StatusOK)
221}
222
223// TestUploadWithoutStoreIs503 is this deployment today: no bucket
224// granted, so there is nowhere for bytes to go, and every endpoint that
225// would move them says so in words rather than half-working.
226func TestUploadWithoutStoreIs503(t *testing.T) {
227 a := newApp(t) // no Config.Blobs
228 cookies := signedIn(t, a, allowedAddress)
229 deck := createDeck(t, a, cookies, "No store here")
230
231 for _, target := range []string{deck + "/uploads", deck + "/assets"} {
232 rec := postJSON(t, a, target, `{"name":"x.png","size":10,"type":"image/png","hash":"`+hashOf(onePixelPNG)+`"}`, cookies)
233 if rec.Code != http.StatusServiceUnavailable {
234 t.Fatalf("POST %s: status %d, want 503\n%s", target, rec.Code, rec.Body)
235 }
236 out := jsonBody(t, rec)
237 if out["error"] != "no_store" {
238 t.Errorf("POST %s: error = %v, want no_store", target, out["error"])
239 }
240 // The words a person reads come from the catalog, resolved —
241 // never a key, and never the machine-readable identifier.
242 msg, _ := out["message"].(string)
243 if msg == "" || strings.HasPrefix(msg, "slides.") {
244 t.Errorf("POST %s: message = %q, want a resolved catalog string", target, msg)
245 }
246 }
247
248 // And the editor says so on the page, with the file input disabled
249 // rather than offering a picker that leads nowhere.
250 page := mustGet(t, a, deck+"/edit", http.StatusOK, cookies).Body.String()
251 if !strings.Contains(page, `slides-asset-input`) {
252 t.Fatalf("the editor has no image file input:\n%s", page)
253 }
254 if !strings.Contains(page, `data-store="0"`) {
255 t.Error(`the editor does not say data-store="0" with no store bound`)
256 }
257 input := page[strings.Index(page, "slides-asset-input"):]
258 input = input[:strings.Index(input, ">")]
259 if !strings.Contains(input, "disabled") {
260 t.Errorf("the file input is not disabled with no store bound: <input %s>", input)
261 }
262}
263
264// TestUploadAndRegisterWithDirStore is the whole flow over the store
265// development binds: sign, put, register, and then — once the deck
266// itself refers to the asset — fetch the bytes back.
267func TestUploadAndRegisterWithDirStore(t *testing.T) {
268 a := newAppWithStore(t, dirStore(t))
269 cookies := signedIn(t, a, allowedAddress)
270 token := mintAgent(t, a, cookies, "claude")
271 sqid := apiDeck(t, a, token, "Pictures")
272 deck := "/d/" + sqid
273
274 record := upload(t, a, deck, cookies, "chart.png", "image/png", onePixelPNG, "A bar chart")
275 if record["ref"] != hashOf(onePixelPNG) {
276 t.Errorf("ref = %v, want the content hash %s", record["ref"], hashOf(onePixelPNG))
277 }
278 if id, _ := record["id"].(string); id == "" {
279 t.Error("the record carries no id for the client to register")
280 }
281 if record["alt"] != "A bar chart" || record["name"] != "chart.png" {
282 t.Errorf("the record lost its name or alt: %v", record)
283 }
284
285 ref := record["ref"].(string)
286 // Before the DOCUMENT refers to it, the bytes are not this deck's
287 // to serve — registration alone is not a capability.
288 if rec := get(t, a, deck+"/assets/"+ref, cookies...); rec.Code != http.StatusNotFound {
289 t.Errorf("GET %s/assets/%s before register_asset: status %d, want 404", deck, ref, rec.Code)
290 }
291
292 registerInDeck(t, a, token, sqid, record)
293
294 rec := mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies)
295 if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) {
296 t.Errorf("GET %s/assets/%s returned %d bytes, want the %d that went up", deck, ref, rec.Body.Len(), len(onePixelPNG))
297 }
298 if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
299 t.Errorf("Content-Type = %q, want image/png", ct)
300 }
301 if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
302 t.Error("an asset is served without nosniff")
303 }
304 if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
305 t.Errorf("Content-Security-Policy = %q, want default-src 'none'", csp)
306 }
307
308 // A ref nothing holds is a 404, not a 500 or an empty 200.
309 if rec := get(t, a, deck+"/assets/"+hashOf([]byte("nothing")), cookies...); rec.Code != http.StatusNotFound {
310 t.Errorf("GET an unknown ref: status %d, want 404", rec.Code)
311 }
312}
313
314// TestUploadLimits: the four types and the 20 MiB ceiling, refused
315// before a URL is ever minted.
316func TestUploadLimits(t *testing.T) {
317 a := newAppWithStore(t, dirStore(t))
318 cookies := signedIn(t, a, allowedAddress)
319 deck := createDeck(t, a, cookies, "Limits")
320 hash := hashOf(onePixelPNG)
321
322 cases := []struct {
323 name, body string
324 want int
325 code string
326 }{
327 {"a GIF", `{"name":"x.gif","size":10,"type":"image/gif","hash":"` + hash + `"}`, http.StatusUnprocessableEntity, "unsupported_type"},
328 {"an HTML file", `{"name":"x.html","size":10,"type":"text/html","hash":"` + hash + `"}`, http.StatusUnprocessableEntity, "unsupported_type"},
329 {"over 20 MiB", `{"name":"x.png","size":20971521,"type":"image/png","hash":"` + hash + `"}`, http.StatusRequestEntityTooLarge, "too_large"},
330 {"nothing at all", `{"name":"x.png","size":0,"type":"image/png","hash":"` + hash + `"}`, http.StatusRequestEntityTooLarge, "too_large"},
331 {"a ref with a slash", `{"name":"x.png","size":10,"type":"image/png","hash":"a/b"}`, http.StatusUnprocessableEntity, "invalid"},
332 }
333 for _, c := range cases {
334 rec := postJSON(t, a, deck+"/uploads", c.body, cookies)
335 if rec.Code != c.want {
336 t.Errorf("%s: status %d, want %d\n%s", c.name, rec.Code, c.want, rec.Body)
337 continue
338 }
339 if out := jsonBody(t, rec); out["error"] != c.code {
340 t.Errorf("%s: error = %v, want %s", c.name, out["error"], c.code)
341 }
342 }
343
344 // A file that says PNG and is not one is caught at registration,
345 // which is the only place the bytes are ever looked at.
346 notAnImage := []byte("<!doctype html><script>alert(1)</script>")
347 fake := stageUpload(t, a, deck, cookies, "x.png", "image/png", notAnImage)
348 reg := postJSON(t, a, deck+"/assets",
349 fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, fake), cookies)
350 if reg.Code != http.StatusUnprocessableEntity {
351 t.Fatalf("registering a fake PNG: status %d, want 422\n%s", reg.Code, reg.Body)
352 }
353 if out := jsonBody(t, reg); out["error"] != "not_an_image" {
354 t.Errorf("registering a fake PNG: error = %v, want not_an_image", out["error"])
355 }
356
357 // Alt text is required unless the image is marked decorative
358 // (§2.4), and the endpoint is the gate rather than the inspector.
359 staged := stageUpload(t, a, deck, cookies, "x.png", "image/png", onePixelPNG)
360 bare := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":""}`, staged), cookies)
361 if bare.Code != http.StatusUnprocessableEntity {
362 t.Errorf("registering with no alt: status %d, want 422\n%s", bare.Code, bare.Body)
363 }
364 // The refusal above spent nothing: the receipt is still there for
365 // the registration that gets it right.
366 decorative := postJSON(t, a, deck+"/assets",
367 fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"","decorative":true}`, staged), cookies)
368 if decorative.Code != http.StatusOK {
369 t.Errorf("registering a decorative image: status %d, want 200\n%s", decorative.Code, decorative.Body)
370 }
371}
372
373// TestSvgUploadIsSanitisedBeforeItIsServed: an SVG carrying a script
374// goes up, comes back at a DIFFERENT address, and what is served from
375// that address has no script in it. The unit half of this — what
376// exactly is stripped and what is refused — is
377// internal/slides.TestSvgIsSanitised.
378func TestSvgUploadIsSanitisedBeforeItIsServed(t *testing.T) {
379 a := newAppWithStore(t, dirStore(t))
380 cookies := signedIn(t, a, allowedAddress)
381 token := mintAgent(t, a, cookies, "claude")
382 sqid := apiDeck(t, a, token, "Drawings")
383 deck := "/d/" + sqid
384
385 dirty := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" onload="steal()">` +
386 `<script>fetch('/steal')</script><rect width="10" height="10" fill="#c00" onclick="alert(1)"/></svg>`)
387 record := upload(t, a, deck, cookies, "logo.svg", "image/svg+xml", dirty, "A red square")
388
389 ref, _ := record["ref"].(string)
390 if ref == hashOf(dirty) {
391 t.Fatal("the SVG came back at the address it went up at — nothing was stripped")
392 }
393 registerInDeck(t, a, token, sqid, record)
394
395 rec := mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies)
396 body := strings.ToLower(rec.Body.String())
397 for _, gone := range []string{"script", "onload", "onclick", "steal", "alert"} {
398 if strings.Contains(body, gone) {
399 t.Errorf("%q is still being served:\n%s", gone, rec.Body)
400 }
401 }
402 if !strings.Contains(rec.Body.String(), `fill="#c00"`) {
403 t.Errorf("the drawing itself was lost:\n%s", rec.Body)
404 }
405 if ct := rec.Header().Get("Content-Type"); ct != "image/svg+xml" {
406 t.Errorf("Content-Type = %q, want image/svg+xml", ct)
407 }
408
409 // The address the browser first uploaded to is still in the store,
410 // and is still not servable: only a ref this deck's own `assets`
411 // map holds is ever answered.
412 if rec := get(t, a, deck+"/assets/"+hashOf(dirty), cookies...); rec.Code != http.StatusNotFound {
413 t.Errorf("the unsanitised bytes are servable: status %d, want 404", rec.Code)
414 }
415
416 // An SVG that cannot be made safe is refused outright rather than
417 // registered half-cleaned.
418 external := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><image href="https://elsewhere.test/x.png"/></svg>`)
419 staged := stageUpload(t, a, deck, cookies, "e.svg", "image/svg+xml", external)
420 reg := postJSON(t, a, deck+"/assets",
421 fmt.Sprintf(`{"ref":%q,"name":"e.svg","width":1,"height":1,"alt":"a"}`, staged), cookies)
422 if reg.Code != http.StatusUnprocessableEntity {
423 t.Fatalf("registering an SVG that points elsewhere: status %d, want 422\n%s", reg.Code, reg.Body)
424 }
425 if out := jsonBody(t, reg); out["error"] != "unsafe_svg" {
426 t.Errorf("error = %v, want unsafe_svg", out["error"])
427 }
428}
429
430// TestAssetServeRedirects: with a store that can presign, the app hands
431// the browser a URL and the bytes never come through this process.
432func TestAssetServeRedirects(t *testing.T) {
433 store := &presigningStore{Store: dirStore(t)}
434 a := newAppWithStore(t, store)
435 cookies := signedIn(t, a, allowedAddress)
436 token := mintAgent(t, a, cookies, "claude")
437 sqid := apiDeck(t, a, token, "Bucket deck")
438 deck := "/d/" + sqid
439
440 // Signing hands back the bucket's own URL, not one of ours — and
441 // at an address of the app's choosing, never the content hash the
442 // browser sent (codex 1).
443 signed := postJSON(t, a, deck+"/uploads",
444 fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), hashOf(onePixelPNG)), cookies)
445 if signed.Code != http.StatusOK {
446 t.Fatalf("sign: status %d\n%s", signed.Code, signed.Body)
447 }
448 out := jsonBody(t, signed)
449 staged, _ := out["ref"].(string)
450 if staged == hashOf(onePixelPNG) {
451 t.Fatal("the app signed a PUT for the address the browser named")
452 }
453 target, _ := out["url"].(string)
454 if !strings.HasPrefix(target, "https://bucket.test/"+staged) {
455 t.Errorf("url = %q, want the bucket's own presigned URL for %s", target, staged)
456 }
457 headers, _ := out["headers"].(map[string]any)
458 if headers["Content-Type"] != "image/png" {
459 t.Errorf("headers = %v, want the Content-Type the PUT must carry", out["headers"])
460 }
461
462 // And the app refuses to be a proxy for one: the development PUT
463 // does not exist on a store that can presign.
464 if rec := putBytes(t, a, deck+"/uploads/"+staged, "image/png", onePixelPNG, cookies); rec.Code != http.StatusNotFound {
465 t.Errorf("PUT through the app with a presigning store: status %d, want 404", rec.Code)
466 }
467
468 // Put the bytes where the bucket would have — at the staging
469 // address the browser was handed — then register, and fetch.
470 store.putDirect(staged, onePixelPNG)
471 record := jsonBody(t, postJSON(t, a, deck+"/assets",
472 fmt.Sprintf(`{"ref":%q,"name":"x.png","width":10,"height":20,"alt":"A dot"}`, staged), cookies))
473 ref, _ := record["ref"].(string)
474 if ref != hashOf(onePixelPNG) {
475 t.Fatalf("ref = %q, want the hash of the bytes the app read (%s)", ref, hashOf(onePixelPNG))
476 }
477 registerInDeck(t, a, token, sqid, record)
478
479 // Nothing was ever handed a PUT for the address the picture is
480 // served from, which is what makes a verified object immutable
481 // (codex 1).
482 for _, put := range store.mintedPuts() {
483 if put == ref {
484 t.Errorf("a PUT was signed for the verified address %s", ref)
485 }
486 }
487
488 rec := get(t, a, deck+"/assets/"+ref, cookies...)
489 if rec.Code != http.StatusFound {
490 t.Fatalf("GET %s/assets/%s: status %d, want 302\n%s", deck, ref, rec.Code, rec.Body)
491 }
492 loc := rec.Header().Get("Location")
493 if !strings.HasPrefix(loc, "https://bucket.test/"+ref) {
494 t.Errorf("Location = %q, want the bucket's presigned GET", loc)
495 }
496 if rec.Body.Len() > 512 {
497 t.Errorf("the app sent %d bytes with a redirect; the bucket serves the bytes", rec.Body.Len())
498 }
499}
500
501// TestAssetIsKeyedByTheDeckThatHoldsIt: a ref is a content address, so
502// knowing one is knowing the sha256 of somebody's file. Every asset
503// route checks the ref against ITS OWN deck's assets map first, so no
504// capability for one deck reaches another one's pictures.
505func TestAssetIsKeyedByTheDeckThatHoldsIt(t *testing.T) {
506 a := newAppWithStore(t, dirStore(t))
507 cookies := signedIn(t, a, allowedAddress)
508 token := mintAgent(t, a, cookies, "claude")
509
510 mine := apiDeck(t, a, token, "Mine")
511 other := apiDeck(t, a, token, "Other")
512 record := upload(t, a, "/d/"+mine, cookies, "chart.png", "image/png", onePixelPNG, "A chart")
513 registerInDeck(t, a, token, mine, record)
514 ref := record["ref"].(string)
515
516 mustGet(t, a, "/d/"+mine+"/assets/"+ref, http.StatusOK, cookies)
517 if rec := get(t, a, "/d/"+other+"/assets/"+ref, cookies...); rec.Code != http.StatusNotFound {
518 t.Errorf("the same person's OTHER deck served the ref: status %d, want 404", rec.Code)
519 }
520
521 // A share link on the other deck is the same question asked with a
522 // capability rather than a session, and gets the same answer.
523 token2 := mintShareLink(t, a, "/d/"+other, cookies)
524 if rec := get(t, a, "/s/"+token2+"/assets/"+ref); rec.Code != http.StatusNotFound {
525 t.Errorf("a share link to another deck served the ref: status %d, want 404", rec.Code)
526 }
527 // A token that never existed is a 404 too, not a 500.
528 if rec := get(t, a, "/s/nosuchtoken/assets/"+ref); rec.Code != http.StatusNotFound {
529 t.Errorf("an unknown share token: status %d, want 404", rec.Code)
530 }
531}
532
533// TestUnverifiedRefIsNotServed: `register_asset` is an op any member
534// can apply with any ref, and a presigned PUT lands whatever the
535// browser sent under whatever hash it named. Only a ref that went
536// through POST /d/{sqid}/assets — read back, sniffed, sanitised — is
537// ever served, however firmly the deck's own map refers to it.
538func TestUnverifiedRefIsNotServed(t *testing.T) {
539 a := newAppWithStore(t, dirStore(t))
540 cookies := signedIn(t, a, allowedAddress)
541 token := mintAgent(t, a, cookies, "claude")
542 sqid := apiDeck(t, a, token, "Unverified")
543 deck := "/d/" + sqid
544
545 // An SVG with a script in it, staged and never registered: what a
546 // member who skipped the verification step would hold. blobs.Dir is
547 // content-addressed, so the staged bytes are sitting under their
548 // own hash, which is the ref such a member would name in an op.
549 body := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
550 hash := hashOf(body)
551 staged := stageUpload(t, a, deck, cookies, "x.svg", "image/svg+xml", body)
552
553 // The deck now refers to it, by an op nothing looked at.
554 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
555 fmt.Sprintf(`{"ops":[{"op":"register_asset","id":"unverified12","ref":%q,"name":"x.svg","width":1,"height":1,"alt":"x"}]}`, hash),
556 http.StatusOK)
557
558 if rec := get(t, a, deck+"/assets/"+hash, cookies...); rec.Code != http.StatusNotFound {
559 t.Errorf("an unverified ref the deck refers to was served: status %d, want 404", rec.Code)
560 }
561 share := mintShareLink(t, a, deck, cookies)
562 if rec := get(t, a, "/s/"+share+"/assets/"+hash); rec.Code != http.StatusNotFound {
563 t.Errorf("an unverified ref was served over a share link: status %d, want 404", rec.Code)
564 }
565
566 // Verified, the same bytes are served — under the sanitised ref
567 // registration answers with, which is what the row records. The
568 // row was what was missing, not the bytes.
569 reg := postJSON(t, a, deck+"/assets",
570 fmt.Sprintf(`{"ref":%q,"name":"x.svg","width":1,"height":1,"alt":"x"}`, staged), cookies)
571 if reg.Code != http.StatusOK {
572 t.Fatalf("POST %s/assets: status %d\n%s", deck, reg.Code, reg.Body)
573 }
574 record := jsonBody(t, reg)
575 clean := record["ref"].(string)
576 if clean == hash {
577 t.Fatalf("the script-bearing SVG came back under its own ref")
578 }
579 registerInDeck(t, a, token, sqid, record)
580 mustGet(t, a, deck+"/assets/"+clean, http.StatusOK, cookies)
581 // And the unsanitised original is still not.
582 if rec := get(t, a, deck+"/assets/"+hash, cookies...); rec.Code != http.StatusNotFound {
583 t.Errorf("verifying the sanitised copy made the original servable: status %d, want 404", rec.Code)
584 }
585}
586
587// TestSharedDeckServesItsOwnAssets: the share link's viewer renders its
588// images under /s/{token}/assets, and that path works for somebody with
589// no account at all — which is the whole point of the capability.
590func TestSharedDeckServesItsOwnAssets(t *testing.T) {
591 a := newAppWithStore(t, dirStore(t))
592 cookies := signedIn(t, a, allowedAddress)
593 token := mintAgent(t, a, cookies, "claude")
594 sqid := apiDeck(t, a, token, "Shared pictures")
595
596 record := upload(t, a, "/d/"+sqid, cookies, "chart.png", "image/png", onePixelPNG, "A chart")
597 registerInDeck(t, a, token, sqid, record)
598 // A slide with an image block pointing at it, so the rendered page
599 // carries the src this test is about.
600 slide := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
601 `{"ops":[{"op":"add_slide","layout":"flow","template":"image-full"}]}`, http.StatusOK)
602 slideID := slide["ids"].(map[string]any)["0"].(string)
603 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
604 fmt.Sprintf(`{"ops":[{"op":"add_block","slide":%q,"type":"image","region":"media","props":{"asset":%q}}]}`,
605 slideID, record["id"]), http.StatusOK)
606
607 link := mintShareLink(t, a, "/d/"+sqid, cookies)
608 // The slide the image is on: apiDeck's new deck already has one,
609 // and add_slide put this one after it.
610 page := mustGet(t, a, "/s/"+link+"?slide=2", http.StatusOK, nil).Body.String()
611 want := `src="/s/` + link + `/assets/` + record["ref"].(string) + `"`
612 if !strings.Contains(page, want) {
613 t.Fatalf("the shared page does not render its image under its own capability (want %s):\n%s", want, page)
614 }
615 // No cookie: a stranger holding the link.
616 rec := mustGet(t, a, "/s/"+link+"/assets/"+record["ref"].(string), http.StatusOK, nil)
617 if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) {
618 t.Error("the shared asset came back as something other than the bytes that went up")
619 }
620
621 // And the signed-in editor renders the same picture under ITS
622 // base. The slide's own route rather than the editor page, because
623 // the strip is pictures of the slides now (thumbs.go) and the
624 // stage only holds the first one.
625 edit := mustGet(t, a, "/d/"+sqid+"/slides/"+slideID+".html", http.StatusOK, cookies).Body.String()
626 if !strings.Contains(edit, `src="/d/`+sqid+`/assets/`+record["ref"].(string)+`"`) {
627 t.Error("the editor does not render the image under the signed-in base")
628 }
629 // The thumbnail of that slide inlines the bytes instead: an SVG in
630 // an <img> fetches nothing, so a picture that is only linked is a
631 // picture nobody sees.
632 thumb := mustGet(t, a, "/d/"+sqid+"/slides/"+slideID+".svg", http.StatusOK, cookies).Body.String()
633 if !strings.Contains(thumb, "data:image/png;base64,") {
634 t.Errorf("the thumbnail did not inline its picture:\n%s", thumb)
635 }
636}
637
638// TestPresentedDeckServesItsOwnAssets: a projector has no session
639// either, so the presenting surface renders its pictures under
640// /p/{session}/assets and that path works with no cookie at all.
641func TestPresentedDeckServesItsOwnAssets(t *testing.T) {
642 a := newAppWithStore(t, dirStore(t))
643 cookies := signedIn(t, a, allowedAddress)
644 token := mintAgent(t, a, cookies, "claude")
645 sqid := apiDeck(t, a, token, "On a projector")
646
647 record := upload(t, a, "/d/"+sqid, cookies, "chart.png", "image/png", onePixelPNG, "A chart")
648 registerInDeck(t, a, token, sqid, record)
649 slide := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
650 `{"ops":[{"op":"add_slide","layout":"flow","template":"image-full"}]}`, http.StatusOK)
651 slideID := slide["ids"].(map[string]any)["0"].(string)
652 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
653 fmt.Sprintf(`{"ops":[{"op":"add_block","slide":%q,"type":"image","region":"media","props":{"asset":%q}}]}`,
654 slideID, record["id"]), http.StatusOK)
655
656 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/present", token, "", http.StatusOK)
657 session, _ := out["session"].(string)
658 ref := record["ref"].(string)
659
660 page := mustGet(t, a, "/p/"+session+"?slide=2", http.StatusOK, nil).Body.String()
661 want := `src="/p/` + session + `/assets/` + ref + `"`
662 if !strings.Contains(page, want) {
663 t.Fatalf("the presenting surface does not render its image under its own capability (want %s):\n%s", want, page)
664 }
665 rec := mustGet(t, a, "/p/"+session+"/assets/"+ref, http.StatusOK, nil)
666 if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) {
667 t.Error("the presented asset came back as something other than the bytes that went up")
668 }
669 // A session that never existed reaches nothing.
670 if rec := get(t, a, "/p/nosuchsess/assets/"+ref); rec.Code != http.StatusNotFound {
671 t.Errorf("an unknown presentation: status %d, want 404", rec.Code)
672 }
673}
674
675// assetsGoNowhereNearTheDatabase is a compile-time reminder, not a
676// test: Config.Blobs is the only place bytes live. If this stops
677// compiling because slides.Config lost the field, the app has grown a
678// second answer to "where do the bytes go".
679var _ = func() blobs.Store { return slides.Config{}.Blobs }
680