oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
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
14// productionHandler is app behind rastrillo's serving stack, built from
15// the same ServeOptions main hands to rastrillo.Serve: security headers,
16// CSP and all. app.Handler() alone carries no CSP, so a test that serves
17// only the router cannot see what a browser is told.
18func productionHandler(t *testing.T, app *App) http.Handler {
19 t.Helper()
20 h, closeFn, err := rastrillo.Handler(app.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}))
21 if err != nil {
22 t.Fatalf("rastrillo.Handler: %v", err)
23 }
24 if closeFn != nil {
25 t.Cleanup(func() { _ = closeFn() })
26 }
27 return h
28}
29
30// defaultPolicy is rastrillo's own CSP, read off a handler built with no
31// CSP of its own.
32func defaultPolicy(t *testing.T) map[string]string {
33 t.Helper()
34 bare, closeFn, err := rastrillo.Handler(rastrillo.Options{Mux: http.NewServeMux(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
35 if err != nil {
36 t.Fatal(err)
37 }
38 if closeFn != nil {
39 defer closeFn()
40 }
41 rec := httptest.NewRecorder()
42 bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
43 def := directives(rec.Header().Get("Content-Security-Policy"))
44 if len(def) == 0 {
45 t.Fatal("rastrillo set no default CSP")
46 }
47 return def
48}
49
50// directives splits a policy into directive name → source list.
51func directives(policy string) map[string]string {
52 out := map[string]string{}
53 for _, d := range strings.Split(policy, ";") {
54 name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
55 if name != "" {
56 out[name] = strings.TrimSpace(value)
57 }
58 }
59 return out
60}
61
62// samePolicyBut fails unless got is rastrillo's default directive for
63// directive, except where except names a different value.
64func samePolicyBut(t *testing.T, where, got string, except map[string]string) {
65 t.Helper()
66 def, ours := defaultPolicy(t), directives(got)
67 for name, value := range def {
68 if want, ok := except[name]; ok {
69 value = want
70 }
71 if ours[name] != value {
72 t.Errorf("%s: %s is %q, want %q", where, name, ours[name], value)
73 }
74 }
75 for name := range ours {
76 if _, ok := def[name]; !ok {
77 t.Errorf("%s adds %s, which rastrillo's default does not have", where, name)
78 }
79 }
80}
81
82// TestProductionCSPIsTheDefaultBarStyles: Calendar's policy is
83// rastrillo's default with style-src alone widened to 'unsafe-inline'
84// (ContentSecurityPolicy says why). Every other directive must match the
85// framework's, so a rastrillo bump that tightens anything else fails here
86// rather than being silently undone by our constant. A hash or nonce
87// beside 'unsafe-inline' would make the browser ignore 'unsafe-inline'
88// (CSP3), so style-src must be exactly that.
89func TestProductionCSPIsTheDefaultBarStyles(t *testing.T) {
90 samePolicyBut(t, "ContentSecurityPolicy", ContentSecurityPolicy, map[string]string{"style-src": "'self' 'unsafe-inline'"})
91}
92
93// TestStandaloneServesInlineStyles: through the production stack, a day
94// view with an event on it — whose chip is placed by a style attribute —
95// is served under exactly Calendar's policy, which lets that attribute
96// apply. Under rastrillo fb806a2's default the chip and every slot link
97// collapse to the top of the column (TestPlacedChipsSurviveTheProductionCSP
98// shows it in a browser).
99func TestStandaloneServesInlineStyles(t *testing.T) {
100 app := NewForTest(t, t.TempDir())
101 session := app.SignInForTest(t, "someone@example.com")
102 person := app.PersonByAddressForTest(t, "someone@example.com")
103 calendarID, err := app.store.DefaultCalendarID(t.Context(), person.ID)
104 if err != nil {
105 t.Fatal(err)
106 }
107 if _, err := app.store.CreateEvent(t.Context(), person.ID, EventInput{CalendarID: calendarID, Title: "Standup", StartLocal: "2026-09-10T09:30", EndLocal: "2026-09-10T10:00", TZ: "UTC"}); err != nil {
108 t.Fatal(err)
109 }
110 h := productionHandler(t, app)
111 rec := httptest.NewRecorder()
112 h.ServeHTTP(rec, RequestForTest(session, http.MethodGet, "/day/2026-09-10", nil))
113 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `cal-placed`) || !strings.Contains(rec.Body.String(), `style="top:`) {
114 t.Fatalf("day view = %d, or it places no chip", rec.Code)
115 }
116 policies := rec.Header().Values("Content-Security-Policy")
117 if len(policies) != 1 || policies[0] != ContentSecurityPolicy {
118 t.Fatalf("CSP = %q, want exactly Calendar's policy", policies)
119 }
120}
121
122// TestTheConfirmDoorKeepsEveryPolicy: the confirm door, through the
123// production stack, carries Calendar's policy and auth's added
124// frame-ancestors 'none' (browsers enforce every CSP header, so the
125// second only narrows), and the headers auth sets before RenderConfirm
126// survive the render: the URL is a live credential.
127func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
128 app := NewForTest(t, t.TempDir())
129 form := strings.NewReader("address=someone%40example.com&force=1")
130 req := httptest.NewRequest(http.MethodPost, "/signin", form)
131 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
132 req.Header.Set("Origin", TestOrigin)
133 app.mux.ServeHTTP(httptest.NewRecorder(), req)
134 app.signinMail.wait()
135 target, err := linkPath(magicLinkPattern.FindString(app.mailRecorder.last()))
136 if err != nil {
137 t.Fatal(err)
138 }
139 rec := httptest.NewRecorder()
140 productionHandler(t, app).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
141 if action, _, ok := ConfirmFormForTest(rec.Body.String()); rec.Code != http.StatusOK || !ok || action != "/auth/verify" {
142 t.Fatalf("confirm door = %d, action %q", rec.Code, action)
143 }
144 got := rec.Header().Values("Content-Security-Policy")
145 if len(got) != 2 || got[0] != ContentSecurityPolicy || got[1] != "frame-ancestors 'none'" {
146 t.Fatalf("confirm door CSP = %q, want Calendar's policy then frame-ancestors 'none'", got)
147 }
148 for header, want := range map[string]string{"X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin", "Cache-Control": "no-store"} {
149 if v := rec.Header().Get(header); v != want {
150 t.Errorf("%s = %q, want %q", header, v, want)
151 }
152 }
153}
154