test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of 9efc9c7..a59e3d4; reports in the session's scratchpad. Fixed: - B1 (verified bug): store.UpdateStatus read before writing in its transaction; a commit by another connection in between (the dmarc-ingest process opens the same file) failed it at once with "database is locked" and the delivery line was lost. The first statement is now a write. - R3: a delivery line updated every row of its queue id and recipient; it now updates only the newest (Postfix reuses short ids). The sweep closes by row id (QueuedDelivery.ID, CloseUnreported(id)). - R4: a contradicted sweep keeps a mark, reason "reconcile-contradicted", instead of losing which row it was. - R1: contradicted cannot see a lost line (never read late). New counter reconcile_missed: at close, the sweep looks for the row's delivery line in mail.log and mail.log.1 (QueueLines). 0007 edited - it has run on no database but throwaway stands. - R2: queued_over_lifetime cannot measure "what removing the sweep would leave" while the sweep runs; its text now says rows stuck despite the sweep, and the rule uses closed - missed - contradicted. - cert-reload (R5, R6, R7, security medium/low): both files copied to .new and the copies checked as a pair before both are renamed; acts on the copy only when postconf says Postfix reads it; a failed reload is retried next pass; umask 077; says so when the mounted files cannot be read. The e2e case asserts Postfix reads the copy, the copy equals the mount, the key is 600 root. - Security low: a recipient with a quoted local part - the journal cut "a b"@x at the space, deliveryRe cut "a>b"@x at the ">" - matched no delivery line and was closed by the sweep. cleanAddress ends a bracketed address at the first ">" outside quotes; deliveryRe takes the recipient up to the ">, " before relay= (test fixtures given the relay= every real delivery line has). - Security low: postqueue -p's error carries at most 512 bytes of output. - Nits: addDeliveryLogDay refuses a column not on its list; the truncation test checks its message. - To stage 2b (page wording, mockup-first): the history step of a bounced row says the receiving server refused it, false for a row the sweep closed. To the owner when stage 2b is reached: since M13 the "deferred" figure counts whole messages too, which "Connections turned away" would not name. Found while verifying: the e2e's main positive path failed once with "553 not owned by user app1" - an smtpd idle from before the panel's reload took the connection in the reload's second. Measured on the stand (one-off probe, not committed): sending 0, 10, 40, 110 s after the save (two seconds at the soonest) always met an smtpd started after the reload. The e2e now waits for the sender map after each application it creates (waitForSenderMap: AUTH, MAIL, RCPT, RSET - no message, no row), and the level-1 case uses it instead of its own loop. Docs: architecture § Queue reconcile, schema-migrations (rows 7 and 8, chain head 8), CHANGELOG, audit plan (stage 1 note, the measured window, a new stage 2b line: 62 lines), roadmap 15/62. Evidence on the test server for this tree: go vet clean, go test ./... 23 packages ok, make e2e exit 0 (renewed certificate, hung panel, level 1 at 49/60). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
8.7 KiB
Go
219 lines
8.7 KiB
Go
package e2e
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testLevel2RateLimit is plan C.4 negative check 5: a limit set through the
|
|
// panel (not the environment) rejects the message that exceeds it, and the
|
|
// rejection is visible in the send log — the panel -> DB -> milter path.
|
|
func testLevel2RateLimit(t *testing.T, sc *scenario) {
|
|
ip, err := lastSMTPClientIP(h)
|
|
if err != nil {
|
|
t.Fatalf("determine observed SMTP client IP: %v", err)
|
|
}
|
|
sc.clientIP = ip
|
|
|
|
login, password, err := sc.panel.addApplication(sc.domainID, "l2app", "wildcard", "")
|
|
if err != nil {
|
|
t.Fatalf("add application: %v", err)
|
|
}
|
|
if err := waitForSenderMap(login, password, "billing@"+senderDomain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sc.l2AppLogin, sc.l2AppPassword = login, password
|
|
|
|
appID, err := sc.panel.applicationID(sc.domainID, login)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sc.panel.setApplicationLimit(sc.domainID, appID, 1, 3600); err != nil {
|
|
t.Fatalf("save application rate limit: %v", err)
|
|
}
|
|
|
|
from := "billing@" + senderDomain
|
|
|
|
first := attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-first"), body: "ok",
|
|
})
|
|
if !first.ok() {
|
|
t.Fatalf("first message under the level-2 limit was rejected: %v", first.firstErr())
|
|
}
|
|
|
|
second := attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
|
|
})
|
|
// The milter answers tempfail at MAIL FROM, which Postfix words as below.
|
|
if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
|
|
t.Fatalf("the second message over the level-2 limit: %v", err)
|
|
}
|
|
|
|
// The limit holds for every message of a connection, not for its first
|
|
// only. The milter library hands the milter a fresh session after any reply
|
|
// that is not "continue" - the refusal above is one - and Postfix does not
|
|
// introduce the client again, so a second MAIL FROM on the same connection
|
|
// used to arrive with no client address and was let through (found by the
|
|
// technical review of 2026-10-12; the same hole let an application past its
|
|
// client-address list).
|
|
errs := mailTwiceOnOneConnection(login, password, from)
|
|
for i, err := range errs {
|
|
if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil {
|
|
t.Fatalf("over the level-2 limit, MAIL FROM %d of one connection: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
if err := waitFor("a rate-limited row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
|
rows, err := sc.panel.sendLogRows(senderDomain, login)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if containsCell(rows, "rate-limited") {
|
|
return true, nil
|
|
}
|
|
return false, fmt.Errorf("no rate-limited row for l2app yet")
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// testSenderLoginMismatch is plan C.4 negative check 2: an authenticated
|
|
// application cannot send as a sender it does not own (architecture.md § Mail
|
|
// path, reject_sender_login_mismatch) — the core anti-spoofing control.
|
|
func testSenderLoginMismatch(t *testing.T, sc *scenario) {
|
|
res := attemptSend(sendAttempt{
|
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
|
from: "someone@not-" + senderDomain, to: recipient,
|
|
subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected",
|
|
})
|
|
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
|
|
// (the default): the mismatch is detected at MAIL FROM but the reject is
|
|
// only sent back at RCPT TO.
|
|
if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil {
|
|
t.Fatalf("a sender the application does not own: %v", err)
|
|
}
|
|
if err := replyIs(res.rcptErr, 553, "Sender address rejected: not owned by user"); err != nil {
|
|
t.Fatalf("a sender the application does not own: %v", err)
|
|
}
|
|
}
|
|
|
|
// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail.
|
|
// The sender address used here belongs to no registered domain, so the
|
|
// rejection is unambiguously about the missing AUTH and not sender ownership.
|
|
func testNoAuthRejected(t *testing.T, sc *scenario) {
|
|
res := attemptSend(sendAttempt{
|
|
from: "anyone@unregistered.e2e.test", to: recipient,
|
|
subject: "e2e " + uniqueToken("noauth"), body: "should be rejected",
|
|
})
|
|
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
|
|
t.Fatalf("an unauthenticated send: %v", err)
|
|
}
|
|
}
|
|
|
|
// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that
|
|
// the submission port is not an open relay — even the exact recipient the
|
|
// positive path just delivered to is refused without AUTH. The refusal is
|
|
// smtpd_client_restrictions (permit_sasl_authenticated, reject), which on 465
|
|
// and 587 comes before reject_unauth_destination, so the reply is the same as
|
|
// check 1's; reject_unauth_destination's own "Relay access denied" is held on
|
|
// port 25 (testInboundRelay), where clients do not authenticate.
|
|
func testForeignRelayRejected(t *testing.T, sc *scenario) {
|
|
res := attemptSend(sendAttempt{
|
|
from: "anyone@unregistered.e2e.test", to: recipient,
|
|
subject: "e2e " + uniqueToken("relay"), body: "should be rejected",
|
|
})
|
|
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
|
|
t.Fatalf("an unauthenticated relay to an external destination: %v", err)
|
|
}
|
|
}
|
|
|
|
// checkContainerAlive confirms the container is still up after a component
|
|
// stop — the crashexit listener must not have brought it down.
|
|
func checkContainerAlive(s *stack) error {
|
|
out, err := s.execIn("selfpost", "true")
|
|
if err != nil {
|
|
return fmt.Errorf("container not responding to exec: %v (%s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// testSessionSurvivesRestart is plan C.4 negative/regression check 8 (moved
|
|
// here from the manual B.1 stand check, plan item C.4's closing note): the
|
|
// login session (SQLite-backed, plan B.1) must survive `docker restart`.
|
|
func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
|
|
if err := h.restartSelfpost(); err != nil {
|
|
t.Fatalf("restart selfpost: %v", err)
|
|
}
|
|
if err := waitFor("panel HTTP to answer after restart", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
|
_, err := sc.panel.status()
|
|
return err == nil, err
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The panel and Postfix come up independently after a restart (see
|
|
// waitForSMTPSReady) — the next subtest sends mail, so make sure smtpd is
|
|
// actually listening before this one returns.
|
|
if err := waitForSMTPSReady(30 * time.Second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := sc.panel.status()
|
|
if err != nil {
|
|
t.Fatalf("GET /overview after restart: %v", err)
|
|
}
|
|
if resp.Request.URL.Path != "/overview" {
|
|
t.Fatalf("session did not survive restart: landed on %s instead of /overview", resp.Request.URL.Path)
|
|
}
|
|
}
|
|
|
|
// testLevel1RateLimit is plan C.4 negative check 4: the native Postfix anvil
|
|
// backstop (smtpd_client_message_rate_limit, guide § Rate limiting), set by
|
|
// the override to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It
|
|
// uses a dedicated application with no level-2 limit of its own, and retries
|
|
// well past that count, so the result is unambiguous regardless of how much of
|
|
// the shared per-IP budget earlier subtests already spent (they stay well
|
|
// under 50 between them).
|
|
//
|
|
// Only anvil's own reply counts, and only after at least one message of the
|
|
// loop went through: the case once passed on "limit tripped on attempt 1/60"
|
|
// when the connection was reset because Postfix was still starting - and,
|
|
// held to anvil's reply, it turned out the first message had always been
|
|
// refused for another reason: the new login was not yet in the sender map an
|
|
// smtpd from before the panel's reload held (waitForSenderMap).
|
|
func testLevel1RateLimit(t *testing.T, sc *scenario) {
|
|
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
|
|
if err != nil {
|
|
t.Fatalf("add application: %v", err)
|
|
}
|
|
send := func() sendResult {
|
|
return attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: "l1@" + senderDomain, to: recipient,
|
|
subject: "e2e " + uniqueToken("l1"), body: "rate limit probe",
|
|
})
|
|
}
|
|
|
|
if err := waitForSenderMap(login, password, "l1@"+senderDomain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res := send(); !res.ok() {
|
|
t.Fatalf("the first message of %s, before any limit could trip: %v", login, res.firstErr())
|
|
}
|
|
|
|
const maxAttempts = 60
|
|
for i := 1; i < maxAttempts; i++ {
|
|
res := send()
|
|
if res.ok() {
|
|
continue
|
|
}
|
|
if err := res.refusedAt("MAIL", 450, "4.7.1 Error: too much mail from"); err != nil {
|
|
t.Fatalf("attempt %d/%d: %v", i+1, maxAttempts, err)
|
|
}
|
|
t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr())
|
|
return
|
|
}
|
|
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
|
|
}
|