| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "go/ast" |
| 6 | "go/parser" |
| 7 | "go/token" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "path/filepath" |
| 11 | "regexp" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | ) |
| 17 | |
| 18 | // Every error a browser can reach is the error page, inside the normal |
| 19 | // layout and bar, showing the viewer's own chrome (spec §3). The table is |
| 20 | // {unknown route, a GET of a POST-only path, a missing sheet, a missing |
| 21 | // link, a bad parameter, a panic} × {signed in, signed out}. |
| 22 | func TestHTMLErrorsRenderTheErrorPage(t *testing.T) { |
| 23 | app, cookies := signIn(t) |
| 24 | s, _ := newSheet(t, app, cookies, "Budget") |
| 25 | id := strconv.FormatUint(s.ID, 10) |
| 26 | |
| 27 | type result struct { |
| 28 | code int |
| 29 | ct string |
| 30 | body string |
| 31 | } |
| 32 | get := func(path string) func([]*http.Cookie) result { |
| 33 | return func(c []*http.Cookie) result { |
| 34 | rec := page(t, app, c, path) |
| 35 | return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()} |
| 36 | } |
| 37 | } |
| 38 | tooLong := func(c []*http.Cookie) result { |
| 39 | req := postForm("/sheets/"+id+"/table", map[string][]string{"r": {"0"}, "c": {"0"}, "raw": {strings.Repeat("x", maxRawBytes+1)}}) |
| 40 | req.Header.Set("Accept", "text/html") |
| 41 | rec := do(t, app, as(req, c)) |
| 42 | return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()} |
| 43 | } |
| 44 | panics := func(c []*http.Cookie) result { |
| 45 | h := app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") })) |
| 46 | req := httptest.NewRequest(http.MethodGet, "/sheets/"+id, nil) |
| 47 | req.Header.Set("Accept", "text/html") |
| 48 | rec := httptest.NewRecorder() |
| 49 | h.ServeHTTP(rec, as(req, c)) |
| 50 | return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()} |
| 51 | } |
| 52 | |
| 53 | cases := []struct { |
| 54 | name string |
| 55 | signedIn bool |
| 56 | run func([]*http.Cookie) result |
| 57 | status int |
| 58 | title string |
| 59 | }{ |
| 60 | {"unknown route", true, get("/no/such/page"), 404, "Page not found · Sheets"}, |
| 61 | {"unknown route", false, get("/no/such/page"), 404, "Page not found · Sheets"}, |
| 62 | {"GET of a POST-only path", true, get("/signout"), 404, "Page not found · Sheets"}, |
| 63 | {"GET of a POST-only path", false, get("/appearance"), 404, "Page not found · Sheets"}, |
| 64 | {"missing sheet", true, get("/sheets/99999"), 404, "Sheet not available · Sheets"}, |
| 65 | {"not a sheet id", true, get("/sheets/nope/table"), 404, "Sheet not available · Sheets"}, |
| 66 | {"missing link", false, get("/l/doesnotexist"), 404, "Sheet not available · Sheets"}, |
| 67 | {"missing link", true, get("/l/doesnotexist"), 404, "Sheet not available · Sheets"}, |
| 68 | {"bad parameter", true, tooLong, 413, "Too long · Sheets"}, |
| 69 | {"panic", true, panics, 500, "Something went wrong · Sheets"}, |
| 70 | {"panic", false, panics, 500, "Something went wrong · Sheets"}, |
| 71 | } |
| 72 | for _, c := range cases { |
| 73 | who, jar := "signed out", []*http.Cookie(nil) |
| 74 | if c.signedIn { |
| 75 | who, jar = "signed in", cookies |
| 76 | } |
| 77 | t.Run(c.name+", "+who, func(t *testing.T) { |
| 78 | got := c.run(jar) |
| 79 | if got.code != c.status { |
| 80 | t.Fatalf("status %d, want %d", got.code, c.status) |
| 81 | } |
| 82 | if !strings.HasPrefix(got.ct, "text/html") { |
| 83 | t.Fatalf("Content-Type %q, want text/html", got.ct) |
| 84 | } |
| 85 | if title := titleOf(t, got.body); title != c.title { |
| 86 | t.Fatalf("<title> %q, want %q", title, c.title) |
| 87 | } |
| 88 | if !strings.Contains(got.body, "<header estilo-bar") || !strings.Contains(got.body, "<div rst-error>") { |
| 89 | t.Fatal("the error page is not inside the layout and bar") |
| 90 | } |
| 91 | if account := strings.Contains(got.body, "<details estilo-account"); account != c.signedIn { |
| 92 | t.Fatalf("account menu present = %v, want %v", account, c.signedIn) |
| 93 | } |
| 94 | if c.status == 500 && !regexp.MustCompile(`rst-error-ref dir="auto">Reference: [a-z0-9]{6}<`).MatchString(got.body) { |
| 95 | t.Fatal("a 500 page carries no reference") |
| 96 | } |
| 97 | }) |
| 98 | } |
| 99 | |
| 100 | // Non-HTML stays non-HTML: a.notFound is shared by the grid's page |
| 101 | // and its /api fetches (spec §3.3), so the split is by Accept. The |
| 102 | // grid's fetch and a native client never ask for a page and never |
| 103 | // get one. |
| 104 | for _, p := range []string{"/no/such/page", "/sheets/99999", fmt.Sprintf("/api/sheets/99999/tabs/%d/cells", s.TabID)} { |
| 105 | for _, accept := range []string{"", "application/json", "*/*"} { |
| 106 | req := httptest.NewRequest(http.MethodGet, p, nil) |
| 107 | if accept != "" { |
| 108 | req.Header.Set("Accept", accept) |
| 109 | } |
| 110 | rec := do(t, app, as(req, cookies)) |
| 111 | if rec.Code != http.StatusNotFound || strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") || strings.Contains(rec.Body.String(), "<html") { |
| 112 | t.Errorf("%s Accept %q: %d %q, want a plain 404", p, accept, rec.Code, rec.Header().Get("Content-Type")) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | // Any other method to a known path is 405 in plain text, with Allow. |
| 117 | req := httptest.NewRequest(http.MethodDelete, "/signout", nil) |
| 118 | rec := do(t, app, req) |
| 119 | if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != "POST" { |
| 120 | t.Errorf("DELETE /signout: %d Allow=%q", rec.Code, rec.Header().Get("Allow")) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // The error page's Back link is offered only for a same-origin Referer |
| 125 | // that is not this page (rastrillo's rule; otherwise it is an open |
| 126 | // redirect with better manners), and never for a capability path. |
| 127 | func TestErrorPageBackLinkIsSameOriginOnly(t *testing.T) { |
| 128 | app, _ := appWithOrigin(t, testOrigin) |
| 129 | for referer, want := range map[string]string{ |
| 130 | testOrigin + "/sheets?page=2": `<a rst-btn href="/sheets?page=2">Back</a>`, |
| 131 | "https://evil.example/sheets": "", |
| 132 | testOrigin + "/no/such/page": "", |
| 133 | "//evil.example/x": "", |
| 134 | testOrigin + "/l/secret-token": "", |
| 135 | "https://u@sheets.example.com/": "", |
| 136 | } { |
| 137 | req := httptest.NewRequest(http.MethodGet, "/no/such/page", nil) |
| 138 | req.Header.Set("Accept", "text/html") |
| 139 | req.Header.Set("Referer", referer) |
| 140 | body := do(t, app, req).Body.String() |
| 141 | has := strings.Contains(body, `>Back</a>`) |
| 142 | if (want == "") == has || (want != "" && !strings.Contains(body, want)) { |
| 143 | t.Errorf("Referer %q: Back link present = %v, want %q", referer, has, want) |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // Every http.Error left in the package is classified: a plain answer on |
| 149 | // purpose, for a fetch, a stream or a native client, or a middleware |
| 150 | // refusal no navigation meets. A new unclassified site fails here — a |
| 151 | // page handler should call a.notFound, a.fail, a.forbidden or |
| 152 | // a.badRequest, which answer a browser with the page. |
| 153 | func TestEveryPlainTextErrorIsClassified(t *testing.T) { |
| 154 | classified := map[string]struct { |
| 155 | n int |
| 156 | why string |
| 157 | }{ |
| 158 | "errors.go:App.fail": {1, "the non-HTML half of the negotiated answer"}, |
| 159 | "errors.go:App.answer": {1, "the non-HTML half of the negotiated answer"}, |
| 160 | "errors.go:App.errorPage": {1, "the error page itself could not render"}, |
| 161 | "errors.go:App.methodNotAllowed": {1, "405 for a non-GET method, which no navigation sends"}, |
| 162 | "errors.go:App.recoverToErrorPage": {1, "the non-HTML half of a recovered panic"}, |
| 163 | "errors.go:App.ErrorPage": {1, "the non-HTML half of rastrillo's recovered panic"}, |
| 164 | "handlers_cell_batch.go:App.handleCellBatchPost": {7, "grid JSON API"}, |
| 165 | "handlers_format.go:App.handleFormatPost": {6, "grid JSON API"}, |
| 166 | "handlers_grid.go:App.handleCellsGet": {1, "grid JSON API"}, |
| 167 | "handlers_grid.go:App.handleCellPost": {7, "grid JSON API"}, |
| 168 | "handlers_size.go:App.handleSizePost": {7, "grid JSON API"}, |
| 169 | "handlers_rename.go:App.handleSheetRename": {1, "native only: a browser's blank title redirects back"}, |
| 170 | "handlers_view.go:App.handleViewSave": {1, "native only: a browser's bad spec redirects back"}, |
| 171 | "home.go:App.homeRequire": {2, "shared session guard for pages and fetches; a page GET redirects to /signin (R3)"}, |
| 172 | "home.go:App.homeLocalComplete": {2, "Home link completion refusals (R3: moves with home/connect)"}, |
| 173 | "home.go:App.rejectDisabledHome": {1, "shared session guard for pages and fetches (R3)"}, |
| 174 | "jobpages.go:App.renderJobFragment": {2, "the job page's polled fragment, not a page"}, |
| 175 | } |
| 176 | got := httpErrorSites(t) |
| 177 | var problems []string |
| 178 | for site, n := range got { |
| 179 | c, ok := classified[site] |
| 180 | switch { |
| 181 | case !ok: |
| 182 | problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s)", site, n)) |
| 183 | case c.n != n: |
| 184 | problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d (%s)", site, n, c.n, c.why)) |
| 185 | } |
| 186 | } |
| 187 | for site := range classified { |
| 188 | if _, ok := got[site]; !ok { |
| 189 | problems = append(problems, site+": classified but no longer present — drop it from the list") |
| 190 | } |
| 191 | } |
| 192 | sort.Strings(problems) |
| 193 | for _, p := range problems { |
| 194 | t.Error(p) |
| 195 | } |
| 196 | if len(got) == 0 { |
| 197 | t.Fatal("found no http.Error sites at all; the walk is looking at nothing") |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | // httpErrorSites counts http.Error and http.NotFound calls per |
| 202 | // file:function in this package's non-test sources. |
| 203 | func httpErrorSites(t *testing.T) map[string]int { |
| 204 | t.Helper() |
| 205 | fset := token.NewFileSet() |
| 206 | files, err := filepath.Glob("*.go") |
| 207 | if err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | sites := map[string]int{} |
| 211 | for _, f := range files { |
| 212 | if strings.HasSuffix(f, "_test.go") { |
| 213 | continue |
| 214 | } |
| 215 | file, err := parser.ParseFile(fset, f, nil, 0) |
| 216 | if err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | for _, d := range file.Decls { |
| 220 | fn, ok := d.(*ast.FuncDecl) |
| 221 | if !ok { |
| 222 | continue |
| 223 | } |
| 224 | name := fn.Name.Name |
| 225 | if fn.Recv != nil { |
| 226 | typ := fn.Recv.List[0].Type |
| 227 | if s, ok := typ.(*ast.StarExpr); ok { |
| 228 | typ = s.X |
| 229 | } |
| 230 | if id, ok := typ.(*ast.Ident); ok { |
| 231 | name = id.Name + "." + name |
| 232 | } |
| 233 | } |
| 234 | ast.Inspect(fn, func(n ast.Node) bool { |
| 235 | call, ok := n.(*ast.CallExpr) |
| 236 | if !ok { |
| 237 | return true |
| 238 | } |
| 239 | if sel, ok := call.Fun.(*ast.SelectorExpr); ok { |
| 240 | if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" && (sel.Sel.Name == "Error" || sel.Sel.Name == "NotFound") { |
| 241 | sites[f+":"+name]++ |
| 242 | } |
| 243 | } |
| 244 | return true |
| 245 | }) |
| 246 | } |
| 247 | } |
| 248 | return sites |
| 249 | } |
| 250 | |