Test the shared chrome: errors, bar, account, titles, menus, sign-in
Each was seen failing under a mutation of the code it covers (polish-audit/scripts/sheets-r2-mutations.py): 27 tests, all red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+753
−1
internal/sheets/chrome_menu_test.go+204 −0internal/sheets/chrome_signin_test.go+105 −0internal/sheets/chrome_test.go+187 −0internal/sheets/errors_test.go+249 −0internal/sheets/home_test.go+8 −1
CI failed — run details
CI log
amadan: sync for CI failed: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected flush after ref listing (and could not reset the workspace: unlinkat /home/paulca/.local/state/amadan/runner/repos/oficina__sheets/cache/go-mod/modernc.org/libc@v1.74.1/ioutil_darwin.go: permission denied)
diff --git a/internal/sheets/chrome_menu_test.go b/internal/sheets/chrome_menu_test.go| new file mode 100644 |
| index 0000000..1574919 |
| --- /dev/null |
| +++ b/internal/sheets/chrome_menu_test.go |
| @@ -0,0 +1,204 @@ |
| +package sheets |
| + |
| +import ( |
| + "html" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// §5.4: the rename is the bar's editable title — a real form posting to |
| +// the rename route, only for someone who may write. A reader sees the |
| +// title read-only. |
| +func TestTheBarRenamesTheSheet(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| + for _, want := range []string{ |
| + `<form estilo-bar-title method="post" action="/sheets/` + strconv.FormatUint(s.ID, 10) + `/rename">`, |
| + `<input estilo-bar-title-input name="title" value="Budget" aria-label="Sheet title"`, |
| + `<button rst-btn="ghost" type="submit" estilo-bar-title-submit>Rename</button>`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the writer's bar lacks %s", want) |
| + } |
| + } |
| + if strings.Contains(body, "data-doc-title") { |
| + t.Error("the old in-bar rename form is still rendered") |
| + } |
| + // A reader: a viewer grant only, signed in through the real link. |
| + if _, err := app.grant(s.ID, "reader@example.com", SheetViewer, signedInMember(t, app)); err != nil { |
| + t.Fatal(err) |
| + } |
| + postSignin(t, app, "reader@example.com") |
| + u, err := url.Parse(app.testMailer(t).lastLink(t)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reader := do(t, app, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)).Result().Cookies() |
| + body = page(t, app, reader, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| + if strings.Contains(body, "estilo-bar-title") || !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Budget">Budget</span>`) { |
| + t.Error("a reader is offered the rename, or does not see the title") |
| + } |
| +} |
| + |
| +// The rename works through the bar with scripting off: Enter posts the |
| +// form, and the sheet comes back renamed. |
| +func TestTheBarsRenameFormRenames(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + rec := do(t, app, as(postForm("/sheets/"+strconv.FormatUint(s.ID, 10)+"/rename", map[string][]string{"title": {"Commission Q2"}}), cookies)) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("rename = %d", rec.Code) |
| + } |
| + if body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String(); !strings.Contains(body, `value="Commission Q2"`) || titleOf(t, body) != "Commission Q2 · Sheets" { |
| + t.Error("the bar does not show the new title") |
| + } |
| +} |
| + |
| +// §0.5 and §12.1 item 2: <meta charset> first, then the stylesheets in |
| +// the suite's order — tokens, theme, brand, estilo base, menu, chrome, |
| +// then Sheets' own. |
| +func TestEstiloChromeAssetsAreLinkedInOrder(t *testing.T) { |
| + body := page(t, New(Config{}), nil, "/signin").Body.String() |
| + head := body[strings.Index(body, "<head>"):strings.Index(body, "</head>")] |
| + if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(head, "<head>")), `<meta charset="utf-8">`) { |
| + t.Error("<meta charset> is not the first thing in <head>") |
| + } |
| + last := -1 |
| + for _, css := range []string{"/static/tokens.", "/static/theme.", "/static/brand/brand.", "/static/estilo/base.", "/static/estilo/menu.", "/static/estilo/chrome.", "/static/sheets."} { |
| + i := strings.Index(head, css) |
| + if i < 0 || i < last { |
| + t.Fatalf("%s is missing or out of order", css) |
| + } |
| + last = i |
| + } |
| + for _, asset := range []string{"/static/estilo/base.", "/static/estilo/chrome."} { |
| + href := regexp.MustCompile(regexp.QuoteMeta(asset) + `[0-9a-f]+\.(css|js)`).FindString(head) |
| + if href == "" { |
| + t.Fatalf("%s is not fingerprinted", asset) |
| + } |
| + if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, href, nil)); rec.Code != http.StatusOK || rec.Body.Len() == 0 { |
| + t.Errorf("%s does not serve (%d)", href, rec.Code) |
| + } |
| + } |
| + if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, "/static/estilo/brand/sheets.svg", nil)); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" { |
| + t.Errorf("the bar's brand icon does not serve (%d)", rec.Code) |
| + } |
| +} |
| + |
| +// §7.3: shortcuts are labelled for the request's platform, server-side, |
| +// in the menus, the toolbar's tips and the shortcuts dialog alike — and |
| +// the page says it varies by User-Agent. |
| +func TestShortcutLabelsFollowThePlatform(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + path := "/sheets/" + strconv.FormatUint(s.ID, 10) |
| + for ua, want := range map[string][]string{ |
| + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36": {`<kbd estilo-menu-key>⌘Z</kbd>`, `data-tip="Undo (⌘Z)"`, `data-tip="Bold (⌘B)"`, `<dt><kbd>⌘C</kbd></dt>`, `data-key-copy="⌘C"`}, |
| + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36": {`<kbd estilo-menu-key>Ctrl+Z</kbd>`, `data-tip="Undo (Ctrl+Z)"`, `data-tip="Bold (Ctrl+B)"`, `<dt><kbd>Ctrl+C</kbd></dt>`, `data-key-copy="Ctrl+C"`}, |
| + } { |
| + req := httptest.NewRequest(http.MethodGet, path, nil) |
| + req.Header.Set("User-Agent", ua) |
| + rec := do(t, app, as(req, cookies)) |
| + // html/template writes "+" as +; the browser reads it back. |
| + body := html.UnescapeString(rec.Body.String()) |
| + for _, w := range want { |
| + if !strings.Contains(body, w) { |
| + t.Errorf("%s: missing %s", ua[13:30], w) |
| + } |
| + } |
| + for _, stale := range []string{"Ctrl Z", "(Ctrl+Z)</", "Ctrl + C"} { |
| + if strings.Contains(body, stale) { |
| + t.Errorf("%s: an old label %q survives", ua[13:30], stale) |
| + } |
| + } |
| + if !strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "User-Agent") { |
| + t.Error("a page with platform labels does not Vary on User-Agent") |
| + } |
| + } |
| +} |
| + |
| +// §7.3: an item appears only if it works in this build. The Help menu |
| +// (a list of keys, not a menu) left the menu bar for the account menu's |
| +// "Keyboard shortcuts…" dialog; Format keeps no inline submenus; items |
| +// that need script are hidden until it runs. |
| +func TestUnbuiltMenuItemsAreHidden(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| + menubar := body[strings.Index(body, "<nav estilo-menubar"):] |
| + menubar = menubar[:strings.Index(menubar, "</nav>")] |
| + titles := regexp.MustCompile(`<summary estilo-menubar-title>([^<]+)</summary>`).FindAllStringSubmatch(menubar, -1) |
| + var got []string |
| + for _, m := range titles { |
| + got = append(got, m[1]) |
| + } |
| + if strings.Join(got, ",") != "File,Edit,Insert,Format,Data" { |
| + t.Errorf("menu bar titles %v, want File, Edit, Insert, Format, Data", got) |
| + } |
| + if strings.Count(menubar, "<details") != len(titles) { |
| + t.Error("a menu holds a submenu; menus here are one level") |
| + } |
| + for _, gone := range []string{"rst-dropdown", "rst-menu-group", "Fill colour", "Number format"} { |
| + if strings.Contains(menubar, gone) { |
| + t.Errorf("the menu bar still carries %q", gone) |
| + } |
| + } |
| + for _, cmd := range regexp.MustCompile(`<button estilo-menu-item type="button" data-cmd="(cut|copy|paste|clear|fill-down|fill-right|select-all|fn)"[^>]*>`).FindAllString(menubar, -1) { |
| + if !strings.Contains(cmd, " hidden") { |
| + t.Errorf("%s is shown before the script that runs it", cmd) |
| + } |
| + } |
| + // The shortcuts moved to the account menu, as a dialog that exists. |
| + if !strings.Contains(body, `<button estilo-menu-item type="button" commandfor="sheets-shortcuts" command="show-modal"><span estilo-menu-label>Keyboard shortcuts…</span></button>`) { |
| + t.Error("the account menu's Help does not open the shortcuts") |
| + } |
| + if !strings.Contains(body, `<dialog id="sheets-shortcuts"`) { |
| + t.Error("the shortcuts dialog the account menu opens is not on the page") |
| + } |
| +} |
| + |
| +// §4: the only notice vocabulary is rastrillo's callout with a real tone. |
| +// The grep gate: no rst-notice, no valued rst-callout="…" (the old |
| +// "warn" matched nothing and painted grey), no rst-tone="warn". |
| +func TestTemplatesUseOnlyCalloutTones(t *testing.T) { |
| + files, err := filepath.Glob("templates/*.html") |
| + if err != nil || len(files) == 0 { |
| + t.Fatal("no templates found", err) |
| + } |
| + strip := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`) |
| + bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`) |
| + for _, f := range files { |
| + body, err := os.ReadFile(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := bad.FindString(strip.ReplaceAllString(string(body), "")); m != "" { |
| + t.Errorf("%s uses %q", f, m) |
| + } |
| + } |
| + // And the tones reach the page: a created link's warning, a grant's |
| + // positive notice, a refused grant's negative alert. |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + share := "/sheets/" + strconv.FormatUint(s.ID, 10) + "/share" |
| + for query, want := range map[string]string{ |
| + "?notice=shared": `rst-tone="positive"`, |
| + "?notice=bad-role": `rst-tone="negative" role="alert"`, |
| + } { |
| + if body := page(t, app, cookies, share+query).Body.String(); !strings.Contains(body, want) { |
| + t.Errorf("%s: no %s callout", query, want) |
| + } |
| + } |
| + rec := do(t, app, as(postForm(share+"/link", map[string][]string{"role": {"viewer"}}), cookies)) |
| + if body := rec.Body.String(); !strings.Contains(body, `<div rst-callout rst-tone="warning">`) || strings.Count(body, "Copy it now") != 1 { |
| + t.Errorf("a new link is not one warning callout (status %d)", rec.Code) |
| + } |
| +} |
diff --git a/internal/sheets/chrome_signin_test.go b/internal/sheets/chrome_signin_test.go| new file mode 100644 |
| index 0000000..287df87 |
| --- /dev/null |
| +++ b/internal/sheets/chrome_signin_test.go |
| @@ -0,0 +1,105 @@ |
| +package sheets |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// §8.2 item 2: "Connect your Oficina account" is offered with Home |
| +// configured, to a local session, and never to a Home session, whose |
| +// account is already the connection. Sheets used to offer it to both. |
| +func TestConnectYourOficinaAccountIsHiddenForHomeSessions(t *testing.T) { |
| + f := newHomeFixture(t) |
| + f.local(t, signedInAddress) |
| + if body := f.request(t, "GET", "/sheets", nil, false).Body.String(); !strings.Contains(body, `<a estilo-menu-item href="/account/home"><span estilo-menu-label>Connect your Oficina account</span></a>`) { |
| + t.Error("a local session is not offered the connection") |
| + } |
| + g := newHomeFixture(t) |
| + homeSignedIn(t, g) |
| + body := g.request(t, "GET", "/sheets", nil, false).Body.String() |
| + if !strings.Contains(body, "<details estilo-account") { |
| + t.Fatal("the Home session is not signed in") |
| + } |
| + if strings.Contains(body, "/account/home") || strings.Contains(body, "Home account") { |
| + t.Error("a Home session is offered to connect the account it signed in with") |
| + } |
| + app, cookies := signIn(t) |
| + if body := page(t, app, cookies, "/sheets").Body.String(); strings.Contains(body, "/account/home") { |
| + t.Error("a standalone instance offers a connection to a Home it does not have") |
| + } |
| +} |
| + |
| +// Home #74, decision 2: with Home configured, a signed-out "/" starts |
| +// sign-in instead of showing a landing page — the switcher's single-team |
| +// hop lands on the root. Signed in, it is the list, both ways. |
| +func TestTheRootStartsSignInWhenHomeIsConfigured(t *testing.T) { |
| + f := newHomeFixture(t) |
| + if rec := f.request(t, "GET", "/", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" { |
| + t.Fatalf("signed-out / with Home = %d %q, want 303 /signin", rec.Code, rec.Header().Get("Location")) |
| + } |
| + homeSignedIn(t, f) |
| + if rec := f.request(t, "GET", "/", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets" { |
| + t.Fatalf("signed-in / with Home = %d %q, want 303 /sheets", rec.Code, rec.Header().Get("Location")) |
| + } |
| + g := newHomeFixture(t) |
| + g.local(t, signedInAddress) |
| + if rec := g.request(t, "GET", "/", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets" { |
| + t.Fatalf("locally signed-in / with Home = %d %q, want 303 /sheets", rec.Code, rec.Header().Get("Location")) |
| + } |
| + // Standalone keeps its landing page for a stranger. |
| + if rec := page(t, New(Config{}), nil, "/"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<h1>Sheets</h1>") { |
| + t.Fatalf("standalone signed-out / = %d, want the landing page", rec.Code) |
| + } |
| +} |
| + |
| +// §9.2's last row: somebody already signed in never sees the form. With |
| +// Home, connect's /signin does it (home/connect 01166fc); standalone, |
| +// Sheets' own handler does, to a safe return_to or the list, and never |
| +// to a sign-in page (which would loop). |
| +func TestSigninWhenAlreadySignedIn(t *testing.T) { |
| + app, cookies := signIn(t) |
| + for query, want := range map[string]string{ |
| + "": "/sheets", |
| + "?return_to=%2Fsheets%2F4": "/sheets/4", |
| + "?return_to=%2Fsignin": "/sheets", |
| + "?return_to=%2F%2Fevil.test": "/sheets", |
| + } { |
| + rec := page(t, app, cookies, "/signin"+query) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want { |
| + t.Errorf("signed-in /signin%s = %d %q, want 303 %s", query, rec.Code, rec.Header().Get("Location"), want) |
| + } |
| + } |
| + // The outcome pages still render: a keymail failure must be able to |
| + // offer the email path to somebody holding a session. |
| + if rec := page(t, app, cookies, "/signin?force=1&err=keymail"); rec.Code != http.StatusOK { |
| + t.Errorf("?force=1&err=keymail = %d, want the form", rec.Code) |
| + } |
| + f := newHomeFixture(t) |
| + homeSignedIn(t, f) |
| + if rec := f.request(t, "GET", "/signin?return_to=%2Fsheets%2F4", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets/4" { |
| + t.Errorf("Home session /signin = %d %q, want 303 /sheets/4", rec.Code, rec.Header().Get("Location")) |
| + } |
| +} |
| + |
| +// §9.2: Sign out lands on the signed-out page, "You’re signed out", with |
| +// an empty corner and a way back in — standalone too, where rastrillo's |
| +// auth sends it to the sign-in form. |
| +func TestSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| + app, cookies := signIn(t) |
| + req := as(postForm("/signout", nil), cookies) |
| + req.Header.Set("Origin", testOrigin) |
| + rec := do(t, app, req) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" { |
| + t.Fatalf("sign out = %d %q, want 303 /signed-out", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if after := page(t, app, cookies, "/sheets"); after.Code == http.StatusOK { |
| + t.Fatal("the session survived sign-out") |
| + } |
| + body := page(t, app, nil, "/signed-out").Body.String() |
| + for _, want := range []string{"<h1>You’re signed out</h1>", "You’ve signed out of Sheets on this device.", `<a rst-btn="primary" href="/signin">Sign in</a>`} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the signed-out page lacks %s", want) |
| + } |
| + } |
| +} |
diff --git a/internal/sheets/chrome_test.go b/internal/sheets/chrome_test.go| new file mode 100644 |
| index 0000000..720aac6 |
| --- /dev/null |
| +++ b/internal/sheets/chrome_test.go |
| @@ -0,0 +1,187 @@ |
| +package sheets |
| + |
| +// The shared chrome, as Sheets renders it (estilo spec/shared-chrome, |
| +// §5, §6, §7, §8, §9, §10). Each test names the section it holds. |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`) |
| + |
| +// page GETs path as a browser does, with the session cookies. |
| +func page(t *testing.T, app *App, cookies []*http.Cookie, path string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodGet, path, nil) |
| + req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8") |
| + return do(t, app, as(req, cookies)) |
| +} |
| + |
| +func titleOf(t *testing.T, body string) string { |
| + t.Helper() |
| + m := pageTitle.FindStringSubmatch(body) |
| + if m == nil { |
| + t.Fatal("no <title>") |
| + } |
| + return m[1] |
| +} |
| + |
| +// homeSignedIn signs the fixture's browser in THROUGH Home: a linked |
| +// account, then an ordinary sign-in by assertion, so the session's |
| +// method is Home's. |
| +func homeSignedIn(t *testing.T, f *homeFixture) Member { |
| + t.Helper() |
| + member := f.local(t, signedInAddress) |
| + f.link(t, "opaque-home-subject") |
| + f.jar = map[string]*http.Cookie{} |
| + signin := f.request(t, "GET", "/signin?return_to=%2Fsheets", nil, false) |
| + start := f.request(t, "GET", signin.Header().Get("Location"), nil, false) |
| + if finish := f.exchange(t, start, "opaque-home-subject"); finish.Code != 303 { |
| + t.Fatal("Home sign-in", finish.Code) |
| + } |
| + return member |
| +} |
| + |
| +// §10: every <title> is "{part} · {part} · Sheets", built in the layout, |
| +// and a page's {Page} is its h1. None says "Oficina Sheets", "—" or "|". |
| +func TestPageTitlesFollowTheSuitePattern(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Commission Q2") |
| + id := strconv.FormatUint(s.ID, 10) |
| + for path, want := range map[string]string{ |
| + "/sheets": "Sheets", |
| + "/sheets/" + id: "Commission Q2 · Sheets", |
| + "/sheets/" + id + "/table": "Table view · Commission Q2 · Sheets", |
| + "/sheets/" + id + "/history": "Version history · Commission Q2 · Sheets", |
| + "/sheets/" + id + "/comments": "Comments · Commission Q2 · Sheets", |
| + "/sheets/" + id + "/share": "Share · Commission Q2 · Sheets", |
| + "/sheets/import": "Import a spreadsheet · Sheets", |
| + "/sheets/99999": "Sheet not available · Sheets", |
| + "/no/such/page": "Page not found · Sheets", |
| + } { |
| + if got := titleOf(t, page(t, app, cookies, path).Body.String()); got != want { |
| + t.Errorf("%s: <title> %q, want %q", path, got, want) |
| + } |
| + } |
| + // A sub-page's h1 is its {Page}; the sheet's title is in the bar. |
| + for path, h1 := range map[string]string{ |
| + "/sheets/" + id + "/table": "Table view", |
| + "/sheets/" + id + "/history": "Version history", |
| + "/sheets/" + id + "/comments": "Comments", |
| + "/sheets/" + id + "/share": "Share", |
| + "/sheets": "All sheets", |
| + } { |
| + body := page(t, app, cookies, path).Body.String() |
| + if !strings.Contains(body, "<h1>"+h1+"</h1>") { |
| + t.Errorf("%s: the h1 is not %q", path, h1) |
| + } |
| + if path != "/sheets" && !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Commission Q2">Commission Q2</span>`) { |
| + t.Errorf("%s: the sheet's title is not the bar's context", path) |
| + } |
| + } |
| + for path, want := range map[string]string{ |
| + "/": "Sheets", |
| + "/signin": "Sign in · Sheets", |
| + "/signin?sent=1": "Check your email · Sheets", |
| + "/signed-out": "Signed out · Sheets", |
| + "/no/such/page": "Page not found · Sheets", |
| + "/signin?err=bad": "Sign in · Sheets", |
| + } { |
| + if got := titleOf(t, page(t, app, nil, path).Body.String()); got != want { |
| + t.Errorf("%s signed out: <title> %q, want %q", path, got, want) |
| + } |
| + } |
| +} |
| + |
| +// §5.1 / §8.2: on every signed-in page the account menu is the last |
| +// thing in the bar, and "Sign out" appears exactly once in the page — |
| +// it used to be the account list's fourth flat row. |
| +func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + id := strconv.FormatUint(s.ID, 10) |
| + for _, path := range []string{"/sheets", "/sheets/" + id, "/sheets/" + id + "/table", "/sheets/" + id + "/share", "/sheets/" + id + "/history", "/sheets/" + id + "/comments", "/sheets/import", "/no/such/page", "/sheets/99999"} { |
| + body := page(t, app, cookies, path).Body.String() |
| + if n := strings.Count(body, ">Sign out<"); n != 1 { |
| + t.Errorf("%s: %d Sign out, want exactly one", path, n) |
| + } |
| + end := body[strings.Index(body, "<div estilo-bar-end>"):strings.Index(body, "</header>")] |
| + tail := end[strings.LastIndex(end, "</details>"):] |
| + if !strings.Contains(end, "<details estilo-account") || strings.Count(tail, "<") != 2 { // </details> </div> |
| + t.Errorf("%s: the account is not the last thing in the bar", path) |
| + } |
| + if strings.Contains(body, "rst-shell-") { |
| + t.Errorf("%s: the rastrillo topbar is still rendered", path) |
| + } |
| + } |
| +} |
| + |
| +// §5.1 / §8.4: signed out, the corner is "Sign in" carrying the page to |
| +// come back to; on the sign-in pages themselves it is empty; on the |
| +// public link page the brand is plain text and Sign in carries no path |
| +// (the path is a capability). |
| +func TestTheSignedOutCorner(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body := page(t, app, nil, "/no/such/page?x=1").Body.String() |
| + if !strings.Contains(body, `<a rst-btn href="/signin?return_to=%2Fno%2Fsuch%2Fpage%3Fx%3D1" estilo-bar-signin>Sign in</a>`) { |
| + t.Error("a signed-out page's corner is not Sign in with its return_to") |
| + } |
| + for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} { |
| + body := page(t, app, nil, path).Body.String() |
| + if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| + t.Errorf("%s: the corner is not empty on a sign-in page", path) |
| + } |
| + } |
| + link := page(t, app, nil, "/l/"+token).Body.String() |
| + if !strings.Contains(link, "<span estilo-bar-brand>") || strings.Contains(link, "<a estilo-bar-brand") { |
| + t.Error("the public link page's brand is a link a stranger would be bounced through") |
| + } |
| + if !strings.Contains(link, `<a rst-btn href="/signin" estilo-bar-signin>Sign in</a>`) || strings.Contains(link, "return_to=%2Fl%2F") { |
| + t.Error("the link page's Sign in is missing, or copies the capability path into a URL") |
| + } |
| +} |
| + |
| +// §6: the switcher shows only when Home names a hub directory, and then |
| +// holds "All apps" alone (Paul's §13 answer 2) — no tiles yet. |
| +func TestTheSwitcherIsAllAppsOnly(t *testing.T) { |
| + app, cookies := signIn(t) |
| + if body := page(t, app, cookies, "/sheets").Body.String(); strings.Contains(body, "estilo-switcher") { |
| + t.Error("a standalone instance shows a switcher; there is no directory to switch through") |
| + } |
| + f := newHomeFixtureWith(t, "https://oficina.example.com") |
| + f.local(t, signedInAddress) |
| + body := f.request(t, "GET", "/sheets", nil, false).Body.String() |
| + for _, want := range []string{`<details estilo-switcher name="estilo-menus">`, `aria-label="Oficina apps"`, `<a estilo-menu-item href="https://oficina.example.com/"><span estilo-menu-label>All apps</span></a>`} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the switcher lacks %s", want) |
| + } |
| + } |
| + if strings.Contains(body, "estilo-switcher-app") { |
| + t.Error("the switcher renders app tiles before Home's /apps/<slug> hop is ready") |
| + } |
| +} |
| + |
| +// §5.1 / §5.3: the grid is the one page whose shell fills the viewport; |
| +// every other page's shell is plain, and every page has the bar. |
| +func TestTheGridFillsTheViewport(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + grid := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| + if !strings.Contains(grid, `<div estilo-shell="fill">`) || !strings.Contains(grid, `<header estilo-bar>`) { |
| + t.Error("the grid's shell does not fill the viewport, or its bar is sticky in a page that never scrolls") |
| + } |
| + list := page(t, app, cookies, "/sheets").Body.String() |
| + if !strings.Contains(list, "<div estilo-shell>") || !strings.Contains(list, `<header estilo-bar="sticky">`) { |
| + t.Error("an ordinary page's shell or bar is wrong") |
| + } |
| +} |
diff --git a/internal/sheets/errors_test.go b/internal/sheets/errors_test.go| new file mode 100644 |
| index 0000000..7574922 |
| --- /dev/null |
| +++ b/internal/sheets/errors_test.go |
| @@ -0,0 +1,249 @@ |
| +package sheets |
| + |
| +import ( |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "regexp" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every error a browser can reach is the error page, inside the normal |
| +// layout and bar, showing the viewer's own chrome (spec §3). The table is |
| +// {unknown route, a GET of a POST-only path, a missing sheet, a missing |
| +// link, a bad parameter, a panic} × {signed in, signed out}. |
| +func TestHTMLErrorsRenderTheErrorPage(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + id := strconv.FormatUint(s.ID, 10) |
| + |
| + type result struct { |
| + code int |
| + ct string |
| + body string |
| + } |
| + get := func(path string) func([]*http.Cookie) result { |
| + return func(c []*http.Cookie) result { |
| + rec := page(t, app, c, path) |
| + return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()} |
| + } |
| + } |
| + tooLong := func(c []*http.Cookie) result { |
| + req := postForm("/sheets/"+id+"/table", map[string][]string{"r": {"0"}, "c": {"0"}, "raw": {strings.Repeat("x", maxRawBytes+1)}}) |
| + req.Header.Set("Accept", "text/html") |
| + rec := do(t, app, as(req, c)) |
| + return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()} |
| + } |
| + panics := func(c []*http.Cookie) result { |
| + h := app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") })) |
| + req := httptest.NewRequest(http.MethodGet, "/sheets/"+id, nil) |
| + req.Header.Set("Accept", "text/html") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, as(req, c)) |
| + return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()} |
| + } |
| + |
| + cases := []struct { |
| + name string |
| + signedIn bool |
| + run func([]*http.Cookie) result |
| + status int |
| + title string |
| + }{ |
| + {"unknown route", true, get("/no/such/page"), 404, "Page not found · Sheets"}, |
| + {"unknown route", false, get("/no/such/page"), 404, "Page not found · Sheets"}, |
| + {"GET of a POST-only path", true, get("/signout"), 404, "Page not found · Sheets"}, |
| + {"GET of a POST-only path", false, get("/appearance"), 404, "Page not found · Sheets"}, |
| + {"missing sheet", true, get("/sheets/99999"), 404, "Sheet not available · Sheets"}, |
| + {"not a sheet id", true, get("/sheets/nope/table"), 404, "Sheet not available · Sheets"}, |
| + {"missing link", false, get("/l/doesnotexist"), 404, "Sheet not available · Sheets"}, |
| + {"missing link", true, get("/l/doesnotexist"), 404, "Sheet not available · Sheets"}, |
| + {"bad parameter", true, tooLong, 413, "Too long · Sheets"}, |
| + {"panic", true, panics, 500, "Something went wrong · Sheets"}, |
| + {"panic", false, panics, 500, "Something went wrong · Sheets"}, |
| + } |
| + for _, c := range cases { |
| + who, jar := "signed out", []*http.Cookie(nil) |
| + if c.signedIn { |
| + who, jar = "signed in", cookies |
| + } |
| + t.Run(c.name+", "+who, func(t *testing.T) { |
| + got := c.run(jar) |
| + if got.code != c.status { |
| + t.Fatalf("status %d, want %d", got.code, c.status) |
| + } |
| + if !strings.HasPrefix(got.ct, "text/html") { |
| + t.Fatalf("Content-Type %q, want text/html", got.ct) |
| + } |
| + if title := titleOf(t, got.body); title != c.title { |
| + t.Fatalf("<title> %q, want %q", title, c.title) |
| + } |
| + if !strings.Contains(got.body, "<header estilo-bar") || !strings.Contains(got.body, "<div rst-error>") { |
| + t.Fatal("the error page is not inside the layout and bar") |
| + } |
| + if account := strings.Contains(got.body, "<details estilo-account"); account != c.signedIn { |
| + t.Fatalf("account menu present = %v, want %v", account, c.signedIn) |
| + } |
| + if c.status == 500 && !regexp.MustCompile(`rst-error-ref dir="auto">Reference: [a-z0-9]{6}<`).MatchString(got.body) { |
| + t.Fatal("a 500 page carries no reference") |
| + } |
| + }) |
| + } |
| + |
| + // Non-HTML stays non-HTML: a.notFound is shared by the grid's page |
| + // and its /api fetches (spec §3.3), so the split is by Accept. The |
| + // grid's fetch and a native client never ask for a page and never |
| + // get one. |
| + for _, p := range []string{"/no/such/page", "/sheets/99999", fmt.Sprintf("/api/sheets/99999/tabs/%d/cells", s.TabID)} { |
| + for _, accept := range []string{"", "application/json", "*/*"} { |
| + req := httptest.NewRequest(http.MethodGet, p, nil) |
| + if accept != "" { |
| + req.Header.Set("Accept", accept) |
| + } |
| + rec := do(t, app, as(req, cookies)) |
| + if rec.Code != http.StatusNotFound || strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") || strings.Contains(rec.Body.String(), "<html") { |
| + t.Errorf("%s Accept %q: %d %q, want a plain 404", p, accept, rec.Code, rec.Header().Get("Content-Type")) |
| + } |
| + } |
| + } |
| + // Any other method to a known path is 405 in plain text, with Allow. |
| + req := httptest.NewRequest(http.MethodDelete, "/signout", nil) |
| + rec := do(t, app, req) |
| + if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != "POST" { |
| + t.Errorf("DELETE /signout: %d Allow=%q", rec.Code, rec.Header().Get("Allow")) |
| + } |
| +} |
| + |
| +// The error page's Back link is offered only for a same-origin Referer |
| +// that is not this page (rastrillo's rule; otherwise it is an open |
| +// redirect with better manners), and never for a capability path. |
| +func TestErrorPageBackLinkIsSameOriginOnly(t *testing.T) { |
| + app, _ := appWithOrigin(t, testOrigin) |
| + for referer, want := range map[string]string{ |
| + testOrigin + "/sheets?page=2": `<a rst-btn href="/sheets?page=2">Back</a>`, |
| + "https://evil.example/sheets": "", |
| + testOrigin + "/no/such/page": "", |
| + "//evil.example/x": "", |
| + testOrigin + "/l/secret-token": "", |
| + "https://u@sheets.example.com/": "", |
| + } { |
| + req := httptest.NewRequest(http.MethodGet, "/no/such/page", nil) |
| + req.Header.Set("Accept", "text/html") |
| + req.Header.Set("Referer", referer) |
| + body := do(t, app, req).Body.String() |
| + has := strings.Contains(body, `>Back</a>`) |
| + if (want == "") == has || (want != "" && !strings.Contains(body, want)) { |
| + t.Errorf("Referer %q: Back link present = %v, want %q", referer, has, want) |
| + } |
| + } |
| +} |
| + |
| +// Every http.Error left in the package is classified: a plain answer on |
| +// purpose, for a fetch, a stream or a native client, or a middleware |
| +// refusal no navigation meets. A new unclassified site fails here — a |
| +// page handler should call a.notFound, a.fail, a.forbidden or |
| +// a.badRequest, which answer a browser with the page. |
| +func TestEveryPlainTextErrorIsClassified(t *testing.T) { |
| + classified := map[string]struct { |
| + n int |
| + why string |
| + }{ |
| + "errors.go:App.fail": {1, "the non-HTML half of the negotiated answer"}, |
| + "errors.go:App.answer": {1, "the non-HTML half of the negotiated answer"}, |
| + "errors.go:App.errorPage": {1, "the error page itself could not render"}, |
| + "errors.go:App.methodNotAllowed": {1, "405 for a non-GET method, which no navigation sends"}, |
| + "errors.go:App.recoverToErrorPage": {1, "the non-HTML half of a recovered panic"}, |
| + "errors.go:App.ErrorPage": {1, "the non-HTML half of rastrillo's recovered panic"}, |
| + "handlers_cell_batch.go:App.handleCellBatchPost": {7, "grid JSON API"}, |
| + "handlers_format.go:App.handleFormatPost": {6, "grid JSON API"}, |
| + "handlers_grid.go:App.handleCellsGet": {1, "grid JSON API"}, |
| + "handlers_grid.go:App.handleCellPost": {7, "grid JSON API"}, |
| + "handlers_size.go:App.handleSizePost": {7, "grid JSON API"}, |
| + "handlers_rename.go:App.handleSheetRename": {1, "native only: a browser's blank title redirects back"}, |
| + "handlers_view.go:App.handleViewSave": {1, "native only: a browser's bad spec redirects back"}, |
| + "home.go:App.homeRequire": {2, "shared session guard for pages and fetches; a page GET redirects to /signin (R3)"}, |
| + "home.go:App.homeLocalComplete": {2, "Home link completion refusals (R3: moves with home/connect)"}, |
| + "home.go:App.rejectDisabledHome": {1, "shared session guard for pages and fetches (R3)"}, |
| + "jobpages.go:App.renderJobFragment": {2, "the job page's polled fragment, not a page"}, |
| + } |
| + got := httpErrorSites(t) |
| + var problems []string |
| + for site, n := range got { |
| + c, ok := classified[site] |
| + switch { |
| + case !ok: |
| + problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s)", site, n)) |
| + case c.n != n: |
| + problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d (%s)", site, n, c.n, c.why)) |
| + } |
| + } |
| + for site := range classified { |
| + if _, ok := got[site]; !ok { |
| + problems = append(problems, site+": classified but no longer present — drop it from the list") |
| + } |
| + } |
| + sort.Strings(problems) |
| + for _, p := range problems { |
| + t.Error(p) |
| + } |
| + if len(got) == 0 { |
| + t.Fatal("found no http.Error sites at all; the walk is looking at nothing") |
| + } |
| +} |
| + |
| +// httpErrorSites counts http.Error and http.NotFound calls per |
| +// file:function in this package's non-test sources. |
| +func httpErrorSites(t *testing.T) map[string]int { |
| + t.Helper() |
| + fset := token.NewFileSet() |
| + files, err := filepath.Glob("*.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sites := map[string]int{} |
| + for _, f := range files { |
| + if strings.HasSuffix(f, "_test.go") { |
| + continue |
| + } |
| + file, err := parser.ParseFile(fset, f, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, d := range file.Decls { |
| + fn, ok := d.(*ast.FuncDecl) |
| + if !ok { |
| + continue |
| + } |
| + name := fn.Name.Name |
| + if fn.Recv != nil { |
| + typ := fn.Recv.List[0].Type |
| + if s, ok := typ.(*ast.StarExpr); ok { |
| + typ = s.X |
| + } |
| + if id, ok := typ.(*ast.Ident); ok { |
| + name = id.Name + "." + name |
| + } |
| + } |
| + ast.Inspect(fn, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { |
| + if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" && (sel.Sel.Name == "Error" || sel.Sel.Name == "NotFound") { |
| + sites[f+":"+name]++ |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + } |
| + return sites |
| +} |
diff --git a/internal/sheets/home_test.go b/internal/sheets/home_test.go| index 7e4b6e5..8526e34 100644 |
| --- a/internal/sheets/home_test.go |
| +++ b/internal/sheets/home_test.go |
| @@ -26,6 +26,13 @@ type homeFixture struct { |
| } |
| func newHomeFixture(t *testing.T) *homeFixture { |
| + t.Helper() |
| + return newHomeFixtureWith(t, "") |
| +} |
| + |
| +// newHomeFixtureWith is newHomeFixture with the hub directory origin set |
| +// (HOME_DIRECTORY_ORIGIN), which is what turns the app switcher on. |
| +func newHomeFixtureWith(t *testing.T, directory string) *homeFixture { |
| t.Helper() |
| app, _ := appWithOrigin(t, testOrigin) |
| key, err := assertioncrypto.Generate() |
| @@ -33,7 +40,7 @@ func newHomeFixture(t *testing.T) *homeFixture { |
| t.Fatal(err) |
| } |
| keys, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| - cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32))} |
| + cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32)), Directory: directory} |
| d, err := app.db.DB() |
| if err != nil { |
| t.Fatal(err) |