package memoriatest import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strconv" "strings" "testing" ) // The tests in this file cover the asynchronous half of upload: a drop // posts each file on its own, in the background, and a tray in the corner // says how each one went. drop_test.go covers the drag itself and the // scriptless form underneath; read its header first, including the CDP // recipe for driving a real drop, because Go still cannot run the script. // // The safety argument for this feature is one sentence: there is ONE // handler. The tray's request differs from the form's only in an Accept // header, so the caps, the NUL sniff, the whole-body limit that keeps // parts off the disk and the same-origin check are not "also applied" to // it — they are the same lines of code, and // TestTheJSONPathStillEnforcesEveryServerCheck exists to notice the day // somebody adds a shortcut that skips them. // // What Go CANNOT prove here, and what was checked in a browser instead // (Chromium 151, 2026-09-04, over CDP, recipe in drop_test.go's header): // that the tray fills as files upload, that the bar stops claiming a // percentage the moment the body is sent, that a refusal lands in its own // file's row rather than as one sentence for the whole drop, that // dismissing works, and that the scriptless page is unchanged. What the // run found: five files dropped at once gave five rows; the three text // files went "Waiting…" then "Uploading …%" then "Storing…" then // "Stored"; scan.png and blank.txt settled immediately, before any // request, with "scan.png is not text" and "blank.txt is empty" in their // own rows; the summary line read "Stored 3 documents. 2 refused: // scan.png is not text; blank.txt is empty." and the stale notice // appeared with its reload link; the page never navigated; the document // list below was still showing its old three rows until the link was // followed, which is the point of not reloading under someone. // // THAT LAST CLAUSE IS NO LONGER TRUE, and it is left standing because it // is the history this branch changed: the list is refreshed in place now. // It was re-checked the same way on 2026-09-05, Chromium 151, and what // that run showed is recorded in refresh_test.go's header — including the // two runs that matter most, where the refresh could not happen and the // notice and the link above came back exactly as they are described here. // trayReply is the JSON contract, spelled out here so a change to it // fails a test rather than a browser. The tags are the wire names. type trayReply struct { Stored int `json:"stored"` Refused []string `json:"refused"` Message string `json:"message"` Kind string `json:"kind"` } // readReply decodes one judgement, and refuses to decode anything that is // not one: a test that quietly treated an HTML redirect as an empty reply // would pass against a JSON path that does not exist. func readReply(t *testing.T, rec *httptest.ResponseRecorder) trayReply { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("the JSON path answered %d, want 200. The tray reads any non-200 as \"no "+ "answer arrived\" and tells the person that file's fate is unknown, so a "+ "judged submission must not come back as one:\n%s", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Fatalf("the JSON path answered Content-Type %q; the tray parses the body and would "+ "fall back to \"no answer\"", ct) } var reply trayReply if err := json.Unmarshal(rec.Body.Bytes(), &reply); err != nil { t.Fatalf("the JSON path did not answer JSON (%v):\n%s", err, rec.Body.String()) } return reply } // The asynchronous path is the same judgement in a different shape. // // What could still drift, given one handler, is the WORDS. A file posted // both ways must come back with the identical sentence, or the tray and // the scriptless flash have begun to disagree about the same file and // nobody can tell which of them is right. // // The comparison is between two apps, not two posts at one app: the same // filename twice at one app is an update, not a second document, and the // second sentence would be composed against a different corpus. // // MUTATION-TESTED 2026-09-04, each reverted: // // (a) the `wantsJSON` branch deleted from createDocument, so the tray // gets the redirect → "the JSON path answered 303, want 200" // (b) `Message: msg` → `Message: c.storedPart(stored)` in // createDocument's JSON branch — the plausible "the tray shows // refusals in its rows, the sentence only needs the count" edit → // "the two paths say different things about scan.png", printing // both: the JSON one had dropped the names // (c) `Stored: stored` → `Stored: stored + len(refused)` → "the JSON // path says 1 stored but the document list holds 0" func TestTheJSONPathIsTheSameJudgementInADifferentShape(t *testing.T) { cases := []struct { name, body string stored int }{ {"revenue.txt", "Revenue was 100 across the first region we serve.", 1}, {"scan.png", "\x89PNG\x00\x00\x00 binary payload that is not text", 0}, {"blank.txt", " \n ", 0}, } // Vacuity: both outcomes have to be represented, or this proves the // agreement for successes only and misses the half that matters. good, bad := 0, 0 for _, c := range cases { if c.stored == 1 { good++ } else { bad++ } } if good == 0 || bad == 0 { t.Fatal("the fixtures cover only one outcome; the comparison below would prove half of it") } for _, c := range cases { t.Run(c.name, func(t *testing.T) { viaForm := newApp(t) viaTray := newApp(t) said := outcome(t, viaForm, submit(t, viaForm, "", "", file{c.name, c.body})) reply := readReply(t, submitJSON(t, viaTray, file{c.name, c.body})) if reply.Message != said { t.Errorf("the two paths say different things about %s.\n form: %q\n tray: %q\n"+ "One sentence for one judgement, or the tray is copy nobody reviewed.", c.name, said, reply.Message) } // The count is the number of documents that exist, checked // against the list rather than against itself. if n := storedCount(t, viaTray); reply.Stored != n { t.Errorf("the JSON path says %d stored but the document list holds %d", reply.Stored, n) } if reply.Stored != c.stored { t.Errorf("%s: the JSON path stored %d, want %d", c.name, reply.Stored, c.stored) } // A refusal is NAMED, in the list the tray puts beside the // row. A count with no name leaves a person guessing. if c.stored == 0 { if len(reply.Refused) != 1 { t.Fatalf("%s was not stored and the reply names %d refusals: %v", c.name, len(reply.Refused), reply.Refused) } if !strings.Contains(reply.Refused[0], c.name) { t.Errorf("the refusal does not name the file it refused: %q", reply.Refused[0]) } if reply.Kind != "danger" { t.Errorf("%s was refused but the reply is kind %q", c.name, reply.Kind) } } else if len(reply.Refused) != 0 { t.Errorf("%s was stored but the reply refuses %v", c.name, reply.Refused) } }) } } // The JSON path sets no flash cookie. // // The cookie is one-shot and it OUTLIVES the request that set it. The // tray has already said what happened, out loud, in the row the file // belongs to — so a cookie set here would ambush whatever page the person // opens next with a notice about a drop that finished ten minutes ago, // attached to nothing on the screen. And it is the sentence that carries // filenames. // // The scriptless half is asserted first and deliberately: without it this // test passes just as well against an app that has stopped setting the // cookie at all, which would silently delete the naming half of the // scriptless outcome. // // MUTATION-TESTED 2026-09-04, each reverted: // // (a) `flash.Set` moved above the `wantsJSON` branch in createDocument // → "a judged submission on the JSON path set 1 cookie(s)" // (b) the same in refuse() → the same failure, second case // (c) `flash.Set` deleted from the HTML path → "the form path set no // cookie" — the vacuity guard doing its job func TestTheJSONPathSetsNoFlashCookie(t *testing.T) { h := newApp(t) if n := len(submit(t, h, "", "", file{"scan.png", "\x00\x00 not text"}).Result().Cookies()); n == 0 { t.Fatal("the form path set no cookie; this test would pass against an app that had " + "stopped naming refusals altogether, which is what the cookie exists for") } for _, c := range []struct { what string rec *httptest.ResponseRecorder }{ // A judged submission… {"a judged submission", submitJSON(t, newApp(t), file{"notes.txt", "Revenue was 100 across the region."})}, // …and a whole-submission refusal, which goes through refuse() // and is the half that gets forgotten. {"a whole-submission refusal", submitJSON(t, newApp(t), tooManyFiles(t, newApp(t))...)}, } { if n := len(c.rec.Result().Cookies()); n != 0 { t.Errorf("%s on the JSON path set %d cookie(s): %v. The tray has already said this "+ "on screen; a one-shot cookie would say it again over the next page the "+ "person opens.", c.what, n, c.rec.Result().Cookies()) } } } // tooManyFiles builds a drop one file over the cap the page advertises. func tooManyFiles(t *testing.T, h http.Handler) []file { t.Helper() n := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files")) if n < 2 || n > 200 { t.Fatalf("data-max-files is %d; the drop built here would prove nothing", n) } files := make([]file, 0, n+1) for i := 0; i <= n; i++ { files = append(files, file{fmt.Sprintf("over-%02d.txt", i), fmt.Sprintf("Passage %d about revenue.", i)}) } return files } // Every server-side check still applies on the JSON path. // // It applies because it is the same handler, and this test exists to // notice the day that stops being true — the obvious future shortcut is a // branch near the top of createDocument that answers the tray without // walking the rest. So each check is aimed at the JSON path directly: the // per-file cap, the NUL sniff, and both submission caps, which the tray // itself never triggers (it sends one file per request) and which are // therefore exactly the ones a shortcut would take with it. // // MUTATION-TESTED 2026-09-04, each reverted: // // (a) the shortcut itself — `if wantsJSON(r) { writeOutcome(w, // uploadOutcome{Stored: 1, Message: c.StoredOne, Kind: "good"}); // return }` inserted immediately after ParseMultipartForm → // THREE of the four subtests fail: "a file 1 byte over the cap was // answered stored=1", "a NUL inside the sniff window was answered // stored=1", and "the reply to an over-cap drop is stored=1 // kind=\"good\"". // // The total-size subtest stayed GREEN, and that is worth writing // down rather than quietly dropping: the body cap is // http.MaxBytesReader, which fires INSIDE ParseMultipartForm — // before the insertion point — so that drop was already refused // by the time the shortcut ran. It is honest protection, not a // hole, but this test does not prove it: a shortcut placed one // line higher would take that check too and only three subtests // would notice. Move the insertion above the MaxBytesReader and // all four fail. // (b) `case !looksLikeText(raw)` deleted from readUpload → "a NUL // inside the sniff window was answered stored=1" and "1 documents // exist after a binary upload on the JSON path" func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) { page := get(t, newApp(t), "/add").Body.String() capBytes := atoi(t, dropZoneAttr(t, page, "data-max-bytes")) window := atoi(t, dropZoneAttr(t, page, "data-sniff-bytes")) if capBytes < 1024 || window < 16 { t.Fatalf("the advertised cap (%d) or sniff window (%d) is too small for these fixtures "+ "to prove anything", capBytes, window) } filler := "The quarterly figure was recorded in full and then again.\n\n" over := strings.Repeat(filler, capBytes/len(filler)+2)[:capBytes+1] t.Run("the per-file cap", func(t *testing.T) { h := newApp(t) reply := readReply(t, submitJSON(t, h, file{"over.txt", over})) if reply.Stored != 0 { t.Errorf("a file 1 byte over the cap was answered stored=%d", reply.Stored) } if n := storedCount(t, h); n != 0 { t.Errorf("%d documents exist after an over-cap upload on the JSON path", n) } if !strings.Contains(reply.Message, "over.txt") { t.Errorf("the refusal does not name the file: %q", reply.Message) } }) t.Run("the NUL sniff", func(t *testing.T) { h := newApp(t) body := strings.Repeat("a", window-1) + "\x00" + strings.Repeat("b", 64) reply := readReply(t, submitJSON(t, h, file{"inside.txt", body})) if reply.Stored != 0 { t.Errorf("a NUL inside the sniff window was answered stored=%d", reply.Stored) } if n := storedCount(t, h); n != 0 { t.Errorf("%d documents exist after a binary upload on the JSON path", n) } }) t.Run("the file-count cap", func(t *testing.T) { h := newApp(t) files := tooManyFiles(t, h) reply := readReply(t, submitJSON(t, h, files...)) if n := storedCount(t, h); n != 0 { t.Errorf("%d files, one over the cap, stored %d documents on the JSON path. A "+ "submission cap refuses the drop whole or it is not a submission cap.", len(files), n) } if reply.Stored != 0 || reply.Kind != "danger" { t.Errorf("the reply to an over-cap drop is stored=%d kind=%q", reply.Stored, reply.Kind) } if !strings.Contains(reply.Message, strconv.Itoa(len(files))) { t.Errorf("the refusal does not say how many were dropped (%d): %q", len(files), reply.Message) } }) t.Run("the total-size cap", func(t *testing.T) { h := newApp(t) total := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-total-bytes")) if total <= capBytes { t.Fatalf("the advertised total (%d) is not larger than the per-file cap (%d)", total, capBytes) } one := strings.Repeat(filler, capBytes/len(filler)+1)[:capBytes] files := make([]file, 0, total/capBytes+1) for i := 0; len(files)*capBytes <= total; i++ { files = append(files, file{fmt.Sprintf("big-%02d.txt", i), one}) } reply := readReply(t, submitJSON(t, h, files...)) if n := storedCount(t, h); n != 0 { t.Errorf("a drop of %d bytes, over the advertised %d, stored %d documents on the "+ "JSON path", len(files)*capBytes, total, n) } if reply.Stored != 0 { t.Errorf("the reply to an over-total drop is stored=%d", reply.Stored) } }) } // Every word the tray writes into the page comes from Go. // // The tray is the second place this app says what happened to a file, and // the first — the scriptless flash — is composed in handlers.go. A // sentence typed into drop.js would be copy nobody reviewed, in a file no // copy pass reads, saying something subtly different from what the same // person would have seen with scripts off. // // So: every string the script writes is published on the drop zone as a // data attribute, the script reads every one of them by key, and NONE of // their values appears in the script itself. The third check is the one // with teeth — the first two would both pass against a script that read // the attributes and then ignored them. // // MUTATION-TESTED 2026-09-04, each reverted: // // (a) `row.phase.textContent = msg.storing` → a literal in drop.js → // "the drop script spells out the copy "Storing…", which is // published as data-msg-storing" // (b) "storing" removed from drop.js's key list, leaving the attribute // rendered → "the page publishes data-msg-storing and the drop // script never reads it; a sentence nothing reads is a sentence // that has already drifted" // (c) `ReasonBinary: "that file is not text"` — the name taken out of // a clause → "data-reason-binary does not contain {name}" // // It caught one for real while being written, which is worth recording: // a COMMENT added to drop.js used the word the page publishes as // data-msg-row-stored, and the test went red on the untouched build. // That is the direction of error this scan is meant to have — it reads // the file whole, comments included — and the comment was reworded. A // scan that stripped comments first would risk skipping a real string. func TestEveryWordTheTrayWritesComesFromGo(t *testing.T) { h := newApp(t) tag := dropZoneTag(t, get(t, h, "/add").Body.String()) src := servedDropScript(t, h) pairs := regexp.MustCompile(`\bdata-(msg|reason)-([a-z-]+)="([^"]*)"`).FindAllStringSubmatch(tag, -1) // Vacuity, and it has to be here: a pattern that stopped matching // would leave this loop empty and the test green over a script full // of hand-typed sentences. if len(pairs) < 12 { t.Fatalf("only %d published messages were found on the drop zone; the tray alone needs "+ "more than that, so this pattern has stopped matching and the test proves "+ "nothing:\n%s", len(pairs), tag) } for _, p := range pairs { attr, key, value := "data-"+p[1]+"-"+p[2], p[2], unescape(p[3]) // The script reads it by key: it builds the attribute name from // the prefix and the key, so the key is what appears in there. if !strings.Contains(src, `"`+key+`"`) { t.Errorf("the page publishes %s and the drop script never reads it; a sentence "+ "nothing reads is a sentence that has already drifted from the one the "+ "scriptless page shows", attr) } // And the script does not carry the words themselves. if value != "" && strings.Contains(src, value) { t.Errorf("the drop script spells out the copy %q, which is published as %s. Two "+ "copies of one sentence means one of them is out of date and nobody can "+ "tell which — and only one of them was ever reviewed.", value, attr) } // Every REASON clause names its own file, and the tray's layout // now depends on it. A refused row drops its separate name label // and lets the clause be the row, because rendering both put // // scan.png // scan.png is not text // // on the screen — the same shape as the search result that // repeated its own heading, found by looking at the page and // invisible to every test here. A clause that stopped naming its // file would leave that row anonymous: a refusal with nothing to // attach it to, which is the silent failure wearing a red tone. if p[1] == "reason" && !strings.Contains(value, "{name}") { t.Errorf("%s does not contain {name}: %q. Every reason clause names the file it is "+ "about — the tray removes a refused row's name label because the clause "+ "carries it, and the outcome sentence lists these clauses for a whole drop "+ "where nothing else says which file each one belongs to.", attr, value) } } } // The tray is a live region, and the lesson the note taught applies to it // unchanged: a region unhidden in the same tick as its first message is // commonly not announced at all. So the tray enters the accessibility // tree at the ready step, empty, and stays there; the panel paints // nothing until a row is added. // // The other half is what this design could get wrong in the opposite // direction. Five files uploading is five rows whose text changes several // times a second, and a live region over THAT is a screen reader nobody // can use. So exactly one element in the tray is live — the status line, // written once, when everything has settled — and the row list must not // be. // // MUTATION-TESTED 2026-09-04, each reverted: // // (a) `tray.hidden = false` moved into showTray() → "drop.js unhides // the tray before the ready step" // (b) deleting it → "drop.js never unhides the tray" // (c) `aria-live="polite"` added to