| 1 | package 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 | |
| 15 | import ( |
| 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. |
| 38 | var 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 | |
| 49 | func 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). |
| 63 | type presigningStore struct { |
| 64 | blobs.Store |
| 65 | mu sync.Mutex |
| 66 | minted []string |
| 67 | direct map[string][]byte |
| 68 | } |
| 69 | |
| 70 | func (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 | |
| 77 | func (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. |
| 85 | func (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 | |
| 94 | func (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 | |
| 104 | func (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. |
| 112 | func (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. |
| 127 | func 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 | |
| 134 | func 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. |
| 145 | func 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. |
| 152 | func 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 | |
| 166 | func 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). |
| 178 | func 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. |
| 199 | func 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. |
| 215 | func 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. |
| 226 | func 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. |
| 267 | func 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. |
| 316 | func 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. |
| 378 | func 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. |
| 432 | func 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. |
| 505 | func 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. |
| 538 | func 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. |
| 590 | func 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. |
| 641 | func 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". |
| 679 | var _ = func() blobs.Store { return slides.Config{}.Blobs } |
| 680 | |