| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/xml" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "regexp" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | // slideIDOn finds the slide the editor page opened on — the id every |
| 16 | // thumbnail route in this file then asks for. |
| 17 | var slideIDOn = regexp.MustCompile(`data-slide="([a-z2-7]{12})"`) |
| 18 | |
| 19 | func currentSlideID(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) string { |
| 20 | t.Helper() |
| 21 | page := mustGet(t, a, deckPath+"/edit", http.StatusOK, cookies).Body.String() |
| 22 | m := slideIDOn.FindStringSubmatch(page) |
| 23 | if m == nil { |
| 24 | t.Fatalf("no current slide id on the editor page:\n%s", page) |
| 25 | } |
| 26 | return m[1] |
| 27 | } |
| 28 | |
| 29 | // wellFormed insists the body parses as XML and is rooted at an <svg>. |
| 30 | // Every assertion in this file about a thumbnail leans on it: a picture |
| 31 | // that will not parse is not a picture, and a browser shows nothing at |
| 32 | // all for one rather than saying why. |
| 33 | func wellFormed(t *testing.T, body []byte) { |
| 34 | t.Helper() |
| 35 | dec := xml.NewDecoder(bytes.NewReader(body)) |
| 36 | root := "" |
| 37 | for { |
| 38 | tok, err := dec.Token() |
| 39 | if errors.Is(err, io.EOF) { |
| 40 | break |
| 41 | } |
| 42 | if err != nil { |
| 43 | t.Fatalf("the thumbnail is not well-formed XML: %v\n%s", err, body) |
| 44 | } |
| 45 | if s, ok := tok.(xml.StartElement); ok && root == "" { |
| 46 | root = s.Name.Local |
| 47 | } |
| 48 | } |
| 49 | if root != "svg" { |
| 50 | t.Fatalf("the thumbnail is rooted at <%s>, want <svg>:\n%s", root, body) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // thumbHeaders is the header set every surface's thumbnail carries. |
| 55 | func checkThumbHeaders(t *testing.T, rec *httptest.ResponseRecorder, where string) { |
| 56 | t.Helper() |
| 57 | for _, c := range []struct{ header, want string }{ |
| 58 | {"Content-Type", "image/svg+xml; charset=utf-8"}, |
| 59 | {"X-Content-Type-Options", "nosniff"}, |
| 60 | {"Cache-Control", "private, max-age=0, must-revalidate"}, |
| 61 | {"Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:"}, |
| 62 | } { |
| 63 | if got := rec.Header().Get(c.header); got != c.want { |
| 64 | t.Errorf("%s: %s = %q, want %q", where, c.header, got, c.want) |
| 65 | } |
| 66 | } |
| 67 | if rec.Header().Get("ETag") == "" { |
| 68 | t.Errorf("%s: no ETag, so every keystroke refetches every thumbnail", where) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // TestSlideThumbnailIsAnSVGOfTheSlide: the signed-in route answers a |
| 73 | // picture, headed as one, with the slide's own label as its accessible |
| 74 | // name. |
| 75 | func TestSlideThumbnailIsAnSVGOfTheSlide(t *testing.T) { |
| 76 | a := newApp(t) |
| 77 | cookies := signedIn(t, a, allowedAddress) |
| 78 | path := sampleDeck(t, a, cookies) |
| 79 | id := currentSlideID(t, a, path, cookies) |
| 80 | |
| 81 | rec := mustGet(t, a, path+"/slides/"+id+".svg", http.StatusOK, cookies) |
| 82 | checkThumbHeaders(t, rec, "the signed-in route") |
| 83 | body := rec.Body.Bytes() |
| 84 | wellFormed(t, body) |
| 85 | |
| 86 | if !bytes.Contains(body, []byte(`role="img"`)) { |
| 87 | t.Errorf("the thumbnail is not announced as a picture:\n%s", body) |
| 88 | } |
| 89 | if !bytes.Contains(body, []byte(`viewBox="0 0 1920 1080"`)) { |
| 90 | t.Errorf("the thumbnail is not the 16:9 frame:\n%s", body) |
| 91 | } |
| 92 | if !bytes.Contains(body, []byte("<title>Welcome to Slides</title>")) { |
| 93 | t.Errorf("the thumbnail is not named after the slide:\n%s", body) |
| 94 | } |
| 95 | // The words of the slide are IN the picture, as text and not as a |
| 96 | // bitmap: a thumbnail is searchable, selectable and readable by a |
| 97 | // screen reader that opens it, and it is the same file the export |
| 98 | // is made of. |
| 99 | if !bytes.Contains(body, []byte("<text")) { |
| 100 | t.Errorf("the thumbnail has no text in it:\n%s", body) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // The tag is the deck's seq and the slide: an unchanged deck answers |
| 105 | // 304 with no body, which is what makes a strip that refetches nine |
| 106 | // thumbnails after every save cheap. |
| 107 | func TestSlideThumbnailRevalidatesOnItsTag(t *testing.T) { |
| 108 | a := newApp(t) |
| 109 | cookies := signedIn(t, a, allowedAddress) |
| 110 | path := sampleDeck(t, a, cookies) |
| 111 | id := currentSlideID(t, a, path, cookies) |
| 112 | target := path + "/slides/" + id + ".svg" |
| 113 | |
| 114 | first := mustGet(t, a, target, http.StatusOK, cookies) |
| 115 | etag := first.Header().Get("ETag") |
| 116 | |
| 117 | req := httptest.NewRequest(http.MethodGet, target, nil) |
| 118 | for _, c := range cookies { |
| 119 | req.AddCookie(c) |
| 120 | } |
| 121 | req.Header.Set("If-None-Match", etag) |
| 122 | rec := httptest.NewRecorder() |
| 123 | a.ServeHTTP(rec, req) |
| 124 | if rec.Code != http.StatusNotModified { |
| 125 | t.Fatalf("a matching tag answered %d, want 304\n%s", rec.Code, rec.Body) |
| 126 | } |
| 127 | if rec.Body.Len() != 0 { |
| 128 | t.Errorf("the 304 carried %d bytes", rec.Body.Len()) |
| 129 | } |
| 130 | if got := rec.Header().Get("ETag"); got != etag { |
| 131 | t.Errorf("the 304's ETag is %q, want %q", got, etag) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // ?w= is accepted and changes nothing about the bytes — an SVG scales |
| 136 | // — but it IS in the tag, so a raster twin can land later without a |
| 137 | // cache in between already holding one size for every width. |
| 138 | func TestThumbnailWidthIsAcceptedAndIsInTheTag(t *testing.T) { |
| 139 | a := newApp(t) |
| 140 | cookies := signedIn(t, a, allowedAddress) |
| 141 | path := sampleDeck(t, a, cookies) |
| 142 | id := currentSlideID(t, a, path, cookies) |
| 143 | |
| 144 | plain := mustGet(t, a, path+"/slides/"+id+".svg", http.StatusOK, cookies) |
| 145 | narrow := mustGet(t, a, path+"/slides/"+id+".svg?w=180", http.StatusOK, cookies) |
| 146 | if !bytes.Equal(plain.Body.Bytes(), narrow.Body.Bytes()) { |
| 147 | t.Error("?w= changed the bytes; an SVG scales, so it should not have") |
| 148 | } |
| 149 | if plain.Header().Get("ETag") == narrow.Header().Get("ETag") { |
| 150 | t.Errorf("both widths share the tag %q", plain.Header().Get("ETag")) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // A slide this deck does not have is a 404 with no tag on it: a tag on |
| 155 | // "there is no such slide" invites a cache to treat the absence as the |
| 156 | // thing. |
| 157 | func TestAMissingSlideHasNoThumbnail(t *testing.T) { |
| 158 | a := newApp(t) |
| 159 | cookies := signedIn(t, a, allowedAddress) |
| 160 | path := sampleDeck(t, a, cookies) |
| 161 | |
| 162 | rec := mustGet(t, a, path+"/slides/aaaaaaaaaaaa.svg", http.StatusNotFound, cookies) |
| 163 | if got := rec.Header().Get("ETag"); got != "" { |
| 164 | t.Errorf("the 404 carries ETag %q", got) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // The `.html` twin still works: the stage swaps real markup in, and |
| 169 | // ?vp=thumb is still a desktop rendering (editor.go's rule). The strip |
| 170 | // no longer asks for it; the Both view still can. |
| 171 | func TestTheSlideHTMLRouteStillServesTheSameSlide(t *testing.T) { |
| 172 | a := newApp(t) |
| 173 | cookies := signedIn(t, a, allowedAddress) |
| 174 | path := sampleDeck(t, a, cookies) |
| 175 | id := currentSlideID(t, a, path, cookies) |
| 176 | |
| 177 | html := mustGet(t, a, path+"/slides/"+id+".html?vp=thumb", http.StatusOK, cookies) |
| 178 | if ct := html.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { |
| 179 | t.Errorf("the .html route answers %q", ct) |
| 180 | } |
| 181 | if !strings.HasPrefix(strings.TrimSpace(html.Body.String()), "<section slides-slide") { |
| 182 | t.Errorf("the .html route is not a bare section:\n%s", html.Body) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // ── The other three surfaces ───────────────────────────────────────── |
| 187 | |
| 188 | // A share link reaches the thumbnails, because the presenter view and |
| 189 | // the strip are not the only things that show a slide small — and |
| 190 | // because the token is the whole capability, exactly as it is for the |
| 191 | // page and for the pictures on it. |
| 192 | func TestShareLinkReachesTheThumbnails(t *testing.T) { |
| 193 | a := newApp(t) |
| 194 | cookies := signedIn(t, a, allowedAddress) |
| 195 | path := sampleDeck(t, a, cookies) |
| 196 | id := currentSlideID(t, a, path, cookies) |
| 197 | token := mintShareLink(t, a, path, cookies) |
| 198 | |
| 199 | rec := get(t, a, "/s/"+token+"/slides/"+id+".svg") |
| 200 | if rec.Code != http.StatusOK { |
| 201 | t.Fatalf("GET /s/{token}/slides/{id}.svg: status %d, want 200\n%s", rec.Code, rec.Body) |
| 202 | } |
| 203 | checkThumbHeaders(t, rec, "the share route") |
| 204 | wellFormed(t, rec.Body.Bytes()) |
| 205 | |
| 206 | // Revoking the link closes the thumbnails with the page. |
| 207 | page := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String() |
| 208 | m := revokeAction.FindStringSubmatch(page) |
| 209 | if m == nil { |
| 210 | t.Fatalf("no revoke form on the share page:\n%s", page) |
| 211 | } |
| 212 | if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther { |
| 213 | t.Fatalf("revoke: status %d", rec.Code) |
| 214 | } |
| 215 | if rec := get(t, a, "/s/"+token+"/slides/"+id+".svg"); rec.Code != http.StatusNotFound { |
| 216 | t.Errorf("a revoked link still draws thumbnails: status %d", rec.Code) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // The presentation surface serves them too: the presenter's notes |
| 221 | // window and the remote both show the current slide and the next one |
| 222 | // small, and neither has a session. |
| 223 | func TestPresentationReachesTheThumbnails(t *testing.T) { |
| 224 | a := newApp(t) |
| 225 | cookies := signedIn(t, a, allowedAddress) |
| 226 | path := sampleDeck(t, a, cookies) |
| 227 | id := currentSlideID(t, a, path, cookies) |
| 228 | session, _ := startPresenting(t, a, path, cookies) |
| 229 | |
| 230 | rec := get(t, a, "/p/"+session+"/slides/"+id+".svg") |
| 231 | if rec.Code != http.StatusOK { |
| 232 | t.Fatalf("GET /p/{session}/slides/{id}.svg: status %d, want 200\n%s", rec.Code, rec.Body) |
| 233 | } |
| 234 | checkThumbHeaders(t, rec, "the presentation route") |
| 235 | wellFormed(t, rec.Body.Bytes()) |
| 236 | } |
| 237 | |
| 238 | // The API twin, for an agent that wants to LOOK at what it made |
| 239 | // (§7.2). Same bytes, same tag, the API's own 404 shape. |
| 240 | func TestAgentCanFetchASlideAsAPicture(t *testing.T) { |
| 241 | a := newApp(t) |
| 242 | cookies := signedIn(t, a, allowedAddress) |
| 243 | path := sampleDeck(t, a, cookies) |
| 244 | id := currentSlideID(t, a, path, cookies) |
| 245 | sqid := strings.TrimPrefix(path, "/d/") |
| 246 | token := mintAgent(t, a, cookies, "claude") |
| 247 | |
| 248 | rec := send(t, a, bearerRequest(t, http.MethodGet, |
| 249 | "/api/v1/decks/"+sqid+"/slides/"+id+".svg", token, "")) |
| 250 | if rec.Code != http.StatusOK { |
| 251 | t.Fatalf("GET the API thumbnail: status %d, want 200\n%s", rec.Code, rec.Body) |
| 252 | } |
| 253 | checkThumbHeaders(t, rec, "the API route") |
| 254 | wellFormed(t, rec.Body.Bytes()) |
| 255 | |
| 256 | // A slide that is not there answers the API's JSON, not a page. |
| 257 | apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid+"/slides/aaaaaaaaaaaa.svg", |
| 258 | token, "", http.StatusNotFound) |
| 259 | |
| 260 | // And without a token, nothing. |
| 261 | anon := send(t, a, bearerRequest(t, http.MethodGet, |
| 262 | "/api/v1/decks/"+sqid+"/slides/"+id+".svg", "", "")) |
| 263 | if anon.Code != http.StatusUnauthorized { |
| 264 | t.Errorf("an unauthenticated API thumbnail answered %d, want 401", anon.Code) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // A stranger with a session of their own cannot draw somebody else's |
| 269 | // deck: the thumbnail goes through deckFor like every other read. |
| 270 | func TestAThumbnailIsBehindTheDecksOwnGate(t *testing.T) { |
| 271 | a := newSharedApp(t) |
| 272 | owner := signedIn(t, a, allowedAddress) |
| 273 | path := sampleDeck(t, a, owner) |
| 274 | id := currentSlideID(t, a, path, owner) |
| 275 | |
| 276 | stranger := signedIn(t, a, otherAddress) |
| 277 | if rec := get(t, a, path+"/slides/"+id+".svg", stranger...); rec.Code != http.StatusNotFound { |
| 278 | t.Errorf("a stranger drew somebody else's slide: status %d", rec.Code) |
| 279 | } |
| 280 | if rec := get(t, a, path+"/slides/"+id+".svg"); rec.Code == http.StatusOK { |
| 281 | t.Error("a signed-out request drew a private slide") |
| 282 | } |
| 283 | } |
| 284 | |