| 1 | package 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 | |
| 6 | import ( |
| 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. |
| 24 | func 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. |
| 72 | func 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. |
| 94 | func 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 | |
| 116 | func 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". |
| 133 | func 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. |
| 151 | func 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. |
| 165 | func 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. |
| 180 | func 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 | |