| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "mime/multipart" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "regexp" |
| 12 | "slices" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | ) |
| 17 | |
| 18 | // The tests in this file cover drag-and-drop upload. Read this before |
| 19 | // adding to them, because half of the feature is JavaScript and Go |
| 20 | // cannot run it: |
| 21 | // |
| 22 | // What these tests DO prove is the half that decides whether the feature |
| 23 | // is safe — that the scriptless form is untouched, that the script is |
| 24 | // actually served and has no upload path of its own, and that the limits |
| 25 | // and the words the browser refuses with are the handler's own, checked |
| 26 | // by posting files either side of each advertised boundary. |
| 27 | // |
| 28 | // What they CANNOT prove is that the drop handler does what its copy |
| 29 | // says. That was verified in a real browser instead, and here is the |
| 30 | // recipe, because a verification nobody can repeat is a claim: |
| 31 | // |
| 32 | // go run ./tools/drive # serves on :8824, no sign-in |
| 33 | // chromium --headless --remote-debugging-port=9222 about:blank |
| 34 | // |
| 35 | // (drive puts its throwaway database under the system temp directory. |
| 36 | // -dir moves it, but MkdirTemp does not create the parent, so whatever |
| 37 | // you pass has to exist already — an earlier version of this recipe said |
| 38 | // `-dir /tmp/x` and panicked on any machine without a /tmp/x.) |
| 39 | // |
| 40 | // then over CDP, with the page open, send Input.dispatchDragEvent — |
| 41 | // dragEnter, dragOver, drop — with real paths in data.files. Use that |
| 42 | // rather than a synthetic DragEvent: it goes through the browser's own |
| 43 | // drag machinery, so it is the only way to see that the page does not |
| 44 | // navigate away, which is what a dropped file used to do. Then read |
| 45 | // location.href, input.files.length and [rst-dropzone-note]. |
| 46 | // |
| 47 | // Checked that way on 2026-09-04, Chromium 151, BEFORE multi-file: one |
| 48 | // file dropped on the textarea and on the document list both landed in |
| 49 | // the input with the page still on "/" and the textarea untouched; two |
| 50 | // files at once left input.files empty and said "That drop had 2 files"; |
| 51 | // a folder was named as a folder; and pressing Store it after a drop |
| 52 | // stored the document, which then came back from a search. |
| 53 | // |
| 54 | // Re-checked the same way on 2026-09-04, Chromium 151, for multi-file. |
| 55 | // Five files dropped at once — three text, one binary, one empty — left |
| 56 | // input.files holding exactly the three text files, the page still on |
| 57 | // "/", and the note reading "Ready to store: alpha.md, beta.md, |
| 58 | // gamma.md. Press Store it. 2 refused: scan.png is not text; blank.txt |
| 59 | // is empty." Text typed into the paste box before the drop was still |
| 60 | // there afterwards; pressing Store it then stored four documents (three |
| 61 | // files and the paste) and the table went from three rows to seven. A |
| 62 | // drop of 26 files left input.files EMPTY and said "That drop had 26 |
| 63 | // files. memoria takes 25 at a time…". A drop of only the binary and |
| 64 | // the empty file left the input empty and said "2 refused: …". Nothing |
| 65 | // but a favicon 404 in the console. |
| 66 | // |
| 67 | // What that run does NOT cover, and nothing here does: the total-size |
| 68 | // refusal in the browser, and a folder dropped alongside files. Both |
| 69 | // are refused by the handler too, and the handler's half is tested. |
| 70 | |
| 71 | // dropZoneAttr pulls one attribute off the drop zone element. |
| 72 | // |
| 73 | // It matches inside the zone's own tag rather than anywhere in the page, |
| 74 | // which matters more than it looks: the front page renders the search |
| 75 | // results and the whole document list too, so "the string appears in the |
| 76 | // HTML" is a false pass waiting to happen. |
| 77 | func dropZoneAttr(t *testing.T, body, attr string) string { |
| 78 | t.Helper() |
| 79 | tag := dropZoneTag(t, body) |
| 80 | m := regexp.MustCompile(`\b` + regexp.QuoteMeta(attr) + `="([^"]*)"`).FindStringSubmatch(tag) |
| 81 | if m == nil { |
| 82 | t.Fatalf("the drop zone carries no %s:\n%s", attr, tag) |
| 83 | } |
| 84 | return m[1] |
| 85 | } |
| 86 | |
| 87 | // dropZoneTag returns the opening tag of the drop zone. |
| 88 | func dropZoneTag(t *testing.T, body string) string { |
| 89 | t.Helper() |
| 90 | i := strings.Index(body, "<div rst-dropzone") |
| 91 | if i < 0 { |
| 92 | t.Fatalf("no drop zone on the page:\n%s", body) |
| 93 | } |
| 94 | j := strings.Index(body[i:], ">") |
| 95 | if j < 0 { |
| 96 | t.Fatal("the drop zone tag never closes") |
| 97 | } |
| 98 | return body[i : i+j+1] |
| 99 | } |
| 100 | |
| 101 | // The whole feature is an enhancement, so the form underneath it has to |
| 102 | // be the form it was. Three things make that true and each is checked: |
| 103 | // the file input still exists inside the drop zone (wrapped, not |
| 104 | // replaced), it accepts every file a person chose, and nothing the |
| 105 | // script controls is visible before the script has run. |
| 106 | // |
| 107 | // `multiple` is the one to watch, and the assertion on it is INVERTED |
| 108 | // from the one that stood here. While createDocument read one |
| 109 | // r.FormFile("file"), `multiple` was forbidden: it would have let the |
| 110 | // chooser hand over five files and had four vanish without a word. Now |
| 111 | // the handler iterates r.MultipartForm.File["file"] and `multiple` is |
| 112 | // required, because without it the chooser offers one file and four of |
| 113 | // a person's five never become parts at all. The two states are one |
| 114 | // edit apart in each direction, so both reasons are recorded. |
| 115 | // |
| 116 | // MUTATION-TESTED 2026-09-04, in index.html, each reverted: |
| 117 | // |
| 118 | // (a) deleting the <input type="file"> → "the upload form has no file |
| 119 | // input; the drop zone replaced it instead of wrapping it" |
| 120 | // (a2) moving that input above `{{with .Drop}}`, so it is on the page |
| 121 | // but outside the zone → "the file input is outside the drop zone" |
| 122 | // (b) removing `multiple` from it → "the file input is not `multiple`" |
| 123 | // (c) deleting `hidden` from the hint → "rst-dropzone-hint is visible |
| 124 | // before any script has run" |
| 125 | // (c2) replacing `hidden` on the hint with `data-was-hidden="yes"` → |
| 126 | // the same failure. Recorded because it did NOT fail before: the |
| 127 | // check was strings.Contains(tag, "hidden"), which that attribute |
| 128 | // satisfies, so the guard passed over a hint that was visible on a |
| 129 | // scriptless page. It is a boundary match now. |
| 130 | // (d) writing rst-dropzone="ready" in the template → "the drop zone is |
| 131 | // pre-marked in the template" |
| 132 | func TestTheUploadFormStillWorksWithoutScript(t *testing.T) { |
| 133 | body := get(t, newApp(t), "/add").Body.String() |
| 134 | |
| 135 | // Isolate the form that posts to /documents. Everything below is |
| 136 | // asserted inside it, never against the whole page. |
| 137 | i := strings.Index(body, `action="/documents"`) |
| 138 | if i < 0 { |
| 139 | t.Fatalf("no upload form on the page:\n%s", body) |
| 140 | } |
| 141 | j := strings.Index(body[i:], "</form>") |
| 142 | if j < 0 { |
| 143 | t.Fatal("the upload form never closes") |
| 144 | } |
| 145 | form := body[i : i+j] |
| 146 | |
| 147 | if !strings.Contains(form, `<button rst-btn="primary">Store it</button>`) { |
| 148 | t.Errorf("the scriptless submit button is gone from the upload form:\n%s", form) |
| 149 | } |
| 150 | |
| 151 | input := regexp.MustCompile(`<input[^>]*type="file"[^>]*>`).FindString(form) |
| 152 | if input == "" { |
| 153 | t.Fatalf("the upload form has no file input; the drop zone replaced it "+ |
| 154 | "instead of wrapping it, and the form no longer works without script:\n%s", form) |
| 155 | } |
| 156 | if !strings.Contains(input, `name="file"`) { |
| 157 | t.Errorf("the file input is not named `file`, so createDocument's "+ |
| 158 | "MultipartForm.File[\"file\"] will not see it: %s", input) |
| 159 | } |
| 160 | if !regexp.MustCompile(`\bmultiple\b`).MatchString(input) { |
| 161 | t.Errorf("the file input is not `multiple`. It is the whole scriptless half of "+ |
| 162 | "multi-file upload: without it the chooser takes one file, four of five never "+ |
| 163 | "leave the browser, and the handler happily reports one document stored: %s", input) |
| 164 | } |
| 165 | |
| 166 | // Containment: the input must sit between the drop zone's opening |
| 167 | // tag and the end of the zone. |
| 168 | zone := strings.Index(form, "<div rst-dropzone") |
| 169 | if zone < 0 { |
| 170 | t.Fatalf("no drop zone inside the upload form:\n%s", form) |
| 171 | } |
| 172 | end := strings.Index(form[zone:], "</div>") |
| 173 | if end < 0 { |
| 174 | t.Fatal("the drop zone never closes") |
| 175 | } |
| 176 | if !strings.Contains(form[zone:zone+end], input) { |
| 177 | t.Error("the file input is outside the drop zone; dropping onto the zone would " + |
| 178 | "have nowhere to put the file") |
| 179 | } |
| 180 | |
| 181 | // Nothing the script drives may show before the script has run: a |
| 182 | // hint promising a drop target, on a page with no drop target, is a |
| 183 | // promise the page cannot keep. |
| 184 | // `hidden` is matched at its boundaries, not as a substring: the |
| 185 | // substring form was satisfied by any attribute with those six |
| 186 | // letters anywhere in it — data-was-hidden="yes" passed — which is |
| 187 | // the same shape of false pass as "result" matching "0 results". |
| 188 | hiddenAttr := regexp.MustCompile(`(^|\s)hidden(\s|>|=)`) |
| 189 | for _, el := range []string{"rst-dropzone-hint", "rst-dropzone-note"} { |
| 190 | tag := regexp.MustCompile(`<p ` + el + `[^>]*>`).FindString(form) |
| 191 | if tag == "" { |
| 192 | t.Errorf("no %s element in the form:\n%s", el, form) |
| 193 | continue |
| 194 | } |
| 195 | if !hiddenAttr.MatchString(tag) { |
| 196 | t.Errorf("%s is visible before any script has run, so a scriptless page "+ |
| 197 | "promises a drop zone it does not have: %s", el, tag) |
| 198 | } |
| 199 | } |
| 200 | // And the zone itself is unstyled until the script marks it ready. |
| 201 | if v := dropZoneTag(t, body); strings.Contains(v, `rst-dropzone="`) { |
| 202 | t.Errorf("the drop zone is pre-marked in the template: %s\n"+ |
| 203 | "The styling keys off rst-dropzone=\"ready\", which only the script may "+ |
| 204 | "write — otherwise a page whose script failed draws a dashed box that "+ |
| 205 | "eats files.", v) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // The script has to actually be served, and it may send a file to |
| 210 | // exactly one place: the form's own action, read off the form. |
| 211 | // |
| 212 | // THE PROPERTY HERE WAS NARROWED, on 2026-09-04, on the owner's explicit |
| 213 | // approval of the asynchronous-upload design. What stood here was "the |
| 214 | // drop fills the form's own input and does nothing else", enforced by |
| 215 | // forbidding XMLHttpRequest outright alongside fetch( and both spellings |
| 216 | // of submit. That was the right property while a drop meant filling an |
| 217 | // input; it is not the property of a design where each dropped file is |
| 218 | // posted on its own, in the background, with a progress tray beside it. |
| 219 | // |
| 220 | // What is emphatically NOT narrowed is the reason that rule existed: a |
| 221 | // second way in is a way in that the caps, the NUL sniff, the whole-body |
| 222 | // limit that keeps parts off the disk and the same-origin check were |
| 223 | // never asked about. So this pins the ADDRESS instead of the mechanism, |
| 224 | // and it is stricter about the address than the old one was about |
| 225 | // anything: |
| 226 | // |
| 227 | // - fetch(, sendBeacon, .submit( and requestSubmit( stay forbidden. |
| 228 | // One posting mechanism, so there is one thing to check. |
| 229 | // - exactly TWO .open( call sites exist, and each is one of exactly |
| 230 | // two permitted shapes — never a literal, never an expression built |
| 231 | // out of one. |
| 232 | // - `endpoint` is assigned exactly once, from the form's own action |
| 233 | // attribute, and the form is the file input's own form. |
| 234 | // - no address literal appears anywhere in the file. A second endpoint |
| 235 | // has to be spelled somewhere, and this is where. |
| 236 | // |
| 237 | // WIDENED BY ONE REQUEST, on 2026-09-05, on the owner's approval of the |
| 238 | // refreshing document list. A drop that never navigates leaves the list |
| 239 | // below it as the server drew it before the drop, and the tray said so |
| 240 | // and offered a link. It now asks the server for the page again and puts |
| 241 | // the freshly rendered regions in place of the old ones — which needs a |
| 242 | // GET, and this test permitted exactly one request of any kind. |
| 243 | // |
| 244 | // It is widened by ONE SHAPE and no more, because narrowing it once |
| 245 | // already proved insufficient: an earlier version was walked straight |
| 246 | // past with `endpoint += '/../upload'`. So the second shape is not "a |
| 247 | // GET" and not "a same-origin GET" — it is the page the browser is |
| 248 | // already on, spelled as location.href or as location.pathname + |
| 249 | // location.search, and nothing else parses. A constructed path, a |
| 250 | // literal, a concatenation onto either of those, or a third call site of |
| 251 | // any kind fails, and so does a POST that is not the form's action. |
| 252 | // |
| 253 | // Why that address is safe to permit when no other is: it is a GET, of a |
| 254 | // page this person was already served, at an address the browser chose |
| 255 | // rather than this file. It stores nothing, and it can reach nothing the |
| 256 | // reader could not reach by pressing reload. |
| 257 | // |
| 258 | // Note for whoever edits drop.js next: this scans the file whole, |
| 259 | // comments included, so prose in that file may not spell the forbidden |
| 260 | // names either, nor quote a path. That is the deliberate direction of |
| 261 | // error — a comment that trips this fails loudly, where stripping |
| 262 | // comments first would risk over-stripping and passing something real. |
| 263 | // |
| 264 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 265 | // |
| 266 | // (a) `mv static/drop.js static/drop2.js` → "GET /static/drop.js: |
| 267 | // status 404, want 200" |
| 268 | // (b) `xhr.open("POST", endpoint, true)` → `xhr.open("POST", |
| 269 | // "/upload", true)`, the second endpoint this whole test exists |
| 270 | // for → "the drop script opens a request on something other than |
| 271 | // the endpoint read off the form" AND "the drop script contains |
| 272 | // the address literal "/upload"" |
| 273 | // (c) a second call site added (`var probe = new XMLHttpRequest(); |
| 274 | // probe.open("POST", endpoint, true);`) → "the drop script opens 2 |
| 275 | // requests; exactly one call site may exist" |
| 276 | // (d) `endpoint = form.getAttribute("action")` → `endpoint = |
| 277 | // "/documents"` — the plausible "it is right there in the |
| 278 | // template" edit, and the one that would let the template's action |
| 279 | // and the script's address drift apart → "the drop script does not |
| 280 | // read its address off the form's own action attribute", plus the |
| 281 | // literal failure |
| 282 | // (e) `xhr.send(body)` → `form.requestSubmit()` → "the drop script |
| 283 | // uses requestSubmit(" |
| 284 | // (f) adding `console.log("dropped", file.name)` → "the drop script |
| 285 | // writes to the console; a dropped file's name is content" |
| 286 | // |
| 287 | // MUTATION-TESTED 2026-09-04, second round, after a code review walked |
| 288 | // past this test twice on a build it had not been able to fail. Both were |
| 289 | // replayed here and both were green before the two regexes above were |
| 290 | // widened; both are red now, and each was reverted: |
| 291 | // |
| 292 | // (g) `endpoint += '/../upload';` on the line after the read — the |
| 293 | // reviewer's own mutation, and the reason "assigned once" is now |
| 294 | // spelled to include compound assignment → "the drop script assigns |
| 295 | // `endpoint` 2 times" AND "the drop script contains the address |
| 296 | // literal '/../upload'" |
| 297 | // (h) ``var alt = `/upload`;`` — the same address in the third quoting |
| 298 | // style, which the old double-quotes-only pattern could not see → |
| 299 | // "the drop script contains the address literal `/upload`" |
| 300 | // |
| 301 | // MUTATION-TESTED 2026-09-05, third round, after the second request was |
| 302 | // permitted. Every one of these was run against the narrowed test, and |
| 303 | // each was reverted: |
| 304 | // |
| 305 | // (i) `req.open("GET", "/documents", true)` — the re-read pointed at |
| 306 | // another address → "the drop script opens a request that is |
| 307 | // neither of the two things it is allowed to open", "0 re-reads of |
| 308 | // this page", and "the address literal "/documents"" |
| 309 | // (j) the reviewer's own shape, moved to the new request: `var where = |
| 310 | // location.pathname + location.search; where += '/../upload'; |
| 311 | // req.open("GET", where, true)` → the same three failures, the |
| 312 | // third naming '/../upload' |
| 313 | // (k) `req.open("GET", location.pathname + location.search + endpoint, |
| 314 | // true)` — the sharp one, because it writes NO literal down and |
| 315 | // would slip past the literal ban entirely → red on the shape |
| 316 | // alone: the trailing anchor is what stops an address that merely |
| 317 | // begins with the permitted one |
| 318 | // (l) a second re-read call site added → "the drop script opens 3 |
| 319 | // requests; exactly two call sites may exist" |
| 320 | // (m) `req.open("POST", location.pathname + location.search, true)` — |
| 321 | // the permitted address with the verb changed, which would be a |
| 322 | // second way in wearing the re-read's clothes → "neither of the two |
| 323 | // things it is allowed to open" and "0 re-reads of this page" |
| 324 | // (n) the ORIGINAL property, replayed to prove widening did not blunt |
| 325 | // it: `xhr.open("POST", "/upload", true)` → "0 posts to the form's |
| 326 | // action" and "the address literal "/upload"" |
| 327 | // (o) and the code review's compound assignment, replayed for the same |
| 328 | // reason: `endpoint += '/../upload'` after the read → "the drop |
| 329 | // script assigns `endpoint` 2 times" and "the address literal |
| 330 | // '/../upload'" |
| 331 | // |
| 332 | // And two that do NOT fail, recorded because a reader will try them: |
| 333 | // changing the form's action in index.html (the script reads it, so both |
| 334 | // sides move together — the point of reading it rather than writing it |
| 335 | // down twice), and spelling the re-read `location.href` instead of |
| 336 | // `location.pathname + location.search`. The second is deliberate: both |
| 337 | // name the page the browser is already on, neither is written down here, |
| 338 | // and pinning one spelling would be pinning a style rather than the |
| 339 | // property. |
| 340 | func TestTheDropScriptPostsOnlyToTheFormsOwnAction(t *testing.T) { |
| 341 | src := servedDropScript(t, newApp(t)) |
| 342 | |
| 343 | // requestSubmit( is listed separately and deliberately: it is not a |
| 344 | // suffix of ".submit(" and the whole point of the rule is that the |
| 345 | // script never submits the form, by any spelling. |
| 346 | for _, forbidden := range []string{"fetch(", "navigator.sendBeacon", ".submit(", "requestSubmit("} { |
| 347 | if strings.Contains(src, forbidden) { |
| 348 | t.Errorf("the drop script uses %s. There is one posting mechanism and one address: "+ |
| 349 | "a second way in is a way in that the server's caps, its NUL check, its "+ |
| 350 | "whole-body limit and its same-origin check were not asked about.", forbidden) |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // Exactly two requests are opened, and each is one of exactly two |
| 355 | // shapes: the POST to the address read off the form, and the GET of |
| 356 | // the page the browser is already on. Neither address may be a |
| 357 | // literal, and neither may be an expression built out of one. |
| 358 | opens := regexp.MustCompile(`\.open\s*\(([^)]*)\)`).FindAllStringSubmatch(src, -1) |
| 359 | if len(opens) != 2 { |
| 360 | t.Fatalf("the drop script opens %d requests; exactly two call sites may exist — the "+ |
| 361 | "upload and the page it re-reads — or this test cannot say where all of them go", |
| 362 | len(opens)) |
| 363 | } |
| 364 | // The upload. Unchanged: the variable, and only the variable. |
| 365 | toTheForm := regexp.MustCompile(`^\s*"POST"\s*,\s*endpoint\s*(,[^,]*)?$`) |
| 366 | // The re-read. Both spellings of "where the browser already is", and |
| 367 | // nothing that merely starts with one: the trailing anchor is what |
| 368 | // stops `location.href + "/../upload"` from parsing as this shape. |
| 369 | toThisPage := regexp.MustCompile( |
| 370 | `^\s*"GET"\s*,\s*(?:location\.href|location\.pathname\s*\+\s*location\.search)\s*(,[^,]*)?$`) |
| 371 | posts, rereads := 0, 0 |
| 372 | for _, o := range opens { |
| 373 | switch { |
| 374 | case toTheForm.MatchString(o[1]): |
| 375 | posts++ |
| 376 | case toThisPage.MatchString(o[1]): |
| 377 | rereads++ |
| 378 | default: |
| 379 | t.Errorf("the drop script opens a request that is neither of the two things it is "+ |
| 380 | "allowed to open: .open(%s). It may POST to the endpoint read off the form, "+ |
| 381 | "and it may GET the page the reader is already on (location.href, or "+ |
| 382 | "location.pathname + location.search). An address that is a literal, or built "+ |
| 383 | "out of one, or anything else at all, is an address nothing on the server "+ |
| 384 | "side agreed to.", o[1]) |
| 385 | } |
| 386 | } |
| 387 | if posts != 1 || rereads != 1 { |
| 388 | t.Errorf("the drop script opens %d posts to the form's action and %d re-reads of this "+ |
| 389 | "page; it may open exactly one of each. Two posts is a second way in; two "+ |
| 390 | "re-reads is a race for which answer lands last.", posts, rereads) |
| 391 | } |
| 392 | |
| 393 | // And that variable is the form's action, assigned once. A second |
| 394 | // assignment could be anything at all. |
| 395 | // |
| 396 | // COMPOUND assignment counts. `endpoint = …` was all this matched |
| 397 | // until a code review wrote `endpoint += '/../upload'` on the line |
| 398 | // after the read and watched the whole test stay green: `+=` is not |
| 399 | // `=`, and the address that request would have gone to is /upload. |
| 400 | // The alternation covers every JavaScript assignment operator, |
| 401 | // longest first, and the trailing [^=] keeps `==` and `===` out. |
| 402 | assign := regexp.MustCompile(`\bendpoint\s*(?:>>>|\*\*|<<|>>|\|\||&&|\?\?|[-+*/%&|^])?=[^=]`) |
| 403 | if n := len(assign.FindAllString(src, -1)); n != 1 { |
| 404 | t.Errorf("the drop script assigns `endpoint` %d times; the address is read once, from "+ |
| 405 | "one place, or the value opened above is not the value read below", n) |
| 406 | } |
| 407 | if !strings.Contains(src, `endpoint = form.getAttribute("action")`) { |
| 408 | t.Error("the drop script does not read its address off the form's own action " + |
| 409 | "attribute. Anything else — a literal, a constructed path, another element's " + |
| 410 | "action — is an address that can drift from the one the scriptless submit uses.") |
| 411 | } |
| 412 | if !strings.Contains(src, "var form = input.form") { |
| 413 | t.Error("the drop script does not take its form from the file input. It has to be THAT " + |
| 414 | "form: the one the person would have posted by pressing Store it.") |
| 415 | } |
| 416 | |
| 417 | // No address is written down in here at all — in ANY of the three |
| 418 | // ways JavaScript spells a string. This used to inspect double-quoted |
| 419 | // strings only, so '/upload' and `/upload` were both invisible to it; |
| 420 | // the same code review that found the compound assignment above wrote |
| 421 | // its second endpoint in single quotes, which is the spelling half |
| 422 | // this file's own selectors already use. |
| 423 | var quoted []string |
| 424 | for _, q := range []string{`"`, `'`, "`"} { |
| 425 | quoted = append(quoted, |
| 426 | q+`(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?//[^`+q+`]*`+q, |
| 427 | q+`/[^`+q+`]*`+q) |
| 428 | } |
| 429 | if lit := regexp.MustCompile(strings.Join(quoted, "|")).FindAllString(src, -1); len(lit) > 0 { |
| 430 | t.Errorf("the drop script contains the address literal %s. Every address it uses comes "+ |
| 431 | "off the page; one written down in here is one the server never agreed to.", |
| 432 | strings.Join(lit, ", ")) |
| 433 | } |
| 434 | |
| 435 | // A filename is somebody's content. It never goes to a log, and the |
| 436 | // browser console is close enough to one to keep the rule. |
| 437 | if strings.Contains(src, "console.") { |
| 438 | t.Error("the drop script writes to the console; a dropped file's name is content") |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | // The cap the page advertises to the browser is the cap the handler |
| 443 | // enforces. Not "roughly": the same integer. |
| 444 | // |
| 445 | // This is the check that makes the client-side size refusal honest. A |
| 446 | // drifted cap is worse than none — the page would refuse a file the |
| 447 | // server would have taken, and the person would have no way to find that |
| 448 | // out. So the number is read off the rendered page and files of exactly |
| 449 | // that size and exactly one byte more are posted at the real handler. |
| 450 | // |
| 451 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 452 | // |
| 453 | // (a) data-max-bytes="100000" in index.html → "the page advertises a |
| 454 | // cap of 100000 bytes but the handler answered "/?stored=1" for a |
| 455 | // file 1 byte over it", plus "the refusal does not name the file |
| 456 | // it refused" and "2 documents are stored ... want 1" |
| 457 | // (b) `io.LimitReader(f, maxUpload+1)` → `io.LimitReader(f, maxUpload)` |
| 458 | // in readUpload, which truncates instead of refusing → the same |
| 459 | // failure, at the real cap. This is the mutation worth knowing: an |
| 460 | // over-cap upload came back "/?stored=1" having quietly lost its |
| 461 | // last byte. |
| 462 | // |
| 463 | // And one that does NOT fail, recorded because a reader will try it: |
| 464 | // changing maxUpload itself. The page renders that constant, so both |
| 465 | // sides move together and the test stays green — which is the whole |
| 466 | // point of publishing the number rather than writing it twice. What |
| 467 | // this test can catch is the two halves disagreeing, and (a) and (b) are |
| 468 | // each of those halves. |
| 469 | func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) { |
| 470 | h := newApp(t) |
| 471 | cap := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-bytes")) |
| 472 | if cap < 1024 { |
| 473 | t.Fatalf("data-max-bytes is %d, which no real cap is; the tests below would prove nothing", cap) |
| 474 | } |
| 475 | |
| 476 | // Text, not one long line: the chunker's job is not what is being |
| 477 | // tested, but a document it cannot split is not a fair sample. |
| 478 | filler := "The quarterly figure was recorded in full and then again.\n\n" |
| 479 | at := strings.Repeat(filler, cap/len(filler)+1)[:cap] |
| 480 | if rec := upload(t, h, "at-the-cap.txt", at); rec.Header().Get("Location") != "/add?stored=1" { |
| 481 | t.Errorf("a file of exactly the advertised cap (%d bytes) was refused with %q; "+ |
| 482 | "the page tells the browser it will be taken", cap, rec.Header().Get("Location")) |
| 483 | } |
| 484 | |
| 485 | // One byte over: refused, and refused BY NAME. The assertion is on |
| 486 | // the outcome sentence rather than on a URL code, because that is |
| 487 | // the whole of what a person is told and it is the half that can |
| 488 | // silently become "stored". |
| 489 | rec := upload(t, h, "over-the-cap.txt", at+"x") |
| 490 | if loc := rec.Header().Get("Location"); loc != "/add?stored=0&refused=1" { |
| 491 | t.Errorf("the page advertises a cap of %d bytes but the handler answered %q for a "+ |
| 492 | "file 1 byte over it; the browser would refuse a file this server accepts, "+ |
| 493 | "or the other way round", cap, loc) |
| 494 | } |
| 495 | said := outcome(t, h, rec) |
| 496 | if !strings.Contains(said, "over-the-cap.txt") { |
| 497 | t.Errorf("the refusal does not name the file it refused: %q", said) |
| 498 | } |
| 499 | if n := storedCount(t, h); n != 1 { |
| 500 | t.Errorf("%d documents are stored after one at the cap and one over it, want 1", n) |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | // Same again for the sniff window. looksLikeText reads a prefix and the |
| 505 | // drop zone reads the same prefix, so the page publishes its length and |
| 506 | // this proves the handler agrees: a NUL at the last byte inside the |
| 507 | // window is refused, and a NUL one byte past it is stored. |
| 508 | // |
| 509 | // The second half is the one that catches drift. If the handler's window |
| 510 | // were larger than the page's, the browser would wave through a file the |
| 511 | // server then rejects — and the person would be told "binary" by a page |
| 512 | // that had just said the file was fine. |
| 513 | // |
| 514 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 515 | // |
| 516 | // (a) data-sniff-bytes="64" in index.html → "a NUL at byte 64 — one |
| 517 | // past the advertised 64-byte window — was answered |
| 518 | // "/?stored=0&refused=1", want /?stored=1" |
| 519 | // (b) looksLikeText's window hard-coded back to 4096 → "a NUL at byte |
| 520 | // 8191 — the last byte inside the advertised 8192-byte window — |
| 521 | // was answered "/?stored=1", want a refusal" |
| 522 | // |
| 523 | // As with the cap: changing sniffBytes itself moves both sides and |
| 524 | // stays green on purpose. |
| 525 | func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) { |
| 526 | h := newApp(t) |
| 527 | window := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-sniff-bytes")) |
| 528 | if window < 16 { |
| 529 | t.Fatalf("data-sniff-bytes is %d; the tests below would prove nothing", window) |
| 530 | } |
| 531 | |
| 532 | // A NUL at the last byte the window covers: refused, by name. |
| 533 | inside := strings.Repeat("a", window-1) + "\x00" + strings.Repeat("b", 64) |
| 534 | rec := upload(t, h, "inside.txt", inside) |
| 535 | if loc := rec.Header().Get("Location"); loc != "/add?stored=0&refused=1" { |
| 536 | t.Errorf("a NUL at byte %d — the last byte inside the advertised %d-byte window — "+ |
| 537 | "was answered %q, want a refusal", window-1, window, loc) |
| 538 | } else if said := outcome(t, h, rec); !strings.Contains(said, "inside.txt") { |
| 539 | t.Errorf("the refusal does not name the file it refused: %q", said) |
| 540 | } |
| 541 | |
| 542 | // A NUL one byte past the window: stored, because neither side |
| 543 | // looks that far. If this now fails, the handler reads further than |
| 544 | // the page says it does and the browser's check has gone blind. |
| 545 | outside := strings.Repeat("a", window) + "\x00" + strings.Repeat("b", 64) |
| 546 | if loc := upload(t, h, "outside.txt", outside).Header().Get("Location"); loc != "/add?stored=1" { |
| 547 | t.Errorf("a NUL at byte %d — one past the advertised %d-byte window — was answered "+ |
| 548 | "%q, want /add?stored=1; the handler inspects more than the page tells the "+ |
| 549 | "browser it does", window, window, loc) |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | // The file-count cap the page advertises is the one the handler |
| 554 | // enforces, and a drop over it is refused WHOLE — nothing stored, and |
| 555 | // the sentence says nothing was stored. |
| 556 | // |
| 557 | // The number is read off the rendered page, so this cannot pass by |
| 558 | // agreeing with itself: exactly that many files are stored and exactly |
| 559 | // one more is refused. The half that matters is the second — a cap |
| 560 | // applied part-way through a drop would store the first twenty-five of |
| 561 | // twenty-six and report success, which is this codebase's worst failure |
| 562 | // shape wearing a limit as a costume. |
| 563 | // |
| 564 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 565 | // |
| 566 | // (a) `len(files) > maxDropFiles` → `len(files) > maxDropFiles*2` in |
| 567 | // createDocument, so the advertised cap is not the enforced one → |
| 568 | // "26 files — one over the advertised cap of 25 — stored 26 |
| 569 | // documents" |
| 570 | // (b) truncating instead of refusing (`files = files[:maxDropFiles]` |
| 571 | // in place of the refusal) → "26 files ... stored 25 documents", |
| 572 | // which is the silent truncation itself |
| 573 | // (c) data-max-files="4" in index.html → "5 files — one over the |
| 574 | // advertised cap of 4 — stored 5 documents": the handler still |
| 575 | // refuses at 25, so page and handler have drifted and the test |
| 576 | // says so from the page's side |
| 577 | func TestTheAdvertisedFileCountCapIsTheHandlersCap(t *testing.T) { |
| 578 | h := newApp(t) |
| 579 | maxFiles := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files")) |
| 580 | if maxFiles < 2 || maxFiles > 200 { |
| 581 | t.Fatalf("data-max-files is %d; the drop built below would prove nothing", maxFiles) |
| 582 | } |
| 583 | |
| 584 | atCap := make([]file, 0, maxFiles) |
| 585 | for i := 0; i < maxFiles; i++ { |
| 586 | atCap = append(atCap, file{fmt.Sprintf("at-%02d.txt", i), fmt.Sprintf("Passage %d about quarterly revenue and hosting.", i)}) |
| 587 | } |
| 588 | submit(t, h, "", "", atCap...) |
| 589 | if n := storedCount(t, h); n != maxFiles { |
| 590 | t.Fatalf("%d files, the advertised cap, stored %d documents", maxFiles, n) |
| 591 | } |
| 592 | |
| 593 | h2 := newApp(t) |
| 594 | overCap := append(atCap, file{"one-too-many.txt", "One more passage about revenue."}) |
| 595 | rec := submit(t, h2, "", "", overCap...) |
| 596 | if n := storedCount(t, h2); n != 0 { |
| 597 | t.Errorf("%d files — one over the advertised cap of %d — stored %d documents. A "+ |
| 598 | "submission cap must refuse the drop whole: storing some of it and reporting "+ |
| 599 | "success is the silent truncation this app treats as its worst failure", |
| 600 | len(overCap), maxFiles, n) |
| 601 | } |
| 602 | said := outcome(t, h2, rec) |
| 603 | if !strings.Contains(said, strconv.Itoa(len(overCap))) || !strings.Contains(said, strconv.Itoa(maxFiles)) { |
| 604 | t.Errorf("the refusal names neither how many were dropped (%d) nor how many are "+ |
| 605 | "taken at a time (%d): %q", len(overCap), maxFiles, said) |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | // Same again for the total-size cap. A drop over it is refused before |
| 610 | // anything is opened, so the answer is "none of it was stored". |
| 611 | // |
| 612 | // This one is enforced twice on purpose and the test does not care |
| 613 | // which half caught it: http.MaxBytesReader caps the BODY (framing |
| 614 | // included) and the handler sums the parts' own sizes against the |
| 615 | // advertised total. A drop landing between the two would otherwise be |
| 616 | // taken, which is why the second check exists at all. |
| 617 | // |
| 618 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 619 | // |
| 620 | // (a) removing the `total > maxDropBytes` check AND raising the |
| 621 | // MaxBytesReader to `maxDropBytes*4` → "a drop of 23068672 bytes, |
| 622 | // over the advertised 20971520, stored 11 documents" |
| 623 | // (b) data-max-total-bytes="{{.MaxBytes}}" in index.html, so the page |
| 624 | // advertises the per-file cap as the total → "the advertised |
| 625 | // total (2097152) is not larger than the advertised per-file cap |
| 626 | // (2097152); a drop over the total could not be built out of |
| 627 | // legal files". A page that advertises a total it does not have |
| 628 | // fails here rather than passing quietly. |
| 629 | func TestTheAdvertisedTotalCapIsTheHandlersCap(t *testing.T) { |
| 630 | h := newApp(t) |
| 631 | body := get(t, h, "/add").Body.String() |
| 632 | total := atoi(t, dropZoneAttr(t, body, "data-max-total-bytes")) |
| 633 | per := atoi(t, dropZoneAttr(t, body, "data-max-bytes")) |
| 634 | if total <= per { |
| 635 | t.Fatalf("the advertised total (%d) is not larger than the advertised per-file cap "+ |
| 636 | "(%d); a drop over the total could not be built out of legal files", total, per) |
| 637 | } |
| 638 | |
| 639 | // Every file legal on its own — at the per-file cap — and one more |
| 640 | // of them than the total allows. Nothing here is refusable except |
| 641 | // the total. |
| 642 | filler := "The quarterly figure was recorded in full and then again.\n\n" |
| 643 | one := strings.Repeat(filler, per/len(filler)+1)[:per] |
| 644 | files := make([]file, 0, total/per+1) |
| 645 | for i := 0; len(files)*per <= total; i++ { |
| 646 | files = append(files, file{fmt.Sprintf("big-%02d.txt", i), one}) |
| 647 | } |
| 648 | rec := submit(t, h, "", "", files...) |
| 649 | if n := storedCount(t, h); n != 0 { |
| 650 | t.Errorf("a drop of %d bytes, over the advertised %d, stored %d documents. The total "+ |
| 651 | "cap must refuse the drop whole, before anything is stored", |
| 652 | len(files)*per, total, n) |
| 653 | } |
| 654 | if said := outcome(t, h, rec); !strings.Contains(said, "20 MiB") { |
| 655 | t.Errorf("the refusal does not name the total it enforces: %q", said) |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | // The browser refuses in the handler's own words. A person who drops a |
| 660 | // PDF and a person who picks one from the file chooser are told the same |
| 661 | // thing about it, because it is the same sentence with the same |
| 662 | // filename in it — the clause on the drop zone and the clause the |
| 663 | // handler composes come from one field of newDropZone. |
| 664 | // |
| 665 | // The server half is produced by actually posting the file, not by |
| 666 | // reading a string back off a page: a template that renders the clause |
| 667 | // correctly and a handler that composes a different one would otherwise |
| 668 | // agree perfectly. |
| 669 | // |
| 670 | // MUTATION-TESTED 2026-09-04, each reverted: (a) data-reason-binary set |
| 671 | // to a literal "{name} is a binary file." in index.html → the binary |
| 672 | // mismatch, printing both sentences; (b) ReasonEmpty used in place of |
| 673 | // ReasonBinary in readUpload → the same failure from the handler's |
| 674 | // side. Editing the field in newDropZone moves both at once and stays |
| 675 | // green, which is what sharing the sentence is for. |
| 676 | func TestTheBrowserRefusesInTheHandlersWords(t *testing.T) { |
| 677 | h := newApp(t) |
| 678 | page := get(t, h, "/add").Body.String() |
| 679 | |
| 680 | for _, c := range []struct { |
| 681 | attr, name, body string |
| 682 | }{ |
| 683 | {"data-reason-binary", "scan.png", "PNG\x00\x00 not text at all"}, |
| 684 | {"data-reason-empty", "blank.txt", " \n "}, |
| 685 | } { |
| 686 | // The browser's clause, with the filename in it. |
| 687 | browser := strings.ReplaceAll(dropZoneAttr(t, page, c.attr), "{name}", c.name) |
| 688 | // The handler's clause, produced by refusing the real file. It |
| 689 | // arrives inside the outcome sentence, so this looks for the |
| 690 | // clause within it rather than for equality. |
| 691 | said := outcome(t, h, submit(t, h, "", "", file{c.name, c.body})) |
| 692 | if !strings.Contains(said, browser) { |
| 693 | t.Errorf("the drop zone and the handler refuse %s differently.\n browser: %q\n server: %q\n"+ |
| 694 | "Two sentences for one rule means one of them is out of date and nobody can tell which.", |
| 695 | c.attr, browser, said) |
| 696 | } |
| 697 | } |
| 698 | } |
| 699 | |
| 700 | // servedDropScript fetches the script the page actually links, and |
| 701 | // proves it is that script before anything is asserted about its |
| 702 | // contents. Fetched through the handler rather than read off disk: |
| 703 | // what ships is what the asset route serves. |
| 704 | func servedDropScript(t *testing.T, h http.Handler) string { |
| 705 | t.Helper() |
| 706 | body := get(t, h, "/add").Body.String() |
| 707 | m := regexp.MustCompile(`<script[^>]+src="([^"]*drop[^"]*\.js[^"]*)"`).FindStringSubmatch(body) |
| 708 | if m == nil { |
| 709 | t.Fatalf("the page links no drop script:\n%s", body) |
| 710 | } |
| 711 | rec := get(t, h, m[1]) |
| 712 | if rec.Code != http.StatusOK { |
| 713 | t.Fatalf("GET %s: status %d, want 200 — the page links a script that is not served, "+ |
| 714 | "so the drop zone never appears and nobody finds out from a test", m[1], rec.Code) |
| 715 | } |
| 716 | src := rec.Body.String() |
| 717 | if len(src) < 500 { |
| 718 | t.Fatalf("GET %s served %d bytes; that is not the drop script, and every "+ |
| 719 | "assertion against it would pass against an empty file", m[1], len(src)) |
| 720 | } |
| 721 | // The vacuity guard: prove this is really the file, so an absence |
| 722 | // asserted against it means something. |
| 723 | if !strings.Contains(src, "rst-dropzone") { |
| 724 | t.Fatalf("GET %s did not serve the drop script (no rst-dropzone in it)", m[1]) |
| 725 | } |
| 726 | return src |
| 727 | } |
| 728 | |
| 729 | // The note is a live region, and a live region has to be in the |
| 730 | // accessibility tree BEFORE the text it announces is written into it. |
| 731 | // Unhiding it and filling it in the same tick is the common way to build |
| 732 | // one that never announces anything — and this particular region carries |
| 733 | // the refusals: "that drop had five files", "that is a folder". A |
| 734 | // refusal a blind person cannot hear is exactly the silent failure this |
| 735 | // codebase treats as its worst, so the empty paragraph is unhidden at |
| 736 | // the ready step and stays there. |
| 737 | // |
| 738 | // Go cannot run the script, so this pins the shape instead: exactly one |
| 739 | // place unhides the note, and it is the ready step at the end of the |
| 740 | // file rather than say(). The CSS half is checked too — an empty |
| 741 | // paragraph that still takes a half-line of margin is a visible cost for |
| 742 | // an invisible element, and the obvious way to "fix" that is to hide the |
| 743 | // note again, which is the bug coming back. |
| 744 | // |
| 745 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 746 | // |
| 747 | // (a) moving `note.hidden = false` back into say() → "drop.js unhides |
| 748 | // the note before the ready step" |
| 749 | // (b) deleting it entirely → "drop.js never unhides the note" |
| 750 | // (c) deleting the [rst-dropzone-note]:empty rule from memoria.css → |
| 751 | // "memoria.css has no :empty rule for the note" |
| 752 | func TestTheLiveRegionExistsBeforeItHasAnythingToSay(t *testing.T) { |
| 753 | h := newApp(t) |
| 754 | src := servedDropScript(t, h) |
| 755 | |
| 756 | // The anchor is the ready step's own marker comment. Asserted |
| 757 | // first, because without it the position check below compares |
| 758 | // against -1 and passes for the wrong reason. |
| 759 | const anchor = "and only now does the zone exist" |
| 760 | at := strings.Index(src, anchor) |
| 761 | if at < 0 { |
| 762 | t.Fatalf("drop.js no longer carries the %q marker, so this test cannot tell the "+ |
| 763 | "ready step from the rest of the file and proves nothing", anchor) |
| 764 | } |
| 765 | |
| 766 | unhide := regexp.MustCompile(`note\.hidden\s*=`).FindAllStringIndex(src, -1) |
| 767 | if len(unhide) == 0 { |
| 768 | t.Fatal("drop.js never unhides the note; the live region stays hidden and every " + |
| 769 | "refusal a drop makes is silent to a screen reader") |
| 770 | } |
| 771 | if len(unhide) > 1 { |
| 772 | t.Errorf("drop.js assigns note.hidden in %d places. One of them is the ready step; "+ |
| 773 | "any other can only be unhiding it later, beside the text — which is the "+ |
| 774 | "pattern that does not announce.", len(unhide)) |
| 775 | } |
| 776 | for _, u := range unhide { |
| 777 | if u[0] < at { |
| 778 | t.Errorf("drop.js unhides the note before the ready step (byte %d, marker at %d). "+ |
| 779 | "The live region must enter the accessibility tree empty and early: unhidden "+ |
| 780 | "in the same tick as its first message, it is commonly not announced at all.", |
| 781 | u[0], at) |
| 782 | } |
| 783 | } |
| 784 | |
| 785 | // The rendered note is still a live region at all. |
| 786 | tag := regexp.MustCompile(`<p rst-dropzone-note[^>]*>`).FindString(get(t, h, "/add").Body.String()) |
| 787 | if !strings.Contains(tag, `aria-live="polite"`) || !strings.Contains(tag, `role="status"`) { |
| 788 | t.Errorf("the note is not a polite live region, so unhiding it early buys nothing: %s", tag) |
| 789 | } |
| 790 | |
| 791 | // And an empty note costs no space. |
| 792 | css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| 793 | if err != nil { |
| 794 | t.Fatal(err) |
| 795 | } |
| 796 | if !regexp.MustCompile(`\[rst-dropzone-note\]:empty\s*\{[^}]*margin\s*:\s*0`).Match(css) { |
| 797 | t.Error("memoria.css has no :empty rule for the note zeroing its margin. The note is " + |
| 798 | "unhidden from the moment the script runs, so an empty paragraph with a top " + |
| 799 | "margin pushes the Store it button down for no visible reason — and the " + |
| 800 | "obvious fix for that is to hide the note again, which is the bug.") |
| 801 | } |
| 802 | } |
| 803 | |
| 804 | // Every refusal names the cap it enforces, and the hint above the input |
| 805 | // names all three. The per-file cap, the total and the file count are |
| 806 | // each one number said in several places, and each is written down once. |
| 807 | // |
| 808 | // Not pedantry: the refusal used to carry "2 MiB" as a literal while the |
| 809 | // hint beside it derived its number from maxUpload. Raising the cap |
| 810 | // would have left the page refusing a 3 MiB file with the words "that is |
| 811 | // larger than 2 MiB" — and the person, who had just read a hint saying |
| 812 | // 4 MiB, would have no way to tell which of the two lines was lying. |
| 813 | // With three caps there are three ways to make that mistake. |
| 814 | // |
| 815 | // The per-file refusal comes from a real over-cap upload rather than |
| 816 | // from a rendered attribute, so a handler composing its own sentence |
| 817 | // cannot pass by agreeing with a template. |
| 818 | // |
| 819 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 820 | // |
| 821 | // (a) `ReasonTooBig: "{name} is larger than 2 MiB"` — the literal put |
| 822 | // back — with maxUpload raised to `4 << 20` → "the refusal names |
| 823 | // 2 MiB but the per-file cap it enforces is 4 MiB" |
| 824 | // (b) `dropLimit()` swapped for `uploadLimit()` in the Hint → "the |
| 825 | // hint does not name the total cap (20 MiB)" |
| 826 | // (c) `fileLimit()` replaced with `"25"` in the Hint, with maxDropFiles |
| 827 | // raised to 30 → "the hint does not name the file-count cap (30)" |
| 828 | // |
| 829 | // Changing any of the three constants ALONE keeps this green, which is |
| 830 | // the point: one number, said everywhere from one place. |
| 831 | func TestTheRefusalsNameTheCapsTheyEnforce(t *testing.T) { |
| 832 | h := newApp(t) |
| 833 | body := get(t, h, "/add").Body.String() |
| 834 | |
| 835 | capBytes := atoi(t, dropZoneAttr(t, body, "data-max-bytes")) |
| 836 | totalBytes := atoi(t, dropZoneAttr(t, body, "data-max-total-bytes")) |
| 837 | maxFiles := atoi(t, dropZoneAttr(t, body, "data-max-files")) |
| 838 | if capBytes>>20 < 1 || totalBytes>>20 < 1 { |
| 839 | t.Fatalf("the advertised caps are %d and %d bytes; this test compares whole MiB and "+ |
| 840 | "would prove nothing", capBytes, totalBytes) |
| 841 | } |
| 842 | want := fmt.Sprintf("%d MiB", capBytes>>20) |
| 843 | wantTotal := fmt.Sprintf("%d MiB", totalBytes>>20) |
| 844 | |
| 845 | // The refusal the handler actually composes, for a real file over |
| 846 | // the real cap. |
| 847 | filler := "The quarterly figure was recorded in full and then again.\n\n" |
| 848 | over := strings.Repeat(filler, capBytes/len(filler)+2)[:capBytes+1] |
| 849 | refusal := outcome(t, h, upload(t, h, "over.txt", over)) |
| 850 | said := regexp.MustCompile(`\d+\s*MiB`).FindAllString(refusal, -1) |
| 851 | if len(said) == 0 { |
| 852 | t.Fatalf("the too-big refusal names no size at all, so it can neither agree nor "+ |
| 853 | "disagree with the cap: %q", refusal) |
| 854 | } |
| 855 | for _, got := range said { |
| 856 | if got != want { |
| 857 | t.Errorf("the refusal names %s but the per-file cap it enforces is %s (%d bytes, "+ |
| 858 | "read off the same page the browser reads). One of the two moved and the "+ |
| 859 | "other did not: %q", got, want, capBytes, refusal) |
| 860 | } |
| 861 | } |
| 862 | |
| 863 | // And the hint above the input says all three, because they are the |
| 864 | // same numbers from the same place. |
| 865 | hint := regexp.MustCompile(`<p rst-dropzone-hint[^>]*>([^<]*)</p>`).FindStringSubmatch(body) |
| 866 | if hint == nil { |
| 867 | t.Fatalf("no drop zone hint on the page:\n%s", body) |
| 868 | } |
| 869 | for _, w := range []struct{ what, value string }{ |
| 870 | {"per-file cap", want}, |
| 871 | {"total cap", wantTotal}, |
| 872 | {"file-count cap", strconv.Itoa(maxFiles)}, |
| 873 | } { |
| 874 | if !strings.Contains(hint[1], w.value) { |
| 875 | t.Errorf("the hint does not name the %s (%s): %q — a person is told one set of "+ |
| 876 | "limits before the upload and a different one after it", |
| 877 | w.what, w.value, strings.TrimSpace(hint[1])) |
| 878 | } |
| 879 | } |
| 880 | } |
| 881 | |
| 882 | // flashText pulls the rendered flash message out of a page. The callout |
| 883 | // body is rastrillo's shape, <div rst-callout-body><p>…</p></div> (shared |
| 884 | // chrome §4); it was a <p rst-callout-body>, whose own margins doubled the |
| 885 | // callout's height. |
| 886 | func flashText(t *testing.T, body string) string { |
| 887 | t.Helper() |
| 888 | m := regexp.MustCompile(`<div rst-callout-body><p>([^<]*)</p></div>`).FindStringSubmatch(body) |
| 889 | if m == nil { |
| 890 | t.Fatalf("no flash on the page:\n%s", body) |
| 891 | } |
| 892 | return strings.TrimSpace(unescape(m[1])) |
| 893 | } |
| 894 | |
| 895 | // unescape undoes html/template's attribute and text escaping for the |
| 896 | // few entities this app's copy can produce. Both sides of the comparison |
| 897 | // go through it, so a difference is a difference in the copy. |
| 898 | func unescape(s string) string { |
| 899 | return strings.NewReplacer( |
| 900 | """, `"`, """, `"`, |
| 901 | "'", "'", "'", "'", |
| 902 | "&", "&", "<", "<", ">", ">", |
| 903 | "+", "+", |
| 904 | ).Replace(s) |
| 905 | } |
| 906 | |
| 907 | func atoi(t *testing.T, s string) int { |
| 908 | t.Helper() |
| 909 | n := 0 |
| 910 | if s == "" { |
| 911 | t.Fatal("empty number") |
| 912 | } |
| 913 | for _, c := range s { |
| 914 | if c < '0' || c > '9' { |
| 915 | t.Fatalf("%q is not a number", s) |
| 916 | } |
| 917 | n = n*10 + int(c-'0') |
| 918 | } |
| 919 | return n |
| 920 | } |
| 921 | |
| 922 | // ---- one submission, several documents -------------------------------- |
| 923 | |
| 924 | // Five files in, five documents out — each with its own title, its own |
| 925 | // chunks and its own row. |
| 926 | // |
| 927 | // The assertions are on the document TABLE, never on the page: the front |
| 928 | // page renders the search results, the flash and the list, and the flash |
| 929 | // alone contains the words "5 documents". A filename appearing somewhere |
| 930 | // in the HTML is the false pass this whole file is written against. |
| 931 | // |
| 932 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 933 | // |
| 934 | // (a) `for _, hdr := range files` → `for _, hdr := range files[:1]` in |
| 935 | // createDocument → "5 files stored 1 documents: [alpha]" — the |
| 936 | // old single-file behaviour, caught |
| 937 | // (b) `stored++` moved outside the loop (a plain `stored = 1` after |
| 938 | // it) → "the outcome sentence does not say how many documents |
| 939 | // were stored: "Stored 1 document."" |
| 940 | func TestEveryFileInADropBecomesItsOwnDocument(t *testing.T) { |
| 941 | h := newApp(t) |
| 942 | files := []file{ |
| 943 | {"alpha.md", "## Revenue\nAlpha revenue was 100 in the quarter just gone."}, |
| 944 | {"beta.md", "## Revenue\nBeta revenue was 200 in the quarter just gone."}, |
| 945 | {"gamma.md", "## Costs\nGamma hosting fell after the migration completed."}, |
| 946 | {"delta.md", "## Risks\nDelta concentration is the single largest risk."}, |
| 947 | {"epsilon.md", "## Notes\nEpsilon minutes from the August meeting."}, |
| 948 | } |
| 949 | rec := submit(t, h, "", "", files...) |
| 950 | |
| 951 | // The redirect lands on /add, which carries the outcome; the document |
| 952 | // list stayed on the index when adding moved to its own page. |
| 953 | follow(t, h, rec) |
| 954 | titles := storedTitles(t, get(t, h, "/").Body.String()) |
| 955 | if len(titles) != len(files) { |
| 956 | t.Fatalf("%d files stored %d documents: %v", len(files), len(titles), titles) |
| 957 | } |
| 958 | for _, f := range files { |
| 959 | want := strings.TrimSuffix(f.name, ".md") |
| 960 | if !slices.Contains(titles, want) { |
| 961 | t.Errorf("%q is not in the document list: %v", want, titles) |
| 962 | } |
| 963 | } |
| 964 | // Each is separately indexed, not one document with five sections. |
| 965 | for _, term := range []string{"epsilon", "gamma"} { |
| 966 | if n := strings.Count(get(t, h, "/?q="+term).Body.String(), "rst-result-title"); n != 1 { |
| 967 | t.Errorf("searching %q returns %d results, want 1; the files did not become "+ |
| 968 | "separate documents", term, n) |
| 969 | } |
| 970 | } |
| 971 | if said := outcome(t, h, rec); !strings.Contains(said, "Stored 5 documents") { |
| 972 | t.Errorf("the outcome sentence does not say how many documents were stored: %q", said) |
| 973 | } |
| 974 | } |
| 975 | |
| 976 | // The half that matters: some of a drop is refused and the rest is |
| 977 | // stored anyway, and the person is told BOTH — how many landed, and |
| 978 | // every refusal by name with the reason for that file. |
| 979 | // |
| 980 | // Storing four of five while saying "Stored 4 documents." and nothing |
| 981 | // else is the silent truncation this codebase treats as its worst |
| 982 | // failure. So is refusing one without saying which one. |
| 983 | // |
| 984 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 985 | // |
| 986 | // (a) `refused = append(...)` in the binary branch of readUpload |
| 987 | // replaced with a bare `continue` (refuse it, say nothing) → |
| 988 | // "nothing in the outcome names the refused file: "Stored 4 |
| 989 | // documents."" |
| 990 | // (b) the per-file refusal replaced by a whole-drop refusal (`a.refuse` |
| 991 | // and return, instead of appending) → "4 good files among 5 |
| 992 | // stored 2 documents: [two one]" — the two that happened to come |
| 993 | // before the bad one, which is the half-taken drop itself |
| 994 | // (c) `fill(c.ReasonBinary, ...)` → `fill(c.ReasonEmpty, ...)` for the |
| 995 | // binary case → "the refusal gives the wrong reason for |
| 996 | // scan.png": it said "is empty" for a file that is not text |
| 997 | func TestAPartialDropStoresTheGoodOnesAndNamesTheBad(t *testing.T) { |
| 998 | h := newApp(t) |
| 999 | rec := submit(t, h, "", "", |
| 1000 | file{"one.txt", "Revenue was 100 for the first region we serve."}, |
| 1001 | file{"two.txt", "Revenue was 200 for the second region we serve."}, |
| 1002 | file{"scan.png", "\x89PNG\x00\x00\x00 binary payload that is not text"}, |
| 1003 | file{"three.txt", "Revenue was 300 for the third region we serve."}, |
| 1004 | file{"four.txt", "Revenue was 400 for the fourth region we serve."}, |
| 1005 | ) |
| 1006 | |
| 1007 | page := follow(t, h, rec).Body.String() |
| 1008 | // The outcome is on /add, where the redirect lands; the list stayed |
| 1009 | // on the index when adding moved to its own page. |
| 1010 | list := get(t, h, "/").Body.String() |
| 1011 | titles := storedTitles(t, list) |
| 1012 | if len(titles) != 4 { |
| 1013 | t.Fatalf("4 good files among 5 stored %d documents: %v", len(titles), titles) |
| 1014 | } |
| 1015 | if slices.Contains(titles, "scan") { |
| 1016 | t.Errorf("the binary file was stored anyway: %v", titles) |
| 1017 | } |
| 1018 | |
| 1019 | said := flashText(t, page) |
| 1020 | if !strings.Contains(said, "Stored 4 documents") { |
| 1021 | t.Errorf("the outcome does not say how many landed: %q", said) |
| 1022 | } |
| 1023 | if !strings.Contains(said, "scan.png") { |
| 1024 | t.Errorf("nothing in the outcome names the refused file: %q", said) |
| 1025 | } |
| 1026 | if !strings.Contains(said, "1 refused") { |
| 1027 | t.Errorf("the outcome does not say how many were refused: %q", said) |
| 1028 | } |
| 1029 | if !strings.Contains(said, "scan.png is not text") { |
| 1030 | t.Errorf("the refusal gives the wrong reason for scan.png: %q", said) |
| 1031 | } |
| 1032 | // And the good ones are not named as refusals. The clause list is |
| 1033 | // everything after the count, so this is where a reason attached to |
| 1034 | // the wrong file would show up. Guarded on the count being there at |
| 1035 | // all: a missing "1 refused" is already reported above, and slicing |
| 1036 | // from -1 would panic rather than fail. |
| 1037 | if at := strings.Index(said, "1 refused"); at >= 0 { |
| 1038 | for _, good := range []string{"one.txt", "two.txt", "three.txt", "four.txt"} { |
| 1039 | if strings.Contains(said[at:], good) { |
| 1040 | t.Errorf("%s was stored but appears in the refusal list: %q", good, said) |
| 1041 | } |
| 1042 | } |
| 1043 | } |
| 1044 | } |
| 1045 | |
| 1046 | // The count in the sentence is the number of documents that exist. |
| 1047 | // |
| 1048 | // "A count that fails is not a count of zero" is already an AGENTS.md |
| 1049 | // rule; this is its neighbour — a count that is written by hand beside |
| 1050 | // the loop that does the work drifts from it the first time the loop |
| 1051 | // gains a branch. So the number is read out of the sentence and compared |
| 1052 | // with the rows in the table. |
| 1053 | // |
| 1054 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 1055 | // |
| 1056 | // (a) `stored++` → `stored += 2` in the file loop → "the outcome says |
| 1057 | // 7 documents were stored but the list holds 4" |
| 1058 | // (b) `c.storedPart(stored)` → `c.storedPart(stored+len(refused))` in |
| 1059 | // outcome(), the plausible "say how many we looked at" edit → |
| 1060 | // "the outcome says 6 documents were stored but the list holds 4" |
| 1061 | // (c) `stored++` deleted from the paste branch → "the outcome says 3 |
| 1062 | // documents were stored but the list holds 4" |
| 1063 | func TestTheStoredCountIsTheNumberActuallyStored(t *testing.T) { |
| 1064 | h := newApp(t) |
| 1065 | rec := submit(t, h, "Minutes", "The August meeting agreed the migration date.", |
| 1066 | file{"good-one.txt", "Revenue was 100 across the first region."}, |
| 1067 | file{"bad-one.png", "\x00\x00 binary"}, |
| 1068 | file{"good-two.txt", "Revenue was 200 across the second region."}, |
| 1069 | file{"bad-two.png", "\x00\x00 binary"}, |
| 1070 | file{"good-three.txt", "Revenue was 300 across the third region."}, |
| 1071 | ) |
| 1072 | |
| 1073 | page := follow(t, h, rec).Body.String() |
| 1074 | // The outcome is on /add, where the redirect lands; the list stayed |
| 1075 | // on the index when adding moved to its own page. |
| 1076 | list := get(t, h, "/").Body.String() |
| 1077 | rows := len(storedTitles(t, list)) |
| 1078 | said := flashText(t, page) |
| 1079 | |
| 1080 | m := regexp.MustCompile(`Stored (\d+) documents?\.`).FindStringSubmatch(said) |
| 1081 | if m == nil { |
| 1082 | t.Fatalf("the outcome sentence states no stored count, so nothing here can be "+ |
| 1083 | "compared: %q", said) |
| 1084 | } |
| 1085 | if got := atoi(t, m[1]); got != rows { |
| 1086 | t.Errorf("the outcome says %d documents were stored but the list holds %d.\n %q\n %v", |
| 1087 | got, rows, said, storedTitles(t, list)) |
| 1088 | } |
| 1089 | // Three files and the paste box: four. Stated so a change to the |
| 1090 | // fixture cannot quietly make the comparison above trivial. |
| 1091 | if rows != 4 { |
| 1092 | t.Errorf("3 good files and a paste stored %d documents, want 4: %v", rows, storedTitles(t, list)) |
| 1093 | } |
| 1094 | } |
| 1095 | |
| 1096 | // Dropping an edited file again UPDATES its document. The filename is |
| 1097 | // the stable key, exactly as it is for a single upload, so a second drop |
| 1098 | // of the same five files is five updates and not five more rows. |
| 1099 | // |
| 1100 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 1101 | // |
| 1102 | // (a) `a.store.Ingest(ctx, "upload", hdr.Filename, …)` → `Ingest(ctx, |
| 1103 | // "upload", "", …)` in createDocument, so the key falls back to |
| 1104 | // the title → still green (the title is derived from the filename, |
| 1105 | // so it is the same key by another route). Recorded because it is |
| 1106 | // the mutation a reader will try first and it proves nothing. |
| 1107 | // (b) the key changed back to the body — `Ingest(ctx, "upload", |
| 1108 | // string(raw), …)`, the old content-hash scheme → "a re-drop of 2 |
| 1109 | // files made 3 documents" and "the superseded passage is still |
| 1110 | // searchable", which is the duplication this test exists for |
| 1111 | // (c) the stale-passage half: `s.ix.RemoveDocument(docID)` commented |
| 1112 | // out in Store.Ingest → "the superseded passage is still |
| 1113 | // searchable" |
| 1114 | func TestRedroppingFilesUpdatesThemRatherThanDuplicating(t *testing.T) { |
| 1115 | h := newApp(t) |
| 1116 | submit(t, h, "", "", |
| 1117 | file{"board.md", "## Revenue\nThe zebrafinch figure was 100 for the quarter."}, |
| 1118 | file{"minutes.md", "## Present\nEveryone was at the August meeting."}, |
| 1119 | ) |
| 1120 | submit(t, h, "", "", |
| 1121 | file{"board.md", "## Revenue\nThe wildebeest figure was 200 for the quarter."}, |
| 1122 | file{"minutes.md", "## Present\nEveryone was at the August meeting."}, |
| 1123 | ) |
| 1124 | |
| 1125 | if n := storedCount(t, h); n != 2 { |
| 1126 | t.Errorf("a re-drop of 2 files made %d documents", n) |
| 1127 | } |
| 1128 | if got := get(t, h, "/?q=zebrafinch").Body.String(); strings.Contains(got, "<mark>") { |
| 1129 | t.Error("the superseded passage is still searchable; a citation would point at text " + |
| 1130 | "that is no longer there") |
| 1131 | } |
| 1132 | if got := get(t, h, "/?q=wildebeest").Body.String(); !strings.Contains(got, "<mark>") { |
| 1133 | t.Error("the edited passage was not indexed") |
| 1134 | } |
| 1135 | } |
| 1136 | |
| 1137 | // The paste box is one more document, and files no longer overwrite it. |
| 1138 | // |
| 1139 | // This was real data loss: a file in the same submission silently |
| 1140 | // replaced whatever had been typed, and the page said "Stored, chunked |
| 1141 | // and indexed." With one document per file the conflict is gone — |
| 1142 | // everything submitted is stored, and the title field titles the paste. |
| 1143 | // |
| 1144 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 1145 | // |
| 1146 | // (a) `if body != ""` → `if body != "" && len(files) == 0`, which is |
| 1147 | // what the old overwrite amounted to → "a paste submitted |
| 1148 | // alongside 2 files stored 2 documents, want 3: [costs revenue]" |
| 1149 | // (b) `Ingest(ctx, "paste", title, title, body)` → `Ingest(ctx, |
| 1150 | // "paste", title, "", body)`, so the title field stops titling |
| 1151 | // the paste → "the paste did not become a document under its |
| 1152 | // title: [The August meeting agreed the pangolin timetable. costs |
| 1153 | // revenue]" |
| 1154 | func TestThePasteBoxIsOneMoreDocumentAndIsNeverDiscarded(t *testing.T) { |
| 1155 | h := newApp(t) |
| 1156 | rec := submit(t, h, "Board minutes", "The August meeting agreed the pangolin timetable.", |
| 1157 | file{"revenue.txt", "Revenue was 100 across the first region we serve."}, |
| 1158 | file{"costs.txt", "Hosting costs fell after the migration completed."}, |
| 1159 | ) |
| 1160 | |
| 1161 | follow(t, h, rec) |
| 1162 | // The outcome is on /add, where the redirect lands; the list stayed |
| 1163 | // on the index when adding moved to its own page. |
| 1164 | list := get(t, h, "/").Body.String() |
| 1165 | titles := storedTitles(t, list) |
| 1166 | if len(titles) != 3 { |
| 1167 | t.Fatalf("a paste submitted alongside 2 files stored %d documents, want 3: %v", |
| 1168 | len(titles), titles) |
| 1169 | } |
| 1170 | if !slices.Contains(titles, "Board minutes") { |
| 1171 | t.Errorf("the paste did not become a document under its title: %v", titles) |
| 1172 | } |
| 1173 | if n := strings.Count(get(t, h, "/?q=pangolin").Body.String(), "rst-result-title"); n != 1 { |
| 1174 | t.Errorf("the pasted text is not searchable: it was discarded. %d results for a term "+ |
| 1175 | "only it contains, want 1", n) |
| 1176 | } |
| 1177 | // The files kept their own names rather than being titled by the |
| 1178 | // paste's title field. |
| 1179 | for _, want := range []string{"revenue", "costs"} { |
| 1180 | if !slices.Contains(titles, want) { |
| 1181 | t.Errorf("%q is not in the document list; the title field took a file's name: %v", want, titles) |
| 1182 | } |
| 1183 | } |
| 1184 | } |
| 1185 | |
| 1186 | // The scriptless path. AGENTS.md: every affordance works scriptless, and |
| 1187 | // multi-file upload is one — `multiple` is plain HTML and the handler |
| 1188 | // change is server-side, so a browser with JavaScript switched off must |
| 1189 | // store all five. |
| 1190 | // |
| 1191 | // Go cannot switch JavaScript off, so this asserts the two things that |
| 1192 | // make the claim true and then exercises the request they produce: the |
| 1193 | // rendered form is a plain multipart form with a `multiple` input named |
| 1194 | // "file", and a POST of several parts under that one name — which is |
| 1195 | // exactly what such a form submits — stores several documents. No part |
| 1196 | // of the request comes from drop.js; the harness builds it from the |
| 1197 | // attributes read off the page. |
| 1198 | // |
| 1199 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 1200 | // |
| 1201 | // (a) removing `multiple` from index.html → "the rendered file input |
| 1202 | // is not `multiple`, so a scriptless chooser can offer only one |
| 1203 | // file" |
| 1204 | // (b) `enctype="multipart/form-data"` deleted from the form → "the |
| 1205 | // upload form is not multipart, so a scriptless browser would post |
| 1206 | // filenames and no file contents" |
| 1207 | // (c) `for _, hdr := range files` → `files[:1]` in createDocument → |
| 1208 | // "a scriptless multipart POST of 3 parts named "file" stored 1 |
| 1209 | // document" |
| 1210 | func TestTheScriptlessFormStoresEveryFileItSubmits(t *testing.T) { |
| 1211 | h := newApp(t) |
| 1212 | body := get(t, h, "/add").Body.String() |
| 1213 | |
| 1214 | i := strings.Index(body, `action="/documents"`) |
| 1215 | if i < 0 { |
| 1216 | t.Fatalf("no upload form on the page:\n%s", body) |
| 1217 | } |
| 1218 | j := strings.Index(body[i:], "</form>") |
| 1219 | if j < 0 { |
| 1220 | t.Fatal("the upload form never closes") |
| 1221 | } |
| 1222 | form := body[i : i+j] |
| 1223 | if !strings.Contains(body[max(i-200, 0):i+j], `enctype="multipart/form-data"`) { |
| 1224 | t.Errorf("the upload form is not multipart, so a scriptless browser would post "+ |
| 1225 | "filenames and no file contents:\n%s", form) |
| 1226 | } |
| 1227 | input := regexp.MustCompile(`<input[^>]*type="file"[^>]*>`).FindString(form) |
| 1228 | if !regexp.MustCompile(`\bmultiple\b`).MatchString(input) { |
| 1229 | t.Errorf("the rendered file input is not `multiple`, so a scriptless chooser can "+ |
| 1230 | "offer only one file: %s", input) |
| 1231 | } |
| 1232 | name := regexp.MustCompile(`name="([^"]*)"`).FindStringSubmatch(input) |
| 1233 | if name == nil { |
| 1234 | t.Fatalf("the file input has no name: %s", input) |
| 1235 | } |
| 1236 | |
| 1237 | // The request that form makes: several parts, one name, no script. |
| 1238 | var buf bytes.Buffer |
| 1239 | mw := multipart.NewWriter(&buf) |
| 1240 | parts := []file{ |
| 1241 | {"first.txt", "Revenue was 100 across the first region we serve."}, |
| 1242 | {"second.txt", "Revenue was 200 across the second region we serve."}, |
| 1243 | {"third.txt", "Revenue was 300 across the third region we serve."}, |
| 1244 | } |
| 1245 | for _, p := range parts { |
| 1246 | w, err := mw.CreateFormFile(name[1], p.name) |
| 1247 | if err != nil { |
| 1248 | t.Fatal(err) |
| 1249 | } |
| 1250 | if _, err := w.Write([]byte(p.body)); err != nil { |
| 1251 | t.Fatal(err) |
| 1252 | } |
| 1253 | } |
| 1254 | mw.Close() |
| 1255 | req := httptest.NewRequest(http.MethodPost, "/documents", &buf) |
| 1256 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 1257 | req.Header.Set("Origin", testOrigin) |
| 1258 | rec := httptest.NewRecorder() |
| 1259 | h.ServeHTTP(rec, req) |
| 1260 | |
| 1261 | if n := storedCount(t, h); n != len(parts) { |
| 1262 | t.Errorf("a scriptless multipart POST of %d parts named %q stored %d documents", |
| 1263 | len(parts), name[1], n) |
| 1264 | } |
| 1265 | if said := outcome(t, h, rec); !strings.Contains(said, "Stored 3 documents") { |
| 1266 | t.Errorf("the scriptless path does not report what it stored: %q", said) |
| 1267 | } |
| 1268 | } |
| 1269 | |