oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "fmt"
5 "go/ast"
6 "go/parser"
7 "go/token"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "path/filepath"
12 "regexp"
13 "sort"
14 "strings"
15 "testing"
16)
17
18func browserGet(app *App, cookies []*http.Cookie, method, path string) *httptest.ResponseRecorder {
19 req := httptest.NewRequest(method, path, nil)
20 req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8")
21 for _, c := range cookies {
22 req.AddCookie(c)
23 }
24 rec := httptest.NewRecorder()
25 app.Handler().ServeHTTP(rec, req)
26 return rec
27}
28
29func signedInForTest(t *testing.T, app *App, address string) []*http.Cookie {
30 t.Helper()
31 link, err := app.TestSignIn(t, address)
32 if err != nil {
33 t.Fatal(err)
34 }
35 rec := httptest.NewRecorder()
36 app.Handler().ServeHTTP(rec, confirmRequest(link))
37 return rec.Result().Cookies()
38}
39
40var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`)
41
42// Every error a browser can navigate to is the error page, inside the
43// normal layout and bar, showing the viewer's own chrome (spec §3). The
44// table is {unknown route, a page GET of a POST-only path, a missing
45// document, a bad parameter, a panic} × {signed in, signed out}.
46func TestHTMLErrorsRenderTheErrorPage(t *testing.T) {
47 app, err := NewForTest(t)
48 if err != nil {
49 t.Fatal(err)
50 }
51 alice := signedInForTest(t, app, "alice@example.com")
52 panics := app.handlers.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") }))
53
54 type result struct {
55 code int
56 contentType string
57 body string
58 }
59 serveCase := func(cookies []*http.Cookie, run func([]*http.Cookie) *httptest.ResponseRecorder) result {
60 rec := run(cookies)
61 return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()}
62 }
63 path := func(method, p string) func([]*http.Cookie) *httptest.ResponseRecorder {
64 return func(c []*http.Cookie) *httptest.ResponseRecorder { return browserGet(app, c, method, p) }
65 }
66 panicking := func(c []*http.Cookie) *httptest.ResponseRecorder {
67 req := httptest.NewRequest(http.MethodGet, "/d/anything", nil)
68 req.Header.Set("Accept", "text/html")
69 for _, cookie := range c {
70 req.AddCookie(cookie)
71 }
72 rec := httptest.NewRecorder()
73 panics.ServeHTTP(rec, req)
74 return rec
75 }
76 badParameter := func(c []*http.Cookie) *httptest.ResponseRecorder {
77 req := httptest.NewRequest(http.MethodPost, "/documents", strings.NewReader("title="+strings.Repeat("x", 2000)))
78 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
79 req.Header.Set("Accept", "text/html")
80 req.Header.Set("Sec-Fetch-Site", "same-origin")
81 for _, cookie := range c {
82 req.AddCookie(cookie)
83 }
84 rec := httptest.NewRecorder()
85 app.Handler().ServeHTTP(rec, req)
86 return rec
87 }
88
89 cases := []struct {
90 name string
91 signedIn bool
92 run func([]*http.Cookie) *httptest.ResponseRecorder
93 status int
94 title string
95 }{
96 {"unknown route", true, path("GET", "/no/such/page"), 404, "docs.error.page.404.title · docs.app.name"},
97 {"unknown route", false, path("GET", "/no/such/page"), 404, "docs.error.page.404.title · docs.app.name"},
98 {"GET of a POST-only path", true, path("GET", "/signout"), 404, "docs.error.page.404.title · docs.app.name"},
99 {"GET of a POST-only path", false, path("GET", "/documents"), 404, "docs.error.page.404.title · docs.app.name"},
100 {"missing document", true, path("GET", "/d/doesnotexist"), 404, "docs.error.page.document.title · docs.app.name"},
101 {"missing published document", false, path("GET", "/p/doesnotexist"), 404, "docs.error.page.document.title · docs.app.name"},
102 {"missing export", true, path("GET", "/d/doesnotexist/export.pdf"), 404, "docs.error.page.document.title · docs.app.name"},
103 {"bad parameter", true, badParameter, 400, "docs.error.page.400.title · docs.app.name"},
104 {"panic", true, panicking, 500, "docs.error.page.500.title · docs.app.name"},
105 {"panic", false, panicking, 500, "docs.error.page.500.title · docs.app.name"},
106 }
107 for _, c := range cases {
108 who := "signed out"
109 var cookies []*http.Cookie
110 if c.signedIn {
111 who, cookies = "signed in", alice
112 }
113 t.Run(c.name+", "+who, func(t *testing.T) {
114 got := serveCase(cookies, c.run)
115 if got.code != c.status {
116 t.Fatalf("status %d, want %d", got.code, c.status)
117 }
118 if !strings.HasPrefix(got.contentType, "text/html") {
119 t.Fatalf("Content-Type %q, want text/html", got.contentType)
120 }
121 if m := pageTitle.FindStringSubmatch(got.body); m == nil || m[1] != c.title {
122 t.Fatalf("<title> %v, want %q", m, c.title)
123 }
124 if !strings.Contains(got.body, "<header estilo-bar") || !strings.Contains(got.body, "<div rst-error>") {
125 t.Fatal("the error page is not inside the layout and bar")
126 }
127 if account := strings.Contains(got.body, "<details estilo-account"); account != c.signedIn {
128 t.Fatalf("account menu present = %v, want %v", account, c.signedIn)
129 }
130 if c.status == 500 && !regexp.MustCompile(`rst-error-ref dir="auto">docs.error.ref [a-z0-9]{6}<`).MatchString(got.body) {
131 t.Fatal("a 500 page carries no reference")
132 }
133 })
134 }
135
136 // Non-HTML stays non-HTML: the editor's fetches and the native apps
137 // never ask for a page and never get one.
138 for _, p := range []string{"/no/such/page", "/d/doesnotexist/updates"} {
139 req := httptest.NewRequest(http.MethodGet, p, nil)
140 for _, c := range alice {
141 req.AddCookie(c)
142 }
143 rec := httptest.NewRecorder()
144 app.Handler().ServeHTTP(rec, req)
145 if rec.Code != 404 || strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
146 t.Errorf("%s without Accept: %d %q, want a plain 404", p, rec.Code, rec.Header().Get("Content-Type"))
147 }
148 }
149 // Any other method to a known path is 405 in plain text, with Allow.
150 req := httptest.NewRequest(http.MethodDelete, "/signout", nil)
151 req.Header.Set("Sec-Fetch-Site", "same-origin")
152 rec := httptest.NewRecorder()
153 app.Handler().ServeHTTP(rec, req)
154 if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != "POST" {
155 t.Errorf("DELETE /signout: %d Allow=%q", rec.Code, rec.Header().Get("Allow"))
156 }
157}
158
159// The error page's Back link is offered only for a same-origin Referer
160// that is not this page (rastrillo's rule; otherwise it is an open
161// redirect).
162func TestErrorPageBackLinkIsSameOriginOnly(t *testing.T) {
163 app, _ := NewForTest(t)
164 h := app.handlers
165 for referer, want := range map[string]string{
166 DefaultOrigin + "/d/abc?x=1": "/d/abc?x=1",
167 "https://evil.example/d/abc": "",
168 DefaultOrigin + "/no/such/page": "",
169 "//evil.example/": "",
170 "http://user@localhost:8080/d/abc": "",
171 } {
172 req := httptest.NewRequest(http.MethodGet, "/no/such/page", nil)
173 req.Header.Set("Referer", referer)
174 if got := h.sameOriginReferer(req); got != want {
175 t.Errorf("Referer %q: back %q, want %q", referer, got, want)
176 }
177 }
178}
179
180// httpErrorSites lists every remaining http.Error / http.NotFound call in
181// this package, keyed "file:function", with how many each has. Every one
182// is classified below as a non-HTML answer; an unclassified new site
183// fails the test, so a page handler cannot quietly go back to plain text.
184func httpErrorSites(t *testing.T) map[string]int {
185 t.Helper()
186 fset := token.NewFileSet()
187 files, err := filepath.Glob("*.go")
188 if err != nil {
189 t.Fatal(err)
190 }
191 sites := map[string]int{}
192 for _, f := range files {
193 if strings.HasSuffix(f, "_test.go") || f == "testing.go" {
194 continue
195 }
196 file, err := parser.ParseFile(fset, f, nil, 0)
197 if err != nil {
198 t.Fatal(err)
199 }
200 for _, d := range file.Decls {
201 fn, ok := d.(*ast.FuncDecl)
202 if !ok {
203 continue
204 }
205 name := fn.Name.Name
206 if fn.Recv != nil {
207 typ := fn.Recv.List[0].Type
208 if s, ok := typ.(*ast.StarExpr); ok {
209 typ = s.X
210 }
211 if id, ok := typ.(*ast.Ident); ok {
212 name = id.Name + "." + name
213 }
214 }
215 ast.Inspect(fn, func(n ast.Node) bool {
216 call, ok := n.(*ast.CallExpr)
217 if !ok {
218 return true
219 }
220 if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
221 if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" && (sel.Sel.Name == "Error" || sel.Sel.Name == "NotFound") {
222 sites[f+":"+name]++
223 }
224 }
225 return true
226 })
227 }
228 }
229 return sites
230}
231
232func TestEveryPlainTextErrorIsClassified(t *testing.T) {
233 // file:function → count, and why it is not a page.
234 classified := map[string]struct {
235 n int
236 why string
237 }{
238 "app.go:New": {1, "live stream re-check: an EventSource, never a page"},
239 "assets.go:serveIcon": {1, "static asset 404"},
240 "assets.go:serveBundle": {1, "static asset 404"},
241 "auth.go:requireAdmissionOf": {2, "shared guard in front of page and fetch routes; its non-GET 403 must stay text for save-queue.mjs, and a GET never reaches the 503 without a database stall (R3)"},
242 "errors.go:handlers.fail": {1, "the non-HTML half of the negotiated answer"},
243 "errors.go:handlers.errorPage": {1, "the error page itself could not render"},
244 "errors.go:handlers.methodNotAllowed": {1, "405 for a non-GET method, which no navigation sends"},
245 "errors.go:handlers.recoverToErrorPage": {1, "the non-HTML half of a recovered panic"},
246 "errors.go:App.ErrorPage": {1, "the non-HTML half of rastrillo's recovered panic"},
247 "handlers.go:handlers.updatesGet": {3, "the update log, fetched by the editor"},
248 "handlers.go:handlers.updatesPost": {7, "the update log, posted by the editor"},
249 "handlers.go:parseSince": {1, "the update log's cursor"},
250 "home_local_navigation.go:localLinkNavigation.guard": {1, "405 for a non-GET method"},
251 "home_local_navigation.go:localLinkNavigation.Begin": {2, "Home link start, sealing failure (R3: moves with home/connect)"},
252 "home_render.go:renderer.homeLink": {1, "the page itself could not render"},
253 "home_routes.go:homeCSRF": {1, "unknown Host: not a page anyone navigates to"},
254 "identity.go:identityResolver.middleware": {1, "shared guard in front of page and fetch routes (R3)"},
255 "identity.go:refuseIdentity": {1, "non-GET refusal; a GET redirects to sign-in"},
256 "native.go:nativeJSON": {1, "native API JSON"},
257 "native.go:handlers.nativeList": {2, "native API JSON"},
258 "search.go:handlers.searchJSON": {3, "jump-to palette JSON"},
259 "stream.go:handlers.stream": {6, "live update stream"},
260 "team_access.go:teamRuntime.admission": {1, "shared guard, identity missing: a wiring failure, not a page state"},
261 "team_emission.go:writeGuardedBody": {1, "response already guarded"},
262 }
263 got := httpErrorSites(t)
264 var problems []string
265 for site, n := range got {
266 c, ok := classified[site]
267 switch {
268 case !ok:
269 problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s) — a page handler should call h.fail", site, n))
270 case c.n != n:
271 problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d (%s)", site, n, c.n, c.why))
272 }
273 }
274 for site := range classified {
275 if _, ok := got[site]; !ok {
276 problems = append(problems, site+": classified but no longer present — drop it from the list")
277 }
278 }
279 sort.Strings(problems)
280 for _, p := range problems {
281 t.Error(p)
282 }
283 if len(got) == 0 {
284 t.Fatal("found no http.Error sites at all; the walk is looking at nothing")
285 }
286}
287
288// A deep link opened signed out, then signed in, lands on the deep link
289// (spec §9.4), in standalone Docs: RequireSession's redirect now carries
290// return_to, GET /signin remembers it, and the magic link lands on
291// /auth/local/complete, which goes back there once.
292func TestSigningInReturnsToTheDeepLink(t *testing.T) {
293 app, _ := NewForTest(t)
294 owner := signedInForTest(t, app, "alice@example.com")
295 req := httptest.NewRequest(http.MethodPost, "/documents", strings.NewReader("title=Minutes"))
296 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
297 req.Header.Set("Sec-Fetch-Site", "same-origin")
298 for _, c := range owner {
299 req.AddCookie(c)
300 }
301 created := httptest.NewRecorder()
302 app.Handler().ServeHTTP(created, req)
303 deep := created.Header().Get("Location") + "?view=1"
304
305 jar := map[string]*http.Cookie{}
306 do := func(method, path string, form ...string) *httptest.ResponseRecorder {
307 // The landing checks the Host, so ask it the way a browser would.
308 req := httptest.NewRequest(method, DefaultOrigin+path, strings.NewReader(strings.Join(form, "")))
309 if method == http.MethodPost {
310 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
311 req.Header.Set("Sec-Fetch-Site", "same-origin")
312 }
313 for _, c := range jar {
314 req.AddCookie(c)
315 }
316 rec := httptest.NewRecorder()
317 app.Handler().ServeHTTP(rec, req)
318 for _, c := range rec.Result().Cookies() {
319 if c.MaxAge < 0 {
320 delete(jar, c.Name)
321 } else {
322 jar[c.Name] = c
323 }
324 }
325 return rec
326 }
327 first := do("GET", deep)
328 signin := first.Header().Get("Location")
329 if first.Code != http.StatusSeeOther || signin != "/signin?return_to="+url.QueryEscape(deep) {
330 t.Fatalf("signed-out deep link: %d %q", first.Code, signin)
331 }
332 if rec := do("GET", signin); rec.Code != 200 || jar[homeReturnCookie] == nil {
333 t.Fatalf("GET %s: %d, return remembered: %v", signin, rec.Code, jar[homeReturnCookie] != nil)
334 }
335 link, err := app.TestSignIn(t, "alice@example.com")
336 if err != nil {
337 t.Fatal(err)
338 }
339 verifyPath, verifyForm := confirmForm(link)
340 verified := do("POST", verifyPath, verifyForm)
341 if verified.Header().Get("Location") != localLinkCompletePath {
342 t.Fatalf("the link landed on %q, not %s", verified.Header().Get("Location"), localLinkCompletePath)
343 }
344 landed := do("GET", localLinkCompletePath)
345 if landed.Code != http.StatusSeeOther || landed.Header().Get("Location") != deep {
346 t.Fatalf("landed on %d %q, want %q", landed.Code, landed.Header().Get("Location"), deep)
347 }
348 if jar[homeReturnCookie] != nil {
349 t.Fatal("the return path outlived its one use")
350 }
351 if again := do("GET", localLinkCompletePath); again.Header().Get("Location") != "/" {
352 t.Fatalf("a second landing went to %q, want the list", again.Header().Get("Location"))
353 }
354 // An abandoned attempt does not win over a later one: a deep link
355 // remembered, then a plain /signin, lands on the list.
356 stale := httptest.NewRecorder()
357 app.Handler().ServeHTTP(stale, httptest.NewRequest(http.MethodGet, DefaultOrigin+"/signin?return_to="+url.QueryEscape(deep), nil))
358 fresh := httptest.NewRequest(http.MethodGet, DefaultOrigin+"/signin", nil)
359 for _, c := range stale.Result().Cookies() {
360 fresh.AddCookie(c)
361 }
362 freshRec := httptest.NewRecorder()
363 app.Handler().ServeHTTP(freshRec, fresh)
364 // The browser's jar: the stale attempt's cookie, replaced by whatever
365 // the plain /signin set.
366 cookies := map[string]*http.Cookie{}
367 for _, c := range append(stale.Result().Cookies(), freshRec.Result().Cookies()...) {
368 cookies[c.Name] = c
369 }
370 landing := httptest.NewRequest(http.MethodGet, DefaultOrigin+localLinkCompletePath, nil)
371 for _, c := range cookies {
372 landing.AddCookie(c)
373 }
374 landingRec := httptest.NewRecorder()
375 app.Handler().ServeHTTP(landingRec, landing)
376 if landingRec.Header().Get("Location") != "/" {
377 t.Fatalf("a plain /signin after an abandoned deep link lands on %q, want the list", landingRec.Header().Get("Location"))
378 }
379
380 // Signed in now: GET /signin goes straight back, with no form.
381 if back := do("GET", "/signin?return_to="+url.QueryEscape(deep)); back.Code != http.StatusSeeOther || back.Header().Get("Location") != deep {
382 t.Fatalf("already signed in: %d %q", back.Code, back.Header().Get("Location"))
383 }
384 for _, bad := range []string{"https://evil.example/", "//evil.example/", "/\\evil", "/%09/evil"} {
385 if back := do("GET", "/signin?return_to="+url.QueryEscape(bad)); back.Header().Get("Location") != "/" {
386 t.Errorf("return_to %q sent a signed-in person to %q", bad, back.Header().Get("Location"))
387 }
388 }
389 // Signing out lands on the signed-out page, not straight back on the
390 // form.
391 out := httptest.NewRequest(http.MethodPost, "/signout", nil)
392 out.Header.Set("Sec-Fetch-Site", "same-origin")
393 out.Header.Set("Origin", DefaultOrigin)
394 for _, c := range jar {
395 out.AddCookie(c)
396 }
397 outRec := httptest.NewRecorder()
398 app.Handler().ServeHTTP(outRec, out)
399 if outRec.Code != http.StatusSeeOther || outRec.Header().Get("Location") != "/signed-out" {
400 t.Fatalf("sign out: %d %q", outRec.Code, outRec.Header().Get("Location"))
401 }
402}
403
404// The layout links estilo's stylesheets in the suite's order, each at a
405// content-hashed path, and the brand icons are served from estilo.
406func TestEstiloChromeAssetsAreLinkedInOrder(t *testing.T) {
407 app, _ := NewForTest(t)
408 body := browserGet(app, nil, "GET", "/signin").Body.String()
409 order := []string{`<meta charset="utf-8">`, "/static/tokens.", "/static/theme.", "/static/brand/brand.", "/static/estilo/base.", "/static/estilo/menu.", "/static/estilo/chrome."}
410 last := -1
411 for _, marker := range order {
412 i := strings.Index(body, marker)
413 if i < 0 || i < last {
414 t.Fatalf("%s is missing or out of order", marker)
415 }
416 last = i
417 }
418 for _, name := range []string{"base", "menu", "chrome"} {
419 m := regexp.MustCompile(`/static/estilo/` + name + `\.[0-9a-f]{16}\.css`).FindString(body)
420 if m == "" {
421 t.Fatalf("%s.css is not fingerprinted", name)
422 }
423 if rec := browserGet(app, nil, "GET", m); rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") {
424 t.Fatalf("%s: %d", m, rec.Code)
425 }
426 }
427 icon := browserGet(app, nil, "GET", "/static/estilo/brand/docs.svg")
428 if icon.Code != 200 || icon.Header().Get("Content-Type") != "image/svg+xml" {
429 t.Fatalf("brand icon: %d", icon.Code)
430 }
431}
432