The owner's "проверь, что документация актуальна с кодом" (2026-10-16); designc5edbd8andf1cc3f3settled which side moves. The panel follows its mockups: an inbound domain's MX box ends with "Checked N ago" (dnscheck.InboundMXCheckedAt, from the cache) and its side menu has See also › Log for this domain (the Inbound log, filtered); a deferred message's history ends with the open dot of the wait in the queue, the lifetime from this Postfix; Health's lead says six cards; Overview's head says it refreshes on its own; DMARC's hint for an unaligned third party is the mockup's sentence. Help, word for word with the mockup and true to the code: the application's password kept by the server (sasldb2) for an export, a From:/Sender: refusal one row of the Outbound log, every backup password check counted (AllowPasswordChange before the compare), a password set for another user ends all their sessions, the limit mail daily per sending domain. The Filter lists topic and its menu entry are no longer shown to a domain administrator: their page is 404 to one (requireInboundGlobal), and Help shows only what its viewer can open. guide.md: Health's readings in the order of the boxes, with Notification mail; the menu and role tables have the Inbound log and Filter lists; port 25's chain has the inbound journal; level 2 does not depend on the client address, the client list refuses a message without one; four kinds of refusal (wrong Sender); the log is filtered, not searched, and a message is traced by its row; a bounced row shows only the queue lifetime; TRUSTED_PROXY_CIDR serves sign-in and setup, the account-change limit is per user; notifications' first check and hourly ceiling. The Apache vhost's comment names mod_rewrite. Tests: TestHelpHasTheInboundLogAndFilterListsTopics (the topic per viewer), TestDeliveryPageDeferredUsesRetryPolicy (the wait), TestInDomainShowsTheMockupsBoxes (checked, the log link), the Overview head; the DMARC tests take the new hint. go vet + go test (26 packages) and make e2e on the test server for this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
12 KiB
Go
246 lines
12 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`, `Hosted addresses`, `dmarc-reports+<domain>@mail.example.org`, `500 reports a domain 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), `<script>alert(1)</script>`, `<u>1.2.3.4</u>`)
|
|
}
|
|
|
|
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. Failures come from a source that is not this relay — review before tightening <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</span>`)
|
|
}
|
|
|
|
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`,
|
|
`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>`)
|
|
// The times are the container's zone's, and say no zone (local-time).
|
|
lacks(t, "report", body, ` UTC</dd>`)
|
|
}
|
|
|
|
// 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: the hub lists only theirs and does not carry the ingest
|
|
// statistics of the whole instance, and another tenant's domain page and report
|
|
// are not there. 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)
|
|
|
|
// The hub is open to them: their own domain's reports, no one else's, and
|
|
// not the statistics of the instance's ingest box.
|
|
hub := send(s.h.HandleDMARCList, &ops, "GET", "/outbound/dmarc", nil, nil)
|
|
if hub.Code != http.StatusOK {
|
|
t.Fatalf("hub for its administrator = %d, want 200: %s", hub.Code, hub.Body.String())
|
|
}
|
|
has(t, "hub for its administrator", hub.Body.String(), `<h1 class="title is-3">DMARC reports</h1>`, `<h2>Recent reports</h2>`,
|
|
`<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`"`)
|
|
lacks(t, "hub for its administrator", hub.Body.String(), "other.example.net", "google.com",
|
|
`/outbound/dmarc/reports/`+idStr(otherReport)+`"`, `<h2>Ingest</h2>`, `Hosted addresses`, `dmarc-reports+<domain>`,
|
|
`parse failure`, `Last report`, `Retention`)
|
|
|
|
// Their own domain and report are theirs, and so is the way back to the hub.
|
|
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>`, `<a href="/outbound/dmarc">DMARC reports</a>`)
|
|
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)
|
|
}
|
|
has(t, "report for its administrator", rep.Body.String(), `<a href="/outbound/dmarc">DMARC reports</a>`)
|
|
|
|
for name, code := range map[string]int{
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A domain administrator with no outbound domain is not shown every tenant's
|
|
// reports because their own list is empty: the hub has no rows, and no error.
|
|
func TestDMARCHubForAnAdministratorWithoutOutboundDomains(t *testing.T) {
|
|
s := newDMARCStand(t)
|
|
nobody := domainAdmin(t, s.h.store, "nobody")
|
|
|
|
rec := send(s.h.HandleDMARCList, &nobody, "GET", "/outbound/dmarc", nil, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("hub = %d, want 200: %s", rec.Code, rec.Body.String())
|
|
}
|
|
has(t, "hub", rec.Body.String(), `No reports yet.`)
|
|
lacks(t, "hub", rec.Body.String(), "example.org", "Outlook.com", `<table`, `<h2>Ingest</h2>`)
|
|
}
|
|
|
|
// The global role sees the ingest box and every report, including those of a
|
|
// domain that has since been removed.
|
|
func TestDMARCHubForTheGlobalRoleSeesEverything(t *testing.T) {
|
|
s := newDMARCStand(t)
|
|
if _, err := s.h.store.InsertDMARCReport(store.DMARCReport{
|
|
Domain: "gone.example.net", Reporter: "yahoo.com", ReportID: "gone", ReceivedAt: time.Now(),
|
|
PeriodBegin: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now(), PassCount: 3,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
|
|
has(t, "hub", body, `<h2>Ingest</h2>`, `<h2>Recent reports</h2>`, `example.org`, `Outlook.com`, `gone.example.net`, `yahoo.com`)
|
|
}
|