package slidestest // The three image endpoints, end to end (spec §2.7, internal/slides/ // assets.go): signing an upload, putting the bytes, registering what // landed, and serving it back to each of the surfaces that renders a // slide. // // Two stores are exercised, because production and development take // different halves of the same code. blobs.Dir is what development // binds and has nothing to presign, so the app receives the PUT and // serves the GET itself; presigningStore below is the shape a real // bucket has, and the same requests then become URLs the browser uses // directly. import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "amadan.net/rastrillo/rastrillo/blobs" slides "amadan.net/oficina/slides/internal/slides" ) // onePixelPNG is the smallest real PNG: eight bytes of signature and // the chunks a decoder needs. Real bytes rather than a made-up magic // number, because registration sniffs what actually landed. var onePixelPNG = []byte{ 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 'I', 'D', 'A', 'T', 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 'I', 'E', 'N', 'D', 0xae, 0x42, 0x60, 0x82, } func hashOf(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } // presigningStore is blobs.Dir with the two methods a real bucket has, // plus the one thing blobs.Dir cannot do: hold bytes at an address it // did not choose. The URLs it mints are not real — nothing fetches // them — which is exactly the point: what is under test is that the app // hands the browser a URL instead of the bytes. // // putDirect is what a browser does with a presigned PUT, and the tests // need it because a staged upload lands at the staging key the app // minted rather than at its own content hash (assets.go, codex 1). type presigningStore struct { blobs.Store mu sync.Mutex minted []string direct map[string][]byte } func (s *presigningStore) PresignPut(hash string, _ time.Duration) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.minted = append(s.minted, "PUT "+hash) return "https://bucket.test/" + hash + "?X-Amz-Signature=put", nil } func (s *presigningStore) PresignGet(hash string, _ time.Duration) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.minted = append(s.minted, "GET "+hash) return "https://bucket.test/" + hash + "?X-Amz-Signature=get", nil } // putDirect writes body at key, the way a presigned PUT does. func (s *presigningStore) putDirect(key string, body []byte) { s.mu.Lock() defer s.mu.Unlock() if s.direct == nil { s.direct = map[string][]byte{} } s.direct[key] = body } func (s *presigningStore) Get(ctx context.Context, hash string) (io.ReadCloser, error) { s.mu.Lock() body, ok := s.direct[hash] s.mu.Unlock() if ok { return io.NopCloser(bytes.NewReader(body)), nil } return s.Store.Get(ctx, hash) } func (s *presigningStore) Delete(ctx context.Context, hash string) error { s.mu.Lock() delete(s.direct, hash) s.mu.Unlock() return s.Store.Delete(ctx, hash) } // mintedPuts is every address this store was asked to sign a PUT for. func (s *presigningStore) mintedPuts() []string { s.mu.Lock() defer s.mu.Unlock() out := []string{} for _, m := range s.minted { if strings.HasPrefix(m, "PUT ") { out = append(out, strings.TrimPrefix(m, "PUT ")) } } return out } // newAppWithStore is newApp with an object store bound — the shape // cmd/slides/main.go builds in development, or on an instance the // platform granted a bucket. func newAppWithStore(t *testing.T, store blobs.Store) *testApp { t.Helper() cfg, mailer := newConfig(t) cfg.Blobs = store return newAppWith(t, cfg, mailer) } func dirStore(t *testing.T) blobs.Store { t.Helper() store, err := blobs.Dir(t.TempDir()) if err != nil { t.Fatalf("blobs.Dir: %v", err) } return store } // postJSON is a browser's JSON POST: the body, and the same-origin // evidence csrf.Protect looks for. func postJSON(t *testing.T, a *testApp, target, body string, cookies []*http.Cookie) *httptest.ResponseRecorder { t.Helper() return postBody(t, a, target, "application/json", []byte(body), cookies) } // putBytes is the development upload: the same-origin PUT signUpload // hands back when the bound store cannot presign. func putBytes(t *testing.T, a *testApp, target, contentType string, body []byte, cookies []*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPut, target, bytes.NewReader(body)) req.Header.Set("Content-Type", contentType) req.Header.Set("Origin", testOrigin) req.Header.Set("Sec-Fetch-Site", "same-origin") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() a.ServeHTTP(rec, req) return rec } func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("body is not JSON: %v\n%s", err, rec.Body) } return out } // stageUpload is the first half of the browser flow: sign, then PUT. // It returns the staging key the app minted, which is what registration // takes — never a hash the caller chose (assets.go, codex 1). func stageUpload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte) string { t.Helper() signed := postJSON(t, a, deck+"/uploads", fmt.Sprintf(`{"name":%q,"size":%d,"type":%q,"hash":%q}`, name, len(body), kind, hashOf(body)), cookies) if signed.Code != http.StatusOK { t.Fatalf("POST %s/uploads: status %d, want 200\n%s", deck, signed.Code, signed.Body) } out := jsonBody(t, signed) key, _ := out["ref"].(string) if key == "" { t.Fatalf("POST %s/uploads answered no staging address: %v", deck, out) } target, _ := out["url"].(string) if put := putBytes(t, a, target, kind, body, cookies); put.Code != http.StatusNoContent { t.Fatalf("PUT %s: status %d, want 204\n%s", target, put.Code, put.Body) } return key } // upload runs the whole browser flow for one file and returns the // record the server validated: sign, PUT, register. func upload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte, alt string) map[string]any { t.Helper() key := stageUpload(t, a, deck, cookies, name, kind, body) reg := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":%q,"width":10,"height":20,"alt":%q}`, key, name, alt), cookies) if reg.Code != http.StatusOK { t.Fatalf("POST %s/assets: status %d, want 200\n%s", deck, reg.Code, reg.Body) } return jsonBody(t, reg) } // registerInDeck writes the asset into the document over the drive API, // which is what the browser's own register_asset op does — the server // deliberately does not write it (assets.go). Only a deck that REFERS // to a ref will serve its bytes, so this is what makes the asset // fetchable. func registerInDeck(t *testing.T, a *testApp, token, sqid string, record map[string]any) { t.Helper() body := fmt.Sprintf(`{"ops":[{"op":"register_asset","id":%q,"ref":%q,"name":%q,"width":%d,"height":%d,"alt":%q}]}`, record["id"], record["ref"], record["name"], int(record["width"].(float64)), int(record["height"].(float64)), record["alt"]) apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, body, http.StatusOK) } // TestUploadWithoutStoreIs503 is this deployment today: no bucket // granted, so there is nowhere for bytes to go, and every endpoint that // would move them says so in words rather than half-working. func TestUploadWithoutStoreIs503(t *testing.T) { a := newApp(t) // no Config.Blobs cookies := signedIn(t, a, allowedAddress) deck := createDeck(t, a, cookies, "No store here") for _, target := range []string{deck + "/uploads", deck + "/assets"} { rec := postJSON(t, a, target, `{"name":"x.png","size":10,"type":"image/png","hash":"`+hashOf(onePixelPNG)+`"}`, cookies) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("POST %s: status %d, want 503\n%s", target, rec.Code, rec.Body) } out := jsonBody(t, rec) if out["error"] != "no_store" { t.Errorf("POST %s: error = %v, want no_store", target, out["error"]) } // The words a person reads come from the catalog, resolved — // never a key, and never the machine-readable identifier. msg, _ := out["message"].(string) if msg == "" || strings.HasPrefix(msg, "slides.") { t.Errorf("POST %s: message = %q, want a resolved catalog string", target, msg) } } // And the editor says so on the page, with the file input disabled // rather than offering a picker that leads nowhere. page := mustGet(t, a, deck+"/edit", http.StatusOK, cookies).Body.String() if !strings.Contains(page, `slides-asset-input`) { t.Fatalf("the editor has no image file input:\n%s", page) } if !strings.Contains(page, `data-store="0"`) { t.Error(`the editor does not say data-store="0" with no store bound`) } input := page[strings.Index(page, "slides-asset-input"):] input = input[:strings.Index(input, ">")] if !strings.Contains(input, "disabled") { t.Errorf("the file input is not disabled with no store bound: ", input) } } // TestUploadAndRegisterWithDirStore is the whole flow over the store // development binds: sign, put, register, and then — once the deck // itself refers to the asset — fetch the bytes back. func TestUploadAndRegisterWithDirStore(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Pictures") deck := "/d/" + sqid record := upload(t, a, deck, cookies, "chart.png", "image/png", onePixelPNG, "A bar chart") if record["ref"] != hashOf(onePixelPNG) { t.Errorf("ref = %v, want the content hash %s", record["ref"], hashOf(onePixelPNG)) } if id, _ := record["id"].(string); id == "" { t.Error("the record carries no id for the client to register") } if record["alt"] != "A bar chart" || record["name"] != "chart.png" { t.Errorf("the record lost its name or alt: %v", record) } ref := record["ref"].(string) // Before the DOCUMENT refers to it, the bytes are not this deck's // to serve — registration alone is not a capability. if rec := get(t, a, deck+"/assets/"+ref, cookies...); rec.Code != http.StatusNotFound { t.Errorf("GET %s/assets/%s before register_asset: status %d, want 404", deck, ref, rec.Code) } registerInDeck(t, a, token, sqid, record) rec := mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies) if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) { t.Errorf("GET %s/assets/%s returned %d bytes, want the %d that went up", deck, ref, rec.Body.Len(), len(onePixelPNG)) } if ct := rec.Header().Get("Content-Type"); ct != "image/png" { t.Errorf("Content-Type = %q, want image/png", ct) } if rec.Header().Get("X-Content-Type-Options") != "nosniff" { t.Error("an asset is served without nosniff") } if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") { t.Errorf("Content-Security-Policy = %q, want default-src 'none'", csp) } // A ref nothing holds is a 404, not a 500 or an empty 200. if rec := get(t, a, deck+"/assets/"+hashOf([]byte("nothing")), cookies...); rec.Code != http.StatusNotFound { t.Errorf("GET an unknown ref: status %d, want 404", rec.Code) } } // TestUploadLimits: the four types and the 20 MiB ceiling, refused // before a URL is ever minted. func TestUploadLimits(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) deck := createDeck(t, a, cookies, "Limits") hash := hashOf(onePixelPNG) cases := []struct { name, body string want int code string }{ {"a GIF", `{"name":"x.gif","size":10,"type":"image/gif","hash":"` + hash + `"}`, http.StatusUnprocessableEntity, "unsupported_type"}, {"an HTML file", `{"name":"x.html","size":10,"type":"text/html","hash":"` + hash + `"}`, http.StatusUnprocessableEntity, "unsupported_type"}, {"over 20 MiB", `{"name":"x.png","size":20971521,"type":"image/png","hash":"` + hash + `"}`, http.StatusRequestEntityTooLarge, "too_large"}, {"nothing at all", `{"name":"x.png","size":0,"type":"image/png","hash":"` + hash + `"}`, http.StatusRequestEntityTooLarge, "too_large"}, {"a ref with a slash", `{"name":"x.png","size":10,"type":"image/png","hash":"a/b"}`, http.StatusUnprocessableEntity, "invalid"}, } for _, c := range cases { rec := postJSON(t, a, deck+"/uploads", c.body, cookies) if rec.Code != c.want { t.Errorf("%s: status %d, want %d\n%s", c.name, rec.Code, c.want, rec.Body) continue } if out := jsonBody(t, rec); out["error"] != c.code { t.Errorf("%s: error = %v, want %s", c.name, out["error"], c.code) } } // A file that says PNG and is not one is caught at registration, // which is the only place the bytes are ever looked at. notAnImage := []byte("") fake := stageUpload(t, a, deck, cookies, "x.png", "image/png", notAnImage) reg := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, fake), cookies) if reg.Code != http.StatusUnprocessableEntity { t.Fatalf("registering a fake PNG: status %d, want 422\n%s", reg.Code, reg.Body) } if out := jsonBody(t, reg); out["error"] != "not_an_image" { t.Errorf("registering a fake PNG: error = %v, want not_an_image", out["error"]) } // Alt text is required unless the image is marked decorative // (§2.4), and the endpoint is the gate rather than the inspector. staged := stageUpload(t, a, deck, cookies, "x.png", "image/png", onePixelPNG) bare := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":""}`, staged), cookies) if bare.Code != http.StatusUnprocessableEntity { t.Errorf("registering with no alt: status %d, want 422\n%s", bare.Code, bare.Body) } // The refusal above spent nothing: the receipt is still there for // the registration that gets it right. decorative := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"","decorative":true}`, staged), cookies) if decorative.Code != http.StatusOK { t.Errorf("registering a decorative image: status %d, want 200\n%s", decorative.Code, decorative.Body) } } // TestSvgUploadIsSanitisedBeforeItIsServed: an SVG carrying a script // goes up, comes back at a DIFFERENT address, and what is served from // that address has no script in it. The unit half of this — what // exactly is stripped and what is refused — is // internal/slides.TestSvgIsSanitised. func TestSvgUploadIsSanitisedBeforeItIsServed(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Drawings") deck := "/d/" + sqid dirty := []byte(`` + ``) record := upload(t, a, deck, cookies, "logo.svg", "image/svg+xml", dirty, "A red square") ref, _ := record["ref"].(string) if ref == hashOf(dirty) { t.Fatal("the SVG came back at the address it went up at — nothing was stripped") } registerInDeck(t, a, token, sqid, record) rec := mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies) body := strings.ToLower(rec.Body.String()) for _, gone := range []string{"script", "onload", "onclick", "steal", "alert"} { if strings.Contains(body, gone) { t.Errorf("%q is still being served:\n%s", gone, rec.Body) } } if !strings.Contains(rec.Body.String(), `fill="#c00"`) { t.Errorf("the drawing itself was lost:\n%s", rec.Body) } if ct := rec.Header().Get("Content-Type"); ct != "image/svg+xml" { t.Errorf("Content-Type = %q, want image/svg+xml", ct) } // The address the browser first uploaded to is still in the store, // and is still not servable: only a ref this deck's own `assets` // map holds is ever answered. if rec := get(t, a, deck+"/assets/"+hashOf(dirty), cookies...); rec.Code != http.StatusNotFound { t.Errorf("the unsanitised bytes are servable: status %d, want 404", rec.Code) } // An SVG that cannot be made safe is refused outright rather than // registered half-cleaned. external := []byte(``) staged := stageUpload(t, a, deck, cookies, "e.svg", "image/svg+xml", external) reg := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"e.svg","width":1,"height":1,"alt":"a"}`, staged), cookies) if reg.Code != http.StatusUnprocessableEntity { t.Fatalf("registering an SVG that points elsewhere: status %d, want 422\n%s", reg.Code, reg.Body) } if out := jsonBody(t, reg); out["error"] != "unsafe_svg" { t.Errorf("error = %v, want unsafe_svg", out["error"]) } } // TestAssetServeRedirects: with a store that can presign, the app hands // the browser a URL and the bytes never come through this process. func TestAssetServeRedirects(t *testing.T) { store := &presigningStore{Store: dirStore(t)} a := newAppWithStore(t, store) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Bucket deck") deck := "/d/" + sqid // Signing hands back the bucket's own URL, not one of ours — and // at an address of the app's choosing, never the content hash the // browser sent (codex 1). signed := postJSON(t, a, deck+"/uploads", fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), hashOf(onePixelPNG)), cookies) if signed.Code != http.StatusOK { t.Fatalf("sign: status %d\n%s", signed.Code, signed.Body) } out := jsonBody(t, signed) staged, _ := out["ref"].(string) if staged == hashOf(onePixelPNG) { t.Fatal("the app signed a PUT for the address the browser named") } target, _ := out["url"].(string) if !strings.HasPrefix(target, "https://bucket.test/"+staged) { t.Errorf("url = %q, want the bucket's own presigned URL for %s", target, staged) } headers, _ := out["headers"].(map[string]any) if headers["Content-Type"] != "image/png" { t.Errorf("headers = %v, want the Content-Type the PUT must carry", out["headers"]) } // And the app refuses to be a proxy for one: the development PUT // does not exist on a store that can presign. if rec := putBytes(t, a, deck+"/uploads/"+staged, "image/png", onePixelPNG, cookies); rec.Code != http.StatusNotFound { t.Errorf("PUT through the app with a presigning store: status %d, want 404", rec.Code) } // Put the bytes where the bucket would have — at the staging // address the browser was handed — then register, and fetch. store.putDirect(staged, onePixelPNG) record := jsonBody(t, postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":10,"height":20,"alt":"A dot"}`, staged), cookies)) ref, _ := record["ref"].(string) if ref != hashOf(onePixelPNG) { t.Fatalf("ref = %q, want the hash of the bytes the app read (%s)", ref, hashOf(onePixelPNG)) } registerInDeck(t, a, token, sqid, record) // Nothing was ever handed a PUT for the address the picture is // served from, which is what makes a verified object immutable // (codex 1). for _, put := range store.mintedPuts() { if put == ref { t.Errorf("a PUT was signed for the verified address %s", ref) } } rec := get(t, a, deck+"/assets/"+ref, cookies...) if rec.Code != http.StatusFound { t.Fatalf("GET %s/assets/%s: status %d, want 302\n%s", deck, ref, rec.Code, rec.Body) } loc := rec.Header().Get("Location") if !strings.HasPrefix(loc, "https://bucket.test/"+ref) { t.Errorf("Location = %q, want the bucket's presigned GET", loc) } if rec.Body.Len() > 512 { t.Errorf("the app sent %d bytes with a redirect; the bucket serves the bytes", rec.Body.Len()) } } // TestAssetIsKeyedByTheDeckThatHoldsIt: a ref is a content address, so // knowing one is knowing the sha256 of somebody's file. Every asset // route checks the ref against ITS OWN deck's assets map first, so no // capability for one deck reaches another one's pictures. func TestAssetIsKeyedByTheDeckThatHoldsIt(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") mine := apiDeck(t, a, token, "Mine") other := apiDeck(t, a, token, "Other") record := upload(t, a, "/d/"+mine, cookies, "chart.png", "image/png", onePixelPNG, "A chart") registerInDeck(t, a, token, mine, record) ref := record["ref"].(string) mustGet(t, a, "/d/"+mine+"/assets/"+ref, http.StatusOK, cookies) if rec := get(t, a, "/d/"+other+"/assets/"+ref, cookies...); rec.Code != http.StatusNotFound { t.Errorf("the same person's OTHER deck served the ref: status %d, want 404", rec.Code) } // A share link on the other deck is the same question asked with a // capability rather than a session, and gets the same answer. token2 := mintShareLink(t, a, "/d/"+other, cookies) if rec := get(t, a, "/s/"+token2+"/assets/"+ref); rec.Code != http.StatusNotFound { t.Errorf("a share link to another deck served the ref: status %d, want 404", rec.Code) } // A token that never existed is a 404 too, not a 500. if rec := get(t, a, "/s/nosuchtoken/assets/"+ref); rec.Code != http.StatusNotFound { t.Errorf("an unknown share token: status %d, want 404", rec.Code) } } // TestUnverifiedRefIsNotServed: `register_asset` is an op any member // can apply with any ref, and a presigned PUT lands whatever the // browser sent under whatever hash it named. Only a ref that went // through POST /d/{sqid}/assets — read back, sniffed, sanitised — is // ever served, however firmly the deck's own map refers to it. func TestUnverifiedRefIsNotServed(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Unverified") deck := "/d/" + sqid // An SVG with a script in it, staged and never registered: what a // member who skipped the verification step would hold. blobs.Dir is // content-addressed, so the staged bytes are sitting under their // own hash, which is the ref such a member would name in an op. body := []byte(``) hash := hashOf(body) staged := stageUpload(t, a, deck, cookies, "x.svg", "image/svg+xml", body) // The deck now refers to it, by an op nothing looked at. apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, fmt.Sprintf(`{"ops":[{"op":"register_asset","id":"unverified12","ref":%q,"name":"x.svg","width":1,"height":1,"alt":"x"}]}`, hash), http.StatusOK) if rec := get(t, a, deck+"/assets/"+hash, cookies...); rec.Code != http.StatusNotFound { t.Errorf("an unverified ref the deck refers to was served: status %d, want 404", rec.Code) } share := mintShareLink(t, a, deck, cookies) if rec := get(t, a, "/s/"+share+"/assets/"+hash); rec.Code != http.StatusNotFound { t.Errorf("an unverified ref was served over a share link: status %d, want 404", rec.Code) } // Verified, the same bytes are served — under the sanitised ref // registration answers with, which is what the row records. The // row was what was missing, not the bytes. reg := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.svg","width":1,"height":1,"alt":"x"}`, staged), cookies) if reg.Code != http.StatusOK { t.Fatalf("POST %s/assets: status %d\n%s", deck, reg.Code, reg.Body) } record := jsonBody(t, reg) clean := record["ref"].(string) if clean == hash { t.Fatalf("the script-bearing SVG came back under its own ref") } registerInDeck(t, a, token, sqid, record) mustGet(t, a, deck+"/assets/"+clean, http.StatusOK, cookies) // And the unsanitised original is still not. if rec := get(t, a, deck+"/assets/"+hash, cookies...); rec.Code != http.StatusNotFound { t.Errorf("verifying the sanitised copy made the original servable: status %d, want 404", rec.Code) } } // TestSharedDeckServesItsOwnAssets: the share link's viewer renders its // images under /s/{token}/assets, and that path works for somebody with // no account at all — which is the whole point of the capability. func TestSharedDeckServesItsOwnAssets(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "Shared pictures") record := upload(t, a, "/d/"+sqid, cookies, "chart.png", "image/png", onePixelPNG, "A chart") registerInDeck(t, a, token, sqid, record) // A slide with an image block pointing at it, so the rendered page // carries the src this test is about. slide := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[{"op":"add_slide","layout":"flow","template":"image-full"}]}`, http.StatusOK) slideID := slide["ids"].(map[string]any)["0"].(string) apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, fmt.Sprintf(`{"ops":[{"op":"add_block","slide":%q,"type":"image","region":"media","props":{"asset":%q}}]}`, slideID, record["id"]), http.StatusOK) link := mintShareLink(t, a, "/d/"+sqid, cookies) // The slide the image is on: apiDeck's new deck already has one, // and add_slide put this one after it. page := mustGet(t, a, "/s/"+link+"?slide=2", http.StatusOK, nil).Body.String() want := `src="/s/` + link + `/assets/` + record["ref"].(string) + `"` if !strings.Contains(page, want) { t.Fatalf("the shared page does not render its image under its own capability (want %s):\n%s", want, page) } // No cookie: a stranger holding the link. rec := mustGet(t, a, "/s/"+link+"/assets/"+record["ref"].(string), http.StatusOK, nil) if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) { t.Error("the shared asset came back as something other than the bytes that went up") } // And the signed-in editor renders the same picture under ITS // base. The slide's own route rather than the editor page, because // the strip is pictures of the slides now (thumbs.go) and the // stage only holds the first one. edit := mustGet(t, a, "/d/"+sqid+"/slides/"+slideID+".html", http.StatusOK, cookies).Body.String() if !strings.Contains(edit, `src="/d/`+sqid+`/assets/`+record["ref"].(string)+`"`) { t.Error("the editor does not render the image under the signed-in base") } // The thumbnail of that slide inlines the bytes instead: an SVG in // an fetches nothing, so a picture that is only linked is a // picture nobody sees. thumb := mustGet(t, a, "/d/"+sqid+"/slides/"+slideID+".svg", http.StatusOK, cookies).Body.String() if !strings.Contains(thumb, "data:image/png;base64,") { t.Errorf("the thumbnail did not inline its picture:\n%s", thumb) } } // TestPresentedDeckServesItsOwnAssets: a projector has no session // either, so the presenting surface renders its pictures under // /p/{session}/assets and that path works with no cookie at all. func TestPresentedDeckServesItsOwnAssets(t *testing.T) { a := newAppWithStore(t, dirStore(t)) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") sqid := apiDeck(t, a, token, "On a projector") record := upload(t, a, "/d/"+sqid, cookies, "chart.png", "image/png", onePixelPNG, "A chart") registerInDeck(t, a, token, sqid, record) slide := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[{"op":"add_slide","layout":"flow","template":"image-full"}]}`, http.StatusOK) slideID := slide["ids"].(map[string]any)["0"].(string) apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, fmt.Sprintf(`{"ops":[{"op":"add_block","slide":%q,"type":"image","region":"media","props":{"asset":%q}}]}`, slideID, record["id"]), http.StatusOK) out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/present", token, "", http.StatusOK) session, _ := out["session"].(string) ref := record["ref"].(string) page := mustGet(t, a, "/p/"+session+"?slide=2", http.StatusOK, nil).Body.String() want := `src="/p/` + session + `/assets/` + ref + `"` if !strings.Contains(page, want) { t.Fatalf("the presenting surface does not render its image under its own capability (want %s):\n%s", want, page) } rec := mustGet(t, a, "/p/"+session+"/assets/"+ref, http.StatusOK, nil) if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) { t.Error("the presented asset came back as something other than the bytes that went up") } // A session that never existed reaches nothing. if rec := get(t, a, "/p/nosuchsess/assets/"+ref); rec.Code != http.StatusNotFound { t.Errorf("an unknown presentation: status %d, want 404", rec.Code) } } // assetsGoNowhereNearTheDatabase is a compile-time reminder, not a // test: Config.Blobs is the only place bytes live. If this stops // compiling because slides.Config lost the field, the app has grown a // second answer to "where do the bytes go". var _ = func() blobs.Store { return slides.Config{}.Blobs }