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.
142 lines
6.6 KiB
Go
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", "<script>alert(1)</script>", "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)
|
|
}
|
|
}
|