Files
mesh-catalog/modules/mesh-mail-web/internal/web/readpane_test.go
T
jochen f554928ce1 issue 1859: modes by account, and the reading pane with its corrections; name a nameless sender (1859.4, 1850)
ADR 0515 §4, §5: the trusted setting modes names the accounts Work and Personal show, Everything shows every
granted account; /read shows one mail as escaped text with its tasks, promises and attachments as downloads,
records it opened, and offers its corrections (mark and group, not a task, not a promise, read later), each
calling correct. Issue 1850: a sender with no display name is named by its address, on the list, the pane and
All mail.
2026-10-22 20:09:29 +02:00

142 lines
6.6 KiB
Go

package web
// Issue 1859/AC6, AC7 (novox/hq ADR 0515 §5, §6; issue 1850): the reading pane shows one mail as escaped
// text, with its corrections, each calling correct; a sender with no display name is named by its address.
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
// paneEntry is mail-reading.entries' answer for one entry (mesh-mail-reader tools.go entryOut).
func paneEntry() map[string]any {
return map[string]any{
"id": 3, "account": "work", "message-id": "<h@harbor.example>", "date": "2026-10-21T08:00:00Z",
"sender-name": "", "sender-address": "lena@harbor.example", "subject": "Hostco renewal", "group": "suppliers",
"summary": "Hostco renews on 1 November", "mark": "matters", "why": "a contract to approve", "due-date": "",
"folder": "INBOX", "uid": 103, "read-later": false,
"tasks": []any{map[string]any{"n": 1, "line": "Approve the Hostco renewal", "due-date": "2026-10-24"}, map[string]any{"n": 2, "line": "Forward the invoice", "due-date": ""}},
"promises": []any{map[string]any{"n": 1, "kind": "on its way", "line": "The new contract", "who": "Hostco", "date": ""}},
}
}
func paneBundle(t *testing.T) (*readingStandIn, *Bundle) {
t.Helper()
reading := &readingStandIn{list: fullList(), entry: paneEntry()}
b := reading.bundle(time.Now())
b.Read = func(_ context.Context, args map[string]any) (json.RawMessage, error) {
reading.mu.Lock()
reading.calls = append(reading.calls, readingCall{"read", args})
reading.mu.Unlock()
return json.Marshal(map[string]any{"account": "work", "folder": "INBOX", "uid": 103, "date": "2026-10-21T08:00:00Z",
"from": []any{map[string]any{"address": "lena@harbor.example"}}, "to": []any{map[string]any{"address": "op@work.example"}},
"subject": "Hostco renewal", "text": "Hi,\n<script>alert(1)</script> the renewal is attached.",
"attachments": []any{map[string]any{"key": "att1", "name": "renewal.pdf", "size": 2048, "type": "application/pdf"}}})
}
return reading, b
}
// 1859/AC7: the pane shows one mail as escaped text with its corrections, and records it opened.
func TestTheReadingPaneShowsOneMailWithItsCorrections(t *testing.T) {
reading, b := paneBundle(t)
s, cookie, _ := signedIn(t, b)
r := newGet("/read?id=3&mode=work", cookie)
w := serve(s, r)
body := w.Body.String()
if w.Code != http.StatusOK {
t.Fatalf("status %d:\n%s", w.Code, body)
}
for _, want := range []string{`id="pane"`, "Hostco renewal", "&lt;script&gt;alert(1)&lt;/script&gt;", "renewal.pdf",
"Approve the Hostco renewal", `name="not-a-task" value="2"`, `name="remove-promise" value="1"`,
`name="read-later" value="on"`, `name="mark"`, `name="group"`, `id="today"`} {
if !strings.Contains(body, want) {
t.Fatalf("the pane does not show %q:\n%s", want, body)
}
}
if strings.Contains(body, "<script>alert") {
t.Fatal("the mail's text was not escaped")
}
if e := reading.callsOf("entries"); len(e) != 1 || e[0].Args["id"] != float64(3) {
t.Fatalf("entries asks %v, want the one entry by its id", e)
}
if rd := reading.callsOf("read"); len(rd) != 1 || rd[0].Args["uid"] != float64(103) || rd[0].Args["folder"] != "INBOX" {
t.Fatalf("mail-store.read asks %v, want the entry's own mail", rd)
}
if rec := reading.callsOf("record"); len(rec) != 1 || rec[0].Args["what"] != "opened" || rec[0].Args["id"] != float64(3) {
t.Fatalf("record asks %v, want one opened of mail 3", rec)
}
}
// 1859/AC7: each correction calls correct with what it says and the mode, then shows the pane again.
func TestEachCorrectionCallsCorrect(t *testing.T) {
for _, tc := range []struct {
form url.Values
want map[string]any
}{
{url.Values{"mark": {"neither"}, "group": {"suppliers"}}, map[string]any{"mark": "neither", "group": "suppliers"}},
{url.Values{"not-a-task": {"2"}}, map[string]any{"not-a-task": float64(2)}},
{url.Values{"remove-promise": {"1"}}, map[string]any{"remove-promise": float64(1)}},
{url.Values{"read-later": {"on"}}, map[string]any{"read-later": true}},
{url.Values{"read-later": {"off"}}, map[string]any{"read-later": false}},
} {
reading, b := paneBundle(t)
s, cookie, token := signedIn(t, b)
tc.form.Set("id", "3")
tc.form.Set("mode", "work")
tc.form.Set(FormTokenField, token)
code, _, h := post(t, s, "/correct", tc.form, cookie, false)
calls := reading.callsOf("correct")
if code != http.StatusSeeOther || h.Get("Location") != "/read?id=3&mode=work" || len(calls) != 1 {
t.Fatalf("correct %v: status %d to %q, calls %v", tc.form, code, h.Get("Location"), calls)
}
want := map[string]any{"id": float64(3), "mode": "work"}
for k, v := range tc.want {
want[k] = v
}
if len(calls[0].Args) != len(want) {
t.Fatalf("correct %v asked %v, want %v", tc.form, calls[0].Args, want)
}
for k, v := range want {
if calls[0].Args[k] != v {
t.Fatalf("correct %v asked %v, want %v", tc.form, calls[0].Args, want)
}
}
tc.form.Del(FormTokenField)
if code, _, _ := post(t, s, "/correct", tc.form, cookie, false); code != http.StatusForbidden {
t.Fatalf("a correction with no token: status %d, want 403", code)
}
}
}
// 1859/AC6, issue 1850: a sender with no display name is named by its address — on the list, in the pane, and
// on All mail's mail that matters.
func TestASenderWithNoNameIsNamedByItsAddress(t *testing.T) {
list := fullList()
list["today"] = []any{line(1, "task 1", "task", "Read the digest", "sender-name", "", "sender-address", "announce@lists.example")}
b := (&readingStandIn{list: list}).bundle(time.Now())
if _, body, _ := get(t, NewServer(testHost, nil, b), "/"); !strings.Contains(body, `<span class="sender">announce@lists.example</span>`) {
t.Fatalf("the list does not name the sender by its address:\n%s", body)
}
_, b2 := paneBundle(t)
if _, body, _ := get(t, NewServer(testHost, nil, b2), "/read?id=3"); !strings.Contains(body, "lena@harbor.example") {
t.Fatalf("the pane does not name the sender by its address:\n%s", body)
}
accountsRaw, _ := accountsAnswerJSON()
b3 := &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": []any{map[string]any{"id": 1, "sender-name": "",
"sender-address": "announce@lists.example", "summary": "the Arch digest", "mark": "matters"}}}),
MailGroups: seatAskJSON(t, map[string]any{"groups": []any{}}),
MailLessons: seatAskJSON(t, map[string]any{"lessons": []any{}}),
}
if _, body, _ := get(t, NewServer(testHost, nil, b3), "/all"); !strings.Contains(body, "announce@lists.example — the Arch digest") {
t.Fatalf("All mail's mail that matters does not name the sender by its address:\n%s", body)
}
}