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 }