| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "mime/multipart" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 14 | "amadan.net/oficina/sheets/internal/sheets/xlsx" |
| 15 | ) |
| 16 | |
| 17 | // TestImportRefusesWhatItCannotRead covers every refusal a person can |
| 18 | // hit before a job is ever started — each one answering on the import |
| 19 | // page they posted from, with the reason above the form. |
| 20 | // |
| 21 | // That page used to be the sheet list, which was where the import form |
| 22 | // lived. The form moved; the principle did not, and it is the reason a |
| 23 | // refusal still needs no flash, no cookie and no redirect. |
| 24 | func TestImportRefusesWhatItCannotRead(t *testing.T) { |
| 25 | for _, tc := range []struct { |
| 26 | name string |
| 27 | filename string |
| 28 | body []byte |
| 29 | want string |
| 30 | }{ |
| 31 | {"not a workbook", "notes.pdf", []byte("%PDF-1.4"), refusalNotWorkbook}, |
| 32 | {"no extension at all", "spreadsheet", []byte("anything"), refusalNotWorkbook}, |
| 33 | {"too large", "huge.xlsx", make([]byte, maxImportBytes+1), refusalTooLarge}, |
| 34 | } { |
| 35 | t.Run(tc.name, func(t *testing.T) { |
| 36 | app, cookies := signIn(t) |
| 37 | rec := do(t, app, as(uploadRequest(t, tc.filename, tc.body), cookies)) |
| 38 | |
| 39 | if rec.Code != http.StatusBadRequest { |
| 40 | t.Fatalf("answered %d, want 400", rec.Code) |
| 41 | } |
| 42 | if !strings.Contains(rec.Body.String(), tc.want) { |
| 43 | t.Errorf("page does not carry the refusal %q", tc.want) |
| 44 | } |
| 45 | // A refusal must not leave a sheet behind. The whole point |
| 46 | // of checking before createSheet is that nothing is made. |
| 47 | list, err := app.listSheets(signedInMember(t, app)) |
| 48 | if err != nil { |
| 49 | t.Fatalf("listSheets: %v", err) |
| 50 | } |
| 51 | if len(list) != 0 { |
| 52 | t.Errorf("a refused import left %d sheet(s) in the list", len(list)) |
| 53 | } |
| 54 | }) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // TestImportWithNoFileSaysSo covers the empty form — a person who hit |
| 59 | // Import without choosing anything. |
| 60 | func TestImportWithNoFileSaysSo(t *testing.T) { |
| 61 | app, cookies := signIn(t) |
| 62 | |
| 63 | var buf bytes.Buffer |
| 64 | w := multipart.NewWriter(&buf) |
| 65 | if err := w.Close(); err != nil { |
| 66 | t.Fatalf("close writer: %v", err) |
| 67 | } |
| 68 | req := httptest.NewRequest(http.MethodPost, "/sheets/import", &buf) |
| 69 | req.Header.Set("Content-Type", w.FormDataContentType()) |
| 70 | |
| 71 | rec := do(t, app, as(req, cookies)) |
| 72 | if rec.Code != http.StatusBadRequest { |
| 73 | t.Fatalf("answered %d, want 400", rec.Code) |
| 74 | } |
| 75 | if !strings.Contains(rec.Body.String(), refusalNoFile) { |
| 76 | t.Errorf("page does not carry %q", refusalNoFile) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // TestAFailedImportLeavesNoSheetBehind pins discardSheet. |
| 81 | // |
| 82 | // A half-imported sheet looks complete, sits in a list the whole team |
| 83 | // can see, and the only evidence it is short of rows is a status page |
| 84 | // nobody else saw. The file here is a valid ZIP that is not a workbook, |
| 85 | // so it passes the extension check and fails inside the job — which is |
| 86 | // exactly the shape that reaches discardSheet. |
| 87 | func TestAFailedImportLeavesNoSheetBehind(t *testing.T) { |
| 88 | app, cookies := signIn(t) |
| 89 | |
| 90 | rec := do(t, app, as(uploadRequest(t, "broken.xlsx", []byte("not a zip at all")), cookies)) |
| 91 | if rec.Code != http.StatusSeeOther { |
| 92 | t.Fatalf("import answered %d, want 303", rec.Code) |
| 93 | } |
| 94 | location, page := awaitJob(t, app, cookies, rec.Header().Get("Location")) |
| 95 | if location != "" { |
| 96 | t.Fatalf("a broken file finished successfully, landing on %q", location) |
| 97 | } |
| 98 | if !strings.Contains(page, "failed") { |
| 99 | t.Errorf("status page does not report a failure: %q", page) |
| 100 | } |
| 101 | list, err := app.listSheets(signedInMember(t, app)) |
| 102 | if err != nil { |
| 103 | t.Fatalf("listSheets: %v", err) |
| 104 | } |
| 105 | if len(list) != 0 { |
| 106 | t.Errorf("a failed import left %d sheet(s) in the list", len(list)) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // TestImportFailureNeverEchoesItsCause is the no-content-in-logs rule |
| 111 | // (AGENTS.md, spec §2.4) at the one boundary that can break it. |
| 112 | // |
| 113 | // rastrillo's job runner slogs the error this function returns |
| 114 | // ("jobs: failed", err), and `carlos logs` is platform-held storage |
| 115 | // this app cannot grep or redact. So the guarantee has to be that |
| 116 | // importFailure REPLACES its input rather than wrapping it — and the |
| 117 | // way to prove that is to hand it an error whose text is a canary and |
| 118 | // require the canary gone. |
| 119 | func TestImportFailureNeverEchoesItsCause(t *testing.T) { |
| 120 | const canary = "SECRET-PAYROLL-FIGURE-93f7" |
| 121 | |
| 122 | for _, tc := range []struct { |
| 123 | name string |
| 124 | in error |
| 125 | want string |
| 126 | }{ |
| 127 | { |
| 128 | "a cell that would not parse", |
| 129 | fmt.Errorf("reading %s: %w", canary, &xlsx.CellError{Ref: "B7", Op: "cannot parse cell"}), |
| 130 | "B7", |
| 131 | }, |
| 132 | { |
| 133 | "a workbook too big for one tab", |
| 134 | fmt.Errorf("at %s: %w", canary, store.ErrTabTooLarge), |
| 135 | "more cells", |
| 136 | }, |
| 137 | { |
| 138 | "anything else", |
| 139 | errors.New("excelize exploded holding " + canary), |
| 140 | "could not be read", |
| 141 | }, |
| 142 | } { |
| 143 | t.Run(tc.name, func(t *testing.T) { |
| 144 | got := importFailure(tc.in).Error() |
| 145 | if strings.Contains(got, canary) { |
| 146 | t.Fatalf("import failure text carries cell content: %q", got) |
| 147 | } |
| 148 | if !strings.Contains(got, tc.want) { |
| 149 | t.Errorf("import failure text = %q, want it to mention %q", got, tc.want) |
| 150 | } |
| 151 | }) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // TestTitleFromFilename covers the names a file picker actually hands |
| 156 | // over, including the Windows path some browsers have historically |
| 157 | // sent whole. |
| 158 | func TestTitleFromFilename(t *testing.T) { |
| 159 | for _, tc := range []struct{ in, want string }{ |
| 160 | {"budget.xlsx", "budget"}, |
| 161 | {"Q3 forecast.XLSX", "Q3 forecast"}, |
| 162 | {`C:\Users\ada\books.xlsx`, "books"}, |
| 163 | {"/home/ada/books.xlsm", "books"}, |
| 164 | {"no-extension", "no-extension"}, |
| 165 | {".xlsx", "Untitled sheet"}, |
| 166 | {" ", "Untitled sheet"}, |
| 167 | } { |
| 168 | if got := titleFromFilename(tc.in); got != tc.want { |
| 169 | t.Errorf("titleFromFilename(%q) = %q, want %q", tc.in, got, tc.want) |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // TestImportIsScriptless pins the floor: the drop zone is inside a |
| 175 | // plain multipart form with a file input and a submit button, so a |
| 176 | // person with scripting off imports exactly the same way. |
| 177 | // |
| 178 | // PRODUCT.md's second principle is that the scriptless path is the |
| 179 | // product rather than the fallback, and drag-and-drop is the easiest |
| 180 | // place in this app to break it — a fetch-based uploader would look |
| 181 | // identical in a browser and leave nothing behind for anyone else. |
| 182 | // Asked of /sheets/import, not of /sheets. The form moved to a page of |
| 183 | // its own when the list page's two inline forms came off it; the |
| 184 | // question this test asks is unchanged and is the reason the form |
| 185 | // became a page rather than an item in the File menu, where a hidden |
| 186 | // input behind a label would have needed script to submit at all. |
| 187 | func TestImportIsScriptless(t *testing.T) { |
| 188 | app, cookies := signIn(t) |
| 189 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets/import", nil), cookies)) |
| 190 | if rec.Code != http.StatusOK { |
| 191 | t.Fatalf("import page answered %d, want 200", rec.Code) |
| 192 | } |
| 193 | body := rec.Body.String() |
| 194 | |
| 195 | for _, want := range []string{ |
| 196 | `action="/sheets/import"`, |
| 197 | `enctype="multipart/form-data"`, |
| 198 | `type="file"`, |
| 199 | `name="` + importField + `"`, |
| 200 | `type="submit"`, |
| 201 | } { |
| 202 | if !strings.Contains(body, want) { |
| 203 | t.Errorf("the import form is missing %s, so it does not work without scripting", want) |
| 204 | } |
| 205 | } |
| 206 | // The drop zone must live INSIDE that form, or the enhancement has |
| 207 | // nothing to submit. |
| 208 | form := body[strings.Index(body, `action="/sheets/import"`):] |
| 209 | zone := strings.Index(form, "data-drop") |
| 210 | end := strings.Index(form, "</form>") |
| 211 | if zone == -1 || end == -1 || zone > end { |
| 212 | t.Error("the drop zone is not inside the import form") |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // TestJobFragmentIsNotWrappedInTheLayout pins the polled endpoint. |
| 217 | // |
| 218 | // The shim REPLACES the polling element with whatever this returns, so |
| 219 | // a fragment that came back wrapped in the page layout would nest a |
| 220 | // whole second copy of the page inside the first on every tick — and it |
| 221 | // would do it only in a real browser, where no Go test looks. Hence a |
| 222 | // test that looks. |
| 223 | func TestJobFragmentIsNotWrappedInTheLayout(t *testing.T) { |
| 224 | app, cookies := signIn(t) |
| 225 | |
| 226 | rec := do(t, app, as(uploadRequest(t, "budget.xlsx", corpusFile(t, "formulas.xlsx")), cookies)) |
| 227 | if rec.Code != http.StatusSeeOther { |
| 228 | t.Fatalf("import answered %d, want 303", rec.Code) |
| 229 | } |
| 230 | fragment := rec.Header().Get("Location") + "/fragment" |
| 231 | |
| 232 | got := do(t, app, as(httptest.NewRequest(http.MethodGet, fragment, nil), cookies)) |
| 233 | switch got.Code { |
| 234 | case http.StatusNoContent: |
| 235 | // The job already finished, so the fragment hands the shim |
| 236 | // somewhere to go instead of markup. That is the other half of |
| 237 | // the contract and just as worth pinning. |
| 238 | if loc := got.Header().Get("Rastrillo-Location"); !strings.HasPrefix(loc, "/sheets/") { |
| 239 | t.Errorf("finished fragment sent the shim to %q, want a /sheets/ path", loc) |
| 240 | } |
| 241 | case http.StatusOK: |
| 242 | body := got.Body.String() |
| 243 | if strings.Contains(body, "<html") || strings.Contains(body, "rst-shell-bar") { |
| 244 | t.Error("the polled fragment carries the page layout, so every poll nests another copy of the page") |
| 245 | } |
| 246 | if !strings.Contains(body, "rst-job") { |
| 247 | t.Errorf("the fragment is not the job-status partial: %q", body) |
| 248 | } |
| 249 | default: |
| 250 | t.Fatalf("fragment answered %d, want 200 or 204", got.Code) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // TestJobPageNeedsASession pins the mount point. jobs.Handlers answers |
| 255 | // a job only to its owner and reads that owner from sessions.Current, |
| 256 | // so a route mounted outside the session group 404s every job — a |
| 257 | // working import that looks broken. |
| 258 | func TestJobPageNeedsASession(t *testing.T) { |
| 259 | app, _ := signIn(t) |
| 260 | rec := do(t, app, httptest.NewRequest(http.MethodGet, "/jobs/whatever", nil)) |
| 261 | if rec.Code == http.StatusOK { |
| 262 | t.Error("the job status page answered 200 with no session") |
| 263 | } |
| 264 | } |
| 265 | |