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>
145 lines
5.0 KiB
Go
145 lines
5.0 KiB
Go
package postfix
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
|
|
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
|
|
// queue entries.
|
|
func TestFakePostqueue(t *testing.T) {
|
|
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
|
|
t.Skip("helper process")
|
|
}
|
|
var size int
|
|
fmt.Sscan(os.Getenv("SP_FAKE_POSTQUEUE_BYTES"), &size)
|
|
out := bufio.NewWriter(os.Stdout)
|
|
fmt.Fprintln(out, "-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------")
|
|
for n, i := 0, 0; n < size; i++ {
|
|
entry := fmt.Sprintf("%010X 446 Fri Aug 8 10:13:31 app@example.com\n%41s rcpt@example.net\n\n", i, "")
|
|
out.WriteString(entry)
|
|
n += len(entry)
|
|
}
|
|
out.Flush()
|
|
os.Exit(0)
|
|
}
|
|
|
|
// fakePostqueue puts a postqueue that lists about size bytes of entries in
|
|
// place of the real one for the rest of the test.
|
|
func fakePostqueue(t *testing.T, size int) {
|
|
t.Helper()
|
|
old := postqueueP
|
|
postqueueP = func(ctx context.Context) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestFakePostqueue$")
|
|
cmd.Env = append(os.Environ(), "SP_FAKE_POSTQUEUE=1", fmt.Sprintf("SP_FAKE_POSTQUEUE_BYTES=%d", size))
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { postqueueP = old })
|
|
}
|
|
|
|
// A listing over the cap is no listing for the reconcile sweep: the messages
|
|
// past the cut are still queued, and taking the first 4 MiB of ids for the
|
|
// whole queue closed every row past it as bounced (audit 2026-10-12, H3).
|
|
// The queue page still shows the cut listing, with the note.
|
|
func TestQueueIDsRefusesATruncatedListing(t *testing.T) {
|
|
fakePostqueue(t, 5<<20)
|
|
if ids, err := QueueIDs(); err == nil || !strings.Contains(err.Error(), "listing is larger than") {
|
|
t.Fatalf("QueueIDs on a 5 MiB listing = %d ids, %v; want the listing refused for its size", len(ids), err)
|
|
}
|
|
text, err := Queue()
|
|
if err != nil {
|
|
t.Fatalf("Queue on a 5 MiB listing: %v", err)
|
|
}
|
|
if !strings.HasSuffix(text, fmt.Sprintf("[listing truncated at %d bytes]\n", maxQueueOutput)) {
|
|
t.Fatalf("Queue on a 5 MiB listing does not end with the truncation note: ...%q", text[len(text)-80:])
|
|
}
|
|
}
|
|
|
|
func TestQueueIDsReadsAListingUnderTheCap(t *testing.T) {
|
|
fakePostqueue(t, 1000)
|
|
ids, err := QueueIDs()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := ids["0000000000"]; !ok || len(ids) < 5 {
|
|
t.Fatalf("QueueIDs on a small listing = %v", ids)
|
|
}
|
|
}
|
|
|
|
// The reconcile sweep decides whether a message is still Postfix's problem, so
|
|
// the parser must pick queue ids out of a real listing and nothing else out of
|
|
// it: not the header, not the byte-count trailer, and above all not a deferred
|
|
// entry's reason line, which — unlike the recipient lines — starts at the left
|
|
// margin just as an entry does.
|
|
func TestParseQueueIDs(t *testing.T) {
|
|
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
|
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
|
|
rcpt@example.net
|
|
|
|
5B4A2C1D3E 446 Fri Aug 8 10:13:31 app@example.com
|
|
(connect to mx.example.net[203.0.113.9]:25: Connection timed out)
|
|
deferred@example.net
|
|
|
|
A1B2C3D4E5F! 891 Fri Aug 8 10:14:31 app@example.com
|
|
held@example.net
|
|
|
|
-- 1 Kbytes in 3 Requests.
|
|
`
|
|
ids := parseQueueIDs(listing)
|
|
want := []string{"3C5B04E6C1", "5B4A2C1D3E", "A1B2C3D4E5F"}
|
|
for _, id := range want {
|
|
if _, ok := ids[id]; !ok {
|
|
t.Errorf("queue id %s not found in %v", id, ids)
|
|
}
|
|
}
|
|
if len(ids) != len(want) {
|
|
t.Errorf("got %d ids %v, want exactly %v", len(ids), ids, want)
|
|
}
|
|
}
|
|
|
|
// An empty queue must come back as an empty set, not as a phantom id parsed out
|
|
// of Postfix's prose — every stale row would otherwise be compared against a
|
|
// listing that claims to hold a message called "Mail".
|
|
func TestParseQueueIDsOnAnEmptyQueue(t *testing.T) {
|
|
if ids := parseQueueIDs("Mail queue is empty\n"); len(ids) != 0 {
|
|
t.Errorf("got %v, want no ids", ids)
|
|
}
|
|
}
|
|
|
|
// The queue listing is held in memory and rendered into a page, so a very large
|
|
// queue must not size the panel's allocation: the writer keeps the cap and
|
|
// reports the rest as dropped, while still accepting every write so postqueue
|
|
// itself is never cut short.
|
|
func TestLimitWriterCapsOutput(t *testing.T) {
|
|
w := &limitWriter{remaining: 8}
|
|
n, err := w.Write([]byte("12345"))
|
|
if n != 5 || err != nil {
|
|
t.Fatalf("first write = %d, %v", n, err)
|
|
}
|
|
if w.truncated {
|
|
t.Fatal("truncated before the cap was reached")
|
|
}
|
|
n, err = w.Write([]byte("67890"))
|
|
if n != 5 || err != nil {
|
|
t.Fatalf("second write = %d, %v", n, err)
|
|
}
|
|
if !w.truncated {
|
|
t.Fatal("write past the cap was not recorded as truncated")
|
|
}
|
|
if got := w.buf.String(); got != "12345678" {
|
|
t.Fatalf("kept %q, want the first 8 bytes", got)
|
|
}
|
|
if n, err := w.Write([]byte("more")); n != 4 || err != nil {
|
|
t.Fatalf("write after the cap = %d, %v (must still accept)", n, err)
|
|
}
|
|
if got := w.buf.String(); got != "12345678" {
|
|
t.Fatalf("buffer grew past the cap: %q", got)
|
|
}
|
|
}
|