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 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16 "time"
17)
18
19// exportJobPath finds the status page a started export redirected to:
20// /d/{sqid}/export/{job}, the job id being rastrillo's own base64url.
21var exportJobPath = regexp.MustCompile(`/d/[23456789abcdefghjkmnpqrstuvwxyz]{10}/export/[A-Za-z0-9_-]{22}`)
22
23// startExport posts the export form and returns the status page it
24// redirected to.
25func startExport(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, form url.Values) string {
26 t.Helper()
27 rec := post(t, a, deck+"/export", form, cookies...)
28 if rec.Code != http.StatusSeeOther {
29 t.Fatalf("POST %s/export: status %d, want 303\n%s", deck, rec.Code, rec.Body)
30 }
31 loc := rec.Header().Get("Location")
32 if !exportJobPath.MatchString(loc) {
33 t.Fatalf("POST %s/export: Location = %q, want a job's status page", deck, loc)
34 }
35 return loc
36}
37
38// waitForExport polls the status page's own fragment — the endpoint the
39// shim polls, so this waits exactly as a browser does — until the job
40// stops running. Bounded by a context deadline rather than a count of
41// sleeps, so a slow machine waits longer and a hung job still fails.
42func waitForExport(t *testing.T, a *testApp, status string, cookies []*http.Cookie) string {
43 t.Helper()
44 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
45 defer cancel()
46 for {
47 rec := mustGet(t, a, status+"/fragment", http.StatusOK, cookies)
48 body := rec.Body.String()
49 if !strings.Contains(body, "data-poll=") {
50 return body
51 }
52 select {
53 case <-ctx.Done():
54 t.Fatalf("the export was still running after %v:\n%s", 2*time.Minute, body)
55 case <-time.After(50 * time.Millisecond):
56 }
57 }
58}
59
60// TestExportJobProducesADownload is the whole flow as a person walks
61// it: the form, the POST, the status page while it works, the fragment
62// the shim polls, and the file at the end.
63func TestExportJobProducesADownload(t *testing.T) {
64 a := newApp(t)
65 cookies := signedIn(t, a, allowedAddress)
66 deck := sampleDeck(t, a, cookies)
67
68 // The viewer offers it, to the person who can read the deck.
69 page := mustGet(t, a, deck, http.StatusOK, cookies).Body.String()
70 if !strings.Contains(page, `href="`+deck+`/export"`) {
71 t.Errorf("the viewer has no export button:\n%s", page)
72 }
73
74 form := mustGet(t, a, deck+"/export", http.StatusOK, cookies).Body.String()
75 if !strings.Contains(form, `name="notes"`) {
76 t.Errorf("the export form has no with-notes checkbox:\n%s", form)
77 }
78 if !strings.Contains(form, `method="post"`) {
79 t.Errorf("the export form does not post:\n%s", form)
80 }
81
82 status := startExport(t, a, deck, cookies, url.Values{"notes": {"1"}})
83
84 // The status page is a page in its own right, and while the job
85 // runs it says so in a way that works with scripts off.
86 first := mustGet(t, a, status, http.StatusOK, cookies).Body.String()
87 if strings.Contains(first, "data-poll=") && !strings.Contains(first, "http-equiv=\"refresh\"") {
88 t.Errorf("the running status page polls but has no <noscript> refresh:\n%s", first)
89 }
90
91 fragment := waitForExport(t, a, status, cookies)
92 if strings.Contains(fragment, "rst-tone=\"negative\"") {
93 t.Fatalf("the export failed:\n%s", fragment)
94 }
95 download := status + ".pdf"
96 if !strings.Contains(fragment, download) {
97 t.Fatalf("the finished fragment offers no download of %s:\n%s", download, fragment)
98 }
99
100 // The finished page says the same thing as the fragment: one
101 // template, two holes.
102 done := mustGet(t, a, status, http.StatusOK, cookies).Body.String()
103 if !strings.Contains(done, download) {
104 t.Errorf("the finished status page offers no download:\n%s", done)
105 }
106 if strings.Contains(done, "data-poll=") {
107 t.Errorf("the finished page is still polling:\n%s", done)
108 }
109 if strings.Contains(done, "http-equiv=\"refresh\"") {
110 t.Errorf("the finished page still refreshes itself:\n%s", done)
111 }
112
113 // The stream a browser rides instead of the timer says the same
114 // thing and closes: it carries no markup, only "go and look".
115 events := mustGet(t, a, status+"/events", http.StatusOK, cookies)
116 if got := events.Header().Get("Content-Type"); got != "text/event-stream" {
117 t.Errorf("the events stream's Content-Type is %q", got)
118 }
119 if !strings.Contains(events.Body.String(), "event: done") {
120 t.Errorf("the events stream of a finished job does not say so:\n%s", events.Body)
121 }
122
123 rec := mustGet(t, a, download, http.StatusOK, cookies)
124 pdf := rec.Body.Bytes()
125 if !strings.HasPrefix(string(pdf), "%PDF-") {
126 t.Fatalf("the download is not a PDF: %q", pdf[:min(16, len(pdf))])
127 }
128 if got := rec.Header().Get("Content-Type"); got != "application/pdf" {
129 t.Errorf("Content-Type = %q", got)
130 }
131 if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
132 t.Errorf("X-Content-Type-Options = %q", got)
133 }
134 disposition := rec.Header().Get("Content-Disposition")
135 if !strings.HasPrefix(disposition, "attachment; ") || !strings.Contains(disposition, sampleDeckTitle+".pdf") {
136 t.Errorf("Content-Disposition = %q, want an attachment named after the deck", disposition)
137 }
138
139 // Notes were asked for, so there are at least two pages per slide.
140 // Either spelling of the entry: pdf-writer 0.12 (svg2pdf) wrote
141 // "/Type /Page", 0.15 (krilla) writes "/Type/Page".
142 count := func(entry string) int {
143 return strings.Count(string(pdf), entry) +
144 strings.Count(string(pdf), strings.Replace(entry, " /", "/", 1))
145 }
146 pages := count("/Type /Page") - count("/Type /Pages")
147 if pages < 16 {
148 t.Errorf("the PDF has %d pages; the sample deck with notes should have at least 16", pages)
149 }
150
151 // A real extractor, when the machine has one.
152 if path, err := exec.LookPath("pdftotext"); err == nil {
153 file := filepath.Join(t.TempDir(), "export.pdf")
154 if err := os.WriteFile(file, pdf, 0o644); err != nil {
155 t.Fatal(err)
156 }
157 out, err := exec.Command(path, file, "-").Output()
158 if err != nil {
159 t.Fatalf("pdftotext: %v", err)
160 }
161 if !strings.Contains(string(out), sampleDeckTitle) {
162 t.Errorf("pdftotext did not find the first heading %q; got:\n%s", sampleDeckTitle, out)
163 }
164 } else {
165 t.Log("pdftotext is not on PATH: the extracted-text assertion was skipped")
166 }
167}
168
169// TestExportStatusOutlivesTheJobRecord: rastrillo/jobs forgets a
170// finished job ten minutes after it ends; this app holds the file for
171// an hour, which is what the page told the person. For the fifty
172// minutes in between, the page and the download have to say the same
173// thing — so the status page, its fragment and the API's status all
174// answer from the app's own record once the registry has forgotten.
175func TestExportStatusOutlivesTheJobRecord(t *testing.T) {
176 a := newApp(t)
177 cookies := signedIn(t, a, allowedAddress)
178 deck := sampleDeck(t, a, cookies)
179
180 status := startExport(t, a, deck, cookies, nil)
181 waitForExport(t, a, status, cookies)
182 download := status + ".pdf"
183
184 // Ten minutes pass, as far as the registry is concerned.
185 if err := a.app.ForgetExportJobs(); err != nil {
186 t.Fatalf("ForgetExportJobs: %v", err)
187 }
188
189 page := mustGet(t, a, status, http.StatusOK, cookies).Body.String()
190 if !strings.Contains(page, download) {
191 t.Errorf("the status page no longer offers the download it is still serving:\n%s", page)
192 }
193 if strings.Contains(page, "expired") {
194 t.Errorf("the status page says the export expired while the file still downloads:\n%s", page)
195 }
196 fragment := mustGet(t, a, status+"/fragment", http.StatusOK, cookies).Body.String()
197 if !strings.Contains(fragment, download) {
198 t.Errorf("the polled fragment no longer offers the download:\n%s", fragment)
199 }
200
201 // And the file itself, which is what the page is promising.
202 rec := mustGet(t, a, download, http.StatusOK, cookies)
203 if !strings.HasPrefix(rec.Body.String(), "%PDF-") {
204 t.Errorf("the download stopped being a PDF once the job record went")
205 }
206
207 // The API's status twin says the same: done, not expired, and not
208 // a 404 that would read as "no such job". Its owner is the agent's
209 // operator, which is the person who started this export.
210 token := mintAgent(t, a, cookies, "claude")
211 apiStatus := "/api/v1/decks/" + sqidOf(deck) + "/export/" +
212 strings.TrimPrefix(status, deck+"/export/")
213 out := apiJSON(t, a, http.MethodGet, apiStatus, token, "", http.StatusOK)
214 if out["state"] != "done" {
215 t.Errorf("the API status is %v, want done", out["state"])
216 }
217 if _, expired := out["expired"]; expired {
218 t.Errorf("the API says the export expired while the file still downloads: %v", out)
219 }
220}
221
222// TestExportRefusesWhenOverTheHeldCap: with no object store, the
223// finished PDF is held in this process, and that is bounded. Over the
224// cap the job fails with a sentence the owner can act on, and nothing
225// is held.
226func TestExportRefusesWhenOverTheHeldCap(t *testing.T) {
227 a := newApp(t)
228 // A cap of one byte: every real export is over it, and none of
229 // them has to be big to prove the rule.
230 a.app.SetExportCaps(1, 1)
231 cookies := signedIn(t, a, allowedAddress)
232 deck := sampleDeck(t, a, cookies)
233
234 status := startExport(t, a, deck, cookies, nil)
235 fragment := waitForExport(t, a, status, cookies)
236 if !strings.Contains(fragment, "larger than we can hold") {
237 t.Errorf("a refused export does not say why:\n%s", fragment)
238 }
239 // And the file is not there — a job that failed holds nothing.
240 if rec := get(t, a, status+".pdf", cookies...); rec.Code != http.StatusNotFound {
241 t.Errorf("GET the pdf of a failed export: status %d, want 404", rec.Code)
242 }
243}
244
245// TestExportGoesToTheStoreWhenThereIsOne: with a bucket bound, the
246// bytes belong in it and not in this process. A store that can presign
247// hands the browser a URL of its own; one that cannot — blobs.Dir, in
248// development — is streamed back through the same route, so the page
249// works either way.
250func TestExportGoesToTheStoreWhenThereIsOne(t *testing.T) {
251 dir := dirStore(t)
252 a := newAppWithStore(t, dir)
253 // A cap of one byte, which the held-bytes path would refuse: with
254 // a store there is nothing held, so this is also the proof that
255 // the upload path really is the one being taken.
256 a.app.SetExportCaps(1, 1)
257 cookies := signedIn(t, a, allowedAddress)
258 deck := sampleDeck(t, a, cookies)
259
260 status := startExport(t, a, deck, cookies, nil)
261 fragment := waitForExport(t, a, status, cookies)
262 if strings.Contains(fragment, "rst-tone=\"negative\"") {
263 t.Fatalf("the export failed:\n%s", fragment)
264 }
265 rec := mustGet(t, a, status+".pdf", http.StatusOK, cookies)
266 if !strings.HasPrefix(rec.Body.String(), "%PDF-") {
267 t.Errorf("the streamed export is not a PDF")
268 }
269
270 // The same deck through a store that presigns: a redirect, and the
271 // bytes never come through this process at all.
272 presigning := &presigningStore{Store: dirStore(t)}
273 b := newAppWithStore(t, presigning)
274 bCookies := signedIn(t, b, allowedAddress)
275 bDeck := sampleDeck(t, b, bCookies)
276 bStatus := startExport(t, b, bDeck, bCookies, nil)
277 waitForExport(t, b, bStatus, bCookies)
278 redirect := get(t, b, bStatus+".pdf", bCookies...)
279 if redirect.Code != http.StatusFound {
280 t.Fatalf("GET the pdf with a presigning store: status %d, want 302\n%s", redirect.Code, redirect.Body)
281 }
282 if loc := redirect.Header().Get("Location"); !strings.HasPrefix(loc, "https://bucket.test/") {
283 t.Errorf("Location = %q, want the presigned GET", loc)
284 }
285}
286
287// TestExportPdfIsOwnersOnly: an export belongs to the person who
288// started it. Somebody else — even somebody who can read the same deck
289// — gets the same 404 a stranger gets, because a job id is a
290// capability and a wrong owner must be indistinguishable from a wrong
291// id.
292func TestExportPdfIsOwnersOnly(t *testing.T) {
293 a := newSharedApp(t)
294 mine := signedIn(t, a, allowedAddress)
295 theirs := signedIn(t, a, otherAddress)
296
297 deck := createDeck(t, a, mine, "Shared with the team")
298 // On the team rung, so the other person can read it and could
299 // start an export of their own.
300 rec := post(t, a, deck+"/settings", url.Values{
301 "title": {"Shared with the team"}, "theme": {"ink"}, "rung": {"team"},
302 }, mine...)
303 if rec.Code != http.StatusSeeOther {
304 t.Fatalf("POST %s/settings: status %d\n%s", deck, rec.Code, rec.Body)
305 }
306 mustGet(t, a, deck, http.StatusOK, theirs)
307
308 status := startExport(t, a, deck, mine, nil)
309 waitForExport(t, a, status, mine)
310
311 for _, path := range []string{status, status + ".pdf", status + "/fragment"} {
312 if rec := get(t, a, path, theirs...); rec.Code != http.StatusNotFound {
313 t.Errorf("GET %s as the other person: status %d, want 404", path, rec.Code)
314 }
315 }
316 // Signed out, it is not even a page.
317 if rec := get(t, a, status+".pdf"); rec.Code != http.StatusSeeOther {
318 t.Errorf("GET the pdf signed out: status %d, want a redirect to sign in", rec.Code)
319 }
320}
321
322// TestExportRefusesADeckOverTheSlideCap: 200 slides is the most one
323// PDF will take (spec §4.3), and the refusal happens on the form
324// BEFORE a goroutine is started — a person is told, not shown a failed
325// job.
326func TestExportRefusesADeckOverTheSlideCap(t *testing.T) {
327 a := newApp(t)
328 cookies := signedIn(t, a, allowedAddress)
329 token := mintAgent(t, a, cookies, "filler")
330 deck := createDeck(t, a, cookies, "Too many slides")
331 sqid := sqidOf(deck)
332
333 // 201 slides in one batch through the API: the fastest way to a
334 // deck of this size, and the same op path a person's editor uses.
335 ops := make([]map[string]any, 0, 201)
336 for i := 0; i < 201; i++ {
337 ops = append(ops, map[string]any{"op": "add_slide", "layout": "flow", "template": "section"})
338 }
339 body, err := json.Marshal(map[string]any{"ops": ops})
340 if err != nil {
341 t.Fatal(err)
342 }
343 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, string(body), http.StatusOK)
344
345 rec := post(t, a, deck+"/export", nil, cookies...)
346 if rec.Code != http.StatusSeeOther {
347 t.Fatalf("POST %s/export: status %d, want 303\n%s", deck, rec.Code, rec.Body)
348 }
349 if loc := rec.Header().Get("Location"); loc != deck+"/export" {
350 t.Errorf("a refused export redirected to %q, want the form at %q", loc, deck+"/export")
351 }
352 // The reason travels in a flash cookie and is on the form.
353 form := mustGet(t, a, deck+"/export", http.StatusOK, append(cookies, flashCookie(t, rec))).Body.String()
354 if !strings.Contains(form, "more than 200 slides") {
355 t.Errorf("the form does not say why the export was refused:\n%s", form)
356 }
357}
358
359// flashCookie picks the flash out of a response, so the next request in
360// a test carries it exactly as a browser would.
361func flashCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
362 t.Helper()
363 for _, c := range rec.Result().Cookies() {
364 if c.Name == "rastrillo_flash" {
365 return c
366 }
367 }
368 t.Fatal("no flash cookie on the refusal")
369 return nil
370}
371
372// TestExportAPITwins: the same export, driven by an agent. 202 with
373// somewhere to poll, a status document, and the bytes at the end.
374func TestExportAPITwins(t *testing.T) {
375 a := newApp(t)
376 cookies := signedIn(t, a, allowedAddress)
377 token := mintAgent(t, a, cookies, "claude")
378 sqid := sqidOf(sampleDeck(t, a, cookies))
379
380 started := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/export", token,
381 `{"notes": false}`, http.StatusAccepted)
382 job, _ := started["job"].(string)
383 statusURL, _ := started["status_url"].(string)
384 pdfURL, _ := started["pdf_url"].(string)
385 if job == "" || statusURL == "" || pdfURL == "" {
386 t.Fatalf("POST export: %v, want job, status_url and pdf_url", started)
387 }
388 if want := fmt.Sprintf("/api/v1/decks/%s/export/%s", sqid, job); statusURL != want {
389 t.Errorf("status_url = %q, want %q", statusURL, want)
390 }
391
392 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
393 defer cancel()
394 var state string
395 for state != "done" {
396 body := apiJSON(t, a, http.MethodGet, statusURL, token, "", http.StatusOK)
397 state, _ = body["state"].(string)
398 if state == "failed" {
399 t.Fatalf("the export failed: %v", body)
400 }
401 if state == "done" {
402 if _, has := body["warnings"]; !has {
403 t.Errorf("a finished export reports no warnings key: %v", body)
404 }
405 break
406 }
407 select {
408 case <-ctx.Done():
409 t.Fatalf("the export was still %q after two minutes", state)
410 case <-time.After(50 * time.Millisecond):
411 }
412 }
413
414 req := httptest.NewRequest(http.MethodGet, pdfURL, nil)
415 req.Header.Set("Authorization", "Bearer "+token)
416 rec := httptest.NewRecorder()
417 a.ServeHTTP(rec, req)
418 if rec.Code != http.StatusOK {
419 t.Fatalf("GET %s: status %d\n%s", pdfURL, rec.Code, rec.Body)
420 }
421 if !strings.HasPrefix(rec.Body.String(), "%PDF-") {
422 t.Errorf("the API's download is not a PDF")
423 }
424 if got := rec.Header().Get("Content-Type"); got != "application/pdf" {
425 t.Errorf("Content-Type = %q", got)
426 }
427
428 // A token that is not one of this instance's is not a way in.
429 if body := apiJSON(t, a, http.MethodGet, statusURL, "sl_"+strings.Repeat("x", 40), "", http.StatusUnauthorized); body["error"] != "unauthorized" {
430 t.Errorf("a bad token: %v", body)
431 }
432}
433