Files
selfpost/internal/web/handlers/handlers_dmarc_pages_test.go
T
mixandClaude Opus 5.5 2f1ec65b30 panel-redesign: Outbound log, message and the DMARC report pages on the kit
Stage 2, step 4. Written by Sonnet, reviewed by Opus.

  - Five pages named after their mockups: out-log, out-message, dmarc,
    dmarc-domain, dmarc-report, with typed page data
    (view/outbound_log.go, dmarc.go), fixtures and tests. The engine
    pages deliveries and delivery are gone with their templates; the
    three DMARC templates are written anew. All five old names are off
    legacy_pages.txt.
  - The log polls its table (GET /outbound/log/fragment, as before); the
    page count in the box foot follows out of band, and the filter in
    the head is no longer swapped away while it is being chosen.
  - Handlers pass typed data; routes, query parameters, filters, paging
    and authorization are unchanged.
  - Kit: Foot.ID / Foot.OOB (attributes only, as on Head) and Fact.Small,
    the mockup's "sp-mono sp-small" value, for a report id and an rua
    that wrapped mid-word in their column.
  - test/e2e status scrapers match the new tag markup. Not run: no
    Docker here.

Fixed on the way
  - The DMARC domain page read a field its source rows do not have and
    answered 500 as soon as the domain had a source. It builds the rows
    from the store's rollup now, with a test.

Differences from the mockups, for the owner:
  - the log has no Status filter (the store filters by domain and
    application only); a refused message is "rejected", a neutral tag as
    the mockup's "rate-limited" is;
  - the message history keeps the panel's own wording with full dates and
    has no "accepted from <IP>" step - the client address is not stored;
  - dmarc: before the first report the box says "None yet" with what to
    check; a quiet mailbox is an amber "quiet" tag;
  - dmarc-domain: the stamp is pass / (pass + fail) rounded down, amber
    on any failure, red when nothing passed, absent without mail;
  - dmarc-report: the title has no year; "this relay" marks record rows
    as well as sources.

Known and left as it is (not introduced here): the DMARC hub answers 404
to a domain administrator while the menu shows the entry and the plan
gives that role the reports of its own domains. Opening it needs the
report list to refuse an empty domain filter first - for the stage-3
security review.

Evidence
  - docs/assets/panel-redesign/evidence/out-log.png, out-message.png,
    dmarc.png, dmarc-domain.png, dmarc-report.png: 1440 px wide, headless
    Edge, real templates served locally with their fixtures - NOT from
    the container (no Docker on this machine).
  - panel/audit.js on the five pages at 1440 and 1280: 784 texts checked,
    problems: [].
  - Polling of the log was exercised in a browser against a local
    fixture server with document.hidden overridden: four polls, one
    table and one foot after each, the filter untouched.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:21:06 +03:00

205 lines
9.7 KiB
Go

package handlers
import (
"net/http"
"testing"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
)
// The DMARC pages, rendered from the real stores and the handlers. Their markup
// is held to the mockups by the guards in internal/web/view; what is checked
// here is that the handlers fill the typed pages from what is stored and that
// each page reaches only what its viewer may see.
// dmarcStand is a panel with report ingest on and one report stored for the
// stand's domain: a relay that passed and a source that failed both checks.
type dmarcStand struct {
appStand
reportID int64
}
func newDMARCStand(t *testing.T) dmarcStand {
t.Helper()
s := dmarcStand{appStand: newAppStand(t)}
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
received := time.Now().UTC().Add(-2 * time.Hour)
id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: "example.org", Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3",
PeriodBegin: received.Add(-30 * time.Hour), PeriodEnd: received.Add(-6 * time.Hour), ReceivedAt: received,
ContactEmail: "noreply-dmarc@outlook.example", PolicyP: "none", PolicySP: "none", PolicyPct: 100,
PolicyADKIM: "r", PolicyASPF: "r", PassCount: 1903, FailCount: 7, Recipient: "dmarc-reports@mail.example.org",
Records: []store.DMARCReportRecord{
{SourceIP: "203.0.113.25", Count: 1903, Disposition: "none", SPFResult: "pass", DKIMResult: "pass", HeaderFrom: "example.org"},
{SourceIP: "198.51.100.44", Count: 7, Disposition: "none", SPFResult: "fail", DKIMResult: "fail", HeaderFrom: "example.org"},
},
})
if err != nil {
t.Fatalf("InsertDMARCReport: %v", err)
}
s.reportID = id
return s
}
func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) {
s := newDMARCStand(t)
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
has(t, "hub", body,
`<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
`1 kept · 0 parse failures`, `dmarc-reports@mail.example.org`, `500 reports or 90 days`,
`<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`,
"<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
lacks(t, "hub", body, `None yet`)
}
// Before the first report the page says how to get one rather than showing an
// empty table next to a healthy-looking stamp.
func TestDMARCHubBeforeTheFirstReport(t *testing.T) {
s := newAppStand(t)
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
has(t, "hub", body, `<span class="tag is-warning is-light">quiet</span>`, `None yet`, `<code>rua=</code>`,
`publish MX for <span class="sp-mono">mail.example.org</span>`, `No reports yet.`)
lacks(t, "hub", body, `<table`)
}
// A report names its reporter and its domain in its own words, and what a
// remote sender chose to write is text on the page, never markup.
func TestDMARCPagesEscapeWhatReportersWrote(t *testing.T) {
s := newDMARCStand(t)
id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: "example.org", Reporter: `<script>alert(1)</script>`, ReportID: `"><img src=x>`,
PeriodBegin: time.Now().Add(-48 * time.Hour), PeriodEnd: time.Now().Add(-24 * time.Hour), ReceivedAt: time.Now(),
ContactEmail: `"><b>x</b>`, PolicyP: `<i>none</i>`, PassCount: 1, FailCount: 1,
Records: []store.DMARCReportRecord{{SourceIP: `<u>1.2.3.4</u>`, Count: 2, Disposition: `<s>none</s>`, SPFResult: `<em>x`, DKIMResult: "fail", HeaderFrom: `<p>`}},
})
if err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"hub": getBody(t, s.h.HandleDMARCList, "/outbound/dmarc"),
"domain": pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1"),
"report": reportPage(t, s, id),
} {
lacks(t, name, body, `<script>alert`, `<img src=x>`, `<b>x</b>`, `<i>none</i>`, `<u>1.2.3.4`, `<s>none`, `<em>x`)
}
has(t, "report", reportPage(t, s, id), `&lt;script&gt;alert(1)&lt;/script&gt;`, `&lt;u&gt;1.2.3.4&lt;/u&gt;`)
}
func reportPage(t *testing.T, s dmarcStand, id int64) string {
t.Helper()
rec := send(s.h.HandleDMARCReport, &globalPrincipal, "GET", "/outbound/dmarc/reports/"+idStr(id), map[string]string{"id": idStr(id)}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("report %d = %d:\n%s", id, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// The page of a domain with sources: the table of where its mail came from, the
// stamp that says how much of it passed and the advice that follows. A source
// that is this server is told apart from one that is not.
func TestDMARCDomainShowsSourcesAndWhatToDoAboutThem(t *testing.T) {
s := newDMARCStand(t)
s.h.dns = offlineDNS
body := pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1")
has(t, "domain", body,
`<h1 class="title is-3">example.org</h1>`, `<a href="/outbound/dmarc">DMARC reports</a> /`,
`class="sp-postmark sp-warn"`, `<b>99.6%</b>`, `PASS · 7 DAYS`,
"1\u00a0903 pass · 7 fail in the last 7 days. A third-party source is not aligned. Do not tighten <code>p=</code>",
`href="/outbound/domains/1"`, `<h2>Sources · 7 days</h2>`, `198.51.100.44`, `203.0.113.25`, `<h2>Reports</h2>`,
`href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
// 198.51.100.44 passed nothing, so its disposition is amber.
has(t, "domain", body, `<td>0</td><td>7</td><td><span class="tag is-warning is-light">none</span></td>`)
lacks(t, "domain", body, `this relay`)
}
func TestDMARCDomainWithoutReportsSaysSo(t *testing.T) {
s := newAppStand(t)
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
body := pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1")
has(t, "domain", body, `No reports for this domain yet.`, `No mail from any source in this window.`,
`0 pass · 0 fail in the last 7 days. No messages in the reporting window yet.`)
lacks(t, "domain", body, `sp-postmark`, `<table`)
}
func TestDMARCReportShowsPolicyAndRecords(t *testing.T) {
s := newDMARCStand(t)
body := reportPage(t, s, s.reportID)
has(t, "report", body,
`<h1 class="title is-4">Outlook.com · `, `<a href="/outbound/dmarc">DMARC reports</a> /`, `<a href="/outbound/dmarc/domains/1">example.org</a> /`,
"<span class=\"tag is-success is-light\">1\u00a0903 pass</span>", `<span class="tag is-warning is-light">7 fail</span>`,
`5f1c9a0e7b2d4e61a3`, `noreply-dmarc@outlook.example`, ` UTC</dd>`,
`none / none / 100`, `<dd class="sp-mono">r / r</dd>`, `dmarc-reports@mail.example.org`,
`<h2>Records</h2>`, `Parsed from the aggregate XML`, `203.0.113.25`, `198.51.100.44`,
`<span class="tag is-danger is-light">fail</span>`, `<span class="tag is-success is-light">pass</span>`)
}
// DMARC is a feature of the deployment: with ingest off none of its pages
// exists, whoever asks.
func TestDMARCPagesAreAbsentWithIngestOff(t *testing.T) {
s := newDMARCStand(t)
s.h.cfg.DMARCEnabled = false
for name, h := range map[string]http.HandlerFunc{
"hub": s.h.HandleDMARCList, "domain": s.h.HandleDMARCDomain, "report": s.h.HandleDMARCReport,
} {
rec := send(h, &globalPrincipal, "GET", "/outbound/dmarc", map[string]string{"id": "1"}, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("%s with ingest off = %d, want 404", name, rec.Code)
}
}
}
// A domain administrator reaches the reports of the domains assigned to them
// and nothing else: not the hub, not another tenant's domain page, not another
// tenant's report. A report that does not exist is the same 404 as one that is
// not theirs.
func TestDMARCPagesStayWithinTheViewersDomains(t *testing.T) {
s := newDMARCStand(t)
s.h.dns = offlineDNS
other, err := s.h.store.AddDomain("other.example.net", "mail")
if err != nil {
t.Fatal(err)
}
otherReport, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: other.Name, Reporter: "google.com", ReportID: "other", ReceivedAt: time.Now(),
PeriodBegin: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now(), PassCount: 9,
})
if err != nil {
t.Fatal(err)
}
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
// Their own domain and report are theirs; the way back to the hub is not
// offered, since the hub is not theirs.
own := send(s.h.HandleDMARCDomain, &ops, "GET", "/outbound/dmarc/domains/1", map[string]string{"id": "1"}, nil)
if own.Code != http.StatusOK {
t.Fatalf("own domain = %d", own.Code)
}
has(t, "domain for its administrator", own.Body.String(), `<h1 class="title is-3">example.org</h1>`)
lacks(t, "domain for its administrator", own.Body.String(), `<a href="/outbound/dmarc">`)
rep := send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/"+idStr(s.reportID), map[string]string{"id": idStr(s.reportID)}, nil)
if rep.Code != http.StatusOK {
t.Fatalf("own report = %d", rep.Code)
}
lacks(t, "report for its administrator", rep.Body.String(), `<a href="/outbound/dmarc">`)
for name, code := range map[string]int{
"hub": send(s.h.HandleDMARCList, &ops, "GET", "/outbound/dmarc", nil, nil).Code,
"another tenant's domain": send(s.h.HandleDMARCDomain, &ops, "GET", "/outbound/dmarc/domains/"+idStr(other.ID),
map[string]string{"id": idStr(other.ID)}, nil).Code,
"another tenant's report": send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/"+idStr(otherReport),
map[string]string{"id": idStr(otherReport)}, nil).Code,
"a report that does not exist": send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/999",
map[string]string{"id": "999"}, nil).Code,
} {
if code != http.StatusNotFound {
t.Errorf("%s for a domain administrator = %d, want 404", name, code)
}
}
}