| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/cookiejar" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "golang.org/x/net/html" |
| 13 | ) |
| 14 | |
| 15 | // browser carries cookies from one request to the next the way a browser |
| 16 | // does, deletions included, so a test can visit pages in an order and see |
| 17 | // what each visit spent. |
| 18 | type browser struct { |
| 19 | t *testing.T |
| 20 | h http.Handler |
| 21 | jar *cookiejar.Jar |
| 22 | } |
| 23 | |
| 24 | func newBrowser(t *testing.T, h http.Handler) *browser { |
| 25 | jar, err := cookiejar.New(nil) |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | return &browser{t: t, h: h, jar: jar} |
| 30 | } |
| 31 | |
| 32 | var browserBase = &url.URL{Scheme: "http", Host: "example.com", Path: "/"} |
| 33 | |
| 34 | func (b *browser) keep(rec *httptest.ResponseRecorder) *httptest.ResponseRecorder { |
| 35 | b.jar.SetCookies(browserBase, rec.Result().Cookies()) |
| 36 | return rec |
| 37 | } |
| 38 | |
| 39 | // visit GETs path with the jar's cookies. A non-empty header marks it as |
| 40 | // drop.js's background re-fetch. |
| 41 | func (b *browser) visit(path, header string) string { |
| 42 | b.t.Helper() |
| 43 | req := httptest.NewRequest(http.MethodGet, path, nil) |
| 44 | for _, c := range b.jar.Cookies(browserBase) { |
| 45 | req.AddCookie(c) |
| 46 | } |
| 47 | if header != "" { |
| 48 | req.Header.Set(header, "1") |
| 49 | } |
| 50 | rec := httptest.NewRecorder() |
| 51 | b.h.ServeHTTP(rec, req) |
| 52 | if rec.Code != http.StatusOK { |
| 53 | b.t.Fatalf("GET %s answered %d, want 200", path, rec.Code) |
| 54 | } |
| 55 | return b.keep(rec).Body.String() |
| 56 | } |
| 57 | |
| 58 | // An upload's notice is spent on /add and nowhere else. |
| 59 | // |
| 60 | // The sentence naming a refused file travels in a cookie, because a |
| 61 | // filename must not go in a URL. With one cookie shared by every page, a |
| 62 | // visit to the index spent it, and so did /add's own background refresh. |
| 63 | // Either way /add was left with the bare counts in its URL, and the |
| 64 | // refresh case showed the sentence to nobody. |
| 65 | // |
| 66 | // MUTATION-TESTED 2026-09-29, each reverted: |
| 67 | // |
| 68 | // (a) index() takes addNotice instead of indexNotice → "a visit to the |
| 69 | // index showed the upload's notice, which belongs on /add" |
| 70 | // (b) the refresh-header check deleted from notice.take → "drop.js's |
| 71 | // background refresh showed the upload's notice, which belongs on |
| 72 | // /add" |
| 73 | // (c) the drop zone's data-refresh-header hard-coded to a name notice.go |
| 74 | // does not check → the same line as (b). The refresh is sent with the |
| 75 | // header the PAGE names, which is what drop.js sends. |
| 76 | func TestAnUploadNoticeWaitsForTheAddPage(t *testing.T) { |
| 77 | for _, first := range []struct { |
| 78 | what string |
| 79 | path string |
| 80 | refresh bool |
| 81 | }{ |
| 82 | {"a visit to the index", "/", false}, |
| 83 | {"drop.js's background refresh", "/add", true}, |
| 84 | } { |
| 85 | h := newApp(t) |
| 86 | b := newBrowser(t, h) |
| 87 | rec := b.keep(submit(t, h, "", "", |
| 88 | file{"notes.txt", "Revenue was 100 across the first region we serve."}, |
| 89 | file{"scan.png", "\x89PNG\x00\x00\x00 binary payload that is not text"})) |
| 90 | loc := rec.Header().Get("Location") |
| 91 | if !strings.HasPrefix(loc, "/add?") { |
| 92 | t.Fatalf("the submission redirected to %q, want /add", loc) |
| 93 | } |
| 94 | |
| 95 | header := "" |
| 96 | if first.refresh { |
| 97 | header = refreshHeaderOn(t, get(t, h, "/add").Body.String()) |
| 98 | } |
| 99 | if page := b.visit(first.path, header); strings.Contains(page, "scan.png") { |
| 100 | t.Errorf("%s showed the upload's notice, which belongs on /add", first.what) |
| 101 | } |
| 102 | if said := flashText(t, b.visit(loc, "")); !strings.Contains(said, "scan.png") { |
| 103 | t.Errorf("after %s, /add no longer names the refused file: %q", first.what, said) |
| 104 | } |
| 105 | if page := b.visit("/add", ""); strings.Contains(page, "scan.png") { |
| 106 | t.Error("the notice was shown twice; it is one-shot") |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // refreshHeaderOn is the header name /add tells drop.js to send with its |
| 112 | // re-fetch. Tests send what the page names, never a name of their own, so |
| 113 | // a page and a server that disagree are caught here. |
| 114 | func refreshHeaderOn(t *testing.T, addPage string) string { |
| 115 | t.Helper() |
| 116 | zones := findAll(parsePage(t, addPage), hasAttr("rst-dropzone")) |
| 117 | if len(zones) != 1 { |
| 118 | t.Fatalf("%d drop zones on /add, want 1", len(zones)) |
| 119 | } |
| 120 | name, _ := attrOf(zones[0], "data-refresh-header") |
| 121 | if name == "" { |
| 122 | t.Fatal("the drop zone names no refresh header, so drop.js stops before it starts") |
| 123 | } |
| 124 | return name |
| 125 | } |
| 126 | |
| 127 | // jsFunction returns the body of the named function declaration in src, |
| 128 | // braces matched. drop.js writes no braces inside strings in the functions |
| 129 | // this is used on; a body that does not close is a fatal, not a pass. |
| 130 | func jsFunction(t *testing.T, src, name string) string { |
| 131 | t.Helper() |
| 132 | at := strings.Index(src, "function "+name+"(") |
| 133 | if at < 0 { |
| 134 | t.Fatalf("drop.js has no function %s", name) |
| 135 | } |
| 136 | open := strings.Index(src[at:], "{") |
| 137 | if open < 0 { |
| 138 | t.Fatalf("function %s has no body", name) |
| 139 | } |
| 140 | depth := 0 |
| 141 | for i := at + open; i < len(src); i++ { |
| 142 | switch src[i] { |
| 143 | case '{': |
| 144 | depth++ |
| 145 | case '}': |
| 146 | depth-- |
| 147 | if depth == 0 { |
| 148 | return src[at+open : i+1] |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | t.Fatalf("function %s never closes", name) |
| 153 | return "" |
| 154 | } |
| 155 | |
| 156 | // assignments counts every assignment to name, compound ones included — |
| 157 | // the same rule drop_test.go applies to `endpoint`. |
| 158 | func assignments(src, name string) int { |
| 159 | return len(regexp.MustCompile(`\b`+name+`\s*(?:>>>|\*\*|<<|>>|\|\||&&|\?\?|[-+*/%&|^])?=[^=]`).FindAllString(src, -1)) |
| 160 | } |
| 161 | |
| 162 | // drop.js sends the header the page names, and no other. |
| 163 | // |
| 164 | // Go cannot run the script, so this reads it, and what it can hold is |
| 165 | // structure: there is no header name in drop.js to drift, the one |
| 166 | // variable holding the page's name is assigned once — at the read — and |
| 167 | // the re-fetch in refresh() is the one place it is sent. The behaviour |
| 168 | // (the header a real re-fetch carries, and the notice surviving it) was |
| 169 | // watched in Chromium by Astra on 2026-09-29; TestAnUploadNoticeWaitsForTheAddPage |
| 170 | // pins that the server honours the name the page publishes. |
| 171 | // |
| 172 | // MUTATION-TESTED 2026-09-29, each reverted: |
| 173 | // |
| 174 | // (a) setRequestHeader(refreshHeader, "1") → setRequestHeader( |
| 175 | // "X-Memoria-Refresh-Count", "1"), Astra's divergence → "drop.js |
| 176 | // does not send the header it read off the page" |
| 177 | // (b) the attribute read changed to data-refresh → "drop.js does not |
| 178 | // read its refresh header off the drop zone" |
| 179 | // (c) Astra's: `refreshHeader += "-Count";` after the read → "drop.js |
| 180 | // assigns refreshHeader 2 times" |
| 181 | // (d) the setRequestHeader line deleted from refresh() → "drop.js sends |
| 182 | // refreshHeader in 0 places, want 1" |
| 183 | // (e) Astra's: that line moved above req.open in refresh(). Chromium |
| 184 | // throws InvalidStateError and the footer is never refreshed → |
| 185 | // "req.setRequestHeader comes before req.open" |
| 186 | func TestTheDropScriptSendsTheHeaderThePageNames(t *testing.T) { |
| 187 | h := newApp(t) |
| 188 | rec := get(t, h, "/static/drop.js") |
| 189 | if rec.Code != http.StatusOK { |
| 190 | t.Fatalf("GET /static/drop.js: status %d, want 200", rec.Code) |
| 191 | } |
| 192 | js := rec.Body.String() |
| 193 | if !strings.Contains(js, `var refreshHeader = zone.getAttribute("data-refresh-header");`) { |
| 194 | t.Error("drop.js does not read its refresh header off the drop zone") |
| 195 | } |
| 196 | if n := assignments(js, "refreshHeader"); n != 1 { |
| 197 | t.Errorf("drop.js assigns refreshHeader %d times; once, at the read, or what it sends "+ |
| 198 | "is not what the page named", n) |
| 199 | } |
| 200 | if n := strings.Count(js, "setRequestHeader(refreshHeader"); n != 1 { |
| 201 | t.Errorf("drop.js sends refreshHeader in %d places, want 1", n) |
| 202 | } |
| 203 | if !strings.Contains(js, `req.setRequestHeader(refreshHeader, "1");`) { |
| 204 | t.Error("drop.js does not send the header it read off the page") |
| 205 | } |
| 206 | if !strings.Contains(jsFunction(t, js, "refresh"), `req.setRequestHeader(refreshHeader, "1");`) { |
| 207 | t.Error("refresh() does not send the header; the re-fetch is the request that needs it") |
| 208 | } |
| 209 | if strings.Contains(strings.ToLower(js), "x-memoria") { |
| 210 | t.Error("drop.js spells a header name of its own; the page is where it reads one") |
| 211 | } |
| 212 | |
| 213 | // A header set before open() throws, and after send() is too late. |
| 214 | // Every request in the file, not just this one: the upload's Accept |
| 215 | // header is what gets it a judgement instead of a redirect. |
| 216 | setters := regexp.MustCompile(`(\w+)\.setRequestHeader\(`).FindAllStringSubmatchIndex(js, -1) |
| 217 | if len(setters) == 0 { |
| 218 | t.Fatal("drop.js sets no request headers, so the order check below proves nothing") |
| 219 | } |
| 220 | for _, m := range setters { |
| 221 | v, at := js[m[2]:m[3]], m[0] |
| 222 | open, send := strings.Index(js, v+".open("), strings.Index(js, v+".send(") |
| 223 | if open < 0 || send < 0 { |
| 224 | t.Errorf("%s.setRequestHeader has no %s.open and %s.send around it", v, v, v) |
| 225 | continue |
| 226 | } |
| 227 | if at < open { |
| 228 | t.Errorf("%s.setRequestHeader comes before %s.open; browsers throw InvalidStateError", v, v) |
| 229 | } |
| 230 | if at > send { |
| 231 | t.Errorf("%s.setRequestHeader comes after %s.send; the request has already gone", v, v) |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // The tray points at the document list, which is not on /add, and keeps |
| 237 | // pointing there. |
| 238 | // |
| 239 | // Astra, 2026-09-29: the link went back to /add, which has no list, and |
| 240 | // changing it back passed make ci. Staying visible after the refresh is |
| 241 | // the script's, and Go cannot run it, so the script is read: `listed` is |
| 242 | // touched in exactly four places — found, checked, shown, hidden — and the |
| 243 | // hiding is in hideTray(), which is dismissal. Watched in Chromium by |
| 244 | // Astra on 2026-09-29: the link stayed after a real refresh. |
| 245 | // |
| 246 | // MUTATION-TESTED 2026-09-29, each reverted: |
| 247 | // |
| 248 | // (a) href="/" → href="/add" → "the tray's link goes to "/add"" |
| 249 | // (b) `listed.hidden = true;` added to put() → "drop.js mentions |
| 250 | // `listed` 5 times" |
| 251 | // (c) Astra's: that line MOVED from hideTray() into put() → "hideTray() |
| 252 | // does not hide the pointer" and "put() touches the pointer" |
| 253 | // (d) Astra's: the condition cut to `if (batch.stored > 0)`, so a batch |
| 254 | // with only an unconfirmed file hides the link to the one place its |
| 255 | // fate can be checked → "the pointer is not shown for an unconfirmed |
| 256 | // upload" |
| 257 | func TestTheTrayPointsAtTheDocumentList(t *testing.T) { |
| 258 | h := newApp(t) |
| 259 | doc := parsePage(t, get(t, h, "/add").Body.String()) |
| 260 | listed := findAll(doc, hasAttr("rst-tray-listed")) |
| 261 | if len(listed) != 1 { |
| 262 | t.Fatalf("%d tray pointers on /add, want 1", len(listed)) |
| 263 | } |
| 264 | links := findAll(listed[0], func(n *html.Node) bool { return n.Data == "a" }) |
| 265 | if len(links) != 1 { |
| 266 | t.Fatalf("the tray pointer holds %d links, want 1", len(links)) |
| 267 | } |
| 268 | if href, _ := attrOf(links[0], "href"); href != "/" { |
| 269 | t.Errorf("the tray's link goes to %q; the document list is on \"/\"", href) |
| 270 | } |
| 271 | |
| 272 | js := get(t, h, "/static/drop.js").Body.String() |
| 273 | if n := len(regexp.MustCompile(`(^|[^-\w])listed\b`).FindAllString(js, -1)); n != 4 { |
| 274 | t.Errorf("drop.js mentions `listed` %d times, want 4: found, checked, shown on "+ |
| 275 | "announce, hidden on dismiss. Anything else can hide it after a refresh.", n) |
| 276 | } |
| 277 | if !strings.Contains(jsFunction(t, js, "hideTray"), "listed.hidden = true;") { |
| 278 | t.Error("hideTray() does not hide the pointer") |
| 279 | } |
| 280 | if strings.Contains(jsFunction(t, js, "put"), "listed") { |
| 281 | t.Error("put() touches the pointer; it is true whether or not the refresh arrives") |
| 282 | } |
| 283 | if strings.Count(js, "listed.hidden = false;") != 1 { |
| 284 | t.Error("drop.js does not show the pointer in exactly one place") |
| 285 | } |
| 286 | // Shown when a file was stored OR its fate is unknown. An unconfirmed |
| 287 | // file is the case that most needs the list: it is the only place to |
| 288 | // find out whether the file landed. |
| 289 | shown := regexp.MustCompile(`if \(([^)]*)\) \{\s*listed\.hidden = false;`).FindStringSubmatch(js) |
| 290 | if shown == nil { |
| 291 | t.Fatal("the pointer is not shown under an if, so its condition cannot be checked") |
| 292 | } |
| 293 | for _, want := range []string{"batch.stored > 0", "batch.unsure.length > 0"} { |
| 294 | if !strings.Contains(shown[1], want) { |
| 295 | t.Errorf("the pointer is not shown for %s: its condition is %q", |
| 296 | map[string]string{"batch.stored > 0": "a stored upload", |
| 297 | "batch.unsure.length > 0": "an unconfirmed upload"}[want], shown[1]) |
| 298 | } |
| 299 | } |
| 300 | if !strings.Contains(shown[1], "||") || strings.Contains(shown[1], "&&") { |
| 301 | t.Errorf("the pointer's condition is %q; either case alone must show it", shown[1]) |
| 302 | } |
| 303 | } |
| 304 | |