issue 1859: move the mail page into internal/web, so the local run can serve it
A program cannot import a main package; the page's code moves to modules/mesh-mail-web/internal/web, and cmd/mesh-mail-web is a two-line program calling it, so cmd/mail-page-dev (1859.5) serves the same handlers. Nothing the module does changes.
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
//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, "&" 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, "/", 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, "<script>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package web
|
||||
|
||||
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264): the accounts page lists every account
|
||||
// mail-store.accounts answers, each with its state and its folders' mail and unread counts; the folder page
|
||||
// lists one folder's mails, newest first, 50 a page, from mail-store.search, paged by the date of the last one
|
||||
// shown. Every page behind the sign-in (NewServer's own RequireSession wrapping); rendered only through
|
||||
// html/template, so nothing a mail carries is ever interpreted as markup.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// accountBucketValue and accountBucketFolder mirror mesh-mail's own AccountValue and AccountFolderValue
|
||||
// (modules/mesh-mail/cmd/mesh-mail/mirror.go, issue 1255) — copied, not imported: mesh-mail-web is its own Go
|
||||
// module, and the shape is mesh-mail's own to change. Only the fields this page reads are kept.
|
||||
type accountBucketValue struct {
|
||||
State string `json:"state"`
|
||||
Folders []accountBucketFolder `json:"folders"`
|
||||
}
|
||||
|
||||
type accountBucketFolder struct {
|
||||
Name string `json:"name"`
|
||||
Mails int `json:"mails"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
// accountsAnswer is mail-store.accounts' own answer (mesh-mail tools.go accountsTool), the fields this page
|
||||
// reads.
|
||||
type accountsAnswer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
State string `json:"state"`
|
||||
Why string `json:"why"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
|
||||
// accountRow is one account's own line on the accounts page.
|
||||
type accountRow struct {
|
||||
Name, Kind, Address, State, Why string
|
||||
FoldersKnown bool
|
||||
Folders []accountBucketFolder
|
||||
}
|
||||
|
||||
// mailEntryRow is one mail entry as the mail-reading section shows it (novox/hq ADR 0459 §13, issue 1430):
|
||||
// account, message id, date, sender name, subject, group, summary, mark, why and due date — never a body.
|
||||
// Rendered only through html/template, so nothing a mail carries is ever interpreted as markup (AC1). ID is
|
||||
// 1523's own: the correction form's hidden field.
|
||||
type mailEntryRow struct {
|
||||
ID int64
|
||||
SenderName, Summary, Why, DueDate string
|
||||
}
|
||||
|
||||
// mailGroupRow is one mail group with how many entries hold it (1430/AC1).
|
||||
type mailGroupRow struct {
|
||||
Label string
|
||||
Count int
|
||||
}
|
||||
|
||||
// mailLessonRow is one reading lesson, with its own undo form (1523/AC2).
|
||||
type mailLessonRow struct {
|
||||
ID int64
|
||||
SenderName, Subject, Change, Made string
|
||||
Undone bool
|
||||
}
|
||||
|
||||
// weekRow is one week's reading-lessons-per-100-read-mails measure (1523/AC3).
|
||||
type weekRow struct {
|
||||
WeekStart string
|
||||
Display string // "N per 100" or "no mail was read this week"
|
||||
}
|
||||
|
||||
// mailReadingData is what the accounts page shows above the classical list (1430/AC1-AC3, 1523/AC1-AC3):
|
||||
// with Unavailable set (mail-reading did not answer status at all, 1430/AC3), nothing else here is shown.
|
||||
// Otherwise, Healthy says whether to show Groups (healthy, 1430/AC1) or Cause and Since in their place
|
||||
// (unhealthy, 1430/AC2); Matters and ScamWarnings are shown either way, each with its own correction form
|
||||
// (1523/AC1); Lessons lists the reading lessons, each with its own undo form (1523/AC2); Weeks is the
|
||||
// weekly measure (1523/AC3). TokenField is the signed-in session's own form token (1523/AC1, AC2), set by
|
||||
// accountsPage itself — mailReadingSection knows nothing of the session.
|
||||
type mailReadingData struct {
|
||||
Unavailable string
|
||||
Healthy bool
|
||||
Cause, Since string
|
||||
Matters []mailEntryRow
|
||||
Groups []mailGroupRow
|
||||
ScamWarnings []mailEntryRow
|
||||
Lessons []mailLessonRow
|
||||
Weeks []weekRow
|
||||
TokenField template.HTML
|
||||
}
|
||||
|
||||
var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Mail accounts</title></head><body>
|
||||
<h1>Accounts</h1>
|
||||
<section id="mail-reading">
|
||||
{{with .MailReading}}
|
||||
{{$mr := .}}
|
||||
{{if .Unavailable}}<p>Mail reading is unavailable: {{.Unavailable}}</p>
|
||||
{{else}}
|
||||
<h2>Mail that matters</h2>
|
||||
{{if .Matters}}<ul>{{range .Matters}}<li>{{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}
|
||||
<form method="post" action="/mail-correct">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<select name="mark"><option value="">(keep mark)</option><option value="matters">matters</option><option value="scam warning">scam warning</option><option value="neither">neither</option></select>
|
||||
<input type="text" name="group" placeholder="mail group">
|
||||
{{$mr.TokenField}}
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</li>{{end}}</ul>
|
||||
{{else}}<p>Nothing matters right now.</p>{{end}}
|
||||
{{if .Healthy}}
|
||||
<h2>Mail groups</h2>
|
||||
{{if .Groups}}<ul>{{range .Groups}}<li>{{.Label}}: {{.Count}}</li>{{end}}</ul>{{else}}<p>No mail groups yet.</p>{{end}}
|
||||
{{else}}
|
||||
<h2>Mail reading</h2>
|
||||
<p>Unhealthy: {{.Cause}} (since {{.Since}})</p>
|
||||
{{end}}
|
||||
<h2>Scam warnings</h2>
|
||||
{{if .ScamWarnings}}<ul>{{range .ScamWarnings}}<li>{{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}
|
||||
<form method="post" action="/mail-correct">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<select name="mark"><option value="">(keep mark)</option><option value="matters">matters</option><option value="scam warning">scam warning</option><option value="neither">neither</option></select>
|
||||
<input type="text" name="group" placeholder="mail group">
|
||||
{{$mr.TokenField}}
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</li>{{end}}</ul>
|
||||
{{else}}<p>None.</p>{{end}}
|
||||
<h2>Reading lessons</h2>
|
||||
{{if .Lessons}}<ul>{{range .Lessons}}<li>{{.SenderName}} — {{.Subject}}: {{.Change}} ({{.Made}})
|
||||
<form method="post" action="/mail-undo"><input type="hidden" name="id" value="{{.ID}}">{{$mr.TokenField}}<button type="submit">Undo</button></form>
|
||||
</li>{{end}}</ul>
|
||||
{{else}}<p>No reading lessons yet.</p>{{end}}
|
||||
<h2>Reading lessons per 100 mails read</h2>
|
||||
<ul>{{range .Weeks}}<li>{{.WeekStart}}: {{.Display}}</li>{{end}}</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Accounts}}
|
||||
{{$row := .}}
|
||||
<li><strong>{{.Name}}</strong> ({{.Kind}}, {{.Address}}) — {{.State}}{{if .Why}}: {{.Why}}{{end}}
|
||||
{{if .FoldersKnown}}
|
||||
<ul>
|
||||
{{range .Folders}}<li><a href="/folder?account={{$row.CurrentAccount}}&folder={{.Name}}">{{.Name}}</a>: {{.Mails}} mail(s), {{.Unread}} unread</li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p>its folders are not known yet</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
// accountsTemplateData avoids the html/template range variable losing access to the account's own name inside
|
||||
// its nested range over folders (Go templates have no closures): each row carries its own name twice.
|
||||
type accountsTemplateData struct {
|
||||
MailReading mailReadingData
|
||||
Accounts []accountRowWithName
|
||||
Error string
|
||||
}
|
||||
|
||||
type accountRowWithName struct {
|
||||
accountRow
|
||||
CurrentAccount string
|
||||
}
|
||||
|
||||
// mailStatusAnswer, mailEntriesAnswer and mailGroupsAnswer are mail-reading's own status, entries and
|
||||
// groups verbs' answer shapes (modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go entriesTool,
|
||||
// groupsTool, statusTool), the fields this page reads — never a mail body.
|
||||
type mailStatusAnswer struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Cause string `json:"cause"`
|
||||
Why string `json:"why"`
|
||||
Since string `json:"since"`
|
||||
// Weeks is 1523/AC3: the weekly reading-lessons-per-100-read-mails measure, as mail-reading.status
|
||||
// answers it (modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go statusTool).
|
||||
Weeks []struct {
|
||||
WeekStart string `json:"week-start"`
|
||||
LessonsPer100 *float64 `json:"lessons-per-100"`
|
||||
Why string `json:"why"`
|
||||
} `json:"weeks"`
|
||||
}
|
||||
|
||||
type mailEntriesAnswer struct {
|
||||
Entries []struct {
|
||||
ID int64 `json:"id"`
|
||||
SenderName string `json:"sender-name"`
|
||||
Subject string `json:"subject"`
|
||||
Summary string `json:"summary"`
|
||||
Mark string `json:"mark"`
|
||||
Why string `json:"why"`
|
||||
DueDate string `json:"due-date"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
|
||||
type mailGroupsAnswer struct {
|
||||
Groups []struct {
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
// mailLessonsAnswer is mail-reading.lessons' own answer (1523/AC2).
|
||||
type mailLessonsAnswer struct {
|
||||
Lessons []struct {
|
||||
ID int64 `json:"id"`
|
||||
SenderName string `json:"sender-name"`
|
||||
Subject string `json:"subject"`
|
||||
Change string `json:"change"`
|
||||
Made string `json:"made"`
|
||||
Undone bool `json:"undone"`
|
||||
} `json:"lessons"`
|
||||
}
|
||||
|
||||
// mailReadingSection is 1430/AC1-AC3: asks mail-reading.status first; not answering or refusing at all makes
|
||||
// the whole section say mail reading is unavailable and why (AC3), and nothing else here is asked. Otherwise
|
||||
// entries and groups are asked too (healthy or not: the mail that matters and the scam warnings are shown
|
||||
// either way, AC1, AC2); any later failure here falls back to the same "unavailable" message, since by then
|
||||
// the operator has already been told the one reason that matters.
|
||||
func (b *Bundle) mailReadingSection(ctx context.Context) mailReadingData {
|
||||
statusRaw, err := b.mailStatusAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var status mailStatusAnswer
|
||||
if err := json.Unmarshal(statusRaw, &status); err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading's status could not be read"}
|
||||
}
|
||||
data := mailReadingData{Healthy: status.Healthy, Cause: status.Cause, Since: status.Since}
|
||||
|
||||
entriesRaw, err := b.mailEntriesAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var entries mailEntriesAnswer
|
||||
if err := json.Unmarshal(entriesRaw, &entries); err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading's entries could not be read"}
|
||||
}
|
||||
for _, e := range entries.Entries {
|
||||
row := mailEntryRow{ID: e.ID, SenderName: e.SenderName, Summary: e.Summary, Why: e.Why, DueDate: e.DueDate}
|
||||
switch e.Mark {
|
||||
case "matters":
|
||||
data.Matters = append(data.Matters, row)
|
||||
case "scam warning":
|
||||
data.ScamWarnings = append(data.ScamWarnings, row)
|
||||
}
|
||||
}
|
||||
|
||||
if status.Healthy {
|
||||
groupsRaw, err := b.mailGroupsAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var groups mailGroupsAnswer
|
||||
if err := json.Unmarshal(groupsRaw, &groups); err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading's groups could not be read"}
|
||||
}
|
||||
for _, g := range groups.Groups {
|
||||
data.Groups = append(data.Groups, mailGroupRow{Label: g.Label, Count: g.Count})
|
||||
}
|
||||
}
|
||||
|
||||
// 1523/AC2: the reading lessons, newest first (as mail-reading.lessons already answers them), each with
|
||||
// its own undo form.
|
||||
lessonsRaw, err := b.mailLessonsAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var lessons mailLessonsAnswer
|
||||
if err := json.Unmarshal(lessonsRaw, &lessons); err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading's lessons could not be read"}
|
||||
}
|
||||
for _, l := range lessons.Lessons {
|
||||
data.Lessons = append(data.Lessons, mailLessonRow{
|
||||
ID: l.ID, SenderName: l.SenderName, Subject: l.Subject, Change: l.Change, Made: l.Made, Undone: l.Undone,
|
||||
})
|
||||
}
|
||||
|
||||
// 1523/AC3: the weekly measure, from status's own answer.
|
||||
for _, week := range status.Weeks {
|
||||
display := "no mail was read this week"
|
||||
if week.Why != "" {
|
||||
display = week.Why
|
||||
}
|
||||
if week.LessonsPer100 != nil {
|
||||
display = fmt.Sprintf("%.0f per 100", *week.LessonsPer100)
|
||||
}
|
||||
data.Weeks = append(data.Weeks, weekRow{WeekStart: week.WeekStart, Display: display})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// accountsPage is issue 1264/AC1-AC5, 1430/AC1-AC3: lists every account mail-store.accounts answers (never
|
||||
// one it did not, AC2), each with its folders' mail and unread counts read from mesh-mail's own account
|
||||
// bucket — one Get per account mail-store answered, never Keys() (AC2), and never failing the whole page
|
||||
// when an account has no key there yet (AC4); above it, what mail-reading shows (mailReadingSection, 1430).
|
||||
// The classical list below always renders, even when mail-reading is unavailable (1430/AC3).
|
||||
func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
mailReading := b.mailReadingSection(ctx)
|
||||
// 1523/AC1, AC2: the session's own form token, carried by every correction and undo form on this page.
|
||||
if sess, ok := SessionFrom(r); ok {
|
||||
mailReading.TokenField = FormTokenInput(sess)
|
||||
}
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store does not answer; try again shortly"})
|
||||
return
|
||||
}
|
||||
var answer accountsAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store's answer could not be read"})
|
||||
return
|
||||
}
|
||||
rows := make([]accountRowWithName, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
row := accountRow{Name: a.Account, Kind: a.Kind, Address: a.Address, State: a.State, Why: a.Why}
|
||||
// 1264/AC2, AC4: read only this account's own key, never the bucket's whole list; a missing key (the
|
||||
// account's keeper has not mirrored it yet) is not an error, just "not known yet".
|
||||
entry, err := b.accountBucketGet(a.Account)
|
||||
if err == nil && entry != nil {
|
||||
var v accountBucketValue
|
||||
if json.Unmarshal(entry.Value, &v) == nil {
|
||||
row.Folders = v.Folders
|
||||
row.FoldersKnown = true
|
||||
}
|
||||
}
|
||||
rows = append(rows, accountRowWithName{accountRow: row, CurrentAccount: a.Account})
|
||||
}
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Accounts: rows})
|
||||
}
|
||||
|
||||
// MostFolderResults is the folder and search pages' own page size (novox/hq ADR 0440 §4, issue 1264/AC3).
|
||||
const MostFolderResults = 50
|
||||
|
||||
// searchResult is one mail as mail-store.search answers it (mesh-mail imap.go's metadata), the fields the
|
||||
// folder and search pages read.
|
||||
type searchResult struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID int `json:"uid"`
|
||||
Date string `json:"date"`
|
||||
From []personResult `json:"from"`
|
||||
To []personResult `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Size int64 `json:"size"`
|
||||
Flags []string `json:"flags"`
|
||||
}
|
||||
|
||||
type personResult struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// searchAnswer is mail-store.search's own answer shape for a plain (non group-by-sender) search.
|
||||
type searchAnswer struct {
|
||||
Found []searchResult `json:"found"`
|
||||
}
|
||||
|
||||
var folderTemplate = template.Must(template.New("folder").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>{{.Folder}}</title></head><body>
|
||||
<h1>{{.Account}} — {{.Folder}}</h1>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— <a href="/mail?account={{.Account}}&folder={{.Folder}}&uid={{.UID}}">{{.Subject}}</a></li>{{end}}
|
||||
</ul>
|
||||
{{if .NextBefore}}<p><a href="/folder?account={{.Account}}&folder={{.Folder}}&before={{.NextBefore}}">Older</a></p>{{end}}
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type folderTemplateData struct {
|
||||
Account, Folder, NextBefore, Error string
|
||||
Mails []searchResult
|
||||
}
|
||||
|
||||
// folderPage is issue 1264/AC3: the 50 newest mails of one folder, newest first, from mail-store.search; the
|
||||
// next link asks for mails before the date of the 50th shown. A page with fewer than 50 has no next link: a
|
||||
// mail sharing that exact second with the last one shown could be skipped by the next page (a known limit of
|
||||
// paging by date alone, accepted rather than fixed here).
|
||||
func (b *Bundle) folderPage(w http.ResponseWriter, r *http.Request) {
|
||||
account := r.URL.Query().Get("account")
|
||||
folder := r.URL.Query().Get("folder")
|
||||
before := r.URL.Query().Get("before")
|
||||
if account == "" || folder == "" {
|
||||
http.Error(w, "account and folder are both needed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args := map[string]any{"account": account, "folder": folder, "limit": float64(MostFolderResults)}
|
||||
if before != "" {
|
||||
args["before"] = before
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.searchAsk(ctx, args)
|
||||
if err != nil {
|
||||
renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the folder could not be answered"})
|
||||
return
|
||||
}
|
||||
var answer searchAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the answer could not be read"})
|
||||
return
|
||||
}
|
||||
data := folderTemplateData{Account: account, Folder: folder, Mails: answer.Found}
|
||||
if len(answer.Found) >= MostFolderResults {
|
||||
data.NextBefore = answer.Found[len(answer.Found)-1].Date
|
||||
}
|
||||
renderPage(w, folderTemplate, data)
|
||||
}
|
||||
|
||||
// renderPage executes tmpl, setting the page's own content type first; a template that fails to execute (it
|
||||
// never should, given these fixed templates) says so in words, never a stack trace.
|
||||
func renderPage(w http.ResponseWriter, tmpl *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
fmt.Fprintf(w, "\n<!-- the page could not be rendered whole: %v -->", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package web
|
||||
|
||||
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// fakeAccountsBucket is a fake AccountsBucketGet (tools.go): a map, and a counter of every Get call so a test
|
||||
// can prove which keys were read.
|
||||
type fakeAccountsBucket struct {
|
||||
values map[string]accountBucketValue
|
||||
gets []string
|
||||
}
|
||||
|
||||
func (f *fakeAccountsBucket) get(account string) (*stdio.StateEntry, error) {
|
||||
f.gets = append(f.gets, account)
|
||||
v, ok := f.values[account]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
return &stdio.StateEntry{Key: account, Value: raw}, nil
|
||||
}
|
||||
|
||||
func accountsAnswerJSON(accounts ...map[string]any) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{"accounts": accounts})
|
||||
}
|
||||
|
||||
// 1264/AC1: mail-store answers accounts a and b, both held in the bucket; the page lists both, each with its
|
||||
// state and its folders' mail and unread counts.
|
||||
func TestAccountsPageListsEachAccountWithItsStateAndFolderCounts(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(
|
||||
map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"},
|
||||
map[string]any{"account": "b", "kind": "imap", "address": "b@example.org", "state": "syncing"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 10, Unread: 2}}},
|
||||
"b": {State: "syncing", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3, Unread: 0}}},
|
||||
}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"a", "current", "INBOX", "10 mail(s)", "2 unread", "b", "syncing", "3 mail(s)", "0 unread"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("the page is missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC2: the bucket also holds account c, which mail-store did not answer; nothing of c appears, and its
|
||||
// key is never read.
|
||||
func TestAccountsPageNeverShowsOrReadsAnAccountMailStoreDidNotAnswer(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 1, Unread: 0}}},
|
||||
"c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 99, Unread: 99}}},
|
||||
}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
if strings.Contains(w.Body.String(), "c") && strings.Contains(w.Body.String(), "Secret") {
|
||||
t.Fatalf("the page names account c, which mail-store did not answer:\n%s", w.Body.String())
|
||||
}
|
||||
for _, g := range bucket.gets {
|
||||
if g == "c" {
|
||||
t.Fatalf("account c's key was read, though mail-store did not answer it: %v", bucket.gets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC3: a folder with 120 mails shows the 50 newest, newest first, and its next link asks mail-store.search
|
||||
// for that folder with before the date of the 50th shown.
|
||||
func TestFolderPageShows50NewestAndLinksToTheNextPageByDate(t *testing.T) {
|
||||
var found []map[string]any
|
||||
for i := 0; i < 50; i++ {
|
||||
found = append(found, map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 120 - i, "subject": "s",
|
||||
"date": "2026-01-01T12:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
|
||||
})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"found": found})
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return raw, nil
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/folder?account=mesh&folder=INBOX", nil)
|
||||
b.folderPage(w, r)
|
||||
|
||||
if gotArgs["account"] != "mesh" || gotArgs["folder"] != "INBOX" || gotArgs["limit"] != float64(50) {
|
||||
t.Fatalf("mail-store.search called with %v", gotArgs)
|
||||
}
|
||||
body := strings.ToLower(w.Body.String())
|
||||
if !strings.Contains(body, "before=2026-01-01t12") {
|
||||
t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC4: an account mail-store answered with no key in the bucket yet is listed, its folders said not known
|
||||
// yet, and the page does not fail.
|
||||
func TestAccountsPageSaysFoldersNotKnownYetWithoutFailing(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "unknown"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not known yet") {
|
||||
t.Fatalf("the page does not say folders are not known yet:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC5: across the accounts and folder pages, the only verbs called are mail-store.accounts and
|
||||
// mail-store.search.
|
||||
func TestAccountsAndFolderPagesCallOnlyAccountsAndSearch(t *testing.T) {
|
||||
var accountsCalled, searchCalled int
|
||||
raw, _ := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { accountsCalled++; return raw, nil },
|
||||
Search: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
searchCalled++
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
},
|
||||
AccountBucket: (&fakeAccountsBucket{values: map[string]accountBucketValue{}}).get,
|
||||
Read: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.read called")
|
||||
return nil, nil
|
||||
},
|
||||
Send: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.send called")
|
||||
return nil, nil
|
||||
},
|
||||
SendStatus: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.send-status called")
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
b.accountsPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
b.folderPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil))
|
||||
|
||||
if accountsCalled != 1 || searchCalled != 1 {
|
||||
t.Fatalf("accounts called %d time(s), search %d time(s)", accountsCalled, searchCalled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package web
|
||||
|
||||
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): the authorisation-code flow with state and PKCE against
|
||||
// the bound issuer, the callback at /signed-in, the ID token verified (issuer, audience, expiry, signature from
|
||||
// the issuer's JWKS), the identity (its preferred_username) checked against the trusted setting operators, and
|
||||
// a session kept in memory only, its cookie Secure, HttpOnly, SameSite=Strict, ending SessionLifetime after
|
||||
// sign-in. Library choice, said as the brief asks: golang.org/x/oauth2 for the code exchange and
|
||||
// github.com/coreos/go-oidc/v3 for the ID token's verification (issuer, audience, expiry and signature against
|
||||
// the issuer's own JWKS) — chosen over hand-rolling JWT checks here, since go-oidc's verifier is the
|
||||
// already-reviewed, standard implementation of exactly what ADR 0440 §3 asks for.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// SignedInPath is the callback the issuer redirects back to (the oidc-client contribution's own callback,
|
||||
// module.json).
|
||||
const SignedInPath = "/signed-in"
|
||||
|
||||
// OIDCConfig is the oidc-client binding's own facts this module reads (issue 1262's oidc.json).
|
||||
type OIDCConfig struct {
|
||||
ClientID string `json:"client-id"`
|
||||
ClientSecret string `json:"client-secret"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
// ReadOIDCConfig reads the rendered oidc-client binding.
|
||||
func ReadOIDCConfig(path string) (OIDCConfig, error) {
|
||||
if path == "" {
|
||||
return OIDCConfig{}, errors.New("the module was started without its oidc setting (MESH_MAIL_WEB_OIDC)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return OIDCConfig{}, fmt.Errorf("the oidc setting cannot be read: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var c OIDCConfig
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return OIDCConfig{}, fmt.Errorf("the oidc setting is not the module's: %w", err)
|
||||
}
|
||||
if c.ClientID == "" || c.Issuer == "" {
|
||||
return OIDCConfig{}, errors.New("the oidc setting names no client id or issuer")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Authenticator is how this module reaches the issuer: build the redirect to it, exchange a code for tokens,
|
||||
// and verify the ID token it names.
|
||||
type Authenticator struct {
|
||||
OAuth2 oauth2.Config
|
||||
Verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// NewAuthenticator discovers the issuer (its authorisation, token and JWKS endpoints, novox/hq issue 1263) and
|
||||
// builds an Authenticator for it. redirectURL is this module's own callback, https://<internal host>/signed-in.
|
||||
func NewAuthenticator(ctx context.Context, cfg OIDCConfig, redirectURL string) (*Authenticator, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the issuer %s could not be reached: %w", cfg.Issuer, err)
|
||||
}
|
||||
return &Authenticator{
|
||||
OAuth2: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
||||
},
|
||||
Verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BounceParam marks a request that already spent RequireSession's own one-time bounce (review round 1 of
|
||||
// issue 1263, finding 1): a browser arriving with no session cookie at all may simply have none yet, or may
|
||||
// hold a live one that this particular navigation does not carry — exactly what a cookie marked
|
||||
// SameSite=Strict looks like on a navigation this browser does not count as same-site, which both Keycloak's
|
||||
// own redirect to the callback and a link clicked from another site (Telegram) are, even across an otherwise
|
||||
// live session. A page this module's own origin serves, whose own follow-on navigation (a meta refresh, not an
|
||||
// HTTP redirect) is same-site, carries the cookie if there is one; a second cookieless arrival, marked by this
|
||||
// parameter, means there truly is none, and goes to sign-in instead. Stripped from the return URL a sign-in
|
||||
// keeps (returnURL below), so it never leaks into the page the browser ends up on.
|
||||
const BounceParam = "mww-bounced"
|
||||
|
||||
// bounceTemplate is both RequireSession's own one-time bounce and Callback's own return to the page first
|
||||
// asked: the same shape, a 200 page this origin serves whose own navigation carries a Strict cookie that a
|
||||
// redirect begun elsewhere (Keycloak's 302 to this callback) would not.
|
||||
var bounceTemplate = template.Must(template.New("bounce").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Mail</title><meta http-equiv="refresh" content="0; url={{.}}"></head>
|
||||
<body><p><a href="{{.}}">Continue</a></p></body></html>`))
|
||||
|
||||
// renderBounce answers a 200 page whose own navigation goes to url, next (RequireSession's bounce, or
|
||||
// Callback's own return once signed in).
|
||||
func renderBounce(w http.ResponseWriter, url string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = bounceTemplate.Execute(w, url)
|
||||
}
|
||||
|
||||
// returnURL is the page a sign-in should return to once it succeeds, or RequireSession's own bounce should
|
||||
// repeat: r's own request URI, with BounceParam stripped (it is this module's own bookkeeping, never the page
|
||||
// the browser should end up on).
|
||||
func returnURL(r *http.Request) string {
|
||||
u := *r.URL
|
||||
if u.RawQuery != "" {
|
||||
q := u.Query()
|
||||
q.Del(BounceParam)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
// bouncedURL is r's own request URI with BounceParam added, so a second cookieless arrival at the same page is
|
||||
// told apart from the first.
|
||||
func bouncedURL(r *http.Request) string {
|
||||
u := *r.URL
|
||||
q := u.Query()
|
||||
q.Set(BounceParam, "1")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
// Auth wires sign-in into a Server: RequireSession protects a page, an attachment or a send (issue 1263/AC1),
|
||||
// and Callback is /signed-in.
|
||||
type Auth struct {
|
||||
Auth *Authenticator
|
||||
Sessions *Sessions
|
||||
Pending *PendingAuths
|
||||
Operators Operators
|
||||
Logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
func (a *Auth) logf(format string, args ...any) {
|
||||
if a.Logf != nil {
|
||||
a.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionKey is the context key RequireSession hands the signed-in Session on to the handler it wraps.
|
||||
type sessionKey struct{}
|
||||
|
||||
// SessionFrom is the session a handler wrapped by RequireSession was asked with.
|
||||
func SessionFrom(r *http.Request) (Session, bool) {
|
||||
s, ok := r.Context().Value(sessionKey{}).(Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// RequireSession is issue 1263/AC1, AC5: no session, or one that ended 12 hours ago, is answered a redirect to
|
||||
// the issuer's authorisation endpoint (state, PKCE challenge, this module's own callback) rather than the page,
|
||||
// attachment or send asked for; a live session is handed on to next.
|
||||
//
|
||||
// A GET that carries no session cookie at all (review round 1 finding 1) is bounced once through this origin's
|
||||
// own page first, rather than sent to sign in at once: a cookie marked SameSite=Strict is withheld from
|
||||
// exactly this shape of request when the live session's own cookie would otherwise have been there (Keycloak's
|
||||
// redirect to the callback, a link from another site), and this origin's own follow-on navigation is where it
|
||||
// reappears. A cookie that did reach this request but names no live session (it expired, or never existed) is
|
||||
// not that case — the browser did carry it — so it goes to sign in directly, as does any non-GET (a bounce
|
||||
// would lose its body) and a second cookieless GET already marked by BounceParam.
|
||||
func (a *Auth) RequireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(SessionCookie)
|
||||
if err == nil {
|
||||
if sess, ok := a.Sessions.Get(cookie.Value); ok {
|
||||
ctx := context.WithValue(r.Context(), sessionKey{}, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
a.redirectToSignIn(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Query().Get(BounceParam) == "" {
|
||||
renderBounce(w, bouncedURL(r))
|
||||
return
|
||||
}
|
||||
a.redirectToSignIn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) redirectToSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
state, err := a.Pending.Create(verifier, returnURL(r))
|
||||
if err != nil {
|
||||
http.Error(w, "sign-in could not be started", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
url := a.Auth.OAuth2.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback is /signed-in (issue 1263/AC2, AC3, AC4): a state this module did not issue, an exchange that
|
||||
// fails, an ID token of another audience, signed by another key, or expired, or an identity operators does not
|
||||
// name, is answered 403 and makes no session. Only once the ID token verifies and names an operator is a
|
||||
// session made, its cookie set, and the browser sent back to the page it first asked for — through a 200 page
|
||||
// of this origin's own, not a 302 (review round 1 finding 1): the navigation that landed here started at
|
||||
// Keycloak, so a redirect onward from it is still, in the browser's own eyes, a navigation Keycloak began, and
|
||||
// a cookie marked SameSite=Strict (just set by this very answer) is not sent on it or on anything it redirects
|
||||
// to in turn. A follow-on navigation this page itself starts has no such history, so the cookie is sent on it.
|
||||
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
refuse := func(why string) {
|
||||
a.logf("[mesh-mail-web] sign-in refused: %s", why)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if errDesc := query.Get("error"); errDesc != "" {
|
||||
refuse("the issuer answered " + errDesc)
|
||||
return
|
||||
}
|
||||
pending, ok := a.Pending.Take(query.Get("state"))
|
||||
if !ok {
|
||||
refuse("the state is not one this module issued, or it expired")
|
||||
return
|
||||
}
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
refuse("the callback carries no code")
|
||||
return
|
||||
}
|
||||
tok, err := a.Auth.OAuth2.Exchange(r.Context(), code, oauth2.VerifierOption(pending.Verifier))
|
||||
if err != nil {
|
||||
refuse(fmt.Sprintf("the code could not be exchanged: %v", err))
|
||||
return
|
||||
}
|
||||
rawIDToken, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
refuse("the issuer's answer carries no ID token")
|
||||
return
|
||||
}
|
||||
idToken, err := a.Auth.Verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
// Issuer, audience, expiry and signature are all checked here, by the verifier (1263/AC4).
|
||||
refuse(fmt.Sprintf("the ID token does not verify: %v", err))
|
||||
return
|
||||
}
|
||||
var claims struct {
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
refuse(fmt.Sprintf("the ID token's claims could not be read: %v", err))
|
||||
return
|
||||
}
|
||||
allowed, why := a.Operators.Allows(claims.PreferredUsername)
|
||||
if !allowed {
|
||||
refuse(why)
|
||||
return
|
||||
}
|
||||
id, err := a.Sessions.Create(claims.PreferredUsername)
|
||||
if err != nil {
|
||||
refuse(fmt.Sprintf("the session could not be made: %v", err))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(SessionLifetime / time.Second),
|
||||
})
|
||||
returnTo := pending.Return
|
||||
if returnTo == "" {
|
||||
returnTo = "/"
|
||||
}
|
||||
renderBounce(w, returnTo)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package web
|
||||
|
||||
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): an httptest stand-in issuer, as the lead's prepare
|
||||
// findings said to check this against, minting its own ID tokens (RS256) so a test controls exactly what
|
||||
// go-oidc's verifier is asked to check: the right issuer, audience, expiry and signature, and the wrong ones.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeIssuer is a minimal OIDC issuer: discovery, JWKS and a token endpoint that answers whatever ID token the
|
||||
// test set up, regardless of the code it is asked to exchange (the code's own validity is not what issue
|
||||
// 1263's acceptance criteria are about; the ID token's is).
|
||||
type fakeIssuer struct {
|
||||
*httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
kid string
|
||||
nextIDToken string
|
||||
}
|
||||
|
||||
func newFakeIssuer(t *testing.T) *fakeIssuer {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi := &fakeIssuer{key: key, kid: "test-key"}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", fi.discovery)
|
||||
mux.HandleFunc("/jwks", fi.jwks)
|
||||
mux.HandleFunc("/token", fi.token)
|
||||
fi.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(fi.Server.Close)
|
||||
return fi
|
||||
}
|
||||
|
||||
func (fi *fakeIssuer) discovery(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": fi.Server.URL,
|
||||
"authorization_endpoint": fi.Server.URL + "/authorize",
|
||||
"token_endpoint": fi.Server.URL + "/token",
|
||||
"jwks_uri": fi.Server.URL + "/jwks",
|
||||
"userinfo_endpoint": fi.Server.URL + "/userinfo",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
})
|
||||
}
|
||||
|
||||
func (fi *fakeIssuer) jwks(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
n := base64.RawURLEncoding.EncodeToString(fi.key.PublicKey.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(fi.key.PublicKey.E)).Bytes())
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]any{
|
||||
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": fi.kid, "n": n, "e": e},
|
||||
}})
|
||||
}
|
||||
|
||||
func (fi *fakeIssuer) token(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "fake-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": fi.nextIDToken,
|
||||
})
|
||||
}
|
||||
|
||||
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// sign mints an RS256 JWT of claims, signed with key under kid — the real issuer's own key unless a test asks
|
||||
// for "signed by another key" (1263/AC4).
|
||||
func sign(claims map[string]any, kid string, key *rsa.PrivateKey) string {
|
||||
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid})
|
||||
payload, _ := json.Marshal(claims)
|
||||
signingInput := b64url(header) + "." + b64url(payload)
|
||||
sum := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return signingInput + "." + b64url(sig)
|
||||
}
|
||||
|
||||
// idToken is a valid ID token's claims, as claimsPatch overrides them (so a test changes exactly one thing —
|
||||
// the audience, the expiry, the signing key — and nothing else).
|
||||
func (fi *fakeIssuer) idToken(clientID, username string, now time.Time, claimsPatch map[string]any, key *rsa.PrivateKey, kid string) string {
|
||||
claims := map[string]any{
|
||||
"iss": fi.Server.URL,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"sub": "user-" + username,
|
||||
"preferred_username": username,
|
||||
}
|
||||
for k, v := range claimsPatch {
|
||||
claims[k] = v
|
||||
}
|
||||
return sign(claims, kid, key)
|
||||
}
|
||||
|
||||
// testAuth is an Auth over fi, with its own callback redirect https://mail.g14.internal/signed-in, and
|
||||
// deterministic time so a test controls what "12 hours ago" or "expired" means.
|
||||
func testAuth(t *testing.T, fi *fakeIssuer, operatorsPath string, now func() time.Time) *Auth {
|
||||
t.Helper()
|
||||
cfg := OIDCConfig{ClientID: "mail-web", ClientSecret: "s3cr3t", Issuer: fi.Server.URL}
|
||||
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+InternalHost("g14")+SignedInPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Auth{
|
||||
Auth: authenticator,
|
||||
Sessions: NewSessions(now),
|
||||
Pending: NewPendingAuths(now),
|
||||
Operators: Operators{Path: operatorsPath},
|
||||
Logf: t.Logf,
|
||||
}
|
||||
}
|
||||
|
||||
func operatorsFile(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := dir + "/operators.json"
|
||||
raw, _ := json.Marshal(map[string]any{"operators": names})
|
||||
writeFile(t, path, string(raw))
|
||||
return path
|
||||
}
|
||||
|
||||
// extractRefreshURL reads a bounce or callback page's own meta refresh target out of its rendered HTML
|
||||
// (review round 1 finding 1): html/template's own attribute escaping turns "&" into "&" in the URL it
|
||||
// carries (its contextual auto-escaping recognises content="...;url=..." as a URL inside an HTML attribute,
|
||||
// and escapes it as both), so this reverses exactly that, never by building the URL by hand.
|
||||
func extractRefreshURL(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
marker := `content="0; url=`
|
||||
i := strings.Index(body, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("no meta refresh in the page:\n%s", body)
|
||||
}
|
||||
rest := body[i+len(marker):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
t.Fatalf("the meta refresh's own url is not closed:\n%s", body)
|
||||
}
|
||||
return html.UnescapeString(rest[:j])
|
||||
}
|
||||
|
||||
// followBounce is RequireSession's own one-time bounce (review round 1 finding 1), followed the way a
|
||||
// browser's own meta refresh would: w is the first, cookieless answer (a 200 page of this origin's own,
|
||||
// never a redirect); this reads its refresh target and asks it, answering that second response — the real
|
||||
// 302 to the issuer, since the cookie is still missing on a request this player never carries one on.
|
||||
func followBounce(t *testing.T, s *Server, w *httptest.ResponseRecorder, host string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("the bounce: status %d, want 200 (a page of this origin's own, review round 1 finding 1): %s", w.Code, w.Body.String())
|
||||
}
|
||||
refreshURL := extractRefreshURL(t, w.Body.String())
|
||||
w2 := httptest.NewRecorder()
|
||||
r2 := httptest.NewRequest(http.MethodGet, refreshURL, nil)
|
||||
r2.Host = host
|
||||
s.ServeHTTP(w2, r2)
|
||||
return w2
|
||||
}
|
||||
|
||||
// 1263/AC1: no session: a redirect to the issuer's authorisation endpoint with the client id, a state, a PKCE
|
||||
// challenge, and the redirect https://mail.<node>.internal/signed-in — reached only after RequireSession's own
|
||||
// one-time bounce (review round 1 finding 1), as a browser would, never asked for directly.
|
||||
func TestNoSessionRedirectsToTheIssuersAuthorisationEndpoint(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
w2 := followBounce(t, s, w, InternalHost("g14"))
|
||||
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w2.Code)
|
||||
}
|
||||
loc, err := url.Parse(w2.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") {
|
||||
t.Fatalf("redirected to %s, not the issuer's authorisation endpoint", loc)
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("client_id") != "mail-web" {
|
||||
t.Fatalf("client_id: %q", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("state") == "" {
|
||||
t.Fatalf("no state")
|
||||
}
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("no PKCE challenge: %q %q", q.Get("code_challenge"), q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "https://"+InternalHost("g14")+SignedInPath {
|
||||
t.Fatalf("redirect_uri: %q", q.Get("redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 1: a GET with no session cookie is bounced once through this origin's own page (a
|
||||
// 200 with a meta refresh back to the very page asked, marked by BounceParam) rather than sent to the issuer
|
||||
// at once; a second cookieless arrival at that same marked URL goes to sign in directly — no loop — and the
|
||||
// sign-in's own kept return is the page first asked, with the bounce's own marker stripped so it never leaks
|
||||
// into where the browser ends up. A POST with no session cookie is never bounced (a bounce would lose its
|
||||
// body): it goes to sign in directly, the same as it always has.
|
||||
func TestRequireSessionBouncesOnceThenSignsIn(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("the first cookieless GET: status %d, want 200 (the bounce)", w.Code)
|
||||
}
|
||||
refreshURL := extractRefreshURL(t, w.Body.String())
|
||||
if !strings.HasPrefix(refreshURL, "/folder?") {
|
||||
t.Fatalf("the bounce does not return to the same page: %q", refreshURL)
|
||||
}
|
||||
q, err := url.ParseQuery(strings.SplitN(refreshURL, "?", 2)[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Get(BounceParam) == "" {
|
||||
t.Fatalf("the bounce's own refresh does not mark itself: %q", refreshURL)
|
||||
}
|
||||
if q.Get("account") != "a" || q.Get("folder") != "INBOX" {
|
||||
t.Fatalf("the bounce lost the page's own query: %q", refreshURL)
|
||||
}
|
||||
|
||||
// A second cookieless arrival, at the marked URL: sign in directly, no further bounce (no loop).
|
||||
w2 := httptest.NewRecorder()
|
||||
r2 := httptest.NewRequest(http.MethodGet, refreshURL, nil)
|
||||
r2.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w2, r2)
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("the second cookieless GET: status %d, want 302 to sign in (no loop)", w2.Code)
|
||||
}
|
||||
loc, err := url.Parse(w2.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") {
|
||||
t.Fatalf("the second cookieless GET did not go to the issuer: %s", loc)
|
||||
}
|
||||
|
||||
// The sign-in's own kept return is the page first asked, the bounce's own marker gone.
|
||||
pending, ok := auth.Pending.Take(loc.Query().Get("state"))
|
||||
if !ok {
|
||||
t.Fatal("no pending sign-in kept under that state")
|
||||
}
|
||||
if pending.Return != "/folder?account=a&folder=INBOX" {
|
||||
t.Fatalf("the sign-in's own return: %q, want the page first asked with no bounce marker", pending.Return)
|
||||
}
|
||||
|
||||
// A POST with no session cookie: sign in directly, never bounced.
|
||||
w3 := httptest.NewRecorder()
|
||||
r3 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
|
||||
r3.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w3, r3)
|
||||
if w3.Code != http.StatusFound {
|
||||
t.Fatalf("a POST with no session cookie: status %d, want 302 (no bounce)", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// startSignIn drives the real shape of starting a sign-in from path, as a browser would (review round 1
|
||||
// finding 2): the first cookieless GET is bounced once (RequireSession's own fix, finding 1); following that
|
||||
// bounce — reading its own meta refresh and asking it, never building the issuer's URL by hand — reaches the
|
||||
// real 302 to the issuer, whose state and PKCE verifier the callback needs.
|
||||
func startSignIn(t *testing.T, s *Server, auth *Auth, path string) (state string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
w2 := followBounce(t, s, w, InternalHost("g14"))
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("after the bounce: status %d, want 302 to the issuer", w2.Code)
|
||||
}
|
||||
loc, err := url.Parse(w2.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc.Query().Get("state")
|
||||
}
|
||||
|
||||
func callback(s *Server, state, code string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, SignedInPath+"?state="+url.QueryEscape(state)+"&code="+url.QueryEscape(code), nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// 1263/AC2: a valid callback whose ID token names an operator sets a session cookie Secure, HttpOnly,
|
||||
// SameSite=Strict, lasting 12 hours, and sends the browser back to the page it first asked for — a page the
|
||||
// test starts sign-in from that is not also the no-pending-return fallback (review round 1 finding 2: "/" is
|
||||
// both, so a return to it would pass even with pending.Return ignored), through a 200 refresh page, not a 302
|
||||
// (finding 1: a 302 here would carry the Strict cookie just set on a navigation the browser still counts as
|
||||
// begun at Keycloak).
|
||||
func TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
const returnPath = "/folder?account=a&folder=INBOX"
|
||||
state := startSignIn(t, s, auth, returnPath)
|
||||
// The session clock (now) is mocked to test the 12-hour bound (TestASessionTwelveHoursOldIsTreatedAsNoSession
|
||||
// below); the ID token's own iat/exp are judged by go-oidc against the real wall clock, so they are minted
|
||||
// from it, not from the mocked now.
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200 (a refresh page, not a redirect): %s", w.Code, w.Body.String())
|
||||
}
|
||||
if refreshURL := extractRefreshURL(t, w.Body.String()); refreshURL != returnPath {
|
||||
t.Fatalf("returned to %q, want the page first asked for (%s)", refreshURL, returnPath)
|
||||
}
|
||||
var found *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookie {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("no session cookie set")
|
||||
}
|
||||
if !found.Secure || !found.HttpOnly || found.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("cookie attributes: Secure=%v HttpOnly=%v SameSite=%v", found.Secure, found.HttpOnly, found.SameSite)
|
||||
}
|
||||
if found.MaxAge != int(SessionLifetime/time.Second) {
|
||||
t.Fatalf("MaxAge %d, want %d (12h)", found.MaxAge, int(SessionLifetime/time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC3: an ID token naming an identity not in operators is answered 403, and no session cookie is set.
|
||||
func TestACallbackForAnIdentityNotInOperatorsIsRefused(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth, "/")
|
||||
fi.nextIDToken = fi.idToken("mail-web", "mallory", now, nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("a cookie was set for a refused identity: %v", w.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC4: a state this module did not issue, an ID token of another audience, one signed by another key, or
|
||||
// one expired — each answered 403, no session made.
|
||||
func TestACallbackIsRefusedOnAnUnknownStateOrAnInvalidIDToken(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(auth *Auth, s *Server) (state string)
|
||||
token func() string
|
||||
}{
|
||||
{"unknown state", func(auth *Auth, s *Server) string { return "a-state-nobody-issued" },
|
||||
func() string { return fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid) }},
|
||||
{"wrong audience", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
||||
func() string { return fi.idToken("someone-else", "alice", now, nil, fi.key, fi.kid) }},
|
||||
{"wrong signing key", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
||||
func() string { return fi.idToken("mail-web", "alice", now, nil, otherKey, fi.kid) }},
|
||||
{"expired", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
||||
func() string {
|
||||
return fi.idToken("mail-web", "alice", now, map[string]any{"exp": now.Add(-time.Hour).Unix()}, fi.key, fi.kid)
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
state := c.setup(auth, s)
|
||||
fi.nextIDToken = c.token()
|
||||
w := callback(s, state, "some-code")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: status %d, want 403: %s", c.name, w.Code, w.Body.String())
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("%s: a cookie was set", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC5: a session made 12 hours ago is treated as no session: a page asked then is redirected to sign in
|
||||
// again.
|
||||
func TestASessionTwelveHoursOldIsTreatedAsNoSession(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
now := start
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth, "/")
|
||||
// The ID token's own iat/exp are judged against the real wall clock (see the comment in
|
||||
// TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie above); the session's own age is what
|
||||
// this test mocks, through the session clock (now).
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
var cookie *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookie {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatal("no session cookie set")
|
||||
}
|
||||
|
||||
// Just under 12 hours: still a session.
|
||||
now = start.Add(12*time.Hour - time.Minute)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
r.AddCookie(cookie)
|
||||
w2 := httptest.NewRecorder()
|
||||
s.ServeHTTP(w2, r)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("just under 12h: status %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
// Past 12 hours: no session any more — redirected to sign in again.
|
||||
now = start.Add(12*time.Hour + time.Minute)
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r2.Host = InternalHost("g14")
|
||||
r2.AddCookie(cookie)
|
||||
w3 := httptest.NewRecorder()
|
||||
s.ServeHTTP(w3, r2)
|
||||
if w3.Code != http.StatusFound {
|
||||
t.Fatalf("past 12h: status %d, want 302 (redirect to sign in)", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC6: operators empty lets no identity in.
|
||||
func TestCallbackWithEmptyOperatorsLetsNobodyIn(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
auth := testAuth(t, fi, operatorsFile(t), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth, "/")
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package web
|
||||
|
||||
// The trusted setting `operators` (novox/hq ADR 0440 §3, settlement 5 of design 59 milestone 1's prepare step):
|
||||
// the Keycloak identities (the ID token's preferred_username) let in. Nobody by default; a setting that cannot
|
||||
// be read, is empty, or holds something not its own shape lets nobody in either, and says why (issue 1262/AC4).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env is where the mesh puts what the module reads, as the manifest's build env names it.
|
||||
type Env struct {
|
||||
Operators string // the trusted setting operators
|
||||
OIDC string // the oidc-client binding, rendered (issue 1262's oidc.json)
|
||||
Listen string // the port the mesh gave endpoint `web` (MESH_MAIL_WEB_LISTEN)
|
||||
Node string // this machine's own name (MESH_NODE), for the internal host mail.<node>.internal
|
||||
}
|
||||
|
||||
func envFromOS() Env {
|
||||
return Env{
|
||||
Operators: os.Getenv("MESH_MAIL_WEB_OPERATORS"),
|
||||
OIDC: os.Getenv("MESH_MAIL_WEB_OIDC"),
|
||||
Listen: os.Getenv("MESH_MAIL_WEB_LISTEN"),
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorsFile is the shape of the setting as the mesh writes it.
|
||||
type OperatorsFile struct {
|
||||
Operators []string `json:"operators"`
|
||||
}
|
||||
|
||||
// ReadOperators reads the operators setting (issue 1262/AC4): a setting missing, empty, or holding an unknown
|
||||
// field lets nobody in, and the reason is kept (never silently "everyone", never a panic).
|
||||
func ReadOperators(path string) ([]string, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("the module was started without its operators setting (MESH_MAIL_WEB_OPERATORS)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the operators setting cannot be read: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var f OperatorsFile
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("the operators setting is not the module's: %w", err)
|
||||
}
|
||||
return f.Operators, nil
|
||||
}
|
||||
|
||||
// Operators is the setting read fresh each time it is asked (never cached): it who may sign in, so a setting
|
||||
// that cannot be read grants nobody rather than whoever it last allowed.
|
||||
type Operators struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// Allows says whether identity (the ID token's preferred_username) is let in, and why not when it is refused.
|
||||
func (o Operators) Allows(identity string) (bool, string) {
|
||||
if identity == "" {
|
||||
return false, "no identity was given"
|
||||
}
|
||||
names, err := ReadOperators(o.Path)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
for _, n := range names {
|
||||
if n == identity {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf("%q is not in the setting operators", identity)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1262/AC4: the operators file missing, empty, or holding an unknown field lets nobody in, and says why.
|
||||
func TestOperatorsLetsNobodyInWhenTheSettingIsMissingEmptyOrUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
missing := Operators{Path: filepath.Join(dir, "does-not-exist.json")}
|
||||
if ok, why := missing.Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("missing: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty.json")
|
||||
writeFile(t, empty, ``)
|
||||
if ok, why := (Operators{Path: empty}).Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("empty: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
unknown := filepath.Join(dir, "unknown.json")
|
||||
writeFile(t, unknown, `{"operators": ["alice"], "surprise": true}`)
|
||||
if ok, why := (Operators{Path: unknown}).Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("unknown field: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
// A well-formed setting still lets nobody in who is not named.
|
||||
good := filepath.Join(dir, "good.json")
|
||||
writeFile(t, good, `{"operators": ["alice"]}`)
|
||||
if ok, _ := (Operators{Path: good}).Allows("bob"); ok {
|
||||
t.Fatalf("bob was let in by a setting naming only alice")
|
||||
}
|
||||
if ok, why := (Operators{Path: good}).Allows("alice"); !ok || why != "" {
|
||||
t.Fatalf("alice: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Also 1262/AC4: an empty operators list (the default) lets nobody in.
|
||||
func TestOperatorsDefaultEmptyListLetsNobodyIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "operators.json")
|
||||
writeFile(t, path, `{"operators": []}`)
|
||||
if ok, why := (Operators{Path: path}).Allows("anyone"); ok || why == "" {
|
||||
t.Fatalf("allowed=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package web
|
||||
|
||||
// The form token bound to the session (novox/hq ADR 0440 §3's last clause, issue 1284): every form that
|
||||
// changes something — today mail_send, once issue 1267 builds the write page — carries a hidden field with
|
||||
// the session's own token (a random 32 bytes, made once per session, Sessions.Create); a POST missing it, or
|
||||
// carrying another session's, is refused 403 before the change it would have made ever runs, compared in
|
||||
// constant time so the check is never a timing side-channel.
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FormTokenField is the hidden field's own name every changing form carries.
|
||||
const FormTokenField = "csrf_token"
|
||||
|
||||
// FormTokenInput is the hidden field a changing form's own template includes (issue 1284/AC1): bound to the
|
||||
// session handed to the page that renders it (RequireSession, SessionFrom).
|
||||
func FormTokenInput(sess Session) template.HTML {
|
||||
return template.HTML(`<input type="hidden" name="` + FormTokenField + `" value="` +
|
||||
template.HTMLEscapeString(sess.Token) + `">`)
|
||||
}
|
||||
|
||||
// RequireFormToken wraps a POST handler that changes something (novox/hq issue 1284): it calls next only once
|
||||
// the session RequireSession handed on (SessionFrom) and the form's own FormTokenField compare equal, in
|
||||
// constant time; a POST with no token, or another session's, is answered 403 and next is never called, so the
|
||||
// change it would have made — today mail_send — never runs (AC2, AC3). RequireFormToken is placed inside
|
||||
// RequireSession (RequireSession(RequireFormToken(next))): a POST with no session at all is the sign-in
|
||||
// redirect RequireSession itself gives, never reaching here.
|
||||
func RequireFormToken(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := SessionFrom(r)
|
||||
if !ok {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
given := r.PostFormValue(FormTokenField)
|
||||
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(sess.Token)) != 1 {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package web
|
||||
|
||||
// The form token bound to the session (novox/hq ADR 0440 §3, issue 1284). There is no write page to test
|
||||
// against yet (issue 1267 builds it); these tests stand in for it with a minimal changing handler ("send"),
|
||||
// proving the mechanism issue 1267 will wire mail_send behind: a session, and that session's own token.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sessionAndCookie starts a session directly (bypassing sign-in, which auth_test.go already covers on its
|
||||
// own) and answers it with the cookie a request would carry.
|
||||
func sessionAndCookie(t *testing.T, sessions *Sessions, operator string) (Session, *http.Cookie) {
|
||||
t.Helper()
|
||||
id, err := sessions.Create(operator)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess, ok := sessions.Get(id)
|
||||
if !ok {
|
||||
t.Fatal("the session just created is not there")
|
||||
}
|
||||
return sess, &http.Cookie{Name: SessionCookie, Value: id}
|
||||
}
|
||||
|
||||
// 1284/AC1: a signed-in session's own form carries that session's token.
|
||||
func TestFormTokenInputCarriesTheSessionsOwnToken(t *testing.T) {
|
||||
sess, _ := sessionAndCookie(t, NewSessions(nil), "alice")
|
||||
html := string(FormTokenInput(sess))
|
||||
if !strings.Contains(html, `name="csrf_token"`) || !strings.Contains(html, sess.Token) {
|
||||
t.Fatalf("the form's hidden field does not carry the session's token: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// changingHandler is the stand-in for mail_send (issue 1267 is the real one): it counts how many times it was
|
||||
// actually reached.
|
||||
func changingHandler(calls *int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
*calls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func postSend(t *testing.T, handler http.HandlerFunc, cookie *http.Cookie, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
form := url.Values{}
|
||||
if token != "" {
|
||||
form.Set(FormTokenField, token)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// 1284/AC2: a POST of a send without the token is answered 403, and the change (here, the stand-in) is never
|
||||
// called.
|
||||
func TestPOSTWithoutTheTokenIsRefusedAndTheChangeIsNotCalled(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, cookie, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the change was called %d time(s) with no token", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC3: a POST carrying another session's token is answered 403, and the change is never called.
|
||||
func TestPOSTWithAnotherSessionsTokenIsRefusedAndTheChangeIsNotCalled(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
otherSess, _ := sessionAndCookie(t, sessions, "bob")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, cookie, otherSess.Token)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the change was called %d time(s) with another session's token", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC4: a POST carrying its own session's token proceeds.
|
||||
func TestPOSTWithItsOwnSessionsTokenProceeds(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, cookie, sess.Token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("the change was called %d time(s), want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// No session at all: RequireSession's own redirect runs, RequireFormToken is never reached, and the change is
|
||||
// never called either (a POST attempted with no session is treated the same as a page with no session).
|
||||
func TestPOSTWithNoSessionDoesNotReachTheFormTokenCheckOrTheChange(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, nil, "whatever")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302 (redirect to sign in)", w.Code)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the change was called %d time(s) with no session", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package web
|
||||
|
||||
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266): mail_read already turns a mail's
|
||||
// HTML part into text (mesh-mail's own mime handling); this page renders that text through html/template
|
||||
// alone, so whatever it holds — a script element, a remote image — is shown as escaped text, never parsed as
|
||||
// markup, and nothing on the page names another origin. An attachment is answered as a download: its bytes,
|
||||
// Content-Disposition: attachment with its name escaped, Content-Type application/octet-stream, and
|
||||
// X-Content-Type-Options: nosniff, so a browser never renders it inline. A mail mail-store refuses (an
|
||||
// account not granted) is answered 404, naming nothing of it (issue 1266/AC4) — the same shape as asking for a
|
||||
// mail that was never there.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readAnswer is mail-store.read's own answer (mesh-mail tools.go readTool / imap.go metadata), the fields the
|
||||
// mail page reads. Attachment carries only the fields a plain read needs; a read with `attachment` given
|
||||
// answers a different shape (attachmentAnswer, below), never both.
|
||||
type readAnswer struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID int `json:"uid"`
|
||||
Date string `json:"date"`
|
||||
From []personResult `json:"from"`
|
||||
To []personResult `json:"to"`
|
||||
Cc []personResult `json:"cc"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
Attachments []attachmentRefShown `json:"attachments"`
|
||||
}
|
||||
|
||||
// attachmentRefShown is one attachment as mail-store.read names it (mesh-mail store.go AttachmentRef): only
|
||||
// the fields the mail page's link and the download both need.
|
||||
type attachmentRefShown struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// attachmentAnswer is mail-store.read's own answer when asked with `attachment` (mesh-mail tools.go readTool).
|
||||
type attachmentAnswer struct {
|
||||
Attachment attachmentRefShown `json:"attachment"`
|
||||
Base64 string `json:"base64"`
|
||||
}
|
||||
|
||||
var mailTemplate = template.Must(template.New("mail").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>{{.Subject}}</title></head><body>
|
||||
<h1>{{.Subject}}</h1>
|
||||
<p>{{.Date}} — from {{range .From}}{{.Name}} <{{.Address}}> {{end}}to {{range .To}}{{.Name}} <{{.Address}}> {{end}}</p>
|
||||
<pre>{{.Text}}</pre>
|
||||
{{if .Attachments}}<h2>Attachments</h2><ul>
|
||||
{{range .Attachments}}<li><a href="/attachment?account={{$.Account}}&folder={{$.Folder}}&uid={{$.UID}}&key={{.Key}}">{{.Name}}</a> ({{.Size}} bytes)</li>{{end}}
|
||||
</ul>{{end}}
|
||||
</body></html>`))
|
||||
|
||||
// mailPage is issue 1266/AC1, AC2, AC4: the mail's headers and its text, through html/template alone (its
|
||||
// auto-escaping) — never the mail's own HTML, and nothing on the page names another origin. A read mail-store
|
||||
// refuses is 404, naming nothing of the mail.
|
||||
func (b *Bundle) mailPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account, folder, uidStr := q.Get("account"), q.Get("folder"), q.Get("uid")
|
||||
uid, err := strconv.Atoi(uidStr)
|
||||
if account == "" || folder == "" || err != nil || uid <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.readAsk(ctx, map[string]any{"account": account, "folder": folder, "uid": float64(uid)})
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var answer readAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
renderPage(w, mailTemplate, answer)
|
||||
}
|
||||
|
||||
// attachmentPage is issue 1266/AC3, AC4: an attachment's bytes as a download — Content-Disposition: attachment
|
||||
// (its name escaped by mime.FormatMediaType, novox/hq ADR 0440 §5), Content-Type application/octet-stream, and
|
||||
// X-Content-Type-Options: nosniff, so a browser never renders it inline. A read mail-store refuses is 404.
|
||||
func (b *Bundle) attachmentPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account, folder, uidStr, key := q.Get("account"), q.Get("folder"), q.Get("uid"), q.Get("key")
|
||||
uid, err := strconv.Atoi(uidStr)
|
||||
if account == "" || folder == "" || key == "" || err != nil || uid <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.readAsk(ctx, map[string]any{
|
||||
"account": account, "folder": folder, "uid": float64(uid), "attachment": key,
|
||||
})
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var answer attachmentAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil || answer.Base64 == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(answer.Base64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name := answer.Attachment.Name
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package web
|
||||
|
||||
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1266/AC1, AC2: a mail whose body holds <script>alert(1)</script> and <img src="http://remote/x"> shows that
|
||||
// text escaped, carries no script or img element, and nothing on the page names another origin.
|
||||
func TestMailPageEscapesScriptAndRemoteImageInTheBody(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z",
|
||||
"from": []map[string]any{{"address": "bob@example.org"}}, "to": []map[string]any{{"address": "me@example.org"}},
|
||||
"text": `<script>alert(1)</script> and <img src="http://remote.example/x">`,
|
||||
})
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/mail?account=mesh&folder=INBOX&uid=7", nil)
|
||||
b.mailPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "<script>") || strings.Contains(body, "<img") {
|
||||
t.Fatalf("the page carries a script or img element:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") || !strings.Contains(body, "<img") {
|
||||
t.Fatalf("the body is not shown escaped as text:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `src="http`) || strings.Contains(body, `href="http`) {
|
||||
t.Fatalf("the page names another origin in a src or href:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Also 1266/AC2: a mail's attachment link names only this server's own path, never another origin.
|
||||
func TestMailPageAttachmentLinksNameOnlyThisServersOwnPath(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z",
|
||||
"attachments": []map[string]any{{"key": "abc123", "name": "invoice.pdf", "size": 10, "type": "application/pdf"}},
|
||||
})
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/mail?account=mesh&folder=INBOX&uid=7", nil)
|
||||
b.mailPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `href="/attachment?account=mesh&folder=INBOX&uid=7&key=abc123"`) {
|
||||
t.Fatalf("no same-origin attachment link:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1266/AC3: an attachment asked carries its bytes with Content-Disposition attachment and its name escaped,
|
||||
// Content-Type application/octet-stream, and X-Content-Type-Options nosniff.
|
||||
func TestAttachmentDownloadCarriesTheRightHeadersAndBytes(t *testing.T) {
|
||||
data := []byte("the attachment's own bytes")
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"attachment": map[string]any{"key": "abc123", "name": `weird, name.pdf`, "size": len(data), "type": "application/pdf"},
|
||||
"base64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/attachment?account=mesh&folder=INBOX&uid=7&key=abc123", nil)
|
||||
b.attachmentPage(w, r)
|
||||
|
||||
if w.Header().Get("Content-Type") != "application/octet-stream" {
|
||||
t.Fatalf("Content-Type: %q", w.Header().Get("Content-Type"))
|
||||
}
|
||||
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("X-Content-Type-Options: %q", w.Header().Get("X-Content-Type-Options"))
|
||||
}
|
||||
cd := w.Header().Get("Content-Disposition")
|
||||
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, "weird, name.pdf") {
|
||||
t.Fatalf("Content-Disposition: %q", cd)
|
||||
}
|
||||
if w.Body.String() != string(data) {
|
||||
t.Fatalf("body: %q, want %q", w.Body.String(), data)
|
||||
}
|
||||
}
|
||||
|
||||
// 1266/AC4: a mail mail-store refuses (an account not granted) is answered 404, naming nothing of the mail.
|
||||
func TestMailPageAndAttachmentAre404WhenMailStoreRefuses(t *testing.T) {
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
return nil, errors.New("refused: mesh-web is not granted the mail account secret-project")
|
||||
}}
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
b.mailPage(w1, httptest.NewRequest(http.MethodGet, "/mail?account=secret-project&folder=INBOX&uid=1", nil))
|
||||
if w1.Code != http.StatusNotFound {
|
||||
t.Fatalf("mail page status %d, want 404", w1.Code)
|
||||
}
|
||||
if strings.Contains(w1.Body.String(), "secret-project") {
|
||||
t.Fatalf("the 404 names the account: %s", w1.Body.String())
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
b.attachmentPage(w2, httptest.NewRequest(http.MethodGet, "/attachment?account=secret-project&folder=INBOX&uid=1&key=k", nil))
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("attachment status %d, want 404", w2.Code)
|
||||
}
|
||||
if strings.Contains(w2.Body.String(), "secret-project") {
|
||||
t.Fatalf("the 404 names the account: %s", w2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build integration
|
||||
|
||||
package web
|
||||
|
||||
// Design 59 test 16's page half (novox/hq ADR 0459 §12, §13; issue 1524, split from 1431): mesh-mail-web
|
||||
// started whole, its stand-in mail-reading.status answering unhealthy with a cause, shows that cause above
|
||||
// the classical list, which still renders. Reuses issue 1285's own harness, its stand-in seat extended to
|
||||
// mail-reading by issue 1523 (standInMailReading, runtime_integration_test.go). Compiled by
|
||||
// `go vet -tags integration ./...`; never run on this machine — the lead's try runs it for real.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1524/AC3: mesh-mail-web started whole, its stand-in mail-reading.status answering unhealthy with a
|
||||
// cause, shows that cause above the classical list, and the list's accounts still render.
|
||||
func TestAcceptanceUnhealthyMailReadingShowsItsCauseAboveTheStillRenderingList(t *testing.T) {
|
||||
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
|
||||
mw.store.Accounts = func(map[string]any) (any, error) {
|
||||
return map[string]any{"accounts": []map[string]any{
|
||||
{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"},
|
||||
}}, nil
|
||||
}
|
||||
mw.mailReading.Status = func(map[string]any) (any, error) {
|
||||
return map[string]any{"healthy": false, "cause": "refused", "why": "holds no licence", "since": "2026-10-20T09:30:00Z"}, nil
|
||||
}
|
||||
mw.mailReading.Entries = func(map[string]any) (any, error) {
|
||||
return map[string]any{"entries": []map[string]any{}}, nil
|
||||
}
|
||||
mw.mailReading.Lessons = func(map[string]any) (any, error) {
|
||||
return map[string]any{"lessons": []map[string]any{}}, nil
|
||||
}
|
||||
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
body, _ := io.ReadAll(page.Body)
|
||||
_ = page.Body.Close()
|
||||
if page.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the accounts page: status %d: %s", page.StatusCode, body)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(body), "Unhealthy") || !strings.Contains(string(body), "refused") ||
|
||||
!strings.Contains(string(body), "2026-10-20T09:30:00Z") {
|
||||
t.Fatalf("the page does not show the cause and since when:\n%s", body)
|
||||
}
|
||||
// The classical list — accounts a and c — still renders in full (design 59 test 16; ADR 0459 §13:
|
||||
// "the classical list always opens").
|
||||
if !strings.Contains(string(body), "a@example.org") {
|
||||
t.Fatalf("the classical list did not render:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package web
|
||||
|
||||
// The mail-reading section of the accounts page (novox/hq ADR 0459 §7, §12, §13; issue 1430).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seatAskJSON(t *testing.T, v any) SeatAsk {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }
|
||||
}
|
||||
|
||||
// 1430/AC1: with mail-reading healthy, the page shows the mail that matters first, the mail groups with
|
||||
// their counts, and the scam warnings in their own section, each escaped through html/template.
|
||||
func TestAccountsPageShowsMailThatMattersGroupsAndScamWarnings(t *testing.T) {
|
||||
accountsRaw, err := accountsAnswerJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
|
||||
MailStatus: seatAskJSON(t, map[string]any{"healthy": true}),
|
||||
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{
|
||||
{"sender-name": "Alice", "summary": "pay <the> bill", "mark": "matters", "why": "due soon", "due-date": "2026-11-01"},
|
||||
{"sender-name": "Eve", "summary": "a scam", "mark": "scam warning", "why": "fake bank"},
|
||||
}}),
|
||||
MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{{"label": "bills", "count": 3}}}),
|
||||
MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{"Alice", "due 2026-11-01", "due soon", "bills: 3", "Eve", "a scam", "fake bank"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("body does not contain %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// html/template escapes a mail's own words (AC1: "all through html/template, escaped").
|
||||
if strings.Contains(body, "<the>") {
|
||||
t.Fatalf("the summary was not escaped: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<the>") {
|
||||
t.Fatalf("the escaped summary is missing: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1430/AC2: with status unhealthy, the page shows its cause and since when in place of the mail groups, and
|
||||
// the classical list still below.
|
||||
func TestAccountsPageShowsCauseInPlaceOfGroupsWhenUnhealthy(t *testing.T) {
|
||||
accountsRaw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
|
||||
MailStatus: seatAskJSON(t, map[string]any{"healthy": false, "cause": "refused", "why": "holds no licence", "since": "2026-10-20T09:30:00Z"}),
|
||||
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
|
||||
MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "Unhealthy") || !strings.Contains(body, "refused") || !strings.Contains(body, "2026-10-20T09:30:00Z") {
|
||||
t.Fatalf("body does not show the cause and since when:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Mail groups") {
|
||||
t.Fatalf("the mail groups heading is shown even though unhealthy:\n%s", body)
|
||||
}
|
||||
// The classical list still renders below (AC2).
|
||||
if !strings.Contains(body, "a@example.org") || !strings.Contains(body, "current") {
|
||||
t.Fatalf("the classical list did not render:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1430/AC3: with mail-reading not answering or refusing, the page says mail reading is unavailable and why,
|
||||
// and the classical list still renders in full.
|
||||
func TestAccountsPageSaysMailReadingUnavailableAndStillRendersTheList(t *testing.T) {
|
||||
accountsRaw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
|
||||
MailStatus: func(context.Context, map[string]any) (json.RawMessage, error) { return nil, errRefused },
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "unavailable") {
|
||||
t.Fatalf("body does not say mail reading is unavailable:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "a@example.org") || !strings.Contains(body, "current") {
|
||||
t.Fatalf("the classical list did not render in full:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
type signalError struct{ s string }
|
||||
|
||||
func (e signalError) Error() string { return e.s }
|
||||
|
||||
var errRefused = signalError{"refused"}
|
||||
@@ -0,0 +1,57 @@
|
||||
package web
|
||||
|
||||
// Corrections and undo on the mail page (novox/hq ADR 0459 §8, §13; issue 1523): each POST is called only
|
||||
// once RequireFormToken (issue 1284, csrf.go) has already let it through — a POST with no token, or
|
||||
// another session's, is answered 403 before mail-reading.correct or .undo is ever asked (1523/AC1). Both
|
||||
// show the page again once done, exactly as sendHandler (write.go) does for a send.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mailCorrectHandler is POST /mail-correct (1523/AC1): the mail's id, and whatever of mark or group the
|
||||
// form carried (both optional; neither given asks mail-reading.correct to refuse, exactly as it already
|
||||
// does for nothing to change). A correction that mail-reading itself refuses is not surfaced as an error
|
||||
// here — the page is shown again regardless, and simply does not reflect a change that did not happen.
|
||||
func (b *Bundle) mailCorrectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(r.PostFormValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args := map[string]any{"id": float64(id)}
|
||||
if mark := r.PostFormValue("mark"); mark != "" {
|
||||
args["mark"] = mark
|
||||
}
|
||||
if group := r.PostFormValue("group"); group != "" {
|
||||
args["group"] = group
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
_, _ = b.mailCorrectAsk(ctx, args)
|
||||
b.accountsPage(w, r)
|
||||
}
|
||||
|
||||
// mailUndoHandler is POST /mail-undo (1523/AC2): the reading lesson's id.
|
||||
func (b *Bundle) mailUndoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(r.PostFormValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
_, _ = b.mailUndoAsk(ctx, map[string]any{"id": float64(id)})
|
||||
b.accountsPage(w, r)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build integration
|
||||
|
||||
package web
|
||||
|
||||
// 1523/AC4: mesh-mail-web started whole (the harness issue 1285 built, its stand-in seat extended to
|
||||
// mail-reading by runtime_integration_test.go's standInMailReading): a correction posted on the page
|
||||
// reaches the stand-in's Correct, an undo reaches Undo, and nothing of mail is read but through mail-store
|
||||
// (the classical list's own mail-store.accounts; never mail-store.search or .read, which this flow never
|
||||
// needs). Compiled by `go vet -tags integration ./...`; never run here — the lead's try runs it for real.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recordedArgs is a mutex-guarded holder: a stand-in's own closure (called from the player's readLoop
|
||||
// goroutine, runtime_integration_test.go's handleBundleRequest) records what it was asked here, and the
|
||||
// test (its own, main goroutine) reads it back safely afterward. The coordinator's own fix for issue 1523:
|
||||
// the merge check's `go test -race` found this exact unsynchronized pattern — a bare variable written by
|
||||
// one goroutine and read by another, with no memory synchronization the race detector can see (the two
|
||||
// communicate only over a stdio pipe and an HTTP round trip, neither of which it tracks as ordinary Go
|
||||
// synchronization).
|
||||
type recordedArgs struct {
|
||||
mu sync.Mutex
|
||||
args map[string]any
|
||||
set bool
|
||||
}
|
||||
|
||||
func (r *recordedArgs) record(args map[string]any) {
|
||||
r.mu.Lock()
|
||||
r.args, r.set = args, true
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *recordedArgs) clear() {
|
||||
r.mu.Lock()
|
||||
r.args, r.set = nil, false
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *recordedArgs) get() map[string]any {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.args
|
||||
}
|
||||
|
||||
func (r *recordedArgs) wasSet() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.set
|
||||
}
|
||||
|
||||
func TestAcceptanceMailCorrectionAndUndoReachTheStandIn(t *testing.T) {
|
||||
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
|
||||
// The classical list below the mail-reading section still asks seat:mail-store.accounts on every page
|
||||
// load (accounts.go's own accountsPage), exactly as milestone 1's own tests set it up (review round 1
|
||||
// finding 1, issue 1523): left unset, GET "/" fails this test as an unknown ask, never reaching the
|
||||
// mail-reading flow this test is actually about.
|
||||
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.mailReading.Status = func(map[string]any) (any, error) {
|
||||
return map[string]any{"healthy": true, "weeks": []map[string]any{}}, nil
|
||||
}
|
||||
mw.mailReading.Entries = func(map[string]any) (any, error) {
|
||||
return map[string]any{"entries": []map[string]any{
|
||||
{"id": 1, "sender-name": "Alice", "summary": "pay the bill", "mark": "matters"},
|
||||
}}, nil
|
||||
}
|
||||
mw.mailReading.Groups = func(map[string]any) (any, error) {
|
||||
return map[string]any{"groups": []map[string]any{}}, nil
|
||||
}
|
||||
mw.mailReading.Lessons = func(map[string]any) (any, error) {
|
||||
return map[string]any{"lessons": []map[string]any{
|
||||
{"id": 9, "sender-name": "Bob", "subject": "fyi", "change": "mark: neither -> matters", "made": "2026-10-20T10:00:00Z"},
|
||||
}}, nil
|
||||
}
|
||||
var correctArgs, undoArgs recordedArgs
|
||||
mw.mailReading.Correct = func(args map[string]any) (any, error) {
|
||||
correctArgs.record(args)
|
||||
return map[string]any{"id": args["id"]}, nil
|
||||
}
|
||||
mw.mailReading.Undo = func(args map[string]any) (any, error) {
|
||||
undoArgs.record(args)
|
||||
return map[string]any{"undone": args["id"]}, nil
|
||||
}
|
||||
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
|
||||
page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
pageBody, _ := io.ReadAll(page.Body)
|
||||
_ = page.Body.Close()
|
||||
if page.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the accounts page: status %d: %s", page.StatusCode, pageBody)
|
||||
}
|
||||
token := extractHiddenValue(string(pageBody), FormTokenField)
|
||||
if token == "" {
|
||||
t.Fatalf("the accounts page carries no form token:\n%s", pageBody)
|
||||
}
|
||||
|
||||
// 1523/AC1: a correction posted with the session's own token reaches the stand-in's Correct.
|
||||
correctResp := doForm(t, mw, "/mail-correct", url.Values{
|
||||
"id": {"1"}, "mark": {"matters"}, "group": {"bills"}, FormTokenField: {token},
|
||||
}, cookie)
|
||||
correctBody, _ := io.ReadAll(correctResp.Body)
|
||||
_ = correctResp.Body.Close()
|
||||
if correctResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the correction: status %d: %s", correctResp.StatusCode, correctBody)
|
||||
}
|
||||
if got := correctArgs.get(); got["id"] != float64(1) || got["mark"] != "matters" || got["group"] != "bills" {
|
||||
t.Fatalf("mail-reading.correct was asked %v, want id 1, mark matters, group bills", got)
|
||||
}
|
||||
|
||||
// Without the token, the correction never reaches the stand-in (1523/AC1).
|
||||
correctArgs.clear()
|
||||
noTokenResp := doForm(t, mw, "/mail-correct", url.Values{"id": {"1"}, "mark": {"neither"}}, cookie)
|
||||
_ = noTokenResp.Body.Close()
|
||||
if noTokenResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("a correction with no token: status %d, want 403", noTokenResp.StatusCode)
|
||||
}
|
||||
if correctArgs.wasSet() {
|
||||
t.Fatalf("mail-reading.correct was asked %v with no token", correctArgs.get())
|
||||
}
|
||||
|
||||
// 1523/AC2: an undo posted with the session's own token reaches the stand-in's Undo.
|
||||
undoResp := doForm(t, mw, "/mail-undo", url.Values{"id": {"9"}, FormTokenField: {token}}, cookie)
|
||||
undoBody, _ := io.ReadAll(undoResp.Body)
|
||||
_ = undoResp.Body.Close()
|
||||
if undoResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the undo: status %d: %s", undoResp.StatusCode, undoBody)
|
||||
}
|
||||
if got := undoArgs.get(); got["id"] != float64(9) {
|
||||
t.Fatalf("mail-reading.undo was asked %v, want id 9", got)
|
||||
}
|
||||
|
||||
// 1523/AC4: nothing of mail is read but through mail-store — this flow never asks mail-store.search or
|
||||
// mail-store.read at all (only .accounts, for the classical list beneath).
|
||||
for _, k := range mw.store.Keys() {
|
||||
if k == "seat:mail-store.search" || k == "seat:mail-store.read" {
|
||||
t.Fatalf("mail-store was asked %s, which this flow never needs", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package web
|
||||
|
||||
// Corrections and undo on the mail page (novox/hq ADR 0459 §8, §13; issue 1523).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1523/AC1: posting a correction with the session's own token calls mail-reading.correct with the mail's id
|
||||
// and the change, then shows the page again.
|
||||
func TestMailCorrectHandlerCallsCorrectAndShowsThePageAgain(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
|
||||
accountsRaw, err := accountsAnswerJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
|
||||
MailStatus: seatAskJSON(t, map[string]any{"healthy": true}),
|
||||
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
|
||||
MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{}}),
|
||||
MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
|
||||
MailCorrect: func(_ context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"id": args["id"]})
|
||||
},
|
||||
}
|
||||
handler := auth.RequireSession(RequireFormToken(b.mailCorrectHandler))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("id", "42")
|
||||
form.Set("mark", "matters")
|
||||
form.Set(FormTokenField, sess.Token)
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail-correct", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotArgs["id"] != float64(42) || gotArgs["mark"] != "matters" {
|
||||
t.Fatalf("mail-reading.correct asked with %v", gotArgs)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "<h1>Accounts</h1>") {
|
||||
t.Fatalf("the page was not shown again: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1523/AC1: without the token, the POST is answered 403 and mail-reading.correct is never called.
|
||||
func TestMailCorrectHandlerRefusedWithoutToken(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
called := false
|
||||
b := &Bundle{MailCorrect: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}}
|
||||
handler := auth.RequireSession(RequireFormToken(b.mailCorrectHandler))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("id", "42")
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail-correct", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("mail-reading.correct was called with no token")
|
||||
}
|
||||
}
|
||||
|
||||
// 1523/AC2: an undo form carrying the session's own token calls mail-reading.undo.
|
||||
func TestMailUndoHandlerCallsUndo(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
|
||||
accountsRaw, err := accountsAnswerJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
|
||||
MailStatus: seatAskJSON(t, map[string]any{"healthy": true}),
|
||||
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
|
||||
MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{}}),
|
||||
MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
|
||||
MailUndo: func(_ context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"undone": args["id"]})
|
||||
},
|
||||
}
|
||||
handler := auth.RequireSession(RequireFormToken(b.mailUndoHandler))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("id", "7")
|
||||
form.Set(FormTokenField, sess.Token)
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail-undo", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotArgs["id"] != float64(7) {
|
||||
t.Fatalf("mail-reading.undo asked with %v", gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
// 1523/AC2, AC3: the page lists the reading lessons newest first, each with an undo form carrying the
|
||||
// session's token, and shows reading lessons per 100 read mails for each of the last weeks, from status.
|
||||
func TestAccountsPageListsLessonsWithUndoFormsAndWeeklyMeasure(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
|
||||
accountsRaw, err := accountsAnswerJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
|
||||
MailStatus: seatAskJSON(t, map[string]any{
|
||||
"healthy": true,
|
||||
"weeks": []map[string]any{
|
||||
{"week-start": "2026-10-13", "lessons-per-100": 25.0},
|
||||
{"week-start": "2026-10-20", "lessons-per-100": nil, "why": "no mail was read this week"},
|
||||
},
|
||||
}),
|
||||
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
|
||||
MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{}}),
|
||||
MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{
|
||||
{"id": 5, "sender-name": "Alice", "subject": "pay the bill", "change": "mark changed", "made": "2026-10-20T10:00:00Z"},
|
||||
}}),
|
||||
}
|
||||
handler := auth.RequireSession(b.accountsPage)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{"Alice", "pay the bill", "mark changed", `action="/mail-undo"`, sess.Token, "25 per 100", "no mail was read this week"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("body does not contain %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package web
|
||||
|
||||
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
|
||||
//
|
||||
// 1262/AC5: the repository's own way to run "the controller's manifest rules" is merge-check.sh, which shells
|
||||
// out to the real controller binary ("$MESH_GATE" module check modules/*/module.json, merge-check.sh:127) — not
|
||||
// something this package can call as a unit test, and this batch's brief refuses running merge-check.sh or any
|
||||
// integration test. mesh-mail's and mesh-mail-notifier's own manifest_test.go files do not run the controller
|
||||
// either: they assert the manifest's shape by hand, field by field, in Go. There is no unit-level way in this
|
||||
// repository to run the controller's actual module-check rules, so TestTheManifestSaysWhatTheCodeDoes below is
|
||||
// as far as the repo allows for AC5: it is the same hand-written shape assertion the sibling modules use, now
|
||||
// covering every field AC1 names. The real module check still runs once, for real, through the lead's
|
||||
// mesh-controller.try.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type listen struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
Type string `json:"type"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Default []string `json:"default"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
RunsAs string `json:"runs-as"`
|
||||
Claims []seat `json:"claims"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Reads []string `json:"reads"`
|
||||
Requires []string `json:"requires"`
|
||||
Contributes map[string]map[string]any `json:"contributes"`
|
||||
Binds map[string]string `json:"binds"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Listens []listen `json:"listens"`
|
||||
Config map[string]configEntry `json:"config"`
|
||||
Tools []string `json:"tools"`
|
||||
State []any `json:"state"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 1262/AC1 (and, as far as the repo allows, AC5 — see the file's own comment above).
|
||||
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
|
||||
if m.Module != "mesh-mail-web" {
|
||||
t.Fatalf("module %q", m.Module)
|
||||
}
|
||||
if m.RunsAs != "mesh-mail-web" {
|
||||
t.Fatalf("runs-as %q, want its own account", m.RunsAs)
|
||||
}
|
||||
wantClaims := []seat{{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}}
|
||||
if !reflect.DeepEqual(m.Claims, wantClaims) {
|
||||
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
|
||||
}
|
||||
// 1430/AC: the six seat:mail-reading verbs, beside the five mail-store ones.
|
||||
wantInvokes := []string{
|
||||
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
|
||||
"seat:mail-store.send", "seat:mail-store.send-status",
|
||||
"seat:mail-reading.entries", "seat:mail-reading.groups", "seat:mail-reading.status",
|
||||
"seat:mail-reading.correct", "seat:mail-reading.lessons", "seat:mail-reading.undo",
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
|
||||
t.Fatalf("invokes %v, want exactly %v", m.Invokes, wantInvokes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
|
||||
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
|
||||
}
|
||||
if !contains(m.Requires, "route") || !contains(m.Requires, "oidc-client") {
|
||||
t.Fatalf("requires %v, want route and oidc-client", m.Requires)
|
||||
}
|
||||
if len(m.Listens) != 1 || m.Listens[0].Name != "web" || m.Listens[0].From != "mesh" || m.Listens[0].Protocol != "tcp" {
|
||||
t.Fatalf("listens %+v, want exactly one, named web, from the mesh only", m.Listens)
|
||||
}
|
||||
// "list" is the controller's own word for a trusted array value (novox/hq ADR 0348 §4); "array" is not
|
||||
// one of its five types (number, string, boolean, object, list) and the controller refuses it.
|
||||
op, ok := m.Config["operators"]
|
||||
if !ok || op.Type != "list" || !op.Trusted || len(op.Default) != 0 {
|
||||
t.Fatalf("config.operators %+v, want a trusted list defaulting to nobody", op)
|
||||
}
|
||||
// 1430/AC4: no database, no state bucket, no writable directory beyond the account's own state dir: only the
|
||||
// account (a user resource, not writable), the state directory, and the two files rendered into it.
|
||||
if len(m.State) != 0 {
|
||||
t.Fatalf("state %v, want none", m.State)
|
||||
}
|
||||
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
|
||||
if contains(m.Requires, dbish) {
|
||||
t.Fatalf("requires %v, want no database or object store", m.Requires)
|
||||
}
|
||||
}
|
||||
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
|
||||
if len(m.Resources) != len(wantResourceIDs) {
|
||||
t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if id != "account" && r["type"] == "directory" && id != "state" {
|
||||
t.Fatalf("an extra writable directory %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest's own tools match the code's (mirroring mesh-mail's and mesh-mail-notifier's own check).
|
||||
var names []string
|
||||
for _, tool := range (&Bundle{}).Tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Tools, names) {
|
||||
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
||||
}
|
||||
|
||||
a := m.Build.Artifacts
|
||||
if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) {
|
||||
t.Fatalf("build %+v", m.Build)
|
||||
}
|
||||
for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} {
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == v {
|
||||
found = a[0].Env[k] == r["path"]
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the bundle reads %s at %s, which does not match resource %s's path", k, a[0].Env[k], v)
|
||||
}
|
||||
}
|
||||
// A bundle's word is ${port:<the number a listens entry gives>}, never the endpoint's own name
|
||||
// (novox/hq ADR 0192; mesh-controller internal/catalogue/port_into.go portsFilledInto matches it against
|
||||
// listens[].Port, a plain number, not a label like "web"). len(m.Listens)==1 is already checked above.
|
||||
wantListen := fmt.Sprintf("${port:%d}", m.Listens[0].Port)
|
||||
if a[0].Env["MESH_MAIL_WEB_LISTEN"] != wantListen {
|
||||
t.Fatalf("MESH_MAIL_WEB_LISTEN is %q, want %q (the listens entry's own port number)",
|
||||
a[0].Env["MESH_MAIL_WEB_LISTEN"], wantListen)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package web is mesh-mail-web, the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
|
||||
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
|
||||
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
|
||||
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
|
||||
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
|
||||
// says on stderr; stdout is the MCP channel. It lives in a package of its own (issue 1859) so the local run,
|
||||
// cmd/mail-page-dev, serves these same page handlers; cmd/mesh-mail-web is the module's own program.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// The HTTP endpoint's own state (novox/hq ADR 0440 §8; review round 1 of issue 1283, finding 4): before this
|
||||
// fix, a failure setting up sign-in (the issuer never answering) or starting to listen (its port already
|
||||
// taken) was only logged — mail-webclient.status went on answering serves: true, and mail_web_health healthy,
|
||||
// regardless, since neither asked serveHTTP anything. Kept on the Bundle instead, so both tools say the one
|
||||
// truth.
|
||||
const (
|
||||
HTTPSettingUpSignIn = "setting up sign-in"
|
||||
HTTPListening = "listening"
|
||||
HTTPEnded = "ended"
|
||||
)
|
||||
|
||||
// HTTPStatus is serveHTTP's own phase and (once it has one) its error, read by statusTool and healthTool.
|
||||
type HTTPStatus struct {
|
||||
mu sync.Mutex
|
||||
phase string
|
||||
err error
|
||||
}
|
||||
|
||||
// NewHTTPStatus starts HTTPSettingUpSignIn: serveHTTP's own first phase, before issuer discovery has even been
|
||||
// tried once.
|
||||
func NewHTTPStatus() *HTTPStatus { return &HTTPStatus{phase: HTTPSettingUpSignIn} }
|
||||
|
||||
func (h *HTTPStatus) set(phase string, err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.phase = phase
|
||||
h.err = err
|
||||
}
|
||||
|
||||
// Get answers the endpoint's own phase, and its error once it has one.
|
||||
func (h *HTTPStatus) Get() (phase string, err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.phase, h.err
|
||||
}
|
||||
|
||||
// Main is the module's own program (cmd/mesh-mail-web): the seat verbs on the MCP channel and the HTTP
|
||||
// endpoint beside them.
|
||||
func Main() {
|
||||
b := &Bundle{Env: envFromOS(), HTTP: NewHTTPStatus()}
|
||||
go serveHTTP(b)
|
||||
if err := run(b, os.Stdin, os.Stdout); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot
|
||||
// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel — but it is also kept
|
||||
// on b.HTTP (finding 4), so mail-webclient.status and mail_web_health say it rather than going on claiming to
|
||||
// serve. Sign-in (issue 1263) is set up first, retried on a failure (the issuer may not answer yet): never
|
||||
// served with no session guard at all, which a nil Auth would be (server.go's own fallback, used by its unit
|
||||
// tests alone).
|
||||
func serveHTTP(b *Bundle) {
|
||||
if b.Env.Listen == "" {
|
||||
logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served")
|
||||
b.HTTP.set(HTTPEnded, errors.New("started without its own listen setting (MESH_MAIL_WEB_LISTEN)"))
|
||||
return
|
||||
}
|
||||
host := InternalHost(b.Env.Node)
|
||||
var auth *Auth
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
a, err := buildAuth(host, b.Env)
|
||||
if err == nil {
|
||||
auth = a
|
||||
break
|
||||
}
|
||||
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
|
||||
b.HTTP.set(HTTPSettingUpSignIn, err)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
s := NewServer(host, auth, b)
|
||||
b.HTTP.set(HTTPListening, nil)
|
||||
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
|
||||
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
|
||||
b.HTTP.set(HTTPEnded, err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildAuth reads the oidc-client binding (issue 1262's oidc.json) and discovers the issuer (issue 1263).
|
||||
func buildAuth(host string, env Env) (*Auth, error) {
|
||||
cfg, err := ReadOIDCConfig(env.OIDC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+host+SignedInPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Auth{
|
||||
Auth: authenticator,
|
||||
Sessions: NewSessions(nil),
|
||||
Pending: NewPendingAuths(nil),
|
||||
Operators: Operators{Path: env.Operators},
|
||||
Logf: logf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
|
||||
// (mirroring mesh-mail-notifier's own run).
|
||||
func run(b *Bundle, in io.Reader, out io.Writer) error {
|
||||
return stdio.ServeOn("", b.Tools(), in, out)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
package web
|
||||
|
||||
// The search page (novox/hq ADR 0440 §4, issue 1265): the form's own field names are exactly mail-store.search's
|
||||
// own argument names (mesh-mail tools.go searchArgs, issue 1232), so a filled field maps straight onto the
|
||||
// seat's own argument of the same name; an empty field is left out of the call entirely, never sent as "".
|
||||
// Paged by date exactly as the folder page (issue 1264): 50 a page, the next link asking again with before the
|
||||
// last shown mail's own date.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var searchTemplate = template.Must(template.New("search").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Search mail</title></head><body>
|
||||
<h1>Search</h1>
|
||||
<form method="get" action="/search">
|
||||
<input type="text" name="query" placeholder="words" value="{{.Query}}">
|
||||
<input type="text" name="from" placeholder="from" value="{{.From}}">
|
||||
<input type="text" name="to" placeholder="to" value="{{.To}}">
|
||||
<input type="text" name="on-or-after" placeholder="on or after" value="{{.OnOrAfter}}">
|
||||
<input type="text" name="before" placeholder="before" value="{{.Before}}">
|
||||
<label><input type="checkbox" name="has-attachment" value="on"{{if .HasAttachment}} checked{{end}}> has attachment</label>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— <a href="/mail?account={{.Account}}&folder={{.Folder}}&uid={{.UID}}">{{.Subject}}</a></li>{{end}}
|
||||
</ul>
|
||||
{{if .NextBefore}}<p><a href="{{.NextLink}}">Older</a></p>{{end}}
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type searchTemplateData struct {
|
||||
Query, From, To, OnOrAfter, Before, Error, NextBefore, NextLink string
|
||||
HasAttachment bool
|
||||
Mails []searchResult
|
||||
}
|
||||
|
||||
// searchArgsFromForm builds mail-store.search's own arguments from the request's query parameters: a field
|
||||
// left empty is left out of the call entirely (issue 1265/AC1) — never sent empty. "from" is split into a list
|
||||
// on whitespace or commas, mail-store.search's own array argument. limit is always MostFolderResults (AC2),
|
||||
// mechanical paging the form itself never carries.
|
||||
func searchArgsFromForm(q map[string][]string) map[string]any {
|
||||
get := func(name string) string {
|
||||
if v, ok := q[name]; ok && len(v) > 0 {
|
||||
return strings.TrimSpace(v[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
args := map[string]any{"limit": float64(MostFolderResults)}
|
||||
if v := get("query"); v != "" {
|
||||
args["query"] = v
|
||||
}
|
||||
if v := get("from"); v != "" {
|
||||
var list []string
|
||||
for _, part := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
|
||||
if part != "" {
|
||||
list = append(list, part)
|
||||
}
|
||||
}
|
||||
if len(list) > 0 {
|
||||
args["from"] = list
|
||||
}
|
||||
}
|
||||
if v := get("to"); v != "" {
|
||||
args["to"] = v
|
||||
}
|
||||
if v := get("on-or-after"); v != "" {
|
||||
args["on-or-after"] = v
|
||||
}
|
||||
if v := get("before"); v != "" {
|
||||
args["before"] = v
|
||||
}
|
||||
if v := get("has-attachment"); v != "" {
|
||||
args["has-attachment"] = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// searchPage is issue 1265/AC1-AC4.
|
||||
func (b *Bundle) searchPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
data := searchTemplateData{
|
||||
Query: q.Get("query"), From: q.Get("from"), To: q.Get("to"),
|
||||
OnOrAfter: q.Get("on-or-after"), Before: q.Get("before"), HasAttachment: q.Get("has-attachment") != "",
|
||||
}
|
||||
args := searchArgsFromForm(q)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.searchAsk(ctx, args)
|
||||
if err != nil {
|
||||
data.Error = "the search could not be answered"
|
||||
renderPage(w, searchTemplate, data)
|
||||
return
|
||||
}
|
||||
var answer searchAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
data.Error = "the search could not be answered"
|
||||
renderPage(w, searchTemplate, data)
|
||||
return
|
||||
}
|
||||
data.Mails = answer.Found
|
||||
if len(answer.Found) >= MostFolderResults {
|
||||
last := answer.Found[len(answer.Found)-1].Date
|
||||
data.NextBefore = last
|
||||
next := r.URL.Query()
|
||||
next.Set("before", last)
|
||||
data.NextLink = "/search?" + next.Encode()
|
||||
}
|
||||
renderPage(w, searchTemplate, data)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package web
|
||||
|
||||
// The search page (novox/hq ADR 0440 §4, issue 1265).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1265/AC1: the search form filled with words, sender, recipient, a date range and has-attachment calls
|
||||
// mail-store.search with exactly query, from, to, on-or-after, before and has-attachment carrying those
|
||||
// values (limit is the page's own mechanical paging, not a form field).
|
||||
func TestSearchPageCallsSearchWithExactlyTheFilledFormFields(t *testing.T) {
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
}}
|
||||
q := url.Values{
|
||||
"query": {"invoice"}, "from": {"ops@example.org lead@example.org"}, "to": {"me@example.org"},
|
||||
"on-or-after": {"2026-01-01"}, "before": {"2026-02-01"}, "has-attachment": {"on"},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?"+q.Encode(), nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
want := map[string]any{
|
||||
"query": "invoice", "from": []string{"ops@example.org", "lead@example.org"}, "to": "me@example.org",
|
||||
"on-or-after": "2026-01-01", "before": "2026-02-01", "has-attachment": true, "limit": float64(50),
|
||||
}
|
||||
if !reflect.DeepEqual(gotArgs, want) {
|
||||
t.Fatalf("args %#v, want %#v", gotArgs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unfilled fields are left out of the call entirely, never sent as "".
|
||||
func TestSearchPageLeavesEmptyFieldsOutOfTheCall(t *testing.T) {
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=hi", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
want := map[string]any{"query": "hi", "limit": float64(50)}
|
||||
if !reflect.DeepEqual(gotArgs, want) {
|
||||
t.Fatalf("args %#v, want %#v", gotArgs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1265/AC2: more than 50 results shows 50 newest first, and the next link asks again with before the date of
|
||||
// the 50th shown.
|
||||
func TestSearchPageShows50AndLinksToTheNextPageByDate(t *testing.T) {
|
||||
var found []map[string]any
|
||||
for i := 0; i < 50; i++ {
|
||||
found = append(found, map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 200 - i, "subject": "s",
|
||||
"date": "2026-03-01T08:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
|
||||
})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"found": found})
|
||||
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
body := strings.ToLower(w.Body.String())
|
||||
if !strings.Contains(body, "before=2026-03-01t08") {
|
||||
t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1265/AC3: words holding <script> show as text, and the page carries no script element.
|
||||
func TestSearchPageEscapesScriptInResults(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"found": []map[string]any{
|
||||
{"account": "mesh", "folder": "INBOX", "uid": 1, "subject": "<script>alert(1)</script>",
|
||||
"date": "2026-01-01T00:00:00Z", "from": []map[string]any{{"address": "x@example.org"}}},
|
||||
}})
|
||||
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Fatalf("the page carries a script element:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatalf("the subject is not shown escaped as text:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1265/AC4: mail-store refuses or fails; the page says the search could not be answered, and nothing else
|
||||
// (no partial results, no error detail).
|
||||
func TestSearchPageSaysItCouldNotBeAnsweredOnFailure(t *testing.T) {
|
||||
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "could not be answered") {
|
||||
t.Fatalf("the page does not say the search failed:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "<li>") {
|
||||
t.Fatalf("the page shows results despite the failure:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package web
|
||||
|
||||
// The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail.<node>.internal
|
||||
// (404 for any other Host — a public name, an IP, or none), sets a content security policy allowing only its
|
||||
// own origin on every answer, and writes nothing to disk. Pages are rendered through html/template only (its
|
||||
// auto-escaping), never written to a file. The seat verb open (issue 1262's claim) answers the page's address:
|
||||
// the root, or one mail's, by account, folder and uid (mail_read's own addressing, tools.go of mesh-mail).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// OpenTool is the mail-webclient seat's open verb.
|
||||
const OpenTool = "mail-webclient.open"
|
||||
|
||||
// CSP is ADR 0440 §5's own policy, set on every answer: nothing loaded from elsewhere, and this page never
|
||||
// framed by another site.
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; " +
|
||||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
|
||||
// InternalHost is this machine's own internal name for the module (ADR 0440 §2): the operator's own setting
|
||||
// (set on the machine, outside the manifest) assigns it the label `mail`, so it is always mail.<node>.internal.
|
||||
func InternalHost(node string) string { return "mail." + node + ".internal" }
|
||||
|
||||
// Server answers mesh-mail-web's HTTP endpoint: a Host check and a CSP on every answer (issue 1283), wrapping
|
||||
// a plain mux every page is added to, each behind the sign-in (RequireSession).
|
||||
type Server struct {
|
||||
Host string // InternalHost(node)
|
||||
Mux *http.ServeMux
|
||||
Auth *Auth // nil refuses no page by session (1283 alone); issue 1263 gives one
|
||||
Web *Bundle // nil answers only the stand-in root page (1283 alone); issue 1264 gives one
|
||||
}
|
||||
|
||||
// NewServer is a Server for host: with auth given, the sign-in callback (issue 1263) and every page behind a
|
||||
// session (RequireSession); with b also given, the real pages (accounts and folders, issue 1264; search,
|
||||
// issue 1265; a mail and its attachments, issue 1266; the write page and a send's state, issue 1267) in place
|
||||
// of 1283's own stand-in root.
|
||||
func NewServer(host string, auth *Auth, b *Bundle) *Server {
|
||||
s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth, Web: b}
|
||||
protect := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
if auth != nil {
|
||||
return auth.RequireSession(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
if auth != nil {
|
||||
s.Mux.HandleFunc(SignedInPath, auth.Callback)
|
||||
}
|
||||
if b != nil {
|
||||
s.Mux.HandleFunc("/", protect(b.accountsPage))
|
||||
s.Mux.HandleFunc("/folder", protect(b.folderPage))
|
||||
s.Mux.HandleFunc("/search", protect(b.searchPage))
|
||||
s.Mux.HandleFunc("/mail", protect(b.mailPage))
|
||||
s.Mux.HandleFunc("/attachment", protect(b.attachmentPage))
|
||||
s.Mux.HandleFunc("/write", protect(b.writePage))
|
||||
s.Mux.HandleFunc("/send", protect(RequireFormToken(b.sendHandler)))
|
||||
s.Mux.HandleFunc("/send-status", protect(b.sendStatusPage))
|
||||
s.Mux.HandleFunc("/mail-correct", protect(RequireFormToken(b.mailCorrectHandler)))
|
||||
s.Mux.HandleFunc("/mail-undo", protect(RequireFormToken(b.mailUndoHandler)))
|
||||
} else {
|
||||
s.Mux.HandleFunc("/", protect(s.rootPage))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP is issue 1283/AC1, AC2: every answer carries the CSP; a request for any Host but this server's own
|
||||
// internal name is 404, naming nothing of the mail.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", CSP)
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host != s.Host {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// rootTemplate is 1283/AC4's own stand-in page: no account or mail content yet (issue 1264), only enough to
|
||||
// prove the server answers at all, through html/template (auto-escaping), never a file written to disk.
|
||||
var rootTemplate = template.Must(template.New("root").Parse(
|
||||
`<!DOCTYPE html><html><head><title>Mail</title></head><body><p>mesh-mail-web is up.</p></body></html>`))
|
||||
|
||||
func (s *Server) rootPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = rootTemplate.Execute(w, nil)
|
||||
}
|
||||
|
||||
// openTool is issue 1262's claimed verb open, built here (1283) now that the server's own host exists: with
|
||||
// nothing given, the root; with account, folder and uid together, that mail's own address.
|
||||
func (b *Bundle) openTool(args map[string]any) (any, error) {
|
||||
if err := only(args, "account", "folder", "uid"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, _ := args["account"].(string)
|
||||
folder, _ := args["folder"].(string)
|
||||
uidArg, hasUID := args["uid"]
|
||||
|
||||
base := "https://" + InternalHost(b.Env.Node) + "/"
|
||||
if account == "" && folder == "" && !hasUID {
|
||||
return map[string]any{"url": base}, nil
|
||||
}
|
||||
uid, isNum := uidArg.(float64)
|
||||
if account == "" || folder == "" || !isNum || uid <= 0 {
|
||||
return nil, fmt.Errorf("%s needs account, folder and uid together, or none of them", OpenTool)
|
||||
}
|
||||
q := url.Values{"account": {account}, "folder": {folder}, "uid": {strconv.FormatFloat(uid, 'f', 0, 64)}}
|
||||
return map[string]any{"url": "https://" + InternalHost(b.Env.Node) + "/mail?" + q.Encode()}, nil
|
||||
}
|
||||
|
||||
// only refuses an argument the tool does not take (mirroring mesh-mail's own tools.go).
|
||||
func only(args map[string]any, takes ...string) error {
|
||||
for k := range args {
|
||||
ok := false
|
||||
for _, t := range takes {
|
||||
if k == t {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("the argument %q is not taken", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1283/AC1: a request whose Host is not mail.<node>.internal — a public name, an IP, or none — is answered
|
||||
// 404, naming nothing of the mail.
|
||||
func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = host
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("host %q: status %d, want 404", host, w.Code)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(w.Body.String()), "mail") {
|
||||
t.Fatalf("host %q: the 404 body names mail: %s", host, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Its own internal name is answered (not 404).
|
||||
func TestServerAnswersItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A Host with a port still matches, port and all (the mesh's own listen carries one).
|
||||
func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14") + ":8099"
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC2: every answer (200 and 404 alike) carries the content security policy.
|
||||
func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
cases := []string{"example.com", InternalHost("g14")}
|
||||
for _, host := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = host
|
||||
s.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Content-Security-Policy"); got != CSP {
|
||||
t.Fatalf("host %q: CSP %q, want %q", host, got, CSP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC3: open with nothing given answers the root; with account, folder and uid together, that mail's own
|
||||
// address.
|
||||
func TestOpenAnswersTheRootOrOneMailsAddress(t *testing.T) {
|
||||
b := &Bundle{Env: Env{Node: "g14"}}
|
||||
out, err := b.openTool(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.(map[string]any)["url"]; got != "https://mail.g14.internal/" {
|
||||
t.Fatalf("root url: %v", got)
|
||||
}
|
||||
|
||||
out, err = b.openTool(map[string]any{"account": "mesh", "folder": "INBOX", "uid": float64(7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := out.(map[string]any)["url"].(string)
|
||||
if !strings.HasPrefix(url, "https://mail.g14.internal/mail?") ||
|
||||
!strings.Contains(url, "account=mesh") || !strings.Contains(url, "folder=INBOX") || !strings.Contains(url, "uid=7") {
|
||||
t.Fatalf("mail url: %s", url)
|
||||
}
|
||||
|
||||
if _, err := b.openTool(map[string]any{"account": "mesh"}); err == nil {
|
||||
t.Fatalf("account alone, with no folder or uid, was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC4: with the working directory and home read-only, the server still starts and answers a page (it
|
||||
// writes nothing to disk).
|
||||
func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) {
|
||||
ro := t.TempDir()
|
||||
if err := os.Chmod(ro, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(ro, 0o700) })
|
||||
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldHome := os.Getenv("HOME")
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(oldWd)
|
||||
_ = os.Setenv("HOME", oldHome)
|
||||
})
|
||||
if err := os.Chdir(ro); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist"))
|
||||
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
srv := httptest.NewServer(s)
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = InternalHost("g14")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package web
|
||||
|
||||
// Sessions and the pending authorisations in flight (novox/hq ADR 0440 §3, issue 1263): both kept in memory
|
||||
// only, never on disk — a restart signs everyone out, accepted (design 59 milestone 1's prepare step).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookie is the cookie's own name.
|
||||
const SessionCookie = "mesh_mail_web_session"
|
||||
|
||||
// SessionLifetime is how long a session lasts after it is made (novox/hq ADR 0440 §3, issue 1263/AC2, AC5).
|
||||
const SessionLifetime = 12 * time.Hour
|
||||
|
||||
// PendingLifetime is how long a sign-in may take between the redirect to the issuer and its callback, before
|
||||
// the state is no longer honoured.
|
||||
const PendingLifetime = 10 * time.Minute
|
||||
|
||||
func randomID() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// Session is one signed-in operator's own session: its identity, when it was made (SessionLifetime decides
|
||||
// when it ends), and the per-session token issue 1284 binds every changing form to.
|
||||
type Session struct {
|
||||
Operator string
|
||||
Made time.Time
|
||||
Token string
|
||||
}
|
||||
|
||||
// Sessions keeps every session in memory, by its cookie's own id.
|
||||
type Sessions struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
byID map[string]Session
|
||||
}
|
||||
|
||||
// NewSessions is an empty Sessions; now is time.Now unless a test gives its own.
|
||||
func NewSessions(now func() time.Time) *Sessions {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Sessions{now: now, byID: map[string]Session{}}
|
||||
}
|
||||
|
||||
// Create starts a new session for operator, answering its id (the cookie's own value) and its own per-session
|
||||
// token (issue 1284).
|
||||
func (s *Sessions) Create(operator string) (id string, err error) {
|
||||
id, err = randomID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := randomID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID[id] = Session{Operator: operator, Made: s.now(), Token: token}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Get is the session at id, or false when there is none or it ended SessionLifetime ago (1263/AC5): an expired
|
||||
// session reads as no session at all, dropped on the same look (no background sweep is needed: every read
|
||||
// already checks the time).
|
||||
func (s *Sessions) Get(id string) (Session, bool) {
|
||||
if id == "" {
|
||||
return Session{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.byID[id]
|
||||
if !ok {
|
||||
return Session{}, false
|
||||
}
|
||||
if s.now().Sub(sess.Made) > SessionLifetime {
|
||||
delete(s.byID, id)
|
||||
return Session{}, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// PendingAuth is one sign-in asked of the issuer, remembered between the redirect and its callback: the PKCE
|
||||
// verifier the challenge was made from, and the page the browser first asked for.
|
||||
type PendingAuth struct {
|
||||
Verifier string
|
||||
Return string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// PendingAuths keeps every sign-in in flight, by its own state, in memory only.
|
||||
type PendingAuths struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
byState map[string]PendingAuth
|
||||
}
|
||||
|
||||
// NewPendingAuths is an empty PendingAuths; now is time.Now unless a test gives its own.
|
||||
func NewPendingAuths(now func() time.Time) *PendingAuths {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &PendingAuths{now: now, byState: map[string]PendingAuth{}}
|
||||
}
|
||||
|
||||
// Create remembers one sign-in in flight, answering the state it is kept under.
|
||||
func (p *PendingAuths) Create(verifier, returnTo string) (state string, err error) {
|
||||
state, err = randomID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.byState[state] = PendingAuth{Verifier: verifier, Return: returnTo, Created: p.now()}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// Take reads back and removes the sign-in kept under state — once; a state never issued, already taken, or
|
||||
// older than PendingLifetime answers false (1263/AC4's "a state it did not issue").
|
||||
func (p *PendingAuths) Take(state string) (PendingAuth, bool) {
|
||||
if state == "" {
|
||||
return PendingAuth{}, false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
pa, ok := p.byState[state]
|
||||
if !ok {
|
||||
return PendingAuth{}, false
|
||||
}
|
||||
delete(p.byState, state)
|
||||
if p.now().Sub(pa.Created) > PendingLifetime {
|
||||
return PendingAuth{}, false
|
||||
}
|
||||
return pa, true
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package web
|
||||
|
||||
// The module's seat verbs (novox/hq ADR 0440 §8): mail-webclient.status (whether it serves, and which accounts
|
||||
// it is granted) and mail-webclient.open (issue 1283: the page's address). mail_web_health is its own health
|
||||
// tool, read-only, naming nothing of a page or an account.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Tool names, as the manifest declares them.
|
||||
const (
|
||||
StatusTool = "mail-webclient.status"
|
||||
HealthTool = "mail_web_health"
|
||||
)
|
||||
|
||||
// AccountsAsk is how the Bundle reaches seat:mail-store.accounts: the bus's own stdio.Ask, unless a test gives
|
||||
// its own fake (so a unit test needs no runtime).
|
||||
type AccountsAsk func(ctx context.Context) (json.RawMessage, error)
|
||||
|
||||
func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
|
||||
}
|
||||
|
||||
// SeatAsk is how the Bundle reaches one of mail-store's other verbs (search, read, send, send-status): the
|
||||
// bus's own stdio.Ask, unless a test gives its own fake.
|
||||
type SeatAsk func(ctx context.Context, args map[string]any) (json.RawMessage, error)
|
||||
|
||||
func sdkSeatAsk(verb string) SeatAsk {
|
||||
return func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:"+verb, args)
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsBucketGet is how the Bundle reads mesh-mail's own account bucket (novox/hq ADR 0439 §2, issue 1255),
|
||||
// one account at a time (issue 1264/AC2: never Keys(), never an account mail-store did not itself answer).
|
||||
type AccountsBucketGet func(account string) (*stdio.StateEntry, error)
|
||||
|
||||
func sdkAccountsBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
return stdio.State("mesh-mail.accounts").Get(account)
|
||||
}
|
||||
|
||||
// Bundle is what the tools reach: the module's own settings, and the seats it calls.
|
||||
type Bundle struct {
|
||||
Env Env
|
||||
Accounts AccountsAsk
|
||||
Search SeatAsk
|
||||
Read SeatAsk
|
||||
Send SeatAsk
|
||||
SendStatus SeatAsk
|
||||
AccountBucket AccountsBucketGet
|
||||
// MailEntries, MailGroups and MailStatus are mail-reading's own read verbs the accounts page shows above
|
||||
// the classical list (novox/hq ADR 0459 §13, issue 1430); MailCorrect and MailUndo are its writes, and
|
||||
// MailLessons its lessons list (issue 1523). nil answers mail-reading as unavailable (1430/AC3) in every
|
||||
// test that builds a Bundle directly without one — the same seam Search, Read, Send and SendStatus use.
|
||||
MailEntries SeatAsk
|
||||
MailGroups SeatAsk
|
||||
MailStatus SeatAsk
|
||||
MailCorrect SeatAsk
|
||||
MailLessons SeatAsk
|
||||
MailUndo SeatAsk
|
||||
Now func() time.Time
|
||||
// HTTP is serveHTTP's own state (main.go, review round 1 of issue 1283, finding 4): nil answers
|
||||
// HTTPListening (every test that builds a Bundle directly, never through main's own serveHTTP, means this
|
||||
// module's HTTP endpoint whole).
|
||||
HTTP *HTTPStatus
|
||||
}
|
||||
|
||||
// httpPhase is b.HTTP.Get(), with no HTTP given read as HTTPListening (every unit test that builds a Bundle
|
||||
// directly rather than through main's own serveHTTP).
|
||||
func (b *Bundle) httpPhase() (phase string, err error) {
|
||||
if b.HTTP == nil {
|
||||
return HTTPListening, nil
|
||||
}
|
||||
return b.HTTP.Get()
|
||||
}
|
||||
|
||||
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
if b.Accounts != nil {
|
||||
return b.Accounts(ctx)
|
||||
}
|
||||
return sdkAccountsAsk(ctx)
|
||||
}
|
||||
|
||||
func (b *Bundle) searchAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Search != nil {
|
||||
return b.Search(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.search")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) readAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Read != nil {
|
||||
return b.Read(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.read")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) sendAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Send != nil {
|
||||
return b.Send(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.send")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) sendStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.SendStatus != nil {
|
||||
return b.SendStatus(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.send-status")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailEntriesAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.MailEntries != nil {
|
||||
return b.MailEntries(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-reading.entries")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailGroupsAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.MailGroups != nil {
|
||||
return b.MailGroups(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-reading.groups")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.MailStatus != nil {
|
||||
return b.MailStatus(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-reading.status")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailCorrectAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.MailCorrect != nil {
|
||||
return b.MailCorrect(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-reading.correct")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailLessonsAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.MailLessons != nil {
|
||||
return b.MailLessons(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-reading.lessons")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailUndoAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.MailUndo != nil {
|
||||
return b.MailUndo(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-reading.undo")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
if b.AccountBucket != nil {
|
||||
return b.AccountBucket(account)
|
||||
}
|
||||
return sdkAccountsBucketGet(account)
|
||||
}
|
||||
|
||||
func (b *Bundle) now() time.Time {
|
||||
if b.Now != nil {
|
||||
return b.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Tools are the module's tools.
|
||||
func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: OpenTool,
|
||||
Description: "The mail web client's own address (novox/hq ADR 0440 §8): with nothing given, the " +
|
||||
"page's root; with account, folder and uid together (mail_read's own addressing), that mail's " +
|
||||
"own page. Read-only; answers a URL, never a page's content.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"account": text("the account, by its name in mail_accounts"),
|
||||
"folder": text("the folder, such as INBOX"),
|
||||
"uid": integer("the message's uid in that folder"),
|
||||
}),
|
||||
Run: b.openTool},
|
||||
{Name: StatusTool,
|
||||
Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " +
|
||||
"seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.statusTool},
|
||||
{Name: HealthTool,
|
||||
Description: "How mesh-mail-web is: healthy once its own settings can be read, unhealthy naming why " +
|
||||
"otherwise. Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.healthTool},
|
||||
}
|
||||
}
|
||||
|
||||
// statusTool is issue 1262/AC2, AC3: mail-store's own accounts answer, read as the granted accounts; an ask
|
||||
// mail-store does not answer, or refuses, answers "it does not serve" and why, never a crash. Finding 4 of
|
||||
// review round 1: it also says so whenever the HTTP endpoint itself is not actually listening (still setting
|
||||
// up sign-in, or ended on an error) — serving stdio's own seat verbs is not the same as serving mail-webclient
|
||||
// whole.
|
||||
func (b *Bundle) statusTool(map[string]any) (any, error) {
|
||||
if phase, httpErr := b.httpPhase(); phase != HTTPListening {
|
||||
return map[string]any{"serves": false, "why": httpStatusWhy(phase, httpErr)}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store does not answer: %v", err)}, nil
|
||||
}
|
||||
var answer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store's answer could not be read: %v", err)}, nil
|
||||
}
|
||||
names := make([]string, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
names = append(names, a.Account)
|
||||
}
|
||||
return map[string]any{"serves": true, "accounts": names}, nil
|
||||
}
|
||||
|
||||
// healthTool: finding 4 of review round 1 — unhealthy, naming the HTTP endpoint's own phase, whenever it is
|
||||
// not actually listening, beside its existing check that the operators setting can still be read.
|
||||
func (b *Bundle) healthTool(map[string]any) (any, error) {
|
||||
if phase, httpErr := b.httpPhase(); phase != HTTPListening {
|
||||
return map[string]any{"healthy": false, "why": httpStatusWhy(phase, httpErr)}, nil
|
||||
}
|
||||
if _, err := ReadOperators(b.Env.Operators); err != nil {
|
||||
return map[string]any{"healthy": false, "why": err.Error()}, nil
|
||||
}
|
||||
return map[string]any{"healthy": true}, nil
|
||||
}
|
||||
|
||||
// httpStatusWhy is the one sentence statusTool and healthTool alike say when the HTTP endpoint is not
|
||||
// HTTPListening: its own phase, and its error once it has one.
|
||||
func httpStatusWhy(phase string, err error) string {
|
||||
why := "its HTTP endpoint is " + phase
|
||||
if err != nil {
|
||||
why += fmt.Sprintf(": %v", err)
|
||||
}
|
||||
return why
|
||||
}
|
||||
|
||||
// ---- schema helpers (mirroring mesh-mail's tools.go) -----------------------------------------------------------
|
||||
|
||||
func schema(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1262/AC2: mail-store answers accounts a and b; status answers that it serves, and is granted a and b.
|
||||
func TestStatusAnswersItServesAndTheGrantedAccounts(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"accounts": []map[string]any{
|
||||
{"account": "a", "kind": "imap"}, {"account": "b", "kind": "imap"},
|
||||
}})
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != true {
|
||||
t.Fatalf("serves: %v", m["serves"])
|
||||
}
|
||||
got, _ := m["accounts"].([]string)
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("accounts: %v", m["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1262/AC3: mail-store does not answer (or refuses); status answers that it does not serve, and why, and does
|
||||
// not panic.
|
||||
func TestStatusAnswersItDoesNotServeWhenMailStoreRefuses(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return nil, errors.New("refused: mesh-mail-web is not granted any account")
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != false {
|
||||
t.Fatalf("serves: %v", m["serves"])
|
||||
}
|
||||
why, _ := m["why"].(string)
|
||||
if why == "" {
|
||||
t.Fatalf("no reason given: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Same AC3, the other half: mail-store answers something this module cannot read.
|
||||
func TestStatusAnswersItDoesNotServeWhenMailStoresAnswerCannotBeRead(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"accounts": "not a list"}`), nil
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != false || m["why"] == "" {
|
||||
t.Fatalf("%v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 4: mail-webclient.status and mail_web_health both say the HTTP endpoint's own state
|
||||
// (main.go's serveHTTP) rather than going on claiming to serve and to be healthy regardless — one test per
|
||||
// state (setting up sign-in, listening, ended).
|
||||
func TestStatusAndHealthSayTheHTTPEndpointsState(t *testing.T) {
|
||||
accountsOK := func(context.Context) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"accounts": []any{}})
|
||||
}
|
||||
operatorsOK := operatorsFile(t, "alice")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
phase string
|
||||
err error
|
||||
}{
|
||||
{"setting up sign-in", HTTPSettingUpSignIn, errors.New("the issuer could not be reached")},
|
||||
{"ended", HTTPEnded, errors.New("listen tcp :8080: bind: address already in use")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := NewHTTPStatus()
|
||||
h.set(c.phase, c.err)
|
||||
b := &Bundle{HTTP: h, Accounts: accountsOK, Env: Env{Operators: operatorsOK}}
|
||||
|
||||
statusOut, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sm := statusOut.(map[string]any)
|
||||
if sm["serves"] != false {
|
||||
t.Fatalf("status serves: %v, want false while %s", sm["serves"], c.phase)
|
||||
}
|
||||
if why, _ := sm["why"].(string); !strings.Contains(why, c.phase) {
|
||||
t.Fatalf("status why %q does not name the phase %q", why, c.phase)
|
||||
}
|
||||
|
||||
healthOut, err := b.healthTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hm := healthOut.(map[string]any)
|
||||
if hm["healthy"] != false {
|
||||
t.Fatalf("health healthy: %v, want false while %s", hm["healthy"], c.phase)
|
||||
}
|
||||
if why, _ := hm["why"].(string); !strings.Contains(why, c.phase) {
|
||||
t.Fatalf("health why %q does not name the phase %q", why, c.phase)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// HTTPListening: status and health go back to their own, ordinary checks (mail-store's own accounts
|
||||
// answer, the operators setting), neither blocked by the HTTP endpoint's own state any more.
|
||||
t.Run("listening", func(t *testing.T) {
|
||||
h := NewHTTPStatus()
|
||||
h.set(HTTPListening, nil)
|
||||
b := &Bundle{HTTP: h, Accounts: accountsOK, Env: Env{Operators: operatorsOK}}
|
||||
|
||||
statusOut, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sm := statusOut.(map[string]any); sm["serves"] != true {
|
||||
t.Fatalf("status serves: %v, want true once listening", sm["serves"])
|
||||
}
|
||||
|
||||
healthOut, err := b.healthTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hm := healthOut.(map[string]any); hm["healthy"] != true {
|
||||
t.Fatalf("health healthy: %v, want true once listening: %v", hm["healthy"], hm)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package web
|
||||
|
||||
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267): the form offers only the accounts
|
||||
// mail-store.accounts answers; its POST (behind issue 1284's own per-session token, wired in here as this
|
||||
// form's hidden field) calls mail-store.send, which raises the operator's Approve on Telegram and sends
|
||||
// nothing itself, then shows the send's state from mail-store.send-status — in words, refreshing itself every
|
||||
// 5 seconds with no script (a meta refresh) until a final state (sent, failed, refused, not proven, declined,
|
||||
// expired, unknown; novox/hq ADR 0436 §12, issue 1261).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendStatusRefreshEvery is how often the state page asks again while a send is still waiting (ADR 0440 §6).
|
||||
const SendStatusRefreshEvery = 5
|
||||
|
||||
var writeTemplate = template.Must(template.New("write").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Write mail</title></head><body>
|
||||
<h1>Write</h1>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<form method="post" action="/send">
|
||||
<select name="account">{{range .Accounts}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
<input type="text" name="to" placeholder="to">
|
||||
<input type="text" name="subject" placeholder="subject">
|
||||
<textarea name="body"></textarea>
|
||||
{{.TokenField}}
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type writeTemplateData struct {
|
||||
Accounts []string
|
||||
TokenField template.HTML
|
||||
Error string
|
||||
}
|
||||
|
||||
var sendStatusTemplate = template.Must(template.New("send-status").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Send</title>{{if .Refresh}}<meta http-equiv="refresh" content="{{.RefreshSeconds}}; url={{.RefreshURL}}">{{end}}</head>
|
||||
<body>
|
||||
{{if .Note}}<p>{{.Note}}</p>{{end}}
|
||||
<p>{{.Words}}</p>
|
||||
</body></html>`))
|
||||
|
||||
type sendStatusData struct {
|
||||
Note, Words, RefreshURL string
|
||||
RefreshSeconds int
|
||||
Refresh bool
|
||||
}
|
||||
|
||||
// grantedAccountNames is mail-store.accounts' own answer, read as the list of account names it granted —
|
||||
// shared by the write page (issue 1267/AC1) and the send handler (AC4).
|
||||
func (b *Bundle) grantedAccountNames(ctx context.Context) ([]string, error) {
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answer accountsAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
names = append(names, a.Account)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func containsName(names []string, name string) bool {
|
||||
for _, n := range names {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitAddresses is the write form's own "to" field (one text input) split into mail-store.send's own array
|
||||
// argument, on whitespace or commas.
|
||||
func splitAddresses(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writePage is issue 1267/AC1: offers exactly the accounts mail-store.accounts answers, and (issue 1284) the
|
||||
// signed-in session's own form token as a hidden field, so the POST below can tell this session's own form
|
||||
// from any other's.
|
||||
func (b *Bundle) writePage(w http.ResponseWriter, r *http.Request) {
|
||||
data := writeTemplateData{}
|
||||
if sess, ok := SessionFrom(r); ok {
|
||||
data.TokenField = FormTokenInput(sess)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
names, err := b.grantedAccountNames(ctx)
|
||||
if err != nil {
|
||||
data.Error = "the granted accounts could not be answered"
|
||||
} else {
|
||||
data.Accounts = names
|
||||
}
|
||||
renderPage(w, writeTemplate, data)
|
||||
}
|
||||
|
||||
// sendHandler is issue 1267/AC2, AC4: called only once RequireFormToken (issue 1284) has already let the POST
|
||||
// through. An account mail-store did not answer sends nothing (AC4); otherwise mail-store.send is called with
|
||||
// exactly account, to, subject and body, and the answer is the send's own state page, noting that the
|
||||
// operator's Approve is asked on Telegram (AC2).
|
||||
func (b *Bundle) sendHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
account := r.PostFormValue("account")
|
||||
to := r.PostFormValue("to")
|
||||
subject := r.PostFormValue("subject")
|
||||
body := r.PostFormValue("body")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
granted, err := b.grantedAccountNames(ctx)
|
||||
if err != nil || !containsName(granted, account) {
|
||||
renderPage(w, sendStatusTemplate, sendStatusData{Words: "the account is not granted"})
|
||||
return
|
||||
}
|
||||
raw, err := b.sendAsk(ctx, map[string]any{
|
||||
"account": account, "to": splitAddresses(to), "subject": subject, "body": body,
|
||||
})
|
||||
if err != nil {
|
||||
renderPage(w, sendStatusTemplate, sendStatusData{Words: "the send could not be asked"})
|
||||
return
|
||||
}
|
||||
var answer struct {
|
||||
Ask string `json:"ask"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &answer)
|
||||
b.renderSendStatus(w, answer.Ask, "The operator's Approve is asked on Telegram.")
|
||||
}
|
||||
|
||||
// sendStatusPage is GET /send-status?id=...: the send's own state page, polled by its own meta refresh (issue
|
||||
// 1267/AC3) until a final state.
|
||||
func (b *Bundle) sendStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
b.renderSendStatus(w, r.URL.Query().Get("id"), "")
|
||||
}
|
||||
|
||||
// renderSendStatus is issue 1267/AC3: the send's state in words from mail-store.send-status; "waiting" (not
|
||||
// yet final) refreshes itself every SendStatusRefreshEvery seconds with a meta refresh, no script; every
|
||||
// other state (sent, failed, refused, not proven, declined, expired, unknown) is final and does not refresh.
|
||||
func (b *Bundle) renderSendStatus(w http.ResponseWriter, id, note string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
data := sendStatusData{Note: note}
|
||||
raw, err := b.sendStatusAsk(ctx, map[string]any{"id": id})
|
||||
if err != nil {
|
||||
data.Words = "the send's state could not be answered"
|
||||
renderPage(w, sendStatusTemplate, data)
|
||||
return
|
||||
}
|
||||
var answer struct {
|
||||
Status string `json:"status"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &answer)
|
||||
data.Words = answer.Status
|
||||
if answer.Words != "" {
|
||||
data.Words += ": " + answer.Words
|
||||
}
|
||||
if answer.Status == "waiting" {
|
||||
data.Refresh = true
|
||||
data.RefreshSeconds = SendStatusRefreshEvery
|
||||
data.RefreshURL = "/send-status?id=" + url.QueryEscape(id)
|
||||
}
|
||||
renderPage(w, sendStatusTemplate, data)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package web
|
||||
|
||||
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267), and (beside 1267's own) issue 1284's
|
||||
// form token wired into this real write form and its real POST.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1267/AC1: mail-store answers accounts a and b; the write page offers exactly a and b to send from.
|
||||
func TestWritePageOffersExactlyTheGrantedAccounts(t *testing.T) {
|
||||
raw, _ := accountsAnswerJSON(
|
||||
map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"},
|
||||
map[string]any{"account": "b", "kind": "imap", "address": "b@example.org", "state": "current"},
|
||||
)
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
b.writePage(w, httptest.NewRequest(http.MethodGet, "/write", nil))
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `value="a"`) || !strings.Contains(body, `value="b"`) {
|
||||
t.Fatalf("the form does not offer a and b:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `value="c"`) {
|
||||
t.Fatalf("the form offers an account not granted:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// writeBundle is a Bundle whose Accounts answers exactly the given names, each granted, for the write/send
|
||||
// tests below.
|
||||
func writeBundle(t *testing.T, names []string, send SeatAsk, sendStatus SeatAsk) *Bundle {
|
||||
t.Helper()
|
||||
accounts := make([]map[string]any, 0, len(names))
|
||||
for _, n := range names {
|
||||
accounts = append(accounts, map[string]any{"account": n, "kind": "imap", "address": n + "@example.org", "state": "current"})
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"accounts": accounts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
Send: send,
|
||||
SendStatus: sendStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// 1267/AC2: a POST with the session's token, from a, to, subject and body calls mail-store.send with exactly
|
||||
// account a, to, subject and body, and the page says the Approve is asked on Telegram and shows the send's
|
||||
// state.
|
||||
func TestSendHandlerCallsSendAndShowsTelegramNoteAndState(t *testing.T) {
|
||||
var gotArgs map[string]any
|
||||
b := writeBundle(t, []string{"a"},
|
||||
func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"ask": "send-1"})
|
||||
},
|
||||
func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"status": "waiting", "words": "the operator has not answered yet"})
|
||||
},
|
||||
)
|
||||
form := url.Values{"account": {"a"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"the body"}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
b.sendHandler(w, r)
|
||||
|
||||
wantArgs := map[string]any{"account": "a", "to": []string{"ops@example.org"}, "subject": "hi", "body": "the body"}
|
||||
gotTo, _ := gotArgs["to"].([]string)
|
||||
if gotArgs["account"] != wantArgs["account"] || gotArgs["subject"] != wantArgs["subject"] || gotArgs["body"] != wantArgs["body"] ||
|
||||
len(gotTo) != 1 || gotTo[0] != "ops@example.org" {
|
||||
t.Fatalf("mail-store.send called with %#v", gotArgs)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Telegram") {
|
||||
t.Fatalf("the page does not say the Approve is asked on Telegram:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "waiting") {
|
||||
t.Fatalf("the page does not show the send's state:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1267/AC3: send-status answers a non-final state (waiting): the page says it and refreshes itself without
|
||||
// script; each final state (sent, failed, refused, not proven, declined, expired, unknown) says it and stops
|
||||
// refreshing.
|
||||
func TestSendStatusPageRefreshesOnlyWhileWaiting(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
wantsRefresh bool
|
||||
}{
|
||||
{"waiting", true},
|
||||
{"sent", false}, {"failed", false}, {"refused", false}, {"not proven", false},
|
||||
{"declined", false}, {"expired", false}, {"unknown", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.status, func(t *testing.T) {
|
||||
b := &Bundle{SendStatus: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"status": c.status})
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
b.sendStatusPage(w, httptest.NewRequest(http.MethodGet, "/send-status?id=send-1", nil))
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, c.status) {
|
||||
t.Fatalf("%s: the page does not say the state:\n%s", c.status, body)
|
||||
}
|
||||
hasRefresh := strings.Contains(body, "http-equiv=\"refresh\"")
|
||||
if hasRefresh != c.wantsRefresh {
|
||||
t.Fatalf("%s: refreshes=%v, want %v:\n%s", c.status, hasRefresh, c.wantsRefresh, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1267/AC4: a POST naming an account mail-store did not answer sends nothing, and the page says the account
|
||||
// is not granted.
|
||||
func TestSendHandlerRefusesAnAccountNotGranted(t *testing.T) {
|
||||
sendCalled := false
|
||||
b := writeBundle(t, []string{"a"},
|
||||
func(context.Context, map[string]any) (json.RawMessage, error) { sendCalled = true; return nil, nil },
|
||||
nil,
|
||||
)
|
||||
form := url.Values{"account": {"c"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"body"}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
b.sendHandler(w, r)
|
||||
|
||||
if sendCalled {
|
||||
t.Fatal("mail-store.send was called for an account not granted")
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not granted") {
|
||||
t.Fatalf("the page does not say the account is not granted:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- issue 1284/AC1-AC4, against the real write page and its real POST /send (beside 1267's own tests
|
||||
// above, and beside csrf_test.go's stand-in handler tests, as the brief asked) --------------------------------
|
||||
|
||||
// writeServerBundle wires the real write page and send handler behind a real Auth (RequireSession,
|
||||
// RequireFormToken), exactly as server.go's NewServer does, so these tests exercise the production chain, not
|
||||
// a stand-in.
|
||||
func writeServerBundle(t *testing.T, names []string, send SeatAsk) (*Bundle, *Sessions, *Auth) {
|
||||
t.Helper()
|
||||
b := writeBundle(t, names, send, nil)
|
||||
sessions := NewSessions(nil)
|
||||
auth := &Auth{Sessions: sessions}
|
||||
return b, sessions, auth
|
||||
}
|
||||
|
||||
// 1284/AC1: a signed-in session's write page carries that session's own token.
|
||||
func TestRealWritePageFormCarriesTheSessionsToken(t *testing.T) {
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
handler := auth.RequireSession(b.writePage)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/write", nil)
|
||||
r.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
|
||||
if !strings.Contains(w.Body.String(), sess.Token) {
|
||||
t.Fatalf("the real write page's form does not carry the session's token:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC2: a real POST /send without the token is answered 403, and mail-store.send is not called.
|
||||
func TestRealSendPOSTWithoutTheTokenIsRefused(t *testing.T) {
|
||||
var sendCalled bool
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
sendCalled = true
|
||||
return nil, errors.New("should not be called")
|
||||
})
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
handler := auth.RequireSession(RequireFormToken(b.sendHandler))
|
||||
|
||||
form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if sendCalled {
|
||||
t.Fatal("mail-store.send was called with no token")
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC3: a real POST /send carrying another session's token is answered 403, and mail-store.send is not
|
||||
// called.
|
||||
func TestRealSendPOSTWithAnotherSessionsTokenIsRefused(t *testing.T) {
|
||||
var sendCalled bool
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
sendCalled = true
|
||||
return nil, errors.New("should not be called")
|
||||
})
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
other, _ := sessionAndCookie(t, sessions, "bob")
|
||||
handler := auth.RequireSession(RequireFormToken(b.sendHandler))
|
||||
|
||||
form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}, FormTokenField: {other.Token}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if sendCalled {
|
||||
t.Fatal("mail-store.send was called with another session's token")
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC4: a real POST /send carrying its own session's token proceeds — mail-store.send is called.
|
||||
func TestRealSendPOSTWithItsOwnSessionsTokenProceeds(t *testing.T) {
|
||||
var sendCalled bool
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
sendCalled = true
|
||||
return json.Marshal(map[string]any{"ask": "send-1"})
|
||||
})
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
handler := auth.RequireSession(RequireFormToken(b.sendHandler))
|
||||
|
||||
form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}, FormTokenField: {sess.Token}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
handler(w, r)
|
||||
|
||||
if !sendCalled {
|
||||
t.Fatal("mail-store.send was not called despite the matching token")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user