Files
selfpost/internal/web/view/outbound_log_test.go
T
mixandClaude Opus 5.5 0cceb549cc
test / test (push) Canceled after 0s
sender check: a Sender: line is held to the rule From: is held to
The owner's decision (2026-10-16) on the security review's F1: a Sender:
line - the sending agent's, shown beside From: as "on behalf of", and
signed since 2.8.0 - was neither held to the login nor, before, signed: an
application could write its own From: and Sender: ceo@bank.com under this
server's signature. Now, when a message has one, it must be one address
the application may send as, and at most one such line (RFC 5322 § 3.6);
From: is judged first.

The refusal is "550 5.7.1 Sender: <address> - <why>", a send-log row of
reason "sender" (store.ReasonSender), tagged "wrong Sender"; the message
page names the line. The log tailer recovers such a row from cleanup's line
as it does a From: refusal (fromRefusalRe reads either). Help text as in
the design commit d9d9af3; guide, architecture, security and CHANGELOG say
it, security.md keeps Resent-From: (signed, not judged) as an accepted
risk.

Tests: TestSenderLineHeldToTheSameRule (own address, a name, another
application's, a stranger's, two addresses, two lines, From: first), the
recovery of a Sender: refusal line, the tag, the message page; e2e
sender_check_holds_the_sender_line (own Sender: passes and is signed,
another domain's refused at DATA and logged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:41:42 +03:00

230 lines
14 KiB
Go

package view
import (
"strings"
"testing"
"time"
)
// ---- The log
// The log is the whole of what the journal holds about the viewer's mail: the
// filters offer what the viewer reaches, the head says how long rows live and
// where to change it, and every row leads to its own page.
func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
out := renderSignedIn(t, "out-log", outLogFixture())
pageHas(t, "Outbound log", out,
`<h1 class="title is-3">Outbound log</h1>`,
`Rows older than 30 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
`<form class="sp-end" method="get" action="/outbound/log">`,
`<select name="domain" aria-label="Domain"><option value="" selected>All domains</option><option value="example.org">example.org</option>`,
`<select name="app" aria-label="Application"><option value="" selected>All applications</option>`,
`<th>Time, UTC</th>`, `09-21 14:01:52`,
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-danger is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
`href="/outbound/log/184220">Details</a>`,
`Page 1 of 412`, `<a href="/outbound/log?p=2">Older →</a>`, `New rows appear on their own`,
)
pageLacks(t, "Outbound log", out, `← Newer`)
// Without the global role there is nowhere to change the retention.
p := NewOutLog(Meta{User: "ops", HasOutbound: true}, 7, false, []string{"example.org"}, nil, OutLogFilter{})
out = renderSignedIn(t, "out-log", p.WithRows(nil, 1, 1))
pageHas(t, "Outbound log", out, `Rows older than 7 days are deleted.`)
pageLacks(t, "Outbound log", out, `/server/settings`)
pageLacks(t, "Outbound log", renderSignedIn(t, "out-log", NewOutLog(Meta{User: "ops", HasOutbound: true}, 1, false, nil, nil, OutLogFilter{}).WithRows(nil, 1, 1)),
`older than 1 days`)
}
// The filter in force is the one selected, and the poll, the paging and the
// links of the rows all carry it — and say nothing for a filter that is not set.
func TestOutLogCarriesItsFilters(t *testing.T) {
p := NewOutLog(admin(), 30, true, []string{"example.org", "shop.example.org"}, []string{"alerts"}, OutLogFilter{Domain: "shop.example.org", App: "alerts", Status: "rejected", From: "2026-09-14", To: "2026-09-21"})
p.WithRows([]OutLogRow{{Time: "09-21 14:01:52", From: "a@example.org", To: "b@example.org", Subject: "Hi",
Status: Tag{Status: "sent"}, Href: p.DetailHref(7, 3)}}, 3, 5)
out := renderSignedIn(t, "out-log", p)
// The keys in order (url.Values.Encode sorts them), the page among them.
q := func(page string) string {
return `app=alerts&amp;domain=shop.example.org&amp;from=2026-09-14&amp;p=` + page + `&amp;status=rejected&amp;to=2026-09-21`
}
pageHas(t, "Outbound log", out,
`<option value="shop.example.org" selected>shop.example.org</option>`, `<option value="alerts" selected>alerts</option>`,
`<option value="rejected" selected>rejected</option>`, `<option value="queued">queued</option>`,
`name="from" value="2026-09-14"`, `name="to" value="2026-09-21"`,
`hx-get="/outbound/log/fragment?`+q("3")+`"`,
`href="/outbound/log/7?`+q("3")+`"`,
`Page 3 of 5 · <a href="/outbound/log?`+q("2")+`">← Newer</a>`,
`<a href="/outbound/log?`+q("4")+`">Older →</a>`,
)
// The status list is the statuses of a row, rejected the three refusals
// together; a reason is not a status (owner, 2026-10-13).
pageLacks(t, "Outbound log", out, `<option value="rate-limited"`)
if got := LogHref(OutLogFilter{}, 1); got != "/outbound/log" {
t.Errorf("the first unfiltered page is %q", got)
}
if got := LogHref(OutLogFilter{Domain: "a b&c"}, 2); got != "/outbound/log?domain=a+b%26c&p=2" {
t.Errorf("a filter is not encoded: %q", got)
}
}
// The last page has nothing older, and a page with no rows says so and still
// polls: a log that is empty now is the one that fills while the page is open.
func TestOutLogLastPageAndEmptyPage(t *testing.T) {
p := NewOutLog(admin(), 30, true, nil, nil, OutLogFilter{})
out := renderSignedIn(t, "out-log", p.WithRows([]OutLogRow{{Subject: "x", Status: Tag{Status: "sent"}, Href: "/outbound/log/1"}}, 2, 2))
pageHas(t, "Outbound log", out, `Page 2 of 2`, `← Newer</a>`)
pageLacks(t, "Outbound log", out, `Older →`)
out = renderSignedIn(t, "out-log", NewOutLog(admin(), 30, true, nil, nil, OutLogFilter{}).WithRows(nil, 1, 1))
pageHas(t, "Outbound log", out, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `id="out-log-foot"`, `New rows appear on their own`)
pageLacks(t, "Outbound log", out, `<table`, `Page 1`)
}
// The fragment is the table and, marked to be swapped in by its id, the foot;
// the page's own foot carries the id and no mark.
func TestOutLogFragmentMarksOnlyTheFoot(t *testing.T) {
page := renderSignedIn(t, "out-log", outLogFixture())
frag := renderFragment(t, "out_log_rows", outLogFixture().Fragment())
pageHas(t, "fragment", frag, `<div class="table-container" id="out-log-rows" data-poll`, `<div class="sp-box-foot" id="out-log-foot" hx-swap-oob="true">`)
pageLacks(t, "fragment", frag, `<form`, `<select`, `sp-head`)
pageLacks(t, "page", page, `hx-swap-oob`)
pageHas(t, "page", page, `<div class="sp-box-foot" id="out-log-foot">`)
if strings.Count(frag, `<table`) != 1 {
t.Errorf("the fragment holds %d tables", strings.Count(frag, `<table`))
}
}
func TestOutLogEscapesWhatSendersWrote(t *testing.T) {
p := NewOutLog(admin(), 30, true, []string{`<b>d</b>`}, []string{`"><i>`}, OutLogFilter{})
p.WithRows([]OutLogRow{{Time: "t", From: `"><img src=x>@a`, To: `<u>@b`, Subject: `<script>alert(1)</script>`,
Status: Tag{Status: `<i>`}, Href: "/outbound/log/1"}}, 1, 1)
out := renderSignedIn(t, "out-log", p)
pageLacks(t, "Outbound log", out, `<script>alert`, `<img src=x>`, `<b>d</b>`, `<u>@b`, `<i>`)
pageHas(t, "Outbound log", out, `&lt;script&gt;alert(1)&lt;/script&gt;`)
}
// ---- One message
func TestOutMessageShowsWhatTheJournalRecorded(t *testing.T) {
out := renderSignedIn(t, "out-message", outMessageFixture())
pageHas(t, "Message", out,
`<h1 class="title is-4">[FIRING] disk usage above 90 % on db-2</h1>`,
`<a href="/outbound/log">Outbound log</a> /`,
`<p class="sp-route"><span>alerts@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>noc@example.org</span><span class="tag is-warning is-light">deferred</span></p>`,
`<a href="/outbound/domains/1">example.org</a>`, `<dd class="sp-mono"><a href="/outbound/domains/1/applications/2">alerts</a></dd>`,
`2026-09-21 13:58:07 UTC`, `2026-09-21 13:58:09 UTC`, `<dd class="sp-mono">4XcB7k2Jm9z1</dd>`, `<dd class="sp-mono">184220</dd>`,
`<li><time>2026-09-21 13:58:07 UTC</time><strong>Accepted and queued</strong>`,
`<li class="sp-warn"><time>2026-09-21 13:58:09 UTC</time><strong>Deferred, will be retried</strong>`,
`<li class="sp-pending"><time>not yet</time>`,
`Lines from <code>mail.log</code> with this queue id`, `<pre class="sp-log">`, `<span class="sp-d">13:58:07</span> postfix/smtpd[2114]`,
`<span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;`,
)
}
// A message without a subject is named for it, and a row with nothing to show
// for a field shows a dash. A refused row has no queue id and says so; one
// from before the reason was recorded is plainly "rejected".
func TestOutMessageWithoutSubjectOrRecipient(t *testing.T) {
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ID: 9, Domain: "gone.example", App: "old", Status: "rejected", Accepted: at, Reported: at,
BackHref: "/outbound/log?domain=gone.example&p=2"}).
WithDeliveryLog(nil, "This message never reached the queue, so Postfix wrote no delivery lines for it.")
out := renderSignedIn(t, "out-message", p)
pageHas(t, "Message", out, `<h1 class="title is-4">(no subject)</h1>`, `<span>—</span><i class="ti ti-arrow-right sp-muted"></i><span>—</span>`,
`<span class="tag is-light">rejected</span>`, `href="/outbound/log?domain=gone.example&amp;p=2"`,
`<dd>gone.example</dd>`, `<dd class="sp-mono">old</dd>`, `<dt>Refused</dt>`,
`<dd>none - refused at <span class="sp-mono">MAIL FROM</span>, before one is given</dd>`,
`The line from <code>mail.log</code> for this refusal`, `never reached the queue`)
pageLacks(t, "Message", out, `<pre`, `<a href="/outbound/domains/`, `Status reported`, `From: line`)
}
// A row refused for its From: line says so in its tag, shows what the line
// said, and its History links to the topic that explains the rule. The
// message had a queue id - cleanup gives one before the headers are judged -
// and its box shows Postfix's lines by it, the refusal red.
func TestOutMessageRefusedForItsFromLine(t *testing.T) {
at := time.Date(2026, 9, 21, 13, 40, 55, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ID: 184231, QueueID: "7BCAF3C75DA0", Domain: "shop.example.org", App: "shop",
From: "shop@shop.example.org", To: "k.berg@web.de", Subject: "A message from the director",
Status: "rejected", Reason: "from", HeaderFrom: "director@example.org", Accepted: at, Reported: at}).
WithDeliveryLog([]LogLine{{Time: "13:40:55", Text: "postfix/cleanup[2120]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE from unknown[198.51.100.7]: 5.7.1 From: director@example.org - not an address this application sends as",
Level: LogLineLevel("postfix/cleanup[2120]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE from unknown[198.51.100.7]: 5.7.1 From: director@example.org - not an address this application sends as")}}, "")
out := renderSignedIn(t, "out-message", p)
pageHas(t, "Message", out, `<span class="tag is-danger is-light">wrong From</span>`,
`<dt>From: line</dt><dd class="sp-mono sp-small">director@example.org</dd>`, `<dt>Refused</dt>`,
`<span class="sp-mono">7BCAF3C75DA0</span> - given, the message was not queued`, `href="/help#sender-check"`,
`Lines from <code>mail.log</code> with this queue id`, `<span class="sp-e">postfix/cleanup[2120]: 7BCAF3C75DA0: milter-reject`)
}
// A refused row the log tailer wrote from mail.log says so.
func TestOutMessageRecoveredFromTheLog(t *testing.T) {
at := time.Date(2026, 9, 21, 13, 40, 55, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ID: 184232, QueueID: "E543D3C75DA0", Domain: "shop.example.org", App: "shop",
From: "shop@shop.example.org", To: "k.berg@web.de", Status: "rejected", Reason: "from", HeaderFrom: "director@example.org",
Recovered: true, Accepted: at, Reported: at}).WithDeliveryLog(nil, "")
out := renderSignedIn(t, "out-message", p)
pageHas(t, "Message", out, `<dt>Written</dt>`, `after a restart: the panel stopped before it wrote the refusal`)
}
func TestSendLogTag(t *testing.T) {
for _, c := range []struct {
status, reason string
want Tag
}{
{"sent", "", Tag{Status: "sent"}},
{"rejected", "", Tag{Status: "rejected"}},
{"rejected", "limit", Tag{Status: "rate-limited"}},
{"rejected", "from", Tag{Status: "reject", Label: "wrong From"}},
{"rejected", "sender", Tag{Status: "reject", Label: "wrong Sender"}},
{"rejected", "client", Tag{Status: "reject", Label: "wrong client"}},
} {
if got := SendLogTag(c.status, c.reason); got != c.want {
t.Errorf("SendLogTag(%q, %q) = %+v, want %+v", c.status, c.reason, got, c.want)
}
}
}
func TestOutMessageEscapesWhatSendersWrote(t *testing.T) {
at := time.Now()
p := NewOutMessage(admin(), MessageInput{ID: 1, QueueID: `"><i>`, Domain: `<b>d</b>`, App: `<u>a</u>`, From: `<s>f</s>`, To: `"><img src=x>`,
Subject: `<script>alert(1)</script>`, Status: "sent", Accepted: at, Reported: at, BackHref: "/outbound/log"}).
WithHistory([]Step{{Time: "t", Strong: `<b>s</b>`, Text: Plain(`<i>x</i>`)}}).
WithDeliveryLog([]LogLine{{Time: `<u>`, Text: `<script>y</script>`}}, "")
out := renderSignedIn(t, "out-message", p)
pageLacks(t, "Message", out, `<script>alert`, `<script>y`, `<b>d</b>`, `<u>a</u>`, `<s>f</s>`, `<img src=x>`, `<b>s</b>`, `<i>x</i>`, `<u>`)
}
func TestStepLevelAndLogLineLevel(t *testing.T) {
for _, c := range []struct {
outcome string
happened bool
want string
}{
{"ok", true, ""}, {"", true, ""}, {"warn", true, LevelWarn}, {"error", true, LevelFail},
{"", false, LevelPending}, {"error", false, LevelPending},
} {
if got := StepLevel(c.outcome, c.happened); got != c.want {
t.Errorf("StepLevel(%q, %v) = %q, want %q", c.outcome, c.happened, got, c.want)
}
}
for text, want := range map[string]string{
"postfix/smtp[1]: Q1: to=<a@b>, status=sent (250 OK)": "",
"postfix/smtp[1]: Q1: to=<a@b>, status=deferred (try later)": "warn",
"postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error",
"postfix/qmgr[1]: Q1: status=expired, returned to sender": "error",
"postfix/smtp[1]: warning: TLS library problem": "warn",
"postfix/qmgr[1]: Q1: from=<a@b>, size=1, nrcpt=1 (queue active)": "",
"postfix/cleanup[1]: Q1: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: x - no": "error",
"postfix/smtpd[1]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable": "warn",
"postfix/smtpd[1]: NOQUEUE: reject: RCPT from unknown[2001:db8::1]: 554 5.7.1 <a@b>: Relay access denied": "error",
} {
if got := LogLineLevel(text); got != want {
t.Errorf("LogLineLevel(%q) = %q, want %q", text, got, want)
}
}
if got := StepTime(time.Time{}); got != "not yet" {
t.Errorf("a step that has not happened is at %q", got)
}
}