| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // postForm builds a form POST. Content-Type matters: without it |
| 13 | // r.FormValue reads nothing and every field arrives empty, which fails |
| 14 | // as a validation error rather than as a wiring error. |
| 15 | func postForm(path string, form url.Values) *http.Request { |
| 16 | req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) |
| 17 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 18 | return req |
| 19 | } |
| 20 | |
| 21 | func TestPostSheetsCreatesAndRedirects(t *testing.T) { |
| 22 | app, cookies := signIn(t) |
| 23 | rec := do(t, app, as(postForm("/sheets", url.Values{"title": {"Q3 forecast"}}), cookies)) |
| 24 | |
| 25 | // 303, not a render: a POST that renders is a POST that resubmits |
| 26 | // on reload, and the second submission makes a second sheet. |
| 27 | if rec.Code != http.StatusSeeOther { |
| 28 | t.Fatalf("POST /sheets = %d, want 303: %s", rec.Code, rec.Body.String()) |
| 29 | } |
| 30 | loc := rec.Header().Get("Location") |
| 31 | if !strings.HasPrefix(loc, "/sheets/") || loc == "/sheets/" { |
| 32 | t.Fatalf("Location = %q, want the new sheet", loc) |
| 33 | } |
| 34 | |
| 35 | page := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies)) |
| 36 | if page.Code != http.StatusOK { |
| 37 | t.Fatalf("GET %s = %d, want 200", loc, page.Code) |
| 38 | } |
| 39 | if !strings.Contains(page.Body.String(), "Q3 forecast") { |
| 40 | t.Error("the sheet page does not show the title it was just given") |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestSheetListShowsEveryTitle(t *testing.T) { |
| 45 | app, cookies := signIn(t) |
| 46 | for _, title := range []string{"Budget", "Headcount"} { |
| 47 | if rec := do(t, app, as(postForm("/sheets", url.Values{"title": {title}}), cookies)); rec.Code != http.StatusSeeOther { |
| 48 | t.Fatalf("creating %q = %d", title, rec.Code) |
| 49 | } |
| 50 | } |
| 51 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)).Body.String() |
| 52 | for _, want := range []string{"Budget", "Headcount"} { |
| 53 | if !strings.Contains(body, want) { |
| 54 | t.Errorf("the sheet list is missing %q", want) |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestSheetListIsEmptyStateNotAnError(t *testing.T) { |
| 60 | app, cookies := signIn(t) |
| 61 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)) |
| 62 | if rec.Code != http.StatusOK { |
| 63 | t.Fatalf("GET /sheets with no sheets = %d, want 200", rec.Code) |
| 64 | } |
| 65 | if !strings.Contains(rec.Body.String(), "No sheets yet") { |
| 66 | t.Error("an instance with no sheets does not offer an empty state") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // AGENTS.md: a row that isn't yours 404s, never 403s. A 403 tells |
| 71 | // whoever asked that the row exists, which is a fact about somebody |
| 72 | // else's data. |
| 73 | func TestUnknownSheetIs404NotForbidden(t *testing.T) { |
| 74 | app, cookies := signIn(t) |
| 75 | for _, path := range []string{"/sheets/999999", "/sheets/0", "/sheets/abc"} { |
| 76 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)) |
| 77 | if rec.Code != http.StatusNotFound { |
| 78 | t.Errorf("GET %s = %d, want 404", path, rec.Code) |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // Every sheet route is behind a session. Without this the allowlist |
| 84 | // admits nobody in particular and the instance is public. |
| 85 | func TestSheetRoutesRequireASession(t *testing.T) { |
| 86 | app, _ := appWithRecorder(t) |
| 87 | for _, path := range []string{"/sheets", "/sheets/1"} { |
| 88 | rec := do(t, app, httptest.NewRequest(http.MethodGet, path, nil)) |
| 89 | if rec.Code == http.StatusOK { |
| 90 | t.Errorf("GET %s without a session = 200; it must not serve", path) |
| 91 | } |
| 92 | } |
| 93 | rec := do(t, app, postForm("/sheets", url.Values{"title": {"sneaky"}})) |
| 94 | if rec.Code == http.StatusSeeOther { |
| 95 | t.Error("POST /sheets without a session created a sheet") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // A form field must never be bound onto a GORM model (AGENTS.md), which |
| 100 | // would let a submission set CreatedBy, DeletedAt, or an id. |
| 101 | func TestSheetCreateIgnoresFieldsItWasNotAsking(t *testing.T) { |
| 102 | app, cookies := signIn(t) |
| 103 | rec := do(t, app, as(postForm("/sheets", url.Values{ |
| 104 | "title": {"Legit"}, |
| 105 | "id": {"4242"}, |
| 106 | "created_by": {"9999"}, |
| 107 | "deleted_at": {"2020-01-01"}, |
| 108 | }), cookies)) |
| 109 | if rec.Code != http.StatusSeeOther { |
| 110 | t.Fatalf("POST /sheets = %d", rec.Code) |
| 111 | } |
| 112 | if loc := rec.Header().Get("Location"); loc == "/sheets/4242" { |
| 113 | t.Fatal("a form field set the sheet's id") |
| 114 | } |
| 115 | list, err := app.listSheets(signedInMember(t, app)) |
| 116 | if err != nil { |
| 117 | t.Fatalf("listSheets: %v", err) |
| 118 | } |
| 119 | if len(list) != 1 { |
| 120 | t.Fatalf("expected exactly one sheet, got %d — a deleted_at field may have landed", len(list)) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestSheetCreateWithNoTitleStillMakesASheet(t *testing.T) { |
| 125 | // An empty title is a slip, not an attack. Refusing it would lose |
| 126 | // whatever the person was about to type into the grid. |
| 127 | app, cookies := signIn(t) |
| 128 | rec := do(t, app, as(postForm("/sheets", url.Values{"title": {""}}), cookies)) |
| 129 | if rec.Code != http.StatusSeeOther { |
| 130 | t.Fatalf("POST /sheets with no title = %d, want 303", rec.Code) |
| 131 | } |
| 132 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)).Body.String() |
| 133 | if !strings.Contains(body, "Untitled sheet") { |
| 134 | t.Error("a titleless sheet is not named in the list") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // The whole CSRF defence is the origin check; a route that skips it is |
| 139 | // a route anybody's page can post to on a signed-in person's behalf. |
| 140 | func TestCSRFRefusesACrossOriginPost(t *testing.T) { |
| 141 | app, cookies := signInWithOrigin(t) |
| 142 | req := as(postForm("/sheets", url.Values{"title": {"from elsewhere"}}), cookies) |
| 143 | req.Header.Set("Origin", "https://evil.example.com") |
| 144 | if rec := do(t, app, req); rec.Code != http.StatusForbidden { |
| 145 | t.Fatalf("a cross-origin POST = %d, want 403", rec.Code) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestCSRFAllowsASameOriginPost(t *testing.T) { |
| 150 | app, cookies := signInWithOrigin(t) |
| 151 | req := as(postForm("/sheets", url.Values{"title": {"from here"}}), cookies) |
| 152 | req.Header.Set("Origin", testOrigin) |
| 153 | if rec := do(t, app, req); rec.Code != http.StatusSeeOther { |
| 154 | t.Fatalf("a same-origin POST = %d, want 303: %s", rec.Code, rec.Body.String()) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // The grid page must carry the ids and window its script needs, or the |
| 159 | // client has to go and ask for them and the page reflows on arrival. |
| 160 | func TestSheetPageCarriesItsIDs(t *testing.T) { |
| 161 | app, cookies := signIn(t) |
| 162 | rec := do(t, app, as(postForm("/sheets", url.Values{"title": {"Budget"}}), cookies)) |
| 163 | loc := rec.Header().Get("Location") |
| 164 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies)).Body.String() |
| 165 | |
| 166 | for _, want := range []string{ |
| 167 | "data-sheet-id=", |
| 168 | "data-tab-id=", |
| 169 | fmt.Sprintf(`data-rows="%d"`, defaultWindowRows), |
| 170 | fmt.Sprintf(`data-cols="%d"`, defaultWindowCols), |
| 171 | } { |
| 172 | if !strings.Contains(body, want) { |
| 173 | t.Errorf("the grid page is missing %s", want) |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // The grid is vendored, not fetched: spec §6 and AGENTS.md both rule |
| 179 | // out a CDN. A page that links one would work in development and fail |
| 180 | // on any network that blocks it. |
| 181 | func TestGridPageLoadsOnlyLocalScripts(t *testing.T) { |
| 182 | app, cookies := signIn(t) |
| 183 | rec := do(t, app, as(postForm("/sheets", url.Values{"title": {"Budget"}}), cookies)) |
| 184 | loc := rec.Header().Get("Location") |
| 185 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies)).Body.String() |
| 186 | |
| 187 | for _, want := range []string{"/static/vendor/jspreadsheet.", "/static/vendor/jsuites.", |
| 188 | "/static/grid.", "/static/live."} { |
| 189 | if !strings.Contains(body, want) { |
| 190 | t.Errorf("the grid page does not load %s", want) |
| 191 | } |
| 192 | } |
| 193 | for _, forbidden := range []string{"//cdn.", "https://unpkg", "jsdelivr", "cdnjs"} { |
| 194 | if strings.Contains(body, forbidden) { |
| 195 | t.Errorf("the grid page reaches off-origin for %q", forbidden) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestVendoredAssetsAreServed(t *testing.T) { |
| 201 | app := New(Config{}) |
| 202 | for _, name := range []string{ |
| 203 | "/static/vendor/jspreadsheet.js", |
| 204 | "/static/vendor/jspreadsheet.css", |
| 205 | "/static/vendor/jsuites.js", |
| 206 | "/static/vendor/jsuites.css", |
| 207 | "/static/grid.js", |
| 208 | "/static/live.js", |
| 209 | } { |
| 210 | rec := do(t, app, httptest.NewRequest(http.MethodGet, name, nil)) |
| 211 | if rec.Code != http.StatusOK { |
| 212 | t.Errorf("GET %s = %d, want 200", name, rec.Code) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // A scriptless visitor must land somewhere useful, not on an empty div. |
| 218 | func TestGridPageOffersTheTableViewWithoutScript(t *testing.T) { |
| 219 | app, cookies := signIn(t) |
| 220 | rec := do(t, app, as(postForm("/sheets", url.Values{"title": {"Budget"}}), cookies)) |
| 221 | loc := rec.Header().Get("Location") |
| 222 | body := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies)).Body.String() |
| 223 | |
| 224 | if !strings.Contains(body, "<noscript>") { |
| 225 | t.Fatal("the grid page has no noscript block") |
| 226 | } |
| 227 | if !strings.Contains(body, loc+"/table") { |
| 228 | t.Fatalf("the noscript block does not link the table view (%s/table)", loc) |
| 229 | } |
| 230 | } |
| 231 | |