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
3// Appearance (light / dark / system) is estilo's now: chrome.Appearance
4// reads the estilo_appearance cookie, chrome.HandleAppearance serves
5// POST /appearance, and chrome.ThemeAttr writes <html data-theme>
6// (spec §8.3). Sheets' own /mode, its sheets_mode cookie and "auto" are
7// retired. What these tests keep is the reason this file existed: the
8// choice is in the first response, never fetched, and "system" writes no
9// attribute at all.
10
11import (
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "strings"
16 "testing"
17
18 "amadan.net/oficina/estilo.git/chrome"
19 "amadan.net/rastrillo/rastrillo/ui"
20)
21
22// The bug theme.go was written to stop coming back: data-theme takes the
23// light/dark SCHEME, not rastrillo's theme NAME. A cookie holding a theme
24// name must produce no attribute.
25func TestThemeAttrIsASchemeNeverAThemeName(t *testing.T) {
26 for _, name := range ui.ThemeNames() {
27 r := httptest.NewRequest(http.MethodGet, "/", nil)
28 r.AddCookie(&http.Cookie{Name: chrome.AppearanceCookie, Value: name})
29 if got := chrome.ThemeAttr(chrome.Appearance(r)); got != "" {
30 t.Errorf("a %q cookie produced data-theme=%q; theme names are not schemes", name, got)
31 }
32 }
33}
34
35// theme.css's toggle is exactly these two selectors. If rastrillo ever
36// changes them, ThemeAttr is writing an attribute nothing reads.
37func TestRastrillosToggleStillKeysOnLightAndDark(t *testing.T) {
38 css, err := staticFiles().(interface {
39 ReadFile(string) ([]byte, error)
40 }).ReadFile("static/theme.css")
41 if err != nil {
42 t.Fatalf("read theme.css: %v", err)
43 }
44 for _, want := range []string{`[data-theme="light"]`, `[data-theme="dark"]`} {
45 if !strings.Contains(string(css), want) {
46 t.Fatalf("theme.css no longer carries %s — the light/dark toggle moved", want)
47 }
48 }
49}
50
51// AGENTS.md: light/dark must resolve before first paint, because cell
52// fills resolve against the theme and a late preference repaints every
53// coloured cell. This fails the moment the decision moves into script.
54func TestAppearanceIsInTheFirstResponseNotFetched(t *testing.T) {
55 rec := httptest.NewRecorder()
56 req := httptest.NewRequest(http.MethodGet, "/", nil)
57 req.AddCookie(&http.Cookie{Name: chrome.AppearanceCookie, Value: "dark"})
58 New(Config{}).Router().ServeHTTP(rec, req)
59 if !strings.Contains(rec.Body.String(), `<html lang="en" dir="ltr" data-theme="dark">`) {
60 t.Fatal("the rendered page does not carry the choice in its first bytes; it would flash on load")
61 }
62}
63
64// System must emit no attribute at all, not an empty one, and the
65// retired cookie is ignored rather than honoured.
66func TestSystemEmitsNoThemeAttributeAtAll(t *testing.T) {
67 rec := httptest.NewRecorder()
68 req := httptest.NewRequest(http.MethodGet, "/", nil)
69 req.AddCookie(&http.Cookie{Name: "sheets_mode", Value: "dark"})
70 New(Config{}).Router().ServeHTTP(rec, req)
71 if strings.Contains(rec.Body.String(), "data-theme") {
72 t.Fatal("a viewer with no preference got a data-theme attribute; the OS should decide")
73 }
74}
75
76func postAppearance(t *testing.T, form url.Values) *httptest.ResponseRecorder {
77 t.Helper()
78 req := httptest.NewRequest(http.MethodPost, "/appearance", strings.NewReader(form.Encode()))
79 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
80 rec := httptest.NewRecorder()
81 New(Config{}).Router().ServeHTTP(rec, req)
82 return rec
83}
84
85func TestPostAppearanceSetsTheCookieAndReturns(t *testing.T) {
86 rec := postAppearance(t, url.Values{"mode": {"dark"}, "return_to": {"/sheets/3/table?page=2"}})
87 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets/3/table?page=2" {
88 t.Fatalf("POST /appearance = %d %q, want 303 back to the page", rec.Code, rec.Header().Get("Location"))
89 }
90 var found bool
91 for _, c := range rec.Result().Cookies() {
92 found = found || (c.Name == chrome.AppearanceCookie && c.Value == "dark")
93 }
94 if !found {
95 t.Fatalf("no %s cookie was set: %v", chrome.AppearanceCookie, rec.Result().Cookies())
96 }
97 if rec := postAppearance(t, url.Values{"mode": {"auto"}}); rec.Code != http.StatusBadRequest {
98 t.Fatalf("the retired \"auto\" = %d, want 400", rec.Code)
99 }
100 // The retired route is gone, not quietly kept.
101 if rec := postAppearance(t, nil); rec.Code == http.StatusNotFound {
102 t.Fatal("/appearance is not mounted")
103 }
104 req := httptest.NewRequest(http.MethodPost, "/mode", strings.NewReader("mode=dark"))
105 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
106 rec = httptest.NewRecorder()
107 New(Config{}).Router().ServeHTTP(rec, req)
108 if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
109 t.Fatalf("POST /mode = %d; the retired route still answers", rec.Code)
110 }
111}
112
113// A form field that becomes a Location header is an open redirect unless
114// it is constrained to a path on this origin; rastrillo's SafeReturn is
115// the one check, and the control-character case is the one a prefix
116// test misses.
117func TestAppearanceReturnIsNotAnOpenRedirect(t *testing.T) {
118 for _, hostile := range []string{"https://evil.example.com/", "//evil.example.com/", `/\evil.example.com/`, "/\t/evil.example.com", "javascript:alert(1)"} {
119 rec := postAppearance(t, url.Values{"mode": {"light"}, "return_to": {hostile}})
120 if rec.Header().Get("Location") != "/" {
121 t.Errorf("return_to %q went to %q, want /", hostile, rec.Header().Get("Location"))
122 }
123 }
124}
125
126// The switcher is chrome, and chrome that needs JavaScript is chrome
127// somebody who needs dark mode cannot reach. It is a form in the account
128// menu, under an Appearance heading, with the current choice marked by
129// aria-pressed on every option (the audit found the current one marked
130// with nothing a person could see).
131func TestAppearanceIsAFormWithTheCurrentChoiceMarked(t *testing.T) {
132 app, cookies := signIn(t)
133 req := as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)
134 req.AddCookie(&http.Cookie{Name: chrome.AppearanceCookie, Value: "dark"})
135 body := do(t, app, req).Body.String()
136 for _, want := range []string{
137 `<p estilo-menu-heading id="estilo-appearance">Appearance</p>`,
138 `<form method="post" action="/appearance" role="group" aria-labelledby="estilo-appearance">`,
139 `<input type="hidden" name="return_to" value="/sheets">`,
140 `value="light" aria-pressed="false"`, `value="dark" aria-pressed="true"`, `value="system" aria-pressed="false"`,
141 } {
142 if !strings.Contains(body, want) {
143 t.Errorf("the account menu lacks %s", want)
144 }
145 }
146}
147