| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | slides "amadan.net/oficina/slides/internal/slides" |
| 13 | ) |
| 14 | |
| 15 | // hashedStylesheet is the fingerprinted URL shape a page links: |
| 16 | // /static/tokens.<16 hex>.css. |
| 17 | var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`) |
| 18 | |
| 19 | // The scaffold shipped two tests about the placeholder index page and |
| 20 | // said in AGENTS.md that sign-in would break both, because it does: |
| 21 | // "/" is behind the session gate now, so a signed-out GET is a 303 with |
| 22 | // no <link> in its body. They are repointed at /signin, the one screen |
| 23 | // that is still public — the subject was never the placeholder's |
| 24 | // wording, it was the framework's asset delivery, and that still earns |
| 25 | // its place. The placeholder's own "does this render" assertion is |
| 26 | // covered by TestSigninPageIsPublic (signin_test.go), against a page |
| 27 | // this app actually has. |
| 28 | func TestSigninLinksFingerprintedStylesheet(t *testing.T) { |
| 29 | h := newApp(t) |
| 30 | href := hashedStylesheet.FindString(get(t, h, "/signin").Body.String()) |
| 31 | if href == "" { |
| 32 | t.Fatal("the sign-in page links no fingerprinted stylesheet") |
| 33 | } |
| 34 | rec := get(t, h, href) |
| 35 | if rec.Code != http.StatusOK { |
| 36 | t.Fatalf("GET %s: status %d, want 200", href, rec.Code) |
| 37 | } |
| 38 | if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { |
| 39 | t.Errorf("GET %s: Cache-Control = %q, want immutable year", href, got) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // The bare name keeps working, just never long-cached — so a changed |
| 44 | // file always shows on an ordinary reload. |
| 45 | func TestBareAssetNameStaysFresh(t *testing.T) { |
| 46 | rec := get(t, newApp(t), "/static/tokens.css") |
| 47 | if rec.Code != http.StatusOK { |
| 48 | t.Fatalf("GET /static/tokens.css: status %d, want 200", rec.Code) |
| 49 | } |
| 50 | if got := rec.Header().Get("Cache-Control"); got != "no-cache" { |
| 51 | t.Errorf("Cache-Control = %q, want no-cache", got) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // Static assets are mounted outside the session gate on purpose: the |
| 56 | // sign-in page has to be able to load its own stylesheet before anybody |
| 57 | // is signed in, or the front door renders unstyled. |
| 58 | func TestStaticAssetsAreReachableSignedOut(t *testing.T) { |
| 59 | rec := get(t, newApp(t), "/static/theme.css") |
| 60 | if rec.Code != http.StatusOK { |
| 61 | t.Fatalf("GET /static/theme.css signed out: status %d, want 200", rec.Code) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // ErrorPage is what main.go points Options.ErrorPage at (and the seam |
| 66 | // to wire into Ctx.ErrorPage as handlers grow); called directly, it |
| 67 | // pins that a panic's page is the framework's error-page partial, not |
| 68 | // a stray net/http default. |
| 69 | func TestErrorPageRendersFrameworkPartial(t *testing.T) { |
| 70 | rec := httptest.NewRecorder() |
| 71 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 72 | req.Header.Set("Accept", "text/html") |
| 73 | localised(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 74 | slides.ErrorPage(w, r, http.StatusInternalServerError, "abc123") |
| 75 | })).ServeHTTP(rec, req) |
| 76 | if rec.Code != http.StatusInternalServerError { |
| 77 | t.Fatalf("ErrorPage: status %d, want %d", rec.Code, http.StatusInternalServerError) |
| 78 | } |
| 79 | body := rec.Body.String() |
| 80 | if !strings.Contains(body, "Something went wrong") { |
| 81 | t.Errorf("ErrorPage body missing the 500 title:\n%s", body) |
| 82 | } |
| 83 | if !strings.Contains(body, "abc123") { |
| 84 | t.Errorf("ErrorPage body missing the ref:\n%s", body) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // Every page failure a browser can navigate to is rendered inside Slides' |
| 89 | // layout and bar. A fetch that did not ask for HTML remains plain text; the |
| 90 | // split is by Accept rather than by an accident of which route failed. |
| 91 | func TestNavigableErrorsUseTheSharedErrorPage(t *testing.T) { |
| 92 | a := newApp(t) |
| 93 | signedIn := signedIn(t, a, allowedAddress) |
| 94 | // The user row is created on the first guarded request; optional chrome |
| 95 | // deliberately will not create it while merely resolving an error page. |
| 96 | mustGet(t, a, "/", http.StatusOK, signedIn) |
| 97 | type errorCase struct { |
| 98 | name, path, title string |
| 99 | cookies []*http.Cookie |
| 100 | } |
| 101 | for _, tc := range []errorCase{ |
| 102 | {"unknown route, signed out", "/no/such/page", "Page not found · Slides", nil}, |
| 103 | {"unknown route, signed in", "/no/such/page", "Page not found · Slides", signedIn}, |
| 104 | {"post-only route, signed out", "/signout", "Page not found · Slides", nil}, |
| 105 | {"post-only route, signed in", "/signout", "Page not found · Slides", signedIn}, |
| 106 | {"missing deck", "/d/23456789ab", "Deck not available · Slides", signedIn}, |
| 107 | {"missing share link", "/s/doesnotexist", "Deck not available · Slides", nil}, |
| 108 | } { |
| 109 | t.Run(tc.name, func(t *testing.T) { |
| 110 | req := httptest.NewRequest(http.MethodGet, tc.path, nil) |
| 111 | req.Header.Set("Accept", "text/html,application/xhtml+xml") |
| 112 | for _, c := range tc.cookies { |
| 113 | req.AddCookie(c) |
| 114 | } |
| 115 | rec := httptest.NewRecorder() |
| 116 | a.ServeHTTP(rec, req) |
| 117 | body := rec.Body.String() |
| 118 | if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { |
| 119 | t.Fatalf("status %d, Content-Type %q; want HTML 404", rec.Code, rec.Header().Get("Content-Type")) |
| 120 | } |
| 121 | if title := regexp.MustCompile(`<title>([^<]*)</title>`).FindStringSubmatch(body); title == nil || title[1] != tc.title { |
| 122 | t.Fatalf("title %v, want %q", title, tc.title) |
| 123 | } |
| 124 | if !strings.Contains(body, "<header estilo-bar") || !strings.Contains(body, "<div rst-error>") { |
| 125 | t.Fatal("error is outside the shared layout and bar") |
| 126 | } |
| 127 | if account := strings.Contains(body, "<details estilo-account"); account != (tc.cookies != nil) { |
| 128 | t.Fatalf("account menu present = %v, want %v", account, tc.cookies != nil) |
| 129 | } |
| 130 | }) |
| 131 | } |
| 132 | plain := get(t, a, "/no/such/page", signedIn...) |
| 133 | if plain.Code != http.StatusNotFound || strings.HasPrefix(plain.Header().Get("Content-Type"), "text/html") || strings.Contains(plain.Body.String(), "<html") { |
| 134 | t.Fatalf("non-HTML failure = %d %q, want plain 404", plain.Code, plain.Header().Get("Content-Type")) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // The only callout vocabulary is estilo's real tones. A valued rst-callout |
| 139 | // or the old rst-notice spelling looks plausible but receives no callout |
| 140 | // treatment, so scan every authored template rather than waiting for a path |
| 141 | // to happen to render one. |
| 142 | func TestTemplatesUseOnlyCalloutTones(t *testing.T) { |
| 143 | files, err := filepath.Glob(filepath.Join("..", "slides", "templates", "*.html")) |
| 144 | if err != nil || len(files) == 0 { |
| 145 | t.Fatal("find templates", err) |
| 146 | } |
| 147 | stripComments := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`) |
| 148 | for _, file := range files { |
| 149 | body, err := os.ReadFile(file) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | source := stripComments.ReplaceAllString(string(body), "") |
| 154 | for _, bad := range []string{"rst-notice", `rst-callout="`, `rst-tone="warn"`} { |
| 155 | if strings.Contains(source, bad) { |
| 156 | t.Errorf("%s uses %q", file, bad) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |