oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs_test
2
3import (
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.
26var classVocabPattern = regexp.MustCompile(`class="rst-([a-zA-Z-]+)"`)
27
28var 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.
40func 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
56func 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.
96func 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
129func 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.
180func 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.
235func 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.
278func 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
296func 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.
324func 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".
365func 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
397func 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/.
431func 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.
468func 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