| 1 | package 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 | |
| 11 | import ( |
| 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. |
| 25 | func 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. |
| 37 | func 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. |
| 54 | func 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. |
| 66 | func 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 | |
| 76 | func 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 | |
| 85 | func 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. |
| 117 | func 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). |
| 131 | func 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 | |