Files
selfpost/internal/postfix/queue_test.go
T
mixandClaude Opus 5.5 dfe10f6c27 postfix: a truncated postqueue -p listing is no listing for the sweep (audit H3)
QueueIDs parsed Queue()'s text, which caps postqueue -p at 4 MiB and
appends a note, and returned the ids before the cut with no error: the
reconcile sweep then closed every queued row past the cut as bounced while
Postfix still held the mail. listQueue() now returns the text and whether
it was cut; QueueIDs refuses a cut listing with an error (reconcile already
treats an error as no information), the way postqueueJSON does for the
notifier. Queue() keeps showing the cut listing with its note.

postqueueP is a var so tests run a fake postqueue (the test binary as a
helper process): 5 MiB -> QueueIDs errors, Queue ends with the note; 1000
bytes -> ids read.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 8/61.
Evidence: go test ./internal/postfix ok (Windows); the server run of the
stage follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:13:34 +03:00

145 lines
4.9 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 {
t.Fatalf("QueueIDs on a 5 MiB listing = %d ids and no error, want an error", len(ids))
}
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)
}
}