Files
mesh-catalog/modules/mesh-mail-web/internal/web/acceptance_integration_test.go
T
jochen ad4f312263 issue 1859: open the mail page on the operator's list (1859.1)
ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then
shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam
warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
2026-10-22 20:03:21 +02:00

480 lines
20 KiB
Go

//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.<node>.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": `<script>alert(1)</script> and <img src="http://remote.example/x.png">`,
"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, "&amp;" 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</a>"
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</a>", 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+"</a>") {
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</a>") {
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, "<script>") || strings.Contains(body, "<img") {
t.Fatalf("the mail page carries a script or img element:\n%s", body)
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Fatalf("the mail's body is not shown as escaped text:\n%s", body)
}
// The mail page's own CSP, and every other answer's (review round 1 finding 3): doWithCookie and doForm
// check it on every response they return, not only the ones a test asks for by hand.
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.Header.Get("Content-Type") != "application/octet-stream" {
t.Fatalf("attachment Content-Type: %q", attResp.Header.Get("Content-Type"))
}
if attResp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("attachment X-Content-Type-Options: %q", attResp.Header.Get("X-Content-Type-Options"))
}
if !strings.HasPrefix(attResp.Header.Get("Content-Disposition"), "attachment;") {
t.Fatalf("attachment Content-Disposition: %q", attResp.Header.Get("Content-Disposition"))
}
if string(attBody) != "hello world" {
t.Fatalf("attachment body: %q", attBody)
}
// A cookieless GET still carries the CSP on its own bounce (checked inside doWithCookie too); and a
// request for a really different Host (review round 1 finding 3 — the step here previously asked the
// internal host by mistake, doWithCookie always setting it) is answered 404, and carries the CSP too
// (issue 1283/AC2).
bounceResp := doWithCookie(t, mw, http.MethodGet, "/", nil, nil)
_ = bounceResp.Body.Close()
if bounceResp.StatusCode != http.StatusOK {
t.Fatalf("a cookieless GET: status %d, want 200 (RequireSession's own one-time bounce)", bounceResp.StatusCode)
}
otherHostReq, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+"/", nil)
if err != nil {
t.Fatal(err)
}
otherHostReq.Host = "example.com"
otherHostResp, err := mw.client.Do(otherHostReq)
if err != nil {
t.Fatal(err)
}
defer otherHostResp.Body.Close()
if otherHostResp.StatusCode != http.StatusNotFound {
t.Fatalf("Host example.com: status %d, want 404", otherHostResp.StatusCode)
}
if got := otherHostResp.Header.Get("Content-Security-Policy"); got != CSP {
t.Fatalf("Host example.com's own 404: CSP %q, want %q", got, CSP)
}
}