package memoriatest import ( "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "regexp" "strings" "testing" "golang.org/x/net/html" ) // browser carries cookies from one request to the next the way a browser // does, deletions included, so a test can visit pages in an order and see // what each visit spent. type browser struct { t *testing.T h http.Handler jar *cookiejar.Jar } func newBrowser(t *testing.T, h http.Handler) *browser { jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } return &browser{t: t, h: h, jar: jar} } var browserBase = &url.URL{Scheme: "http", Host: "example.com", Path: "/"} func (b *browser) keep(rec *httptest.ResponseRecorder) *httptest.ResponseRecorder { b.jar.SetCookies(browserBase, rec.Result().Cookies()) return rec } // visit GETs path with the jar's cookies. A non-empty header marks it as // drop.js's background re-fetch. func (b *browser) visit(path, header string) string { b.t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) for _, c := range b.jar.Cookies(browserBase) { req.AddCookie(c) } if header != "" { req.Header.Set(header, "1") } rec := httptest.NewRecorder() b.h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { b.t.Fatalf("GET %s answered %d, want 200", path, rec.Code) } return b.keep(rec).Body.String() } // An upload's notice is spent on /add and nowhere else. // // The sentence naming a refused file travels in a cookie, because a // filename must not go in a URL. With one cookie shared by every page, a // visit to the index spent it, and so did /add's own background refresh. // Either way /add was left with the bare counts in its URL, and the // refresh case showed the sentence to nobody. // // MUTATION-TESTED 2026-09-29, each reverted: // // (a) index() takes addNotice instead of indexNotice → "a visit to the // index showed the upload's notice, which belongs on /add" // (b) the refresh-header check deleted from notice.take → "drop.js's // background refresh showed the upload's notice, which belongs on // /add" // (c) the drop zone's data-refresh-header hard-coded to a name notice.go // does not check → the same line as (b). The refresh is sent with the // header the PAGE names, which is what drop.js sends. func TestAnUploadNoticeWaitsForTheAddPage(t *testing.T) { for _, first := range []struct { what string path string refresh bool }{ {"a visit to the index", "/", false}, {"drop.js's background refresh", "/add", true}, } { h := newApp(t) b := newBrowser(t, h) rec := b.keep(submit(t, h, "", "", file{"notes.txt", "Revenue was 100 across the first region we serve."}, file{"scan.png", "\x89PNG\x00\x00\x00 binary payload that is not text"})) loc := rec.Header().Get("Location") if !strings.HasPrefix(loc, "/add?") { t.Fatalf("the submission redirected to %q, want /add", loc) } header := "" if first.refresh { header = refreshHeaderOn(t, get(t, h, "/add").Body.String()) } if page := b.visit(first.path, header); strings.Contains(page, "scan.png") { t.Errorf("%s showed the upload's notice, which belongs on /add", first.what) } if said := flashText(t, b.visit(loc, "")); !strings.Contains(said, "scan.png") { t.Errorf("after %s, /add no longer names the refused file: %q", first.what, said) } if page := b.visit("/add", ""); strings.Contains(page, "scan.png") { t.Error("the notice was shown twice; it is one-shot") } } } // refreshHeaderOn is the header name /add tells drop.js to send with its // re-fetch. Tests send what the page names, never a name of their own, so // a page and a server that disagree are caught here. func refreshHeaderOn(t *testing.T, addPage string) string { t.Helper() zones := findAll(parsePage(t, addPage), hasAttr("rst-dropzone")) if len(zones) != 1 { t.Fatalf("%d drop zones on /add, want 1", len(zones)) } name, _ := attrOf(zones[0], "data-refresh-header") if name == "" { t.Fatal("the drop zone names no refresh header, so drop.js stops before it starts") } return name } // jsFunction returns the body of the named function declaration in src, // braces matched. drop.js writes no braces inside strings in the functions // this is used on; a body that does not close is a fatal, not a pass. func jsFunction(t *testing.T, src, name string) string { t.Helper() at := strings.Index(src, "function "+name+"(") if at < 0 { t.Fatalf("drop.js has no function %s", name) } open := strings.Index(src[at:], "{") if open < 0 { t.Fatalf("function %s has no body", name) } depth := 0 for i := at + open; i < len(src); i++ { switch src[i] { case '{': depth++ case '}': depth-- if depth == 0 { return src[at+open : i+1] } } } t.Fatalf("function %s never closes", name) return "" } // assignments counts every assignment to name, compound ones included — // the same rule drop_test.go applies to `endpoint`. func assignments(src, name string) int { return len(regexp.MustCompile(`\b`+name+`\s*(?:>>>|\*\*|<<|>>|\|\||&&|\?\?|[-+*/%&|^])?=[^=]`).FindAllString(src, -1)) } // drop.js sends the header the page names, and no other. // // Go cannot run the script, so this reads it, and what it can hold is // structure: there is no header name in drop.js to drift, the one // variable holding the page's name is assigned once — at the read — and // the re-fetch in refresh() is the one place it is sent. The behaviour // (the header a real re-fetch carries, and the notice surviving it) was // watched in Chromium by Astra on 2026-09-29; TestAnUploadNoticeWaitsForTheAddPage // pins that the server honours the name the page publishes. // // MUTATION-TESTED 2026-09-29, each reverted: // // (a) setRequestHeader(refreshHeader, "1") → setRequestHeader( // "X-Memoria-Refresh-Count", "1"), Astra's divergence → "drop.js // does not send the header it read off the page" // (b) the attribute read changed to data-refresh → "drop.js does not // read its refresh header off the drop zone" // (c) Astra's: `refreshHeader += "-Count";` after the read → "drop.js // assigns refreshHeader 2 times" // (d) the setRequestHeader line deleted from refresh() → "drop.js sends // refreshHeader in 0 places, want 1" // (e) Astra's: that line moved above req.open in refresh(). Chromium // throws InvalidStateError and the footer is never refreshed → // "req.setRequestHeader comes before req.open" func TestTheDropScriptSendsTheHeaderThePageNames(t *testing.T) { h := newApp(t) rec := get(t, h, "/static/drop.js") if rec.Code != http.StatusOK { t.Fatalf("GET /static/drop.js: status %d, want 200", rec.Code) } js := rec.Body.String() if !strings.Contains(js, `var refreshHeader = zone.getAttribute("data-refresh-header");`) { t.Error("drop.js does not read its refresh header off the drop zone") } if n := assignments(js, "refreshHeader"); n != 1 { t.Errorf("drop.js assigns refreshHeader %d times; once, at the read, or what it sends "+ "is not what the page named", n) } if n := strings.Count(js, "setRequestHeader(refreshHeader"); n != 1 { t.Errorf("drop.js sends refreshHeader in %d places, want 1", n) } if !strings.Contains(js, `req.setRequestHeader(refreshHeader, "1");`) { t.Error("drop.js does not send the header it read off the page") } if !strings.Contains(jsFunction(t, js, "refresh"), `req.setRequestHeader(refreshHeader, "1");`) { t.Error("refresh() does not send the header; the re-fetch is the request that needs it") } if strings.Contains(strings.ToLower(js), "x-memoria") { t.Error("drop.js spells a header name of its own; the page is where it reads one") } // A header set before open() throws, and after send() is too late. // Every request in the file, not just this one: the upload's Accept // header is what gets it a judgement instead of a redirect. setters := regexp.MustCompile(`(\w+)\.setRequestHeader\(`).FindAllStringSubmatchIndex(js, -1) if len(setters) == 0 { t.Fatal("drop.js sets no request headers, so the order check below proves nothing") } for _, m := range setters { v, at := js[m[2]:m[3]], m[0] open, send := strings.Index(js, v+".open("), strings.Index(js, v+".send(") if open < 0 || send < 0 { t.Errorf("%s.setRequestHeader has no %s.open and %s.send around it", v, v, v) continue } if at < open { t.Errorf("%s.setRequestHeader comes before %s.open; browsers throw InvalidStateError", v, v) } if at > send { t.Errorf("%s.setRequestHeader comes after %s.send; the request has already gone", v, v) } } } // The tray points at the document list, which is not on /add, and keeps // pointing there. // // Astra, 2026-09-29: the link went back to /add, which has no list, and // changing it back passed make ci. Staying visible after the refresh is // the script's, and Go cannot run it, so the script is read: `listed` is // touched in exactly four places — found, checked, shown, hidden — and the // hiding is in hideTray(), which is dismissal. Watched in Chromium by // Astra on 2026-09-29: the link stayed after a real refresh. // // MUTATION-TESTED 2026-09-29, each reverted: // // (a) href="/" → href="/add" → "the tray's link goes to "/add"" // (b) `listed.hidden = true;` added to put() → "drop.js mentions // `listed` 5 times" // (c) Astra's: that line MOVED from hideTray() into put() → "hideTray() // does not hide the pointer" and "put() touches the pointer" // (d) Astra's: the condition cut to `if (batch.stored > 0)`, so a batch // with only an unconfirmed file hides the link to the one place its // fate can be checked → "the pointer is not shown for an unconfirmed // upload" func TestTheTrayPointsAtTheDocumentList(t *testing.T) { h := newApp(t) doc := parsePage(t, get(t, h, "/add").Body.String()) listed := findAll(doc, hasAttr("rst-tray-listed")) if len(listed) != 1 { t.Fatalf("%d tray pointers on /add, want 1", len(listed)) } links := findAll(listed[0], func(n *html.Node) bool { return n.Data == "a" }) if len(links) != 1 { t.Fatalf("the tray pointer holds %d links, want 1", len(links)) } if href, _ := attrOf(links[0], "href"); href != "/" { t.Errorf("the tray's link goes to %q; the document list is on \"/\"", href) } js := get(t, h, "/static/drop.js").Body.String() if n := len(regexp.MustCompile(`(^|[^-\w])listed\b`).FindAllString(js, -1)); n != 4 { t.Errorf("drop.js mentions `listed` %d times, want 4: found, checked, shown on "+ "announce, hidden on dismiss. Anything else can hide it after a refresh.", n) } if !strings.Contains(jsFunction(t, js, "hideTray"), "listed.hidden = true;") { t.Error("hideTray() does not hide the pointer") } if strings.Contains(jsFunction(t, js, "put"), "listed") { t.Error("put() touches the pointer; it is true whether or not the refresh arrives") } if strings.Count(js, "listed.hidden = false;") != 1 { t.Error("drop.js does not show the pointer in exactly one place") } // Shown when a file was stored OR its fate is unknown. An unconfirmed // file is the case that most needs the list: it is the only place to // find out whether the file landed. shown := regexp.MustCompile(`if \(([^)]*)\) \{\s*listed\.hidden = false;`).FindStringSubmatch(js) if shown == nil { t.Fatal("the pointer is not shown under an if, so its condition cannot be checked") } for _, want := range []string{"batch.stored > 0", "batch.unsure.length > 0"} { if !strings.Contains(shown[1], want) { t.Errorf("the pointer is not shown for %s: its condition is %q", map[string]string{"batch.stored > 0": "a stored upload", "batch.unsure.length > 0": "an unconfirmed upload"}[want], shown[1]) } } if !strings.Contains(shown[1], "||") || strings.Contains(shown[1], "&&") { t.Errorf("the pointer's condition is %q; either case alone must show it", shown[1]) } }