feat: implement C.4 — hermetic container e2e gate + native release build
Separate test/e2e Go module drives the shipped deploy/docker-compose.yml (plus a test-only override: self-signed cert, low ports, isolated compose project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as an administrator and their applications would over HTTP/SMTP — covering the class of failure unit tests can't see (container wiring). Positive path: setup -> login -> domain -> DKIM record published into the fake zone -> application -> SMTP AUTH send -> DKIM verified against the DNS-published key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay, sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter fail-open, SELFPOST_HOSTNAME gate, session survives docker restart. release.yml moves off qemu to a native per-arch build (amd64/arm64), each gated by this suite before its tag is pushed and merged into the version manifest. Verified green on selfpost.example.com via `make e2e`; go vet/gofmt clean in both modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
// findSinkMessage polls the sink-MX's dump directory (see test/e2e/sink) for
|
||||
// a file whose contents contain token — the unique Subject-line marker every
|
||||
// test send carries — and returns it. Polling a directory listing rather than
|
||||
// sleeping a fixed duration is what keeps this deterministic even under a
|
||||
// slow CI runner (plan C.4).
|
||||
func findSinkMessage(stageDir, token string, timeout time.Duration) ([]byte, error) {
|
||||
dir := filepath.Join(stageDir, "mail-stage")
|
||||
var found []byte
|
||||
err := waitFor(fmt.Sprintf("sink-MX to receive a message tagged %q", token), timeout, 300*time.Millisecond, func() (bool, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Contains(b, []byte(token)) {
|
||||
found = b
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("no dumped message contains %q yet (%d on disk)", token, len(entries))
|
||||
})
|
||||
return found, err
|
||||
}
|
||||
|
||||
// verifyDKIM checks that raw (a message dumped by the sink) carries a DKIM
|
||||
// signature for domain that validates against the public key published in the
|
||||
// fake zone — i.e. exactly what a real receiver would check, using DNS the
|
||||
// harness itself controls instead of net.DefaultResolver (plan C.4).
|
||||
func verifyDKIM(raw []byte, domain string) error {
|
||||
verifications, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
|
||||
LookupTXT: lookupTXTFunc(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dkim verify: %w", err)
|
||||
}
|
||||
for _, v := range verifications {
|
||||
if v.Domain == domain {
|
||||
if v.Err != nil {
|
||||
return fmt.Errorf("dkim signature for %s did not validate: %w", domain, v.Err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications))
|
||||
}
|
||||
|
||||
// connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log
|
||||
// line, which is how the harness learns the source address Postfix itself
|
||||
// observed for a just-made connection (needed to configure a level-2 rate
|
||||
// limit's allowed-IPs list — the address a host-published port is seen as
|
||||
// inside the container depends on Docker's NAT and isn't worth hard-coding).
|
||||
var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`)
|
||||
|
||||
// lastSMTPClientIP reads mail.log inside the selfpost container and returns
|
||||
// the most recent address Postfix's smtpd logged a connection from.
|
||||
func lastSMTPClientIP(s *stack) (string, error) {
|
||||
out, err := s.execIn("selfpost", "tail", "-n", "200", "/var/log/mail.log")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
matches := connectFromPattern.FindAllStringSubmatch(out, -1)
|
||||
if len(matches) == 0 {
|
||||
return "", fmt.Errorf("no \"connect from\" line in mail.log yet")
|
||||
}
|
||||
return matches[len(matches)-1][1], nil
|
||||
}
|
||||
Reference in New Issue
Block a user