test / test (push) Canceled after 0s
L17: past DeleteDomain both rebuilds run, their errors joined under domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables were rebuilt (they may still name it otherwise); the domain list says the domain is deleted and that Reload configuration finishes it, not "internal error". Tests in domain (the tables failing, the sender map failing) and handlers. The Outbound log's status and day filters (owner, 2026-10-13; the mockup first,80e3979): view.OutLogFilter carried by the poll, the pages, the rows' links and the message's way back; SendLogFilter.Status, Since, Until (whole UTC days, as the Inbound log's); rejected is the three refusals together; a status not in the list is no narrowing. Tests: the filters through the handler, the poll and the way back; the day boundaries in store. The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe). Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 642 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
14 KiB
Go
288 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
// The Outbound log and the page of one message, rendered from the real journal
|
|
// by the handlers. Their markup is held to the mockups by the guards in
|
|
// internal/web/view; what is checked here is the behaviour of the pages — the
|
|
// polled region, the paging, the scoping.
|
|
|
|
// regionOf cuts the polled region (the table and the foot under it) out of a
|
|
// body: from the table's container to the end of the foot.
|
|
func regionOf(t *testing.T, body string) string {
|
|
t.Helper()
|
|
start := strings.Index(body, `<div class="table-container" id="out-log-rows"`)
|
|
foot := strings.Index(body, `<div class="sp-box-foot"`)
|
|
if start < 0 || foot < start {
|
|
t.Fatalf("no polled region in:\n%s", body)
|
|
}
|
|
end := strings.Index(body[foot:], "</div>")
|
|
return body[start : foot+end+len("</div>")]
|
|
}
|
|
|
|
// The fragment answers with the markup the page holds for the same region, so
|
|
// the first poll changes nothing a person sees: the table, byte for byte, and
|
|
// the foot, which only gains the attribute that swaps it into place by its id.
|
|
func TestOutLogFragmentIsTheRegionOfThePage(t *testing.T) {
|
|
h, _ := serverWithDelivery(t)
|
|
page := regionOf(t, getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru"))
|
|
fragment := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment?domain=bs.example.ru")
|
|
|
|
if !strings.Contains(fragment, `id="out-log-foot" hx-swap-oob="true"`) {
|
|
t.Errorf("the fragment's foot is not marked to be swapped in by its id:\n%s", fragment)
|
|
}
|
|
swapped := strings.Replace(fragment, ` hx-swap-oob="true"`, "", 1)
|
|
if swapped != page {
|
|
t.Errorf("the fragment is not the page's polled region\npage:\n%s\nfragment:\n%s", page, fragment)
|
|
}
|
|
// The poll asks for the same filters again, and never replaces the filter form.
|
|
has(t, "region", page, `data-poll`, `aria-live="polite"`, `hx-trigger="load"`, `hx-swap="outerHTML"`,
|
|
`hx-get="/outbound/log/fragment?domain=bs.example.ru"`)
|
|
lacks(t, "fragment", fragment, `<select`, `<form`)
|
|
if strings.Contains(page, "hx-swap-oob") {
|
|
t.Errorf("the page itself marks its foot out of band:\n%s", page)
|
|
}
|
|
}
|
|
|
|
// With more than a page of rows the foot says where the viewer is and leads to
|
|
// the newer and older rows with the filters kept, and the poll refreshes the page
|
|
// being looked at.
|
|
func TestOutLogPagesKeepTheFiltersAndThePage(t *testing.T) {
|
|
h, _ := serverWithDelivery(t)
|
|
for i := 0; i < sendLogPageSize; i++ {
|
|
if err := h.store.InsertQueued(store.SendLogEntry{
|
|
QueueID: fmt.Sprintf("Q%04d", i), Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
|
From: "noreply@bs.example.ru", To: "public@example.ru", Subject: fmt.Sprintf("Message %d", i),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
first := getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru")
|
|
has(t, "page 1", first, `Page 1 of 2`, `<a href="/outbound/log?domain=bs.example.ru&p=2">Older →</a>`, `New rows appear on their own`)
|
|
lacks(t, "page 1", first, `← Newer`)
|
|
|
|
second := getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru&p=2")
|
|
has(t, "page 2", second, `Page 2 of 2`, `<a href="/outbound/log?domain=bs.example.ru">← Newer</a>`,
|
|
`hx-get="/outbound/log/fragment?domain=bs.example.ru&p=2"`)
|
|
lacks(t, "page 2", second, `Older →`)
|
|
// A row's page is opened from the page it is on and leads back to it.
|
|
has(t, "page 2", second, `?domain=bs.example.ru&p=2">Details</a>`)
|
|
|
|
// The edges (audit 2026-10-12, L10): a page past the end is the last,
|
|
// however far past; nonsense, zero or a negative number is the first.
|
|
for _, p := range []string{"3", "9", "99999999999999999999", "9223372036854775807"} {
|
|
body := getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru&p="+p)
|
|
has(t, "page "+p, body, `Page 2 of 2`, `<a href="/outbound/log?domain=bs.example.ru">← Newer</a>`)
|
|
if got := strings.Count(body, `">Details</a>`); got == 0 {
|
|
t.Errorf("page %s shows no rows", p)
|
|
}
|
|
}
|
|
for _, p := range []string{"0", "-1", "x", ""} {
|
|
has(t, "page "+p, getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru&p="+p), `Page 1 of 2`)
|
|
}
|
|
}
|
|
|
|
// Nothing logged yet is a sentence in the polled region, which goes on polling,
|
|
// not a table with no rows; the foot still says that rows will appear.
|
|
func TestOutLogEmpty(t *testing.T) {
|
|
h, _ := serverWithTwoDomains(t)
|
|
p := domainAdmin(t, h.store, "no-domains")
|
|
body := getBodyAs(t, h.HandleDeliveries, "/outbound/log", p)
|
|
has(t, "empty log", body, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `New rows appear on their own`)
|
|
lacks(t, "empty log", body, `<table`, `Page 1`)
|
|
// A page asked of an empty log is the same empty log.
|
|
body = getBodyAs(t, h.HandleDeliveries, "/outbound/log?p=5", p)
|
|
has(t, "empty log, page 5", body, `No messages logged yet.`)
|
|
lacks(t, "empty log, page 5", body, `<table`, `Page `)
|
|
// Only the global role is pointed at the setting that decides how long rows live.
|
|
lacks(t, "empty log", body, `href="/server/settings"`)
|
|
has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `href="/server/settings">Settings</a>`)
|
|
}
|
|
|
|
// What a sender wrote is text on the page: a subject or an address is never
|
|
// markup, in the list, in the fragment or on the message's own page.
|
|
func TestOutLogPagesEscapeWhatSendersWrote(t *testing.T) {
|
|
h, _ := serverWithDelivery(t)
|
|
if err := h.store.InsertQueued(store.SendLogEntry{
|
|
QueueID: "EVIL0001", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
|
From: `"><img src=x onerror=alert(1)>@bs.example.ru`, To: `<script>alert(2)</script>@example.ru`,
|
|
Subject: `<script>alert(3)</script> "quoted"`,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
|
if err != nil || len(rows) != 1 {
|
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
|
}
|
|
for name, body := range map[string]string{
|
|
"list": getBody(t, h.HandleDeliveries, "/outbound/log"),
|
|
"fragment": getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment"),
|
|
"message": getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID)),
|
|
} {
|
|
lacks(t, name, body, `<script>alert`, `<img src=x`)
|
|
has(t, name, body, `<script>alert(3)</script>`)
|
|
}
|
|
}
|
|
|
|
// The names of the domain and the application on a message's page lead to
|
|
// theirs when there is a page to go to, and are plain text when there is not.
|
|
func TestMessagePageLinksTheDomainAndTheApplication(t *testing.T) {
|
|
h, domains := serverWithTwoDomains(t)
|
|
first := domains["first.example.ru"]
|
|
app, err := h.store.GetApplicationByLogin("first-app")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: first.Name, AllDomains: true}, 1, 0)
|
|
if err != nil || len(rows) != 1 {
|
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
|
}
|
|
target := "/outbound/log/" + itoa(rows[0].ID)
|
|
|
|
body := getBody(t, h.HandleDelivery, target)
|
|
has(t, "message", body, `<a href="/outbound/domains/`+itoa(first.ID)+`">first.example.ru</a>`,
|
|
`<a href="/outbound/domains/`+itoa(first.ID)+`/applications/`+itoa(app.ID)+`">first-app</a>`)
|
|
|
|
// A domain administrator of that domain gets the same links.
|
|
ops := domainAdmin(t, h.store, "ops", first.ID)
|
|
has(t, "message for its administrator", getBodyAs(t, h.HandleDelivery, target, ops),
|
|
`<a href="/outbound/domains/`+itoa(first.ID)+`">first.example.ru</a>`)
|
|
|
|
// The domain was deleted since: its name stays, as text, for the global role.
|
|
if err := h.store.DeleteDomain(first.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body = getBody(t, h.HandleDelivery, target)
|
|
has(t, "message of a deleted domain", body, `first.example.ru`)
|
|
lacks(t, "message of a deleted domain", body, `<a href="/outbound/domains/`)
|
|
}
|
|
|
|
// Each outcome of a delivery is drawn at its own level in the history and in the
|
|
// log, so a message that did not go through reads so before a word is read.
|
|
func TestMessagePageDrawsTheOutcomeAtItsLevel(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
status, step, tag string
|
|
}{
|
|
{store.StatusSent, `<li><time>`, `<span class="tag is-success is-light">sent</span>`},
|
|
{store.StatusDeferred, `<li class="sp-warn"><time>`, `<span class="tag is-warning is-light">deferred</span>`},
|
|
{store.StatusBounced, `<li class="sp-fail"><time>`, `<span class="tag is-danger is-light">bounced</span>`},
|
|
} {
|
|
h, row := serverWithDelivery(t)
|
|
h.cfg.MailLogPath = writeMailLog(t,
|
|
"Aug 03 05:16:03 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status="+tc.status+" (reply)")
|
|
if _, err := h.store.UpdateStatus(row.QueueID, row.To, tc.status); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
|
|
has(t, tc.status, body, tc.step, tc.tag)
|
|
switch tc.status {
|
|
case store.StatusDeferred:
|
|
has(t, tc.status, body, `<span class="sp-w">host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=deferred (reply)</span>`)
|
|
case store.StatusBounced:
|
|
has(t, tc.status, body, `<span class="sp-e">host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=bounced (reply)</span>`)
|
|
default:
|
|
lacks(t, tc.status, body, `class="sp-w"`, `class="sp-e"`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A message the milter refused never reached the queue: it has no queue id,
|
|
// and a row from before Postfix's line about it was kept says so instead of
|
|
// showing an empty log.
|
|
func TestMessagePageOfARefusedMessage(t *testing.T) {
|
|
h, _ := serverWithDelivery(t)
|
|
if err := h.store.InsertRejected(store.SendLogEntry{
|
|
Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "late@example.ru", Subject: "Too many",
|
|
}); err != nil {
|
|
t.Skipf("the store has no InsertRejected to refuse a message with: %v", err)
|
|
}
|
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
|
if err != nil || len(rows) != 1 {
|
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
|
}
|
|
body := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID))
|
|
has(t, "refused message", body, `Refused before queueing`, `class="sp-fail"`, `the line was never read`, `<span class="tag is-light">rejected</span>`)
|
|
lacks(t, "refused message", body, `<pre`)
|
|
}
|
|
|
|
// Whatever the viewer reaches, another tenant's message is a 404 on its own
|
|
// page whether the row exists or not.
|
|
func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
|
|
h, domains := serverWithTwoDomains(t)
|
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
|
|
if err != nil || len(rows) != 1 {
|
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
|
}
|
|
ops := domainAdmin(t, h.store, "ops", domains["first.example.ru"].ID)
|
|
for _, id := range []string{itoa(rows[0].ID), "999999"} {
|
|
rec := send(h.HandleDelivery, &ops, "GET", "/outbound/log/"+id, map[string]string{"id": id}, nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("message %s for another tenant = %d, want 404", id, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The status filter narrows to the statuses of a row, rejected being all
|
|
// three refusals; the days narrow to the rows of those days; both survive
|
|
// the paging, the poll and the way back from a message (owner, 2026-10-13).
|
|
func TestOutLogFiltersByStatusAndDay(t *testing.T) {
|
|
h, _ := serverWithDelivery(t) // one sent row of bs.example.ru, by Queuer3C
|
|
for _, reason := range []string{store.ReasonLimit, store.ReasonClient, store.ReasonFrom} {
|
|
if err := h.store.InsertRejected(store.SendLogEntry{
|
|
Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "public@example.ru",
|
|
Subject: "Refused " + reason, Reason: reason,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
rejected := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected")
|
|
for _, reason := range []string{store.ReasonLimit, store.ReasonClient, store.ReasonFrom} {
|
|
has(t, "rejected", rejected, "Refused "+reason)
|
|
}
|
|
has(t, "rejected", rejected, `<option value="rejected" selected>rejected</option>`)
|
|
if got := strings.Count(rejected, `">Details</a>`); got != 3 {
|
|
t.Errorf("status=rejected shows %d rows, want the three refusals", got)
|
|
}
|
|
sent := getBody(t, h.HandleDeliveries, "/outbound/log?status=sent")
|
|
if got := strings.Count(sent, `">Details</a>`); got != 1 {
|
|
t.Errorf("status=sent shows %d rows, want 1", got)
|
|
}
|
|
// A status that is not one is no narrowing, not an empty log.
|
|
if got := strings.Count(getBody(t, h.HandleDeliveries, "/outbound/log?status=rate-limited"), `">Details</a>`); got != 4 {
|
|
t.Errorf("status=rate-limited shows %d rows, want all 4", got)
|
|
}
|
|
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
tomorrow := time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")
|
|
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
|
|
if got := strings.Count(getBody(t, h.HandleDeliveries, "/outbound/log?from="+today+"&to="+today), `">Details</a>`); got != 4 {
|
|
t.Errorf("today shows %d rows, want 4", got)
|
|
}
|
|
for _, q := range []string{"from=" + tomorrow, "to=" + yesterday} {
|
|
if body := getBody(t, h.HandleDeliveries, "/outbound/log?"+q); strings.Contains(body, `">Details</a>`) {
|
|
t.Errorf("%s shows rows from today", q)
|
|
}
|
|
}
|
|
body := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected&from="+today)
|
|
has(t, "filtered", body, `name="from" value="`+today+`"`,
|
|
`hx-get="/outbound/log/fragment?from=`+today+`&status=rejected"`,
|
|
`?from=`+today+`&status=rejected">Details</a>`)
|
|
frag := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment?status=rejected&from="+today)
|
|
if got := strings.Count(frag, `">Details</a>`); got != 3 {
|
|
t.Errorf("the poll of status=rejected shows %d rows, want 3", got)
|
|
}
|
|
// The message's way back keeps the filters.
|
|
if got := deliveriesBackURL(httptest.NewRequest("GET", "/outbound/log/1?status=rejected&from="+today+"&to="+today+"&p=2", nil)); got != "/outbound/log?from="+today+"&p=2&status=rejected&to="+today {
|
|
t.Errorf("way back = %q", got)
|
|
}
|
|
}
|