From 502cda34cb8ff91af0d8f6e8cc6f683b418b5257 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 22:42:27 +0200 Subject: [PATCH] Add design 59 milestone 1's acceptance tests on the runtime player (issue 1268) Four scenarios, entered only through HTTP with Host mail..internal as a browser would, against the built mesh-mail-web, a stand-in identity provider (auth_test.go's own fakeIssuer, reused) and a real account bucket holding granted account a and ungranted account c: sign-in redirects, 403s and the session cookie's attributes; the 404 for another host; browsing and a send followed to sent, only the five mail verbs asked, account c never shown, paging by date, no database or extra writable directory; a mail's script and remote image shown as text, an attachment downloaded with nosniff, and the CSP on every answer. Build tag integration; compiles under go vet -tags integration ./...; never run here. --- .../acceptance_integration_test.go | 397 ++++++++++++++++++ .../mesh-mail-web/runtime_integration_test.go | 37 ++ 2 files changed, 434 insertions(+) create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/acceptance_integration_test.go diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/acceptance_integration_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/acceptance_integration_test.go new file mode 100644 index 00000000..2b8aa626 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/acceptance_integration_test.go @@ -0,0 +1,397 @@ +//go:build integration + +package main + +// 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 ( + "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 +} + +// 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 := doWithCookie(t, mw, http.MethodGet, path, nil, nil) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("%s with no session: status %d, want 302 (redirect to sign in)", 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. + req, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+"/", nil) + if err != nil { + t.Fatal(err) + } + req.Host = mw.internalHost + resp, err := mw.client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + 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, "/", 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. + 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) + // html/template percent-encodes an href's own query value (":" becomes "%3a" etc — issue 1264's own + // unit test, TestFolderPageShows50NewestAndLinksToTheNextPageByDate, hit the same shape): compared only + // up to the date's own colon-free prefix, never byte-for-byte against the raw RFC3339 string. + datePrefix := strings.SplitN(fiftieth, ":", 2)[0] // "2026-01-02T12" + if !strings.Contains(strings.ToLower(string(page1Body)), strings.ToLower("before="+datePrefix)) { + t.Fatalf("the folder's first page does not link to the next page before the 50th mail's date (%s):\n%s", fiftieth, page1Body) + } + + // The folder's second page: asked with that same before. + page2 := doWithCookie(t, mw, http.MethodGet, "/folder?account=a&folder=INBOX&before="+url.QueryEscape(fiftieth), 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) + } + if !strings.Contains(string(page2Body), "mail 5") { // the 55th-50th mails' own subjects + t.Fatalf("the folder's second page does not show the older mails:\n%s", page2Body) + } + + // 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) + } + statusResp := doWithCookie(t, mw, http.MethodGet, "/send-status?id=send-1", 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, "