audit 4: a deleted domain says so, the Outbound log filters by status and day
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>
This commit is contained in:
mix
2026-10-15 11:34:24 +03:00
co-authored by Claude Opus 5.5
parent 80e3979748
commit 2b8e0c086d
15 changed files with 378 additions and 52 deletions
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
@@ -329,3 +330,25 @@ func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
}
has(t, "failed reload", rec.Body.String(), "Reload failed at the DMARC report maps", "Reload configuration")
}
// A domain deleted with a rebuild failing after is said to be deleted, with
// the way to finish, not "internal error" (audit 2026-10-12, L17).
func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
s, _ := hostedStand(t)
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
d, err := s.h.domains.Add("gone.example")
if err != nil {
t.Fatal(err)
}
odk.SetReloadHook(func() error { return errors.New("supervisor away") })
rec := postFormAs(s.h.HandleDeleteDomain, globalPrincipal, "/outbound/domains/x/delete", map[string]string{"id": strconv.FormatInt(d.ID, 10)}, url.Values{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("delete with a failing rebuild = %d", rec.Code)
}
has(t, "delete", rec.Body.String(), "Domain gone.example deleted, but the mail configuration was not fully rewritten", "Reload configuration")
if _, err := s.h.store.GetDomain(d.ID); !errors.Is(err, store.ErrDomainNotFound) {
t.Errorf("the domain is still there: %v", err)
}
}
@@ -8,6 +8,7 @@ import (
"sync"
"time"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
@@ -147,7 +148,9 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
name := ""
if d, err := h.domains.Get(id); err == nil {
name = d.Name
defer h.dns.Forget(d.Name)
}
if err := h.domains.Delete(id); err != nil {
@@ -156,6 +159,11 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
return
}
logf("panel: delete domain %d: %v", id, err)
if errors.Is(err, domain.ErrDeletedIncomplete) {
h.renderDashboard(w, r, http.StatusInternalServerError,
"Domain "+name+" deleted, but the mail configuration was not fully rewritten after it. Run Reload configuration on Server › Health; the panel's log says what failed.", "")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
+22 -2
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"time"
@@ -301,7 +302,7 @@ func logLines(lines []string) []view.LogLine {
func deliveriesBackURL(r *http.Request) string {
q := r.URL.Query()
back := url.Values{}
for _, k := range []string{"domain", "app", "p"} {
for _, k := range []string{"domain", "app", "status", "from", "to", "p"} {
if v := q.Get(k); v != "" {
back.Set(k, v)
}
@@ -364,6 +365,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
filter := store.SendLogFilter{
Domain: q.Get("domain"),
AppLogin: q.Get("app"),
Status: outLogStatus(q.Get("status")),
// A global administrator reads the whole journal, including rows left
// behind by a domain that has since been deleted; anyone else the rows
// of the domains assigned to them, by id - not those an earlier domain
@@ -379,6 +381,15 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
filter.AppLogin = ""
}
}
form := view.OutLogFilter{Domain: filter.Domain, App: filter.AppLogin, Status: filter.Status}
// The days, as on the Inbound log: from the first's midnight to the end of
// the last, UTC; a day that does not parse is no narrowing.
if t, err := time.Parse(logDay, q.Get("from")); err == nil {
form.From, filter.Since = t.Format(logDay), t
}
if t, err := time.Parse(logDay, q.Get("to")); err == nil {
form.To, filter.Until = t.Format(logDay), t
}
total, err := h.store.CountSendLog(filter)
if err != nil {
@@ -396,7 +407,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
if err != nil {
return nil, err
}
out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, filter.Domain, filter.AppLogin)
out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, form)
table := make([]view.OutLogRow, len(rows))
for i, row := range rows {
table[i] = view.OutLogRow{
@@ -408,6 +419,15 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
return out.WithRows(table, page, lastPage), nil
}
// outLogStatus is the status filter of the Outbound log: one of the statuses
// a row has, or none.
func outLogStatus(v string) string {
if slices.Contains(view.OutLogStatuses, v) {
return v
}
return ""
}
// parsePage clamps the "p" query parameter to a valid page number, defaulting
// to 1 for anything missing or malformed rather than rejecting the request.
//
@@ -3,8 +3,10 @@ package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
@@ -228,3 +230,58 @@ func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
}
}
}
// 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+`&amp;status=rejected"`,
`?from=`+today+`&amp;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)
}
}