Files
mixandClaude Opus 5.5 6023feb405
test / test (push) Canceled after 0s
audit 6: documents, comments, dead code and staticcheck; the test gaps closed
The last stage of the 2026-10-12 audit; no cut.

Doc drift: architecture.md (the sender check in § OpenDKIM, the limit:
row's 48 hours, the inbound log and filter-list routes and who may open
them, migrations to 0011 and every table, KitPage as production data,
Reload configuration as a restore writes, the panel's roles counted),
schema-migrations.md (tables and columns since v2), guide.md (a delivery's
lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to
architecture.md's internal list), development.md (the env guard, the
deferral the e2e proves, its fixed sleeps), roadmap.md.

Comments say what the code does now; TestBaselineIsTheOnlyMigration is
TestChainStartsAtTheBaseline; the milter package doc names its three
milters.

Dead code gone: view.Facts, dmarc HostedSuggestion and
ValidateHostedAddress, app UpdateMode (each with the test that alone kept
it), the method checks the method-bound routes made dead;
Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on
both modules, clean: the e2e package builds without its tests now (a test
and two constants moved out of non-test files), three findings fixed.
The test moved, TestImageBuildPreservesLogrotateMode, had sat in a
non-test file and never run; run, it failed on the entrypoint (docker run
without a hostname) and now asks with --entrypoint stat.

The test gaps no step named, each closed: follow() with mail.log gone past
blindMin and made again; CheckRestore with a manifest that is not JSON;
the origin check with a port; every /server/ pattern in web.go through
server() and in the routes table (it found GET /server/health/fragment
missing from the table - guarded, not tested); L9 as the owner decided it
on 2026-10-12, the comment on senderSession.Headers and the test passing
an h that holds the earlier messages' From:.

Plan stage 6 ticked; roadmap audit row done, 81/81.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 513 s) on this tree; staticcheck locally on both modules.

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

157 lines
4.7 KiB
Go

package e2e
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"net/smtp"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// testSASLDBChangedByRename: the panel changes sasldb2 through a copy renamed
// over it (internal/app, replaceSASLDB), so an AUTH while another
// application's password changes reads the old database or the new one,
// whole. Before, saslpasswd2 wrote in the live file, which Cyrus SASL 2.1.28
// opens with no lock (sasldb/db_berkeley.c, berkeleydb_open()) and smtpd
// reads at every AUTH (_sasldb_getdata()) - assumptions C-S3. A torn read is
// a matter of timing, so this guards the mechanism (the inode changes, the
// mode and group stay) and that nothing fails under the load; it cannot prove
// a race absent.
func testSASLDBChangedByRename(t *testing.T, sc *scenario) {
churnLogin, _, err := sc.panel.addApplication(sc.domainID, "churn", "wildcard", "")
if err != nil {
t.Fatalf("add application: %v", err)
}
churnID, err := sc.panel.applicationID(sc.domainID, churnLogin)
if err != nil {
t.Fatal(err)
}
stat := func() string {
t.Helper()
out, err := h.execIn("selfpost", "stat", "-c", "%i %a %U %G", "/data/sasl/sasldb2")
if err != nil {
t.Fatalf("stat sasldb2: %v\n%s", err, out)
}
return strings.TrimSpace(out)
}
newPassword := func(i int) (string, error) {
resp, body, err := sc.panel.postForm("/outbound/domains/"+sc.domainID+"/applications/"+churnID+"/password", url.Values{})
if err != nil || resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("new password %d: %v, status %v\n%.600s", i, err, resp, body)
}
pw, ok := extractCodeRow(body, "Password")
if !ok {
return "", fmt.Errorf("new password %d: no password on the page\n%.600s", i, body)
}
return pw, nil
}
// One change, the inode looked at on both sides of it: the copy is made
// while the old file still exists, so it cannot have the old file's
// number. (After many changes it may: a freed inode number is reused.)
before := strings.Fields(stat())
var churnPassword string
if churnPassword, err = newPassword(0); err != nil {
t.Fatal(err)
}
after := strings.Fields(stat())
if len(before) != 4 || len(after) != 4 {
t.Fatalf("stat sasldb2: %v, %v", before, after)
}
if before[0] == after[0] {
t.Fatalf("sasldb2 kept inode %s across a password change: it was written in place", after[0])
}
// Then changes until other AUTHs have run alongside them for a while.
const wantAuths, maxChanges = 30, 300
stop := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
var auths int
var failures []string
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
err := authOnly(sc.appLogin, sc.appPassword)
mu.Lock()
auths++
if err != nil {
failures = append(failures, err.Error())
}
mu.Unlock()
}
}()
}
changes := 1
for ; changes < maxChanges; changes++ {
pw, err := newPassword(changes)
if err != nil {
close(stop)
wg.Wait()
t.Fatal(err)
}
churnPassword = pw
mu.Lock()
enough := auths >= wantAuths
mu.Unlock()
if enough {
break
}
}
close(stop)
wg.Wait()
if len(failures) > 0 {
t.Fatalf("%d of %d AUTHs of %s failed while %s's password changed %d times:\n%s",
len(failures), auths, sc.appLogin, churnLogin, changes, strings.Join(failures, "\n"))
}
t.Logf("%d AUTHs of %s during %d password changes of %s, none refused", auths, sc.appLogin, changes, churnLogin)
after = strings.Fields(stat())
if strings.Join(after[1:], " ") != "640 panel selfpost" {
t.Fatalf("sasldb2 after the changes: mode, owner, group %v; want 640 panel selfpost (Postfix reads it through the group)", after[1:])
}
if out, _ := h.execIn("selfpost", "sh", "-c", "ls /data/sasl"); strings.TrimSpace(out) != "sasldb2" {
t.Fatalf("/data/sasl holds %q, want sasldb2 alone (no copy left behind)", out)
}
if err := authOnly(churnLogin, churnPassword); err != nil {
t.Fatalf("%s with its last password: %v", churnLogin, err)
}
}
// authOnly connects to the submission port, authenticates and quits.
func authOnly(login, password string) error {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
return err
}
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
c, err := smtp.NewClient(conn, selfpostHostname)
if err != nil {
_ = conn.Close()
return err
}
defer c.Close()
if err := c.Hello("e2e-client.e2e.test"); err != nil {
return err
}
if err := c.Auth(smtp.PlainAuth("", login, password, selfpostHostname)); err != nil {
return fmt.Errorf("AUTH %s: %w", login, err)
}
return c.Quit()
}