test / test (push) Canceled after 0s
Steps 11 and 12 of docs/plans/inbound-antispam-panel.md: 12/12. Two
read-only reviews of d65d632..HEAD by different models (Opus,
technical; Fable, security), each given the code and questions, not
conclusions. Nothing Critical or High in the feature; the High the
technical review found outside it is the commit before this one.
Log tailer (internal/logtail/inbound.go):
- the reply of a refusal ends at the first "; from=<", Postfix's own,
and the HELO name after it is whatever is left. Taking the longest
reply let a HELO name that spelt "; from=<..> to=<..> proto=.."
supply the row's sender, recipient and reply - and so make a
refusal read as the deny list's;
- an address with a quote, a bracket or a space is not kept: a quoted
sender could otherwise supply the recipient;
- the journal is cut to its row cap every 500 rows written, not only
every six hours;
- a queue id is forgotten when the message is queued or the id is
opened by another listener, and a stale place in the queue of
arrivals no longer drops a live id;
- milter-hold lines are not read: the held message reaches the
journal milter, which writes its row - it was written twice.
Lists (internal/inbound/lists.go): a change of the table and the
rewrite of the files are one step under a mutex; a failed rewrite puts
a removed entry back and rewrites after taking a new one out.
Store: the row cap removes the oldest by time, as the page orders them.
Page: on a sheet a refusal's text is a Reply whatever it starts with.
Help and the guide say that an allow entry for a sender address trusts
an address anyone can claim (design commit before this one).
docs/security.md: the two reviews; the roles table gains Inbound > Log
and Filter lists; Mail path describes the milters, the tailer and the
lists; four accepted risks - the filter's verdict while it is down, an
allow entry by sender address, the list files' mode, log rows held by
domain name. The plan's stale lines (size and rate refusals, who reads
the journal) are corrected.
Left as found, told to the owner: the journal's rows after an unclean
stop may be written twice (the tailer saves its place every five
seconds); a "Deny sender" link is offered for an address the form then
refuses; 1.2.3.4 and 1.2.3.4/32 are two entries.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
11 KiB
Go
313 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInsertInboundSpam(t *testing.T) {
|
|
st := openTestStore(t)
|
|
e := InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7",
|
|
From: "sender@example.net", To: "a@in.example.com", Subject: "Hello",
|
|
Decision: SpamDecisionAccept, Engine: SpamEngineRspamd, Detail: "no action, score 0.10 / 15.00",
|
|
}
|
|
if err := st.InsertInboundSpam(e); err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
var got InboundSpamEntry
|
|
var created string
|
|
err := st.db.QueryRow(
|
|
"SELECT inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at FROM inbound_spam_log",
|
|
).Scan(&got.InboundDomain, &got.ClientIP, &got.From, &got.To, &got.Subject, &got.Decision, &got.Engine, &got.Detail, &created)
|
|
if err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if got != e {
|
|
t.Errorf("row = %+v\nwant %+v", got, e)
|
|
}
|
|
if at, err := time.Parse(time.RFC3339, created); err != nil || time.Since(at) > time.Minute {
|
|
t.Errorf("created_at = %q (%v)", created, err)
|
|
}
|
|
|
|
// Every decision and engine the code names is one the table takes.
|
|
for _, d := range []string{SpamDecisionAccept, SpamDecisionReject, SpamDecisionTempfail, SpamDecisionQuarantine} {
|
|
for _, eng := range []string{SpamEngineRspamd, SpamEnginePostfix, SpamEngineSelfPost} {
|
|
e.Decision, e.Engine = d, eng
|
|
if err := st.InsertInboundSpam(e); err != nil {
|
|
t.Errorf("decision %s by %s: %v", d, eng, err)
|
|
}
|
|
}
|
|
}
|
|
e.Decision = "bounce"
|
|
if err := st.InsertInboundSpam(e); err == nil {
|
|
t.Error("an unknown decision was stored")
|
|
}
|
|
}
|
|
|
|
func TestIsInboundDomain(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if _, err := st.AddInboundDomain("in.example.com"); err != nil {
|
|
t.Fatalf("AddInboundDomain: %v", err)
|
|
}
|
|
for name, want := range map[string]bool{
|
|
"in.example.com": true, "IN.Example.COM": true,
|
|
"example.com": false, "": false, "other.example.com": false,
|
|
} {
|
|
if got, err := st.IsInboundDomain(name); err != nil || got != want {
|
|
t.Errorf("IsInboundDomain(%q) = %v, %v; want %v", name, got, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInsertInboundSpamKeepsItsOwnTime(t *testing.T) {
|
|
st := openTestStore(t)
|
|
at := time.Date(2026, 10, 1, 9, 30, 0, 0, time.FixedZone("msk", 3*3600))
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", To: "a@in.example.com",
|
|
Decision: SpamDecisionReject, Engine: SpamEnginePostfix, At: at,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
var created string
|
|
if err := st.db.QueryRow("SELECT created_at FROM inbound_spam_log").Scan(&created); err != nil || created != "2026-10-01T06:30:00Z" {
|
|
t.Errorf("created_at = %q, %v", created, err)
|
|
}
|
|
}
|
|
|
|
func TestPruneInboundSpam(t *testing.T) {
|
|
st := openTestStore(t)
|
|
now := time.Now().UTC()
|
|
add := func(to string, age time.Duration) {
|
|
t.Helper()
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", To: to,
|
|
Decision: SpamDecisionReject, Engine: SpamEnginePostfix, At: now.Add(-age),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
}
|
|
left := func() string {
|
|
t.Helper()
|
|
rows, err := st.db.Query("SELECT to_addr FROM inbound_spam_log ORDER BY id")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var out string
|
|
for rows.Next() {
|
|
var to string
|
|
if err := rows.Scan(&to); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out += to + " "
|
|
}
|
|
return out
|
|
}
|
|
|
|
add("old", 100*24*time.Hour)
|
|
add("a", 3*time.Hour)
|
|
add("b", 2*time.Hour)
|
|
add("c", time.Hour)
|
|
|
|
// Age first: nothing else goes while the rest fits.
|
|
if n, err := st.PruneInboundSpam(now.AddDate(0, 0, -90), 10); err != nil || n != 1 {
|
|
t.Fatalf("prune by age = %d, %v", n, err)
|
|
}
|
|
if got := left(); got != "a b c " {
|
|
t.Fatalf("left = %q", got)
|
|
}
|
|
// Then the cap, oldest first.
|
|
if n, err := st.PruneInboundSpam(now.AddDate(0, 0, -90), 2); err != nil || n != 1 {
|
|
t.Fatalf("prune by count = %d, %v", n, err)
|
|
}
|
|
if got := left(); got != "b c " {
|
|
t.Fatalf("left = %q", got)
|
|
}
|
|
if n, err := st.PruneInboundSpam(now.AddDate(0, 0, -90), 2); err != nil || n != 0 {
|
|
t.Errorf("a second prune removed %d, %v", n, err)
|
|
}
|
|
}
|
|
|
|
func TestInboundSpamLogRetentionDays(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if days, err := st.GetInboundSpamLogRetentionDays(30); err != nil || days != 30 {
|
|
t.Errorf("unset, env 30: %d, %v", days, err)
|
|
}
|
|
if days, err := st.GetInboundSpamLogRetentionDays(3); err != nil || days != InboundSpamLogRetentionDaysDefault {
|
|
t.Errorf("unset, env out of range: %d, %v", days, err)
|
|
}
|
|
if err := st.EnsureInboundSpamLogRetentionDays(30); err != nil {
|
|
t.Fatalf("Ensure: %v", err)
|
|
}
|
|
if err := st.EnsureInboundSpamLogRetentionDays(60); err != nil {
|
|
t.Fatalf("Ensure again: %v", err)
|
|
}
|
|
if days, _ := st.GetInboundSpamLogRetentionDays(90); days != 30 {
|
|
t.Errorf("after Ensure(30), Ensure(60): %d, want the first seed kept", days)
|
|
}
|
|
if err := st.SetInboundSpamLogRetentionDays(6); err == nil {
|
|
t.Error("6 days was accepted")
|
|
}
|
|
if err := st.SetInboundSpamLogRetentionDays(365); err != nil {
|
|
t.Errorf("365 days: %v", err)
|
|
}
|
|
if days, _ := st.GetInboundSpamLogRetentionDays(90); days != 365 {
|
|
t.Errorf("after Set(365): %d", days)
|
|
}
|
|
}
|
|
|
|
func TestQueryInboundSpamFilters(t *testing.T) {
|
|
st := openTestStore(t)
|
|
day := func(d, h int) time.Time { return time.Date(2026, 9, d, h, 0, 0, 0, time.UTC) }
|
|
add := func(domain, to, decision string, at time.Time) {
|
|
t.Helper()
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: domain, ClientIP: "203.0.113.7", From: "s@example.net", To: to,
|
|
Subject: "S " + to, Decision: decision, Engine: SpamEngineRspamd, Detail: "d", At: at,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
}
|
|
add("a.example", "1@a.example", SpamDecisionAccept, day(20, 23))
|
|
add("a.example", "2@a.example", SpamDecisionReject, day(21, 0))
|
|
add("b.example", "3@b.example", SpamDecisionAccept, day(21, 12))
|
|
add("b.example", "4@b.example", SpamDecisionTempfail, day(22, 0))
|
|
// Written last but dated earliest: the tailer reads a refusal after the fact.
|
|
add("a.example", "5@a.example", SpamDecisionReject, day(19, 8))
|
|
|
|
to := func(f InboundSpamFilter) string {
|
|
t.Helper()
|
|
rows, err := st.QueryInboundSpam(f, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("QueryInboundSpam(%+v): %v", f, err)
|
|
}
|
|
n, err := st.CountInboundSpam(f)
|
|
if err != nil || n != int64(len(rows)) {
|
|
t.Fatalf("CountInboundSpam(%+v) = %d, %v; the query returned %d", f, n, err, len(rows))
|
|
}
|
|
var out string
|
|
for _, r := range rows {
|
|
out += r.To[:1]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Newest first by time, not by the order of writing.
|
|
if got := to(InboundSpamFilter{}); got != "43215" {
|
|
t.Errorf("no filter = %q, want 43215", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Domain: "a.example"}); got != "215" {
|
|
t.Errorf("domain = %q, want 215", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Decision: SpamDecisionReject}); got != "25" {
|
|
t.Errorf("decision = %q, want 25", got)
|
|
}
|
|
// Days are whole UTC days and both ends are in: 21 st is 00:00 up to 23:59.
|
|
if got := to(InboundSpamFilter{Since: day(21, 15), Until: day(21, 3)}); got != "32" {
|
|
t.Errorf("one day = %q, want 32", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Since: day(22, 0)}); got != "4" {
|
|
t.Errorf("since = %q, want 4", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Until: day(20, 0)}); got != "15" {
|
|
t.Errorf("until = %q, want 15", got)
|
|
}
|
|
// A time in another zone is read as the UTC day it falls on.
|
|
msk := time.FixedZone("msk", 3*3600)
|
|
if got := to(InboundSpamFilter{Since: time.Date(2026, 9, 22, 2, 0, 0, 0, msk)}); got != "432" {
|
|
t.Errorf("since in another zone = %q, want 432 (it is the 21st in UTC)", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Domain: "b.example", Decision: SpamDecisionAccept, Since: day(21, 0), Until: day(21, 0)}); got != "3" {
|
|
t.Errorf("all filters = %q, want 3", got)
|
|
}
|
|
|
|
// The scope: nil is everything, a set is its domains, an empty set nothing,
|
|
// and a chosen domain outside the set is nothing too.
|
|
if got := to(InboundSpamFilter{Domains: []string{"b.example"}}); got != "43" {
|
|
t.Errorf("scope = %q, want 43", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Domains: []string{}}); got != "" {
|
|
t.Errorf("empty scope = %q, want nothing", got)
|
|
}
|
|
if got := to(InboundSpamFilter{Domains: []string{"b.example"}, Domain: "a.example"}); got != "" {
|
|
t.Errorf("a chosen domain outside the scope = %q, want nothing", got)
|
|
}
|
|
|
|
// Paging.
|
|
rows, err := st.QueryInboundSpam(InboundSpamFilter{}, 2, 2)
|
|
if err != nil || len(rows) != 2 || rows[0].To != "2@a.example" || rows[1].To != "1@a.example" {
|
|
t.Errorf("page 2 of 2 rows = %+v, %v", rows, err)
|
|
}
|
|
if rows[0].ID == 0 || !rows[0].CreatedAt.Equal(day(21, 0)) || rows[0].Subject != "S 2@a.example" ||
|
|
rows[0].Engine != SpamEngineRspamd || rows[0].Detail != "d" || rows[0].ClientIP != "203.0.113.7" || rows[0].From != "s@example.net" {
|
|
t.Errorf("a row read back = %+v", rows[0])
|
|
}
|
|
|
|
// A value is data, not SQL.
|
|
if got := to(InboundSpamFilter{Domain: "a.example' OR '1'='1"}); got != "" {
|
|
t.Errorf("injected domain = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestListSpamListHits(t *testing.T) {
|
|
st := openTestStore(t)
|
|
add := func(engine, decision, detail string) {
|
|
t.Helper()
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", From: "a@example.net", To: "a@in.example.com",
|
|
Decision: decision, Engine: engine, Detail: detail, At: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
add(SpamEngineRspamd, SpamDecisionReject, "5.7.1 Refused by this server's deny list")
|
|
add(SpamEngineRspamd, SpamDecisionAccept, "no action, score -99.00 / 15.00; SELFPOST_ALLOW_IP(-100.00)")
|
|
add(SpamEngineRspamd, SpamDecisionReject, "5.7.1 Spam message rejected")
|
|
add(SpamEnginePostfix, SpamDecisionReject, "550 5.1.1 this server's deny list")
|
|
// a mark with the characters LIKE would treat as wildcards is text
|
|
add(SpamEngineRspamd, SpamDecisionAccept, "no action; SELFPOSTXALLOWX(1.00)")
|
|
|
|
hits, err := st.ListSpamListHits("this server's deny list", "SELFPOST_ALLOW_")
|
|
if err != nil || len(hits) != 2 {
|
|
t.Fatalf("hits = %+v, %v; want the two rows a list decided", hits, err)
|
|
}
|
|
h := hits[0]
|
|
if h.From != "a@example.net" || h.ClientIP != "203.0.113.7" || h.Decision != SpamDecisionReject || h.CreatedAt.Hour() != 9 {
|
|
t.Errorf("hit = %+v", h)
|
|
}
|
|
if hits, err := st.ListSpamListHits(); err != nil || hits != nil {
|
|
t.Errorf("no marks: %v, %v", hits, err)
|
|
}
|
|
}
|
|
|
|
// The cap removes the oldest by when it happened. After the panel was down the
|
|
// tailer writes old refusals with the newest ids, and they are not what stays.
|
|
func TestPruneInboundSpamCapIsByTime(t *testing.T) {
|
|
st := openTestStore(t)
|
|
now := time.Now().UTC()
|
|
for _, r := range []struct {
|
|
to string
|
|
age time.Duration
|
|
}{{"new-1", time.Minute}, {"new-2", 2 * time.Minute}, {"backdated-old", 5 * time.Hour}} {
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", To: r.to,
|
|
Decision: SpamDecisionReject, Engine: SpamEnginePostfix, At: now.Add(-r.age),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if n, err := st.PruneInboundSpam(time.Time{}, 2); err != nil || n != 1 {
|
|
t.Fatalf("prune = %d, %v", n, err)
|
|
}
|
|
rows, err := st.QueryInboundSpam(InboundSpamFilter{}, 10, 0)
|
|
if err != nil || len(rows) != 2 || rows[0].To != "new-1" || rows[1].To != "new-2" {
|
|
t.Errorf("left = %+v, %v; want the two newest by time", rows, err)
|
|
}
|
|
}
|