oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
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.
77func 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.
88func 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"
132func 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.
340func 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.
469func 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.
525func 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
577func 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.
629func 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.
676func 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.
704func 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"
752func 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.
831func 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.
886func 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.
898func unescape(s string) string {
899 return strings.NewReplacer(
900 "&#34;", `"`, "&quot;", `"`,
901 "&#39;", "'", "&apos;", "'",
902 "&amp;", "&", "&lt;", "<", "&gt;", ">",
903 "&#43;", "+",
904 ).Replace(s)
905}
906
907func 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.""
940func 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
997func 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"
1063func 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"
1114func 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]"
1154func 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"
1210func 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