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
3import (
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}.
22func 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.
127func 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.
153func 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.
203func 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