oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9
10 "amadan.net/rastrillo/rastrillo"
11)
12
13// productionHandler is app behind rastrillo's serving stack, built from
14// the same ServeOptions main hands to rastrillo.Serve: security headers,
15// CSP and all. app.Router() alone carries no CSP, so a test that serves
16// only the router cannot see what a browser is told.
17func productionHandler(t *testing.T, app *App) http.Handler {
18 t.Helper()
19 h, closeFn, err := rastrillo.Handler(app.ServeOptions(rastrillo.Options{Logger: quietLogger()}))
20 if err != nil {
21 t.Fatalf("rastrillo.Handler: %v", err)
22 }
23 t.Cleanup(func() { _ = closeFn() })
24 return h
25}
26
27// directives splits a policy into directive name → source list.
28func directives(policy string) map[string]string {
29 out := map[string]string{}
30 for _, d := range strings.Split(policy, ";") {
31 name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
32 if name != "" {
33 out[name] = strings.TrimSpace(value)
34 }
35 }
36 return out
37}
38
39// TestProductionCSPIsTheDefaultBarStyles: Sheets' policy is rastrillo's
40// default with style-src alone widened to 'unsafe-inline'
41// (ContentSecurityPolicy says why). Every other directive must match
42// the framework's, read off a handler built with no CSP of its own, so
43// a rastrillo bump that tightens anything else fails here rather than
44// being silently undone by our constant.
45func TestProductionCSPIsTheDefaultBarStyles(t *testing.T) {
46 bare, closeFn, err := rastrillo.Handler(rastrillo.Options{Mux: http.NewServeMux(), Logger: quietLogger()})
47 if err != nil {
48 t.Fatal(err)
49 }
50 defer closeFn()
51 rec := httptest.NewRecorder()
52 bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
53 def := directives(rec.Header().Get("Content-Security-Policy"))
54 ours := directives(ContentSecurityPolicy)
55 if len(def) == 0 {
56 t.Fatal("rastrillo set no default CSP")
57 }
58 for name, value := range def {
59 if name == "style-src" {
60 continue
61 }
62 if ours[name] != value {
63 t.Errorf("%s: ours %q, rastrillo's default %q", name, ours[name], value)
64 }
65 }
66 for name := range ours {
67 if _, ok := def[name]; !ok {
68 t.Errorf("ours adds %s, which rastrillo's default does not have", name)
69 }
70 }
71 // A hash or nonce beside 'unsafe-inline' makes the browser ignore
72 // 'unsafe-inline' (CSP3), which is exactly how the styles broke.
73 if got := ours["style-src"]; got != "'self' 'unsafe-inline'" {
74 t.Errorf("style-src = %q, want 'self' 'unsafe-inline' and nothing else", got)
75 }
76}
77
78// TestStandaloneServesInlineStyles: through the production stack, a
79// formatted table page — whose cell colours are custom properties on a
80// style attribute — is served under a policy that lets that attribute
81// apply. On 5e7e873 the header was rastrillo's new default and the cell
82// lost its fill (Astra on #46).
83func TestStandaloneServesInlineStyles(t *testing.T) {
84 app, cookies := signIn(t)
85 s, cells := newSheet(t, app, cookies, "Painted")
86 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"heading"}`)
87 postFormatForm(t, app, cookies, s, map[string]string{
88 "r1": "0", "c1": "0", "r2": "0", "c2": "0", "fill": "green", "page": "1",
89 })
90 h := productionHandler(t, app)
91 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/sheets/%d/table", s.ID), nil)
92 rec := httptest.NewRecorder()
93 h.ServeHTTP(rec, as(req, cookies))
94 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `style="--sheet-h:`) {
95 t.Fatalf("table page = %d, or it paints no cell", rec.Code)
96 }
97 policies := rec.Header().Values("Content-Security-Policy")
98 if len(policies) != 1 || policies[0] != ContentSecurityPolicy {
99 t.Fatalf("CSP = %q, want exactly Sheets' policy", policies)
100 }
101}
102
103// TestTheConfirmDoorKeepsEveryPolicy: the confirm door through the
104// production stack carries Sheets' policy and auth's added
105// frame-ancestors 'none'. Browsers enforce every CSP header, so the
106// second one only narrows.
107func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
108 app, mail := appWithRecorder(t)
109 postSignin(t, app, signedInAddress)
110 link := mail.lastLink(t)
111 h := productionHandler(t, app)
112 action, _ := openLink(t, h, link)
113 if action != "/auth/verify" {
114 t.Fatalf("confirm action = %q", action)
115 }
116 rec := httptest.NewRecorder()
117 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(link, testOrigin), nil))
118 got := rec.Header().Values("Content-Security-Policy")
119 if len(got) != 2 || got[0] != ContentSecurityPolicy || got[1] != "frame-ancestors 'none'" {
120 t.Fatalf("confirm door CSP = %q, want Sheets' policy then frame-ancestors 'none'", got)
121 }
122 for header, want := range map[string]string{"X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin", "Cache-Control": "no-store"} {
123 if v := rec.Header().Get(header); v != want {
124 t.Errorf("%s = %q, want %q", header, v, want)
125 }
126 }
127}
128
129// TestHomeModeServesInlineStyles: with Home configured, connect's
130// middleware replaces the policy with connect.FormPolicy. Through the
131// production stack its style-src must still admit the cells' style
132// attributes.
133func TestHomeModeServesInlineStyles(t *testing.T) {
134 f := newHomeFixture(t)
135 h := productionHandler(t, f.app)
136 rec := httptest.NewRecorder()
137 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/signin/local", nil))
138 if rec.Code != http.StatusOK {
139 t.Fatalf("GET /signin/local = %d", rec.Code)
140 }
141 got := rec.Header().Values("Content-Security-Policy")
142 if len(got) != 1 || directives(got[0])["style-src"] != "'self' 'unsafe-inline'" {
143 t.Fatalf("Home-mode CSP = %q, want one policy whose style-src is 'self' 'unsafe-inline'", got)
144 }
145}
146