| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/docs/internal/docs" |
| 12 | ) |
| 13 | |
| 14 | // classVocabPattern finds every class="rst-*" attribute on a rendered |
| 15 | // page. shellClassVocab allowlists the two rastrillo's own vendored |
| 16 | // column shell hard-codes outside any block this app can override — |
| 17 | // the skip link (class="rst-skip") and the <main> wrapper |
| 18 | // (class="rst-page"), ui/layouts/column.html in rastrillo v0.21.0. |
| 19 | // That is rastrillo's output, not this app's markup, and is not |
| 20 | // something render_test.go's TestOurTemplatesUseTheAttributeGrammar |
| 21 | // — the test that actually gates this app's own templates — ever |
| 22 | // sees, since it walks only this app's own viewsFS. This rendered-page |
| 23 | // check exists as a secondary, real-page confirmation that nothing |
| 24 | // else on the page slipped in the class spelling; it is not the test |
| 25 | // this app depends on to catch a class in a template it authors. |
| 26 | var classVocabPattern = regexp.MustCompile(`class="rst-([a-zA-Z-]+)"`) |
| 27 | |
| 28 | var shellClassVocab = map[string]bool{ |
| 29 | "skip": true, |
| 30 | "page": true, |
| 31 | } |
| 32 | |
| 33 | // signInCookies drives a real sign-in through app.TestSignIn |
| 34 | // (docs.App, testing.go) and the magic link it mints, returning the |
| 35 | // cookies that established session sets — every document route now |
| 36 | // sits behind rastrillo/auth's RequireSession (auth.go), so any test |
| 37 | // below that exercises "/", "/documents" or "/d/{sqid}" needs a |
| 38 | // signed-in request to reach the handler at all, not just a |
| 39 | // same-origin one. |
| 40 | func signInCookies(t *testing.T, app *docs.App, address string) []*http.Cookie { |
| 41 | t.Helper() |
| 42 | link, err := app.TestSignIn(t, address) |
| 43 | if err != nil { |
| 44 | t.Fatalf("TestSignIn: %v", err) |
| 45 | } |
| 46 | req := app.TestConfirmRequest(link) |
| 47 | rec := httptest.NewRecorder() |
| 48 | app.Handler().ServeHTTP(rec, req) |
| 49 | cookies := rec.Result().Cookies() |
| 50 | if len(cookies) == 0 { |
| 51 | t.Fatal("following the magic link set no session cookie") |
| 52 | } |
| 53 | return cookies |
| 54 | } |
| 55 | |
| 56 | func TestListShowsDocuments(t *testing.T) { |
| 57 | app, _ := docs.NewForTest(t) |
| 58 | ctx := context.Background() |
| 59 | owner := app.TestPerson(t, "reader@example.com") |
| 60 | if _, err := app.Store().Create(ctx, owner, "Board minutes"); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | cookies := signInCookies(t, app, "reader@example.com") |
| 64 | |
| 65 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 66 | for _, c := range cookies { |
| 67 | req.AddCookie(c) |
| 68 | } |
| 69 | rec := httptest.NewRecorder() |
| 70 | app.Handler().ServeHTTP(rec, req) |
| 71 | |
| 72 | body := rec.Body.String() |
| 73 | if rec.Code != http.StatusOK { |
| 74 | t.Fatalf("status %d", rec.Code) |
| 75 | } |
| 76 | if !strings.Contains(body, "Board minutes") { |
| 77 | t.Error("the document's title is not on the page") |
| 78 | } |
| 79 | for _, m := range classVocabPattern.FindAllStringSubmatch(body, -1) { |
| 80 | if !shellClassVocab[m[1]] { |
| 81 | t.Errorf("class vocabulary found (class=%q) outside rastrillo's own shell — this app writes the attribute grammar (Global Constraints)", `rst-`+m[1]) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // The end-to-end one: a document with real content on its first page |
| 87 | // gets a real thumbnail drawn from that page's own layout, and a |
| 88 | // document with nothing on it gets the empty box the row has always |
| 89 | // shown rather than an invented preview. |
| 90 | // |
| 91 | // It goes through the whole path deliberately — seed a document with |
| 92 | // prose in it, replay it through the core, lay out page one, render |
| 93 | // the list — because every unit below this is a table test on a |
| 94 | // struct, and the thing that actually has to work is that a person |
| 95 | // signing in sees their documents. |
| 96 | func TestListDrawsAThumbnailOfPageOne(t *testing.T) { |
| 97 | app, _ := docs.NewForTest(t) |
| 98 | ctx := context.Background() |
| 99 | owner := app.TestPerson(t, "reader@example.com") |
| 100 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 101 | t.Fatalf("Seed: %v", err) |
| 102 | } |
| 103 | cookies := signInCookies(t, app, "reader@example.com") |
| 104 | |
| 105 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 106 | for _, c := range cookies { |
| 107 | req.AddCookie(c) |
| 108 | } |
| 109 | rec := httptest.NewRecorder() |
| 110 | app.Handler().ServeHTTP(rec, req) |
| 111 | if rec.Code != http.StatusOK { |
| 112 | t.Fatalf("status %d", rec.Code) |
| 113 | } |
| 114 | body := rec.Body.String() |
| 115 | |
| 116 | if n := strings.Count(body, "<svg"); n == 0 { |
| 117 | t.Fatal("no thumbnail was drawn for any seeded document") |
| 118 | } |
| 119 | if n := strings.Count(body, "<rect"); n < 10 { |
| 120 | t.Errorf("only %d line boxes across the whole list — a seeded document has more than that on its first page", n) |
| 121 | } |
| 122 | // The viewBox is the page, so the browser does the scaling and the |
| 123 | // thumbnail is not distorted to fit its box. |
| 124 | if !strings.Contains(body, `viewBox="0 0 612 792"`) { |
| 125 | t.Error("the thumbnail is not drawn in page coordinates") |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestAnEmptyDocumentGetsNoThumbnail(t *testing.T) { |
| 130 | app, _ := docs.NewForTest(t) |
| 131 | ctx := context.Background() |
| 132 | owner := app.TestPerson(t, "reader@example.com") |
| 133 | // Create leaves a document with no updates at all: nothing has |
| 134 | // been typed into it, so its first page has nothing on it. It is |
| 135 | // given a title because every document in the real app has one |
| 136 | // (the create handler substitutes "Untitled document" for a blank |
| 137 | // field) — and because a titleless fixture would make this test |
| 138 | // unable to see the thing it is here to check: the title must not |
| 139 | // be laid out onto the paper, and with no title there is nothing |
| 140 | // to lay out either way. |
| 141 | if _, err := app.Store().Create(ctx, owner, "Untitled document"); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | cookies := signInCookies(t, app, "reader@example.com") |
| 145 | |
| 146 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 147 | for _, c := range cookies { |
| 148 | req.AddCookie(c) |
| 149 | } |
| 150 | rec := httptest.NewRecorder() |
| 151 | app.Handler().ServeHTTP(rec, req) |
| 152 | if rec.Code != http.StatusOK { |
| 153 | t.Fatalf("status %d", rec.Code) |
| 154 | } |
| 155 | // Asserted on the empty box rather than on the absence of any |
| 156 | // <svg> on the page: signing a new person in seeds them sample |
| 157 | // documents (Store.seedIfEnabled), so this list has real |
| 158 | // thumbnails on it too, and "no thumbnail anywhere" would be a |
| 159 | // test of the seeder rather than of the empty document. |
| 160 | if !strings.Contains(rec.Body.String(), `<span docs-thumb aria-hidden="true"></span>`) { |
| 161 | t.Error("an empty document did not render the empty box — it is meant to stay empty rather than show an invented preview") |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // Editing a document changes its thumbnail on the next list render. |
| 166 | // |
| 167 | // This is the cache's central claim and every other test of it is a |
| 168 | // unit test on a map. It holds through the real stack for two reasons |
| 169 | // that are worth pinning here rather than trusting: ApplyUpdate bumps |
| 170 | // updated_at, which is the cache key, so an edit makes the old entry |
| 171 | // unreachable with nobody calling an invalidate; and the sqlite driver |
| 172 | // round-trips that timestamp with enough precision that the two |
| 173 | // versions do not collide. |
| 174 | // |
| 175 | // The one stale case this does not cover, because it is accepted |
| 176 | // rather than prevented: if the update commits and the updated_at bump |
| 177 | // then fails while the process lives, the row has new content under |
| 178 | // its old key. The client's save queue retries, and a restart clears |
| 179 | // the cache regardless. |
| 180 | func TestEditingADocumentChangesItsThumbnail(t *testing.T) { |
| 181 | app, _ := docs.NewForTest(t) |
| 182 | ctx := context.Background() |
| 183 | owner := app.TestPerson(t, "reader@example.com") |
| 184 | doc, err := app.Store().Create(ctx, owner, "Notes") |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | cookies := signInCookies(t, app, "reader@example.com") |
| 189 | |
| 190 | rects := func() int { |
| 191 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 192 | for _, c := range cookies { |
| 193 | req.AddCookie(c) |
| 194 | } |
| 195 | rec := httptest.NewRecorder() |
| 196 | app.Handler().ServeHTTP(rec, req) |
| 197 | if rec.Code != http.StatusOK { |
| 198 | t.Fatalf("status %d", rec.Code) |
| 199 | } |
| 200 | return strings.Count(rec.Body.String(), "<rect") |
| 201 | } |
| 202 | |
| 203 | before := rects() |
| 204 | |
| 205 | // A real update through the real path, which is what bumps |
| 206 | // updated_at. |
| 207 | if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), docs.ReadFragmentFixture(t, "hello")); err != nil { |
| 208 | t.Fatalf("ApplyUpdate: %v", err) |
| 209 | } |
| 210 | |
| 211 | after := rects() |
| 212 | if after == before { |
| 213 | t.Errorf("the list drew %d line boxes before the edit and %d after — the thumbnail did not follow the document", before, after) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // A thumbnail must carry no word of the document it depicts. |
| 218 | // |
| 219 | // This is the property the whole cache argument rests on — a preview |
| 220 | // is geometry, so caching one holds no prose in memory and would hold |
| 221 | // none at rest either. That argument was made in a comment and |
| 222 | // believed; this makes it a gate. Suggested by the Sheets session, |
| 223 | // which built the same feature tonight and made the equivalent check |
| 224 | // there. |
| 225 | // |
| 226 | // Docs cannot make the stronger claim Sheets can. Its preview never |
| 227 | // reads a cell at all, so plaintext cannot reach the drawing code; our |
| 228 | // text does enter the function — PageOne measures every word of the |
| 229 | // paragraph it is breaking — and what holds is that only the geometry |
| 230 | // comes back out. That is exactly the kind of claim worth a test |
| 231 | // rather than a comment. |
| 232 | // |
| 233 | // The second assertion matters as much as the first: without it this |
| 234 | // passes by drawing nothing at all. |
| 235 | func TestAThumbnailCarriesNoneOfTheDocumentsWords(t *testing.T) { |
| 236 | app, _ := docs.NewForTest(t) |
| 237 | ctx := context.Background() |
| 238 | owner := app.TestPerson(t, "reader@example.com") |
| 239 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 240 | t.Fatalf("Seed: %v", err) |
| 241 | } |
| 242 | cookies := signInCookies(t, app, "reader@example.com") |
| 243 | |
| 244 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 245 | for _, c := range cookies { |
| 246 | req.AddCookie(c) |
| 247 | } |
| 248 | rec := httptest.NewRecorder() |
| 249 | app.Handler().ServeHTTP(rec, req) |
| 250 | if rec.Code != http.StatusOK { |
| 251 | t.Fatalf("status %d", rec.Code) |
| 252 | } |
| 253 | body := rec.Body.String() |
| 254 | |
| 255 | if !strings.Contains(body, "<rect") { |
| 256 | t.Fatal("no thumbnail was drawn, so this test would pass without checking anything") |
| 257 | } |
| 258 | |
| 259 | // Words from the seeded documents' bodies. The list shows titles, |
| 260 | // never content, so none of these belongs anywhere on this page — |
| 261 | // and certainly not inside a thumbnail. |
| 262 | for _, canary := range []string{ |
| 263 | "Millbrook", "allotment", "printer", "waterworks", "committee", |
| 264 | } { |
| 265 | if strings.Contains(strings.ToLower(body), strings.ToLower(canary)) { |
| 266 | // Titles legitimately contain some of these, so report only |
| 267 | // what leaked into an <svg>. |
| 268 | for _, svg := range svgFragments(body) { |
| 269 | if strings.Contains(strings.ToLower(svg), strings.ToLower(canary)) { |
| 270 | t.Errorf("a thumbnail carries the document's own word %q", canary) |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // svgFragments returns every <svg>...</svg> on the page. |
| 278 | func svgFragments(body string) []string { |
| 279 | var out []string |
| 280 | rest := body |
| 281 | for { |
| 282 | i := strings.Index(rest, "<svg") |
| 283 | if i < 0 { |
| 284 | return out |
| 285 | } |
| 286 | rest = rest[i:] |
| 287 | j := strings.Index(rest, "</svg>") |
| 288 | if j < 0 { |
| 289 | return append(out, rest) |
| 290 | } |
| 291 | out = append(out, rest[:j]) |
| 292 | rest = rest[j:] |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | func TestEmptyListSaysSo(t *testing.T) { |
| 297 | app, _ := docs.NewForTest(t) |
| 298 | cookies := signInCookies(t, app, "reader@example.com") |
| 299 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 300 | for _, c := range cookies { |
| 301 | req.AddCookie(c) |
| 302 | } |
| 303 | rec := httptest.NewRecorder() |
| 304 | app.Handler().ServeHTTP(rec, req) |
| 305 | if rec.Code != http.StatusOK { |
| 306 | t.Fatalf("status %d", rec.Code) |
| 307 | } |
| 308 | // An empty state is a designed screen, not a blank page. |
| 309 | if len(strings.TrimSpace(rec.Body.String())) < 200 { |
| 310 | t.Error("empty list rendered almost nothing — there should be an empty state") |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // TestCreateThenRead posts as a real browser would: NewForTest's app |
| 315 | // carries no explicit Config.Origin, so it protects itself with |
| 316 | // docs.DefaultOrigin (New's own fallback), and a browser submitting a |
| 317 | // same-origin <form method="post"> to that origin sends exactly this |
| 318 | // Origin header — csrf.Protect checks it and must let the request |
| 319 | // through. Fix round 1: the original version of this test sent no |
| 320 | // Origin/Referer/Sec-Fetch-Site at all, which is not what any browser |
| 321 | // sends and would (correctly) be refused once CSRF protection was |
| 322 | // mounted — the test was the wrong model of a browser, not the |
| 323 | // protection. |
| 324 | func TestCreateThenRead(t *testing.T) { |
| 325 | app, _ := docs.NewForTest(t) |
| 326 | cookies := signInCookies(t, app, "reader@example.com") |
| 327 | |
| 328 | form := strings.NewReader("title=Minutes") |
| 329 | req := httptest.NewRequest(http.MethodPost, "/documents", form) |
| 330 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 331 | req.Header.Set("Origin", docs.DefaultOrigin) |
| 332 | for _, c := range cookies { |
| 333 | req.AddCookie(c) |
| 334 | } |
| 335 | rec := httptest.NewRecorder() |
| 336 | app.Handler().ServeHTTP(rec, req) |
| 337 | |
| 338 | if rec.Code != http.StatusSeeOther { |
| 339 | t.Fatalf("POST /documents: status %d, want 303", rec.Code) |
| 340 | } |
| 341 | loc := rec.Header().Get("Location") |
| 342 | if !strings.HasPrefix(loc, "/d/") { |
| 343 | t.Fatalf("Location = %q, want /d/<sqid>", loc) |
| 344 | } |
| 345 | |
| 346 | req2 := httptest.NewRequest(http.MethodGet, loc, nil) |
| 347 | for _, c := range cookies { |
| 348 | req2.AddCookie(c) |
| 349 | } |
| 350 | rec2 := httptest.NewRecorder() |
| 351 | app.Handler().ServeHTTP(rec2, req2) |
| 352 | if rec2.Code != http.StatusOK { |
| 353 | t.Fatalf("GET %s: status %d", loc, rec2.Code) |
| 354 | } |
| 355 | if !strings.Contains(rec2.Body.String(), "Minutes") { |
| 356 | t.Error("the document's title is not on its own page") |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | // TestCreateWrongOriginRejected proves csrf.Protect is actually wired |
| 361 | // rather than silently a no-op: a cross-origin POST — an attacker's |
| 362 | // page submitting to this app in a signed-in visitor's browser, the |
| 363 | // exact shape CSRF protection exists to stop — must be refused, not |
| 364 | // merely "not tested for success". |
| 365 | func TestCreateWrongOriginRejected(t *testing.T) { |
| 366 | app, _ := docs.NewForTest(t) |
| 367 | |
| 368 | form := strings.NewReader("title=Minutes") |
| 369 | req := httptest.NewRequest(http.MethodPost, "/documents", form) |
| 370 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 371 | req.Header.Set("Origin", "https://evil.example") |
| 372 | rec := httptest.NewRecorder() |
| 373 | app.Handler().ServeHTTP(rec, req) |
| 374 | |
| 375 | if rec.Code != http.StatusForbidden { |
| 376 | t.Fatalf("POST /documents with wrong Origin: status %d, want 403", rec.Code) |
| 377 | } |
| 378 | |
| 379 | // And confirm nothing was actually created — the refusal is real, |
| 380 | // not just a wrong status code on an otherwise-successful write. |
| 381 | // csrf.Protect refuses this request before RequireSession or |
| 382 | // h.owner ever run (app.go mounts it first), so no document could |
| 383 | // have been created under any owner; TestPerson here only mints an |
| 384 | // id to list against, it does not stand in for a session that was |
| 385 | // ever actually established. |
| 386 | ctx := context.Background() |
| 387 | owner := app.TestPerson(t, "reader@example.com") |
| 388 | rows, err := app.Store().List(ctx, owner) |
| 389 | if err != nil { |
| 390 | t.Fatal(err) |
| 391 | } |
| 392 | if len(rows) != 0 { |
| 393 | t.Errorf("a cross-origin POST created %d document(s); want 0", len(rows)) |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | func TestUnknownDocument404s(t *testing.T) { |
| 398 | app, _ := docs.NewForTest(t) |
| 399 | cookies := signInCookies(t, app, "reader@example.com") |
| 400 | req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil) |
| 401 | for _, c := range cookies { |
| 402 | req.AddCookie(c) |
| 403 | } |
| 404 | rec := httptest.NewRecorder() |
| 405 | app.Handler().ServeHTTP(rec, req) |
| 406 | if rec.Code != http.StatusNotFound { |
| 407 | t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code) |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // A document page must offer a way back to the list. Before that link |
| 412 | // landed, GET /d/{sqid} rendered sign-out and no link at all, so |
| 413 | // arriving by a bookmark or a mailed link — the two ways of reaching a |
| 414 | // document that do not pass through GET / on the way — stranded a |
| 415 | // person on it with nothing but the browser's own back button, which |
| 416 | // such an arrival gives nothing to go back to. |
| 417 | // |
| 418 | // The link has moved twice since. It was [docs-back], a breadcrumb this |
| 419 | // page drew above its own title; then the rastrillo topbar's brand slot; |
| 420 | // and now the estilo bar's brand (chrome.go), which every page renders |
| 421 | // from the same data. This test moved with it rather than being deleted, |
| 422 | // because what it holds is the route, not the element that carries it. |
| 423 | // |
| 424 | // The label changed with the last move, deliberately: the brand names the |
| 425 | // app, "Docs", and no longer borrows the list's heading, "Documents" |
| 426 | // (spec §10: the brand is the app's name; the h1 names the screen). |
| 427 | // |
| 428 | // Asserted on the rendered page rather than the template's bytes: the |
| 429 | // href has to survive the handler populating it, not merely be |
| 430 | // written somewhere in views/. |
| 431 | func TestDocumentPageLinksBackToTheList(t *testing.T) { |
| 432 | app, _ := docs.NewForTest(t) |
| 433 | ctx := context.Background() |
| 434 | owner := app.TestPerson(t, "reader@example.com") |
| 435 | doc, err := app.Store().Create(ctx, owner, "Board minutes") |
| 436 | if err != nil { |
| 437 | t.Fatal(err) |
| 438 | } |
| 439 | cookies := signInCookies(t, app, "reader@example.com") |
| 440 | |
| 441 | req := httptest.NewRequest(http.MethodGet, "/d/"+doc.Sqid, nil) |
| 442 | for _, c := range cookies { |
| 443 | req.AddCookie(c) |
| 444 | } |
| 445 | rec := httptest.NewRecorder() |
| 446 | app.Handler().ServeHTTP(rec, req) |
| 447 | if rec.Code != http.StatusOK { |
| 448 | t.Fatalf("status %d", rec.Code) |
| 449 | } |
| 450 | |
| 451 | body := rec.Body.String() |
| 452 | if !strings.Contains(body, `<a estilo-bar-brand href="/">`) { |
| 453 | t.Error("no brand link to / on the document page — a document reached directly has no route to the list") |
| 454 | } |
| 455 | // A direct App.Handler() call installs no locale middleware, so |
| 456 | // rastrillo.T returns the key: the brand's label is the app's name key. |
| 457 | label := betweenTags(t, body, `<a estilo-bar-brand href="/">`, "</a>") |
| 458 | label = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(label, "") |
| 459 | if label != "docs.app.name" { |
| 460 | t.Errorf("brand label = %q, want the app's name (docs.app.name)", label) |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | // betweenTags returns the text between the first open and the next |
| 465 | // close after it, for the small structural assertions above. Not a |
| 466 | // general HTML parser and not trying to be one — it is used only on |
| 467 | // markup this package itself writes, two lines apart. |
| 468 | func betweenTags(t *testing.T, body, open, close string) string { |
| 469 | t.Helper() |
| 470 | i := strings.Index(body, open) |
| 471 | if i < 0 { |
| 472 | return "" |
| 473 | } |
| 474 | rest := body[i+len(open):] |
| 475 | j := strings.Index(rest, close) |
| 476 | if j < 0 { |
| 477 | return "" |
| 478 | } |
| 479 | return rest[:j] |
| 480 | } |
| 481 | |