oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
4 "io"
5 "log/slog"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10
11 "amadan.net/rastrillo/rastrillo"
12
13 slides "amadan.net/oficina/slides/internal/slides"
14)
15
16// productionHandler is a behind rastrillo's serving stack, built from the
17// same ServeOptions main hands to rastrillo.Serve: security headers, CSP,
18// locales and all. a.Handler alone carries no CSP, so a test that serves
19// only the mux cannot see what a browser is told.
20func productionHandler(t *testing.T, a *testApp) http.Handler {
21 t.Helper()
22 opts := a.app.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, a.mux)
23 h, closeFn, err := rastrillo.Handler(opts)
24 if err != nil {
25 t.Fatalf("rastrillo.Handler: %v", err)
26 }
27 t.Cleanup(func() { _ = closeFn() })
28 return h
29}
30
31// directives splits a policy into directive name → source list.
32func directives(policy string) map[string]string {
33 out := map[string]string{}
34 for _, d := range strings.Split(policy, ";") {
35 name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
36 if name != "" {
37 out[name] = strings.TrimSpace(value)
38 }
39 }
40 return out
41}
42
43// TestProductionCSPIsTheDefaultBarStyles: Slides' policy is rastrillo's
44// default with style-src alone widened to 'unsafe-inline'
45// (slides.ContentSecurityPolicy says why). Every other directive must
46// match the framework's, read off a handler built with no CSP of its
47// own, so a rastrillo bump that tightens anything else fails here rather
48// than being silently undone by our constant.
49func TestProductionCSPIsTheDefaultBarStyles(t *testing.T) {
50 bare, closeFn, err := rastrillo.Handler(rastrillo.Options{Mux: http.NewServeMux(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
51 if err != nil {
52 t.Fatal(err)
53 }
54 defer closeFn()
55 rec := httptest.NewRecorder()
56 bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
57 def := directives(rec.Header().Get("Content-Security-Policy"))
58 ours := directives(slides.ContentSecurityPolicy)
59 if len(def) == 0 {
60 t.Fatal("rastrillo set no default CSP")
61 }
62 for name, value := range def {
63 if name == "style-src" {
64 continue
65 }
66 if ours[name] != value {
67 t.Errorf("%s: ours %q, rastrillo's default %q", name, ours[name], value)
68 }
69 }
70 for name := range ours {
71 if _, ok := def[name]; !ok {
72 t.Errorf("ours adds %s, which rastrillo's default does not have", name)
73 }
74 }
75 // A hash or nonce beside 'unsafe-inline' makes the browser ignore
76 // 'unsafe-inline' (CSP3), which is exactly how the slides would break.
77 if got := ours["style-src"]; got != "'self' 'unsafe-inline'" {
78 t.Errorf("style-src = %q, want 'self' 'unsafe-inline' and nothing else", got)
79 }
80}
81
82// TestStandaloneServesInlineStyles: through the production stack, the
83// viewer — whose slide carries its theme as custom properties on a style
84// attribute, and whose own layout is a <style> element — is served under
85// exactly Slides' policy, which lets both apply. Under rastrillo
86// fb806a2's default it would be served with style-src refusing them.
87func TestStandaloneServesInlineStyles(t *testing.T) {
88 a := newApp(t)
89 cookies := signedIn(t, a, allowedAddress)
90 path := sampleDeck(t, a, cookies)
91 h := productionHandler(t, a)
92 req := httptest.NewRequest(http.MethodGet, path, nil)
93 for _, c := range cookies {
94 req.AddCookie(c)
95 }
96 rec := httptest.NewRecorder()
97 h.ServeHTTP(rec, req)
98 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `style="--slides-bg:`) {
99 t.Fatalf("viewer = %d, or its slide carries no theme style attribute", rec.Code)
100 }
101 policies := rec.Header().Values("Content-Security-Policy")
102 if len(policies) != 1 || policies[0] != slides.ContentSecurityPolicy {
103 t.Fatalf("CSP = %q, want exactly Slides' policy", policies)
104 }
105}
106
107// TestTheConfirmDoorKeepsEveryPolicy: the confirm door through the
108// production stack carries Slides' policy and rauth's added
109// frame-ancestors 'none', and rauth's no-store, strict-origin referrer
110// and framing refusal survive the render. Browsers enforce every CSP
111// header, so the second one only narrows.
112func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
113 a := newApp(t)
114 link := mailedLink(t, a, allowedAddress)
115 h := productionHandler(t, a)
116 if action, _ := openLink(t, h, link); action != "/auth/verify" {
117 t.Fatalf("confirm action = %q", action)
118 }
119 rec := httptest.NewRecorder()
120 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(link, testOrigin), nil))
121 got := rec.Header().Values("Content-Security-Policy")
122 if len(got) != 2 || got[0] != slides.ContentSecurityPolicy || got[1] != "frame-ancestors 'none'" {
123 t.Fatalf("confirm door CSP = %q, want Slides' policy then frame-ancestors 'none'", got)
124 }
125 for header, want := range map[string]string{"X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin", "Cache-Control": "no-store"} {
126 if v := rec.Header().Get(header); v != want {
127 t.Errorf("%s = %q, want %q", header, v, want)
128 }
129 }
130}
131