| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | // scriptTag and eventAttr strip a page down to what works with |
| 15 | // JavaScript switched off. |
| 16 | var ( |
| 17 | scriptTag = regexp.MustCompile(`(?is)<script\b.*?</script>`) |
| 18 | eventAttr = regexp.MustCompile(`(?i)\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)`) |
| 19 | ) |
| 20 | |
| 21 | func withoutScript(html string) string { |
| 22 | return eventAttr.ReplaceAllString(scriptTag.ReplaceAllString(html, ""), "") |
| 23 | } |
| 24 | |
| 25 | // The gate spec §6 asks for, made concrete: every write the grid can |
| 26 | // do, the scriptless view can do too — proven by actually doing it, |
| 27 | // with no JavaScript anywhere in the request. |
| 28 | func TestACellCanBeEditedWithNoScriptAtAll(t *testing.T) { |
| 29 | app, cookies := signIn(t) |
| 30 | s, _ := newSheet(t, app, cookies, "Budget") |
| 31 | table := fmt.Sprintf("/sheets/%d/table", s.ID) |
| 32 | |
| 33 | form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=2*21"}, "page": {"1"}} |
| 34 | rec := do(t, app, as(postForm(table, form), cookies)) |
| 35 | if rec.Code != http.StatusSeeOther { |
| 36 | t.Fatalf("scriptless cell edit = %d, want 303: %s", rec.Code, rec.Body.String()) |
| 37 | } |
| 38 | |
| 39 | page := do(t, app, as(httptest.NewRequest(http.MethodGet, table, nil), cookies)).Body.String() |
| 40 | if !strings.Contains(withoutScript(page), ">42<") { |
| 41 | t.Fatal("the scriptless table does not show the value it just computed") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // The two views must be one product. An edit made in either is visible |
| 46 | // in the other, because both go through the same store call. |
| 47 | func TestAScriptlessEditIsVisibleInTheGridAPI(t *testing.T) { |
| 48 | app, cookies := signIn(t) |
| 49 | s, cells := newSheet(t, app, cookies, "Budget") |
| 50 | |
| 51 | form := url.Values{"r": {"3"}, "c": {"2"}, "raw": {"typed here"}, "page": {"1"}} |
| 52 | do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form), cookies)) |
| 53 | |
| 54 | got := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies))) |
| 55 | var found bool |
| 56 | for _, c := range got.Cells { |
| 57 | if c.R == 3 && c.C == 2 && c.Display == "typed here" { |
| 58 | found = true |
| 59 | } |
| 60 | } |
| 61 | if !found { |
| 62 | t.Fatalf("the grid API does not see the scriptless edit: %+v", got.Cells) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // A scriptless edit must fan out like any other, or somebody using the |
| 67 | // fallback is invisible to everybody watching the grid. |
| 68 | func TestAScriptlessEditReachesOpenGrids(t *testing.T) { |
| 69 | app, cookies := signIn(t) |
| 70 | s, _ := newSheet(t, app, cookies, "Budget") |
| 71 | |
| 72 | sub := app.hub.Subscribe(s.TabID) |
| 73 | defer sub.Close() |
| 74 | |
| 75 | form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=3*3"}, "page": {"1"}} |
| 76 | do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form), cookies)) |
| 77 | |
| 78 | select { |
| 79 | case m := <-sub.C(): |
| 80 | var g gridResponse |
| 81 | if err := json.Unmarshal(m.Data, &g); err != nil { |
| 82 | t.Fatalf("payload does not decode: %v", err) |
| 83 | } |
| 84 | if len(g.Cells) != 1 || g.Cells[0].Display != "9" { |
| 85 | t.Fatalf("fanned out %+v, want one cell displaying 9", g.Cells) |
| 86 | } |
| 87 | default: |
| 88 | t.Fatal("a scriptless edit reached no open grid") |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // Spec §6: paginated, "because a 200,000-cell tab rendered as one HTML |
| 93 | // table is a several-hundred-megabyte page". |
| 94 | func TestTableViewIsPaginated(t *testing.T) { |
| 95 | app, cookies := signIn(t) |
| 96 | s, _ := newSheet(t, app, cookies, "Budget") |
| 97 | table := fmt.Sprintf("/sheets/%d/table", s.ID) |
| 98 | |
| 99 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, table, nil), cookies)).Body.String() |
| 100 | if n := strings.Count(body, "<tr>"); n > tableRowsPerPage+2 { |
| 101 | t.Fatalf("the page renders %d rows, want at most %d plus a header", |
| 102 | n, tableRowsPerPage) |
| 103 | } |
| 104 | if !strings.Contains(body, "Next rows") { |
| 105 | t.Error("no way to reach the rows below this page") |
| 106 | } |
| 107 | |
| 108 | second := do(t, app, as(httptest.NewRequest(http.MethodGet, table+"?page=2", nil), cookies)) |
| 109 | if second.Code != http.StatusOK { |
| 110 | t.Fatalf("page 2 = %d", second.Code) |
| 111 | } |
| 112 | // Row 26 is the first row of page two. If both pages showed row 1, |
| 113 | // the pagination would be decoration. |
| 114 | if !strings.Contains(second.Body.String(), ">26<") { |
| 115 | t.Error("page 2 does not start where page 1 ended") |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // The affordance gate. Every page must still offer its actions with |
| 120 | // every script and inline handler stripped out. |
| 121 | func TestEveryPageWorksWithoutScript(t *testing.T) { |
| 122 | app, cookies := signIn(t) |
| 123 | s, _ := newSheet(t, app, cookies, "Budget") |
| 124 | |
| 125 | pages := []struct { |
| 126 | path string |
| 127 | want []string |
| 128 | }{ |
| 129 | // Sign-out is checked on /sheets rather than on "/": it is |
| 130 | // layout chrome, present on every signed-in page, and "/" |
| 131 | // stopped being one when it started redirecting onward. |
| 132 | // |
| 133 | // The list no longer carries name="title": Create is a button |
| 134 | // in the top bar that posts an empty form and lands the sheet |
| 135 | // as "Untitled sheet". The naming moved to the rename field on |
| 136 | // the sheet page, two rows down, which is where the title is |
| 137 | // now asserted. |
| 138 | {"/sheets", []string{`action="/sheets"`, `type="submit"`, `action="/signout"`}}, |
| 139 | {"/sheets/import", []string{`action="/sheets/import"`, `type="file"`, `type="submit"`}}, |
| 140 | {fmt.Sprintf("/sheets/%d", s.ID), []string{ |
| 141 | fmt.Sprintf("/sheets/%d/table", s.ID), |
| 142 | // Rename: the only place a sheet is named now, so it is |
| 143 | // the one that has to survive with scripting off. |
| 144 | fmt.Sprintf(`action="/sheets/%d/rename"`, s.ID), `name="title"`}}, |
| 145 | {fmt.Sprintf("/sheets/%d/table", s.ID), []string{"?page=1&edit=0,0", "Next rows"}}, |
| 146 | {fmt.Sprintf("/sheets/%d/table?page=1&edit=0,0", s.ID), |
| 147 | []string{`method="post"`, `name="raw"`, "Save"}}, |
| 148 | } |
| 149 | for _, p := range pages { |
| 150 | body := withoutScript(do(t, app, |
| 151 | as(httptest.NewRequest(http.MethodGet, p.path, nil), cookies)).Body.String()) |
| 152 | if strings.Contains(strings.ToLower(body), "<script") { |
| 153 | t.Errorf("%s: a script survived stripping", p.path) |
| 154 | } |
| 155 | for _, want := range p.want { |
| 156 | if !strings.Contains(body, want) { |
| 157 | t.Errorf("%s: with scripts stripped, %q is gone — that affordance is script-required", |
| 158 | p.path, want) |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // The edit form must be pre-filled with the cell's INPUT, not its |
| 165 | // result, or editing a formula silently replaces it with its own |
| 166 | // output. |
| 167 | func TestTheEditFormShowsTheFormulaNotTheResult(t *testing.T) { |
| 168 | app, cookies := signIn(t) |
| 169 | s, _ := newSheet(t, app, cookies, "Budget") |
| 170 | table := fmt.Sprintf("/sheets/%d/table", s.ID) |
| 171 | |
| 172 | do(t, app, as(postForm(table, url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=8*8"}, "page": {"1"}}), cookies)) |
| 173 | |
| 174 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, table+"?page=1&edit=0,0", nil), cookies)).Body.String() |
| 175 | if !strings.Contains(body, `value="=8*8"`) { |
| 176 | t.Fatalf("the edit form is not pre-filled with the formula:\n%s", |
| 177 | body[max(0, strings.Index(body, "name=\"raw\"")-200):]) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestTableViewOfAMissingSheetIs404(t *testing.T) { |
| 182 | app, cookies := signIn(t) |
| 183 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets/999999/table", nil), cookies)) |
| 184 | if rec.Code != http.StatusNotFound { |
| 185 | t.Fatalf("table view of a missing sheet = %d, want 404", rec.Code) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestTableWriteRequiresASession(t *testing.T) { |
| 190 | app, cookies := signIn(t) |
| 191 | s, _ := newSheet(t, app, cookies, "Budget") |
| 192 | form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"sneaky"}, "page": {"1"}} |
| 193 | rec := do(t, app, postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form)) |
| 194 | if rec.Code == http.StatusSeeOther { |
| 195 | t.Fatal("a scriptless write with no session was accepted") |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestColLabel(t *testing.T) { |
| 200 | for _, tc := range []struct { |
| 201 | n int |
| 202 | want string |
| 203 | }{{0, "A"}, {1, "B"}, {25, "Z"}, {26, "AA"}, {27, "AB"}, {51, "AZ"}, {52, "BA"}} { |
| 204 | if got := colLabel(tc.n); got != tc.want { |
| 205 | t.Errorf("colLabel(%d) = %q, want %q", tc.n, got, tc.want) |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | func TestParseEditRejectsRubbish(t *testing.T) { |
| 211 | for _, bad := range []string{"", "1", "a,b", "1,", ",2", "1,2,3x"} { |
| 212 | if r, c := parseEdit(bad); r != -1 || c != -1 { |
| 213 | t.Errorf("parseEdit(%q) = %d,%d, want no cell selected", bad, r, c) |
| 214 | } |
| 215 | } |
| 216 | if r, c := parseEdit("3,4"); r != 3 || c != 4 { |
| 217 | t.Errorf("parseEdit(\"3,4\") = %d,%d", r, c) |
| 218 | } |
| 219 | } |
| 220 | |