| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | var 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. |
| 25 | func 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. |
| 42 | func 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. |
| 63 | func 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. |
| 175 | func 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. |
| 226 | func 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. |
| 250 | func 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. |
| 292 | func 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. |
| 326 | func 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. |
| 361 | func 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. |
| 374 | func 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 | |