oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
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.
17var slideIDOn = regexp.MustCompile(`data-slide="([a-z2-7]{12})"`)
18
19func 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.
33func 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.
55func 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.
75func 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.
107func 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.
138func 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.
157func 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.
171func 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.
192func 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.
223func 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.
240func 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.
270func 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