//go:build integration package web // Design 59 milestone 1's acceptance tests, 1 to 4 (novox/hq ADR 0440; issue 1268), on the harness issue // 1285 built: mesh-mail-web started whole, a stand-in identity provider (the same httptest issuer as // auth_test.go's own unit tests, reused here), and an account bucket holding a granted account a and an // ungranted account c. Every scenario enters only through HTTP to the started process, Host // mail..internal, exactly as a browser (through the route proxy) would — never by calling a handler // directly. Compiled by `go vet -tags integration ./...`; never run here (this batch's hard rule) — the // lead's try runs it for real. import ( "encoding/json" "html" "io" "net/http" "net/url" "strconv" "strings" "testing" "time" ) // acceptanceAccounts is the account bucket design test 3 needs: a (granted, with 55 mails in INBOX, so the // folder page's own paging shows), c (never answered by mail-store, so never shown). func acceptanceAccounts() map[string]accountBucketValue { return map[string]accountBucketValue{ "a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 55, Unread: 2}}}, "c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 1, Unread: 1}}}, } } // acceptanceMails is 55 fake mails of account a's INBOX, newest first, each a minute apart — enough for the // folder page's own 50-a-page paging (issue 1264/AC3) to show a second page. func acceptanceMails(base time.Time) []map[string]any { out := make([]map[string]any, 0, 55) for i := 0; i < 55; i++ { out = append(out, map[string]any{ "account": "a", "folder": "INBOX", "uid": 55 - i, "date": base.Add(-time.Duration(i) * time.Minute).UTC().Format(time.RFC3339), "from": []map[string]any{{"address": "sender@example.org"}}, "subject": "mail " + strconv.Itoa(55-i), }) } return out } // searchStandIn answers mail-store.search over mails, filtering by account, folder and before exactly as // mesh-mail's own Search would (newest first, at most limit), so the folder page's own "before" paging // (issue 1264/AC3) is genuinely exercised, not merely trusted. func searchStandIn(mails []map[string]any) func(args map[string]any) (any, error) { return func(args map[string]any) (any, error) { account, _ := args["account"].(string) folder, _ := args["folder"].(string) before, _ := args["before"].(string) limit := 50 if l, ok := args["limit"].(float64); ok { limit = int(l) } var beforeTime time.Time if before != "" { beforeTime, _ = time.Parse(time.RFC3339, before) } var found []map[string]any for _, m := range mails { if account != "" && m["account"] != account { continue } if folder != "" && m["folder"] != folder { continue } if !beforeTime.IsZero() { d, _ := time.Parse(time.RFC3339, m["date"].(string)) if !d.Before(beforeTime) { continue } } found = append(found, m) if len(found) >= limit { break } } return map[string]any{"found": found}, nil } } // readStandIn answers mail-store.read: with `attachment`, the attachment's own bytes (design test 4); without // it, a mail whose body holds a script element and a remote image, and names that attachment (design tests // 3 and 4 share this one mail). func readStandIn(args map[string]any) (any, error) { if key, _ := args["attachment"].(string); key != "" { return map[string]any{ "attachment": map[string]any{"key": key, "name": "invoice.pdf", "size": 11, "type": "application/pdf"}, "base64": "aGVsbG8gd29ybGQ=", // "hello world" }, nil } return map[string]any{ "account": "a", "folder": "INBOX", "uid": 1, "subject": "hi", "date": time.Now().UTC().Format(time.RFC3339), "from": []map[string]any{{"address": "sender@example.org"}}, "to": []map[string]any{{"address": "a@example.org"}}, "text": ` and `, "attachments": []map[string]any{ {"key": "att1", "name": "invoice.pdf", "size": 11, "type": "application/pdf"}, }, }, nil } // sendStandIn answers mail-store.send once with a fixed ask id, and mail-store.send-status "waiting" the // first time it is asked, "sent" from then on — so the write page's own flow (issue 1267) is followed from // its POST to a final state exactly as a browser's own meta-refresh polling would (design test 3). type sendStandIn struct{ statusCalls int } func (s *sendStandIn) send(map[string]any) (any, error) { return map[string]any{"ask": "send-1"}, nil } func (s *sendStandIn) status(map[string]any) (any, error) { s.statusCalls++ if s.statusCalls == 1 { return map[string]any{"status": "waiting", "words": "the operator has not answered yet"}, nil } return map[string]any{"status": "sent", "words": "sent on the operator's Approve"}, nil } // expectSignIn drives one of the three requests RequireSession protects (a page, an attachment, a send) with // no session cookie, answering the end of the road: a GET follows RequireSession's own one-time bounce first // (review round 1 finding 1 — the first cookieless answer is a 200 page of this origin's own, never a // redirect, so this reads its own meta refresh and asks it, exactly as a browser's own would), the same as // followSignInBounce; a POST (a bounce would lose its own body) is sent to sign in directly, unchanged. func expectSignIn(t *testing.T, mw *startedMailWeb, method, path string) *http.Response { t.Helper() if method == http.MethodGet { return followSignInBounce(t, mw, path) } return doWithCookie(t, mw, method, path, nil, nil) } // extractOlderLink reads the folder page's own "Older" link out of its rendered HTML, by its own anchor text // — never built by hand (review round 1 finding 3): html/template's own escaping of its href (percent-encoding // the date's own colons, "&" for the query's own separators) is exactly what a browser's own link carries, // and what this reads back. func extractOlderLink(t *testing.T, body string) string { t.Helper() marker := ">Older" end := strings.Index(body, marker) if end < 0 { t.Fatalf("no Older link in the page:\n%s", body) } head := body[:end] hrefStart := strings.LastIndex(head, `href="`) if hrefStart < 0 { t.Fatalf("the Older link carries no href:\n%s", body) } href := strings.TrimSuffix(head[hrefStart+len(`href="`):], `"`) return html.UnescapeString(href) } // 1268/design test 1 (AC1): no session redirects a page, an attachment and a send to sign-in; an identity // not in operators is 403; a send without its token is 403; the session cookie is Secure, HttpOnly, // SameSite=Strict, 12 hours. func TestAcceptanceSignInRedirectsAndRefusals(t *testing.T) { mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"}) mw.store.Accounts = func(map[string]any) (any, error) { return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil } for _, path := range []string{"/", "/attachment?account=a&folder=INBOX&uid=1&key=att1", "/write"} { resp := expectSignIn(t, mw, http.MethodGet, path) _ = resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("%s with no session: status %d, want 302 (redirect to sign in, after RequireSession's own bounce)", path, resp.StatusCode) } } sendResp := doForm(t, mw, "/send", url.Values{}, nil) _ = sendResp.Body.Close() if sendResp.StatusCode != http.StatusFound { t.Fatalf("POST /send with no session: status %d, want 302", sendResp.StatusCode) } // An identity not in operators: 403, no session cookie. resp := followSignInBounce(t, mw, "/") _ = resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("after the bounce: status %d, want 302 to sign in", resp.StatusCode) } loc, err := url.Parse(resp.Header.Get("Location")) if err != nil { t.Fatal(err) } mw.fakeIssuer.nextIDToken = mw.fakeIssuer.idToken(testClientID, "mallory", time.Now(), nil, mw.fakeIssuer.key, mw.fakeIssuer.kid) cbReq, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+SignedInPath+"?state="+url.QueryEscape(loc.Query().Get("state"))+"&code=c", nil) if err != nil { t.Fatal(err) } cbReq.Host = mw.internalHost cbResp, err := mw.client.Do(cbReq) if err != nil { t.Fatal(err) } defer cbResp.Body.Close() if cbResp.StatusCode != http.StatusForbidden { t.Fatalf("an identity not in operators: status %d, want 403", cbResp.StatusCode) } for _, c := range cbResp.Cookies() { if c.Name == SessionCookie { t.Fatal("a session cookie was set for an identity not in operators") } } // A signed-in operator's send without the token: 403. cookie := signIn(t, mw, "alice", "/") if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { t.Fatalf("cookie attributes: Secure=%v HttpOnly=%v SameSite=%v", cookie.Secure, cookie.HttpOnly, cookie.SameSite) } if cookie.MaxAge != int(SessionLifetime/time.Second) { t.Fatalf("cookie MaxAge %d, want %d (12h)", cookie.MaxAge, int(SessionLifetime/time.Second)) } noTokenResp := doForm(t, mw, "/send", url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}}, cookie) defer noTokenResp.Body.Close() if noTokenResp.StatusCode != http.StatusForbidden { t.Fatalf("a send with no form token: status %d, want 403", noTokenResp.StatusCode) } } // 1268/design test 2 (AC2): a request with any other Host is answered 404. func TestAcceptanceOtherHostIs404(t *testing.T) { mw := startMailWeb(t, nil, nil) req, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+"/", nil) if err != nil { t.Fatal(err) } req.Host = "example.com" resp, err := mw.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("Host example.com: status %d, want 404", resp.StatusCode) } } // 1268/design test 3 (AC3): a signed-in operator browsing the accounts page, a folder's two pages, a search, // a mail and a send followed to sent — the stand-in recorded only the five mail verbs; folders appear only // for account a; the second folder page asked before the last date shown; the send's own page reached sent; // the manifest declares no database and no writable directory beyond the state directory. func TestAcceptanceBrowsingAndSendingEndToEnd(t *testing.T) { mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"}) mw.store.Accounts = func(map[string]any) (any, error) { return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil } mails := acceptanceMails(time.Now()) mw.store.Search = searchStandIn(mails) mw.store.Read = readStandIn sends := &sendStandIn{} mw.store.Send = sends.send mw.store.SendStatus = sends.status cookie := signIn(t, mw, "alice", "/") // The accounts page: a appears, c (ungranted) never does. accResp := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie) accBody, _ := io.ReadAll(accResp.Body) _ = accResp.Body.Close() if accResp.StatusCode != http.StatusOK { t.Fatalf("accounts page: status %d: %s", accResp.StatusCode, accBody) } if !strings.Contains(string(accBody), "INBOX") { t.Fatalf("the accounts page does not show account a's folder:\n%s", accBody) } if strings.Contains(string(accBody), "Secret") { t.Fatalf("the accounts page shows account c's folder, though it was never granted:\n%s", accBody) } // The folder's first page: 50 mails, a next link to the 51st-55th (review round 1 finding 3: read the // "Older" link out of the page and follow it, rather than build page 2's own URL by hand — and compare the // 50th mail's own date exactly, not merely up to its hour, which every one of these 55 mails shares). page1 := doWithCookie(t, mw, http.MethodGet, "/folder?account=a&folder=INBOX", nil, cookie) page1Body, _ := io.ReadAll(page1.Body) _ = page1.Body.Close() fiftieth := mails[49]["date"].(string) olderLink := extractOlderLink(t, string(page1Body)) olderQuery, err := url.ParseQuery(strings.SplitN(olderLink, "?", 2)[1]) if err != nil { t.Fatal(err) } if olderQuery.Get("before") != fiftieth { t.Fatalf("the folder's first page links to before=%q, want exactly the 50th mail's own date %q", olderQuery.Get("before"), fiftieth) } // The folder's second page: followed from that same link, not rebuilt. page2 := doWithCookie(t, mw, http.MethodGet, olderLink, nil, cookie) page2Body, _ := io.ReadAll(page2.Body) _ = page2.Body.Close() if page2.StatusCode != http.StatusOK { t.Fatalf("the folder's second page: status %d: %s", page2.StatusCode, page2Body) } // Exact subjects (">mail 5", never a substring "mail 55" would also satisfy): the second page shows the // 55th-51st mails' own oldest five, and none of the newer ones the first page already showed. for _, subject := range []string{"mail 5", "mail 4", "mail 3", "mail 2", "mail 1"} { if !strings.Contains(string(page2Body), ">"+subject+"") { t.Fatalf("the folder's second page does not show %q (its own exact subject):\n%s", subject, page2Body) } } if strings.Contains(string(page2Body), ">mail 6") { t.Fatalf("the folder's second page repeats mail 6, which belongs on the first page:\n%s", page2Body) } // The second folder page's own mail-store.search was asked before=the 50th mail's own date, not merely // trusted (finding 3). var searchCalls []askRecord for _, c := range mw.store.Calls() { if c.Key == "seat:mail-store.search" { searchCalls = append(searchCalls, c) } } if len(searchCalls) != 2 { t.Fatalf("mail-store.search was asked %d times, want exactly 2 (one per folder page)", len(searchCalls)) } var secondSearchArgs map[string]any if err := json.Unmarshal(searchCalls[1].Body, &secondSearchArgs); err != nil { t.Fatal(err) } if secondSearchArgs["before"] != fiftieth { t.Fatalf("the second folder page's own search asked before=%v, want the 50th mail's own date %q", secondSearchArgs["before"], fiftieth) } // A search. searchResp := doWithCookie(t, mw, http.MethodGet, "/search?query=mail", nil, cookie) _ = searchResp.Body.Close() if searchResp.StatusCode != http.StatusOK { t.Fatalf("the search page: status %d", searchResp.StatusCode) } // A mail, and its attachment. mailResp := doWithCookie(t, mw, http.MethodGet, "/mail?account=a&folder=INBOX&uid=1", nil, cookie) mailBody, _ := io.ReadAll(mailResp.Body) _ = mailResp.Body.Close() if mailResp.StatusCode != http.StatusOK { t.Fatalf("the mail page: status %d: %s", mailResp.StatusCode, mailBody) } attResp := doWithCookie(t, mw, http.MethodGet, "/attachment?account=a&folder=INBOX&uid=1&key=att1", nil, cookie) attBody, _ := io.ReadAll(attResp.Body) _ = attResp.Body.Close() if attResp.StatusCode != http.StatusOK || string(attBody) != "hello world" { t.Fatalf("the attachment: status %d, body %q", attResp.StatusCode, attBody) } // The write page's own form token, then a send followed to sent. writeResp := doWithCookie(t, mw, http.MethodGet, "/write", nil, cookie) writeBody, _ := io.ReadAll(writeResp.Body) _ = writeResp.Body.Close() token := extractHiddenValue(string(writeBody), FormTokenField) if token == "" { t.Fatalf("the write page carries no form token:\n%s", writeBody) } sendResp := doForm(t, mw, "/send", url.Values{ "account": {"a"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"the body"}, FormTokenField: {token}, }, cookie) sendBody, _ := io.ReadAll(sendResp.Body) _ = sendResp.Body.Close() if sendResp.StatusCode != http.StatusOK || !strings.Contains(string(sendBody), "waiting") { t.Fatalf("the send's first answer: status %d: %s", sendResp.StatusCode, sendBody) } // Follow the send's own meta refresh (review round 1 finding 3), never ask /send-status by hand: this both // exercises the real link a browser would follow and proves it names the right send (send-1, mail-store's // own answer to mail_send). statusURL := extractMetaRefreshURL(t, string(sendBody)) if statusURL != "/send-status?id=send-1" { t.Fatalf("the send's own refresh: %q, want /send-status?id=send-1", statusURL) } statusResp := doWithCookie(t, mw, http.MethodGet, statusURL, nil, cookie) statusBody, _ := io.ReadAll(statusResp.Body) _ = statusResp.Body.Close() if !strings.Contains(string(statusBody), "sent") { t.Fatalf("the send's state did not reach sent:\n%s", statusBody) } if strings.Contains(string(statusBody), `http-equiv="refresh"`) { t.Fatalf("the send's final page still refreshes itself:\n%s", statusBody) } // Only the five mail verbs were ever asked. wantKeys := map[string]bool{ "seat:mail-store.accounts": true, "seat:mail-store.search": true, "seat:mail-store.read": true, "seat:mail-store.send": true, "seat:mail-store.send-status": true, } for _, k := range mw.store.Keys() { if !wantKeys[k] { t.Fatalf("an unexpected verb was asked: %s", k) } } // The manifest declares no database and no writable directory beyond the state directory (reusing // manifest_test.go's own reader, this package's always-compiled parser). m := readManifest(t) for _, dbish := range []string{"postgres-database", "s3-bucket"} { if contains(m.Requires, dbish) { t.Fatalf("the manifest requires %s, a database or object store", dbish) } } wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true} for _, r := range m.Resources { id, _ := r["id"].(string) if !wantResourceIDs[id] { t.Fatalf("an unexpected resource %q: no writable directory beyond the state dir is wanted", id) } } } // 1268/design test 4 (AC4): a mail with a script element and a remote image is shown as text; an attachment // comes as a download with nosniff; every page answered carries the content security policy. func TestAcceptanceMailEscapingAttachmentDownloadAndCSP(t *testing.T) { mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"}) mw.store.Accounts = func(map[string]any) (any, error) { return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil } mw.store.Read = readStandIn cookie := signIn(t, mw, "alice", "/") mailResp := doWithCookie(t, mw, http.MethodGet, "/mail?account=a&folder=INBOX&uid=1", nil, cookie) mailBody, _ := io.ReadAll(mailResp.Body) _ = mailResp.Body.Close() body := string(mailBody) if strings.Contains(body, "