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
3// The reclamation half of import: what happens to a sheet whose import
4// never finished, and the line a sweep must not cross.
5
6import (
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "amadan.net/oficina/sheets/internal/sheets/store"
14)
15
16// TestStaleImportsAreSweptAtBoot covers the reclamation path, and the
17// line it must not cross.
18//
19// A second App over one database is what a restart looks like — and
20// also what a rolling replacement and a duplicate start look like,
21// which is the whole reason age is what decides this rather than the
22// mere fact of construction. An import too young to be abandoned has to
23// survive somebody else's boot.
24func TestStaleImportsAreSweptAtBoot(t *testing.T) {
25 app := newTestApp(t)
26 member := testMember(t, app)
27
28 old, err := app.createFillingSheet("interrupted by a deploy", member)
29 if err != nil {
30 t.Fatalf("createFillingSheet: %v", err)
31 }
32 stale := time.Now().UTC().Add(-staleImportAge - time.Minute)
33 if err := app.db.Model(&store.Sheet{}).Where("id = ?", old.ID).
34 Update("filling_since", stale).Error; err != nil {
35 t.Fatalf("backdate: %v", err)
36 }
37
38 // This one started moments ago, so a live job may well still be
39 // filling it in another instance.
40 fresh, err := app.createFillingSheet("still going", member)
41 if err != nil {
42 t.Fatalf("createFillingSheet: %v", err)
43 }
44
45 // A second App over the same database.
46 restarted := New(Config{DB: app.db, Sealer: app.sealer, Admit: app.admit})
47
48 if deletedAt(t, restarted, old.ID) == nil {
49 t.Error("a stale import survived a restart undiscarded")
50 }
51 var grants int64
52 if err := restarted.db.Model(&Grant{}).Where("sheet_id = ?", old.ID).
53 Count(&grants).Error; err != nil {
54 t.Fatalf("count grants: %v", err)
55 }
56 if grants != 0 {
57 t.Errorf("a stale import kept %d grant(s) through a restart", grants)
58 }
59
60 // The guard that matters: a second instance starting must not
61 // delete an import the first one is still running.
62 if deletedAt(t, restarted, fresh.ID) != nil {
63 t.Error("a running import was discarded by another instance starting up")
64 }
65}
66
67// TestARevealRefusesADiscardedSheet closes the last corner of the
68// sweep race. A job's goroutine cannot be killed, so an import slower
69// than staleImportAge can still be running when its sheet is swept —
70// and revealing it then would un-hide a deleted row and send the
71// importer to a sheet no read path serves.
72func TestARevealRefusesADiscardedSheet(t *testing.T) {
73 app := newTestApp(t)
74 member := testMember(t, app)
75
76 s, err := app.createFillingSheet("swept mid-import", member)
77 if err != nil {
78 t.Fatalf("createFillingSheet: %v", err)
79 }
80 app.discardSheet(s.ID)
81
82 if err := app.markFilled(s.ID); err == nil {
83 t.Fatal("a discarded sheet was revealed as a finished import")
84 }
85 // And it stays gone rather than being resurrected invisible-but-live.
86 if deletedAt(t, app, s.ID) == nil {
87 t.Error("the discarded sheet came back")
88 }
89}
90
91// TestImportingReclaimsStaleImports covers the gap a boot-only sweep
92// leaves: a process that dies during a young import is replaced before
93// the threshold, so its replacement's startup sweep finds nothing.
94func TestImportingReclaimsStaleImports(t *testing.T) {
95 app, cookies := signIn(t)
96 member := signedInMember(t, app)
97
98 abandoned, err := app.createFillingSheet("nobody is coming back", member)
99 if err != nil {
100 t.Fatalf("createFillingSheet: %v", err)
101 }
102 stale := time.Now().UTC().Add(-staleImportAge - time.Minute)
103 if err := app.db.Model(&store.Sheet{}).Where("id = ?", abandoned.ID).
104 Update("filling_since", stale).Error; err != nil {
105 t.Fatalf("backdate: %v", err)
106 }
107
108 // Any import at all, even one that is refused, is a look.
109 do(t, app, as(uploadRequest(t, "notes.pdf", []byte("%PDF")), cookies))
110
111 if deletedAt(t, app, abandoned.ID) == nil {
112 t.Error("an abandoned import survived a later import untouched")
113 }
114}
115
116func deletedAt(t *testing.T, app *App, id uint64) *time.Time {
117 t.Helper()
118 var sheet store.Sheet
119 if err := app.db.Where("id = ?", id).First(&sheet).Error; err != nil {
120 t.Fatalf("reload sheet %d: %v", id, err)
121 }
122 return sheet.DeletedAt
123}
124
125// TestAFileOfExactlyTheAdvertisedSizeIsAccepted pins the limit the form
126// promises. MaxBytesReader bounds the whole multipart body, so without
127// slack for the framing a file of exactly 20 MB — the number printed on
128// the page — is refused.
129//
130// The payload is not a workbook, so this stops at the extension check;
131// what matters is that it gets that far rather than dying as "too
132// large".
133func TestAFileOfExactlyTheAdvertisedSizeIsAccepted(t *testing.T) {
134 app, cookies := signIn(t)
135
136 rec := do(t, app, as(uploadRequest(t, "big.bin", make([]byte, maxImportBytes)), cookies))
137 if rec.Code != http.StatusBadRequest {
138 t.Fatalf("answered %d, want 400", rec.Code)
139 }
140 body := rec.Body.String()
141 if strings.Contains(body, refusalTooLarge) {
142 t.Error("a file of exactly the advertised size was refused as too large")
143 }
144 if !strings.Contains(body, refusalNotWorkbook) {
145 t.Errorf("want the extension refusal, got: %q", firstRefusal(body))
146 }
147}
148
149// TestAFileOverTheAdvertisedSizeIsRefused is the other side of it: the
150// slack exists for the framing, not to raise the limit.
151func TestAFileOverTheAdvertisedSizeIsRefused(t *testing.T) {
152 app, cookies := signIn(t)
153
154 rec := do(t, app, as(uploadRequest(t, "big.xlsx", make([]byte, maxImportBytes+1)), cookies))
155 if rec.Code != http.StatusBadRequest {
156 t.Fatalf("answered %d, want 400", rec.Code)
157 }
158 if !strings.Contains(rec.Body.String(), refusalTooLarge) {
159 t.Error("a file over the advertised size was not refused as too large")
160 }
161}
162
163// firstRefusal pulls whichever refusal a page is carrying, for a
164// failure message that says what actually happened.
165func firstRefusal(body string) string {
166 for _, r := range []string{
167 refusalNoFile, refusalNotWorkbook, refusalTooLarge,
168 refusalBusy, refusalUnreadable,
169 } {
170 if strings.Contains(body, r) {
171 return r
172 }
173 }
174 return "no refusal on the page"
175}
176
177// TestImportedSheetIsNotReachableByLinkWhileFilling closes the last
178// read path: share links resolve a sheet by id of their own accord, so
179// they need the same filter as the list and the page.
180func TestImportedSheetIsNotReachableByLinkWhileFilling(t *testing.T) {
181 app := newTestApp(t)
182 member := testMember(t, app)
183
184 s, err := app.createFillingSheet("half a workbook", member)
185 if err != nil {
186 t.Fatalf("createFillingSheet: %v", err)
187 }
188 token, err := app.createLink(s.ID, SheetViewer, member)
189 if err != nil {
190 // Minting a link for a filling sheet may itself be refused,
191 // which is a perfectly good answer to this question.
192 return
193 }
194 rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil))
195 if rec.Code == http.StatusOK {
196 t.Error("a share link opens a sheet that is still being filled")
197 }
198}
199