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 "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.
15func 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
21func 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
44func 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
59func 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.
73func 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.
85func 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.
101func 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
124func 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.
140func 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
149func 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.
160func 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.
181func 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
200func 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.
218func 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