Files
LambdaMail/services/protocols/internal/application/usecase/preflight_test.go
T

267 lines
9.6 KiB
Go

package usecase
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// healthyDeps is a deployment where every probe succeeds; individual tests
// break exactly one thing so the failure being asserted is unambiguous.
func healthyDeps() PreflightDeps {
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
return PreflightDeps{
DialPort25: func(context.Context, string) error { return nil },
LookupAddr: func(_ context.Context, _ string) ([]string, error) { return []string{"mail.example.test."}, nil },
LookupHost: func(_ context.Context, _ string) ([]string, error) { return []string{"192.0.2.10"}, nil },
LookupTXT: func(_ context.Context, _ string) ([]string, error) { return nil, errors.New("NXDOMAIN") },
HasCertificateFor: func(string) bool { return true },
FetchMtaSts: func(context.Context, string) error { return nil },
FreeDiskPercent: func() (float64, error) { return 65, nil },
Now: func() time.Time { return now },
NetworkTime: func(context.Context) (time.Time, error) { return now, nil },
}
}
func healthyInput() PreflightInput {
return PreflightInput{
PrimaryMailHost: "mail.example.test",
MailDomain: "example.test",
PublicIPv4: "192.0.2.10",
TLSMode: "traefik",
}
}
func findCheck(t *testing.T, report PreflightReport, substring string) CheckResult {
t.Helper()
for _, result := range report.Results {
if strings.Contains(result.Name, substring) {
return result
}
}
t.Fatalf("no check matching %q in report:\n%s", substring, report)
return CheckResult{}
}
func TestPreflight_HealthyDeploymentDoesNotBlock(t *testing.T) {
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), healthyInput())
if report.Blocking() {
t.Errorf("a healthy deployment was reported as blocking:\n%s", report)
}
}
// PLAN.md premise 1: without outbound port 25 there is no direct delivery.
func TestPreflight_BlockedPort25IsCriticalAndSuggestsSmarthost(t *testing.T) {
deps := healthyDeps()
deps.DialPort25 = func(context.Context, string) error { return errors.New("connection timed out") }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "port 25")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "smarthost") {
t.Errorf("remedy should point at the smarthost fallback: %q", check.Remedy)
}
if !report.Blocking() {
t.Error("a blocked port 25 must block")
}
}
// With a smarthost configured, direct port 25 delivery is not used, so the
// check must not block the deployment.
func TestPreflight_SmarthostSkipsPort25Check(t *testing.T) {
deps := healthyDeps()
deps.DialPort25 = func(context.Context, string) error { return errors.New("blocked") }
input := healthyInput()
input.SmarthostSet = true
report := NewPreflightUseCase(deps).Execute(context.Background(), input)
if findCheck(t, report, "port 25").Status != CheckSkipped {
t.Error("the port 25 check should be skipped when a smarthost is configured")
}
if report.Blocking() {
t.Errorf("a smarthost deployment was blocked:\n%s", report)
}
}
// PLAN.md premise 2: PTR delegation belongs to the hosting provider, so the
// remedy has to say so.
func TestPreflight_MissingPtrIsCriticalAndNamesTheProvider(t *testing.T) {
deps := healthyDeps()
deps.LookupAddr = func(context.Context, string) ([]string, error) { return nil, errors.New("NXDOMAIN") }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "PTR")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "hosting provider") {
t.Errorf("remedy should name the hosting provider: %q", check.Remedy)
}
}
func TestPreflight_ForwardConfirmedReverseDnsMismatchIsCritical(t *testing.T) {
deps := healthyDeps()
deps.LookupHost = func(context.Context, string) ([]string, error) { return []string{"198.51.100.1"}, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
if check := findCheck(t, report, "FCrDNS"); check.Status != CheckFailed {
t.Errorf("check = %+v, want a failure when the A record and the PTR disagree", check)
}
}
func TestPreflight_ListedIpIsCriticalAndNamesTheList(t *testing.T) {
deps := healthyDeps()
deps.LookupTXT = func(_ context.Context, name string) ([]string, error) {
if strings.Contains(name, "zen.spamhaus.org") {
return []string{"https://www.spamhaus.org/query/ip/192.0.2.10"}, nil
}
return nil, errors.New("NXDOMAIN")
}
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "blocklist")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Detail, "Spamhaus") {
t.Errorf("detail should name the list: %q", check.Detail)
}
}
// PLAN.md section 8.2 trap 2: Traefik only holds certificates for hosts it
// routes, so the remedy has to explain the missing router.
func TestPreflight_MissingCertificateExplainsTheTraefikRouter(t *testing.T) {
deps := healthyDeps()
deps.HasCertificateFor = func(string) bool { return false }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "certificate is loaded")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "router") {
t.Errorf("remedy should explain the missing router: %q", check.Remedy)
}
}
// PLAN.md section 5.1: enabling DANE under Traefik-managed certificates
// guarantees breakage at the next renewal, so the preflight must refuse it.
func TestPreflight_DaneUnderTraefikModeIsRefused(t *testing.T) {
input := healthyInput()
input.DaneEnabled = true
input.TLSMode = "traefik"
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
check := findCheck(t, report, "DANE")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !report.Blocking() {
t.Error("DANE under Traefik mode must block the deployment")
}
}
func TestPreflight_DaneUnderAcmeModePasses(t *testing.T) {
input := healthyInput()
input.DaneEnabled = true
input.TLSMode = "acme"
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
if check := findCheck(t, report, "DANE"); check.Status != CheckPassed {
t.Errorf("check = %+v, want a pass under TLS_MODE=acme", check)
}
}
// DKIM signatures and TOTP codes both break on a drifting clock, but neither
// loses mail outright, so this is high rather than critical.
func TestPreflight_ClockDriftIsHighNotCritical(t *testing.T) {
deps := healthyDeps()
local := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
deps.Now = func() time.Time { return local.Add(90 * time.Second) }
deps.NetworkTime = func(context.Context) (time.Time, error) { return local, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "clock")
if check.Status != CheckFailed || check.Severity != SeverityHigh {
t.Errorf("check = %+v, want a high-severity failure", check)
}
if report.Blocking() {
t.Error("clock drift alone must not block the deployment")
}
}
func TestPreflight_LowDiskIsMediumSeverity(t *testing.T) {
deps := healthyDeps()
deps.FreeDiskPercent = func() (float64, error) { return 4, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "free space")
if check.Status != CheckFailed || check.Severity != SeverityMedium {
t.Errorf("check = %+v, want a medium-severity failure", check)
}
}
// PLAN.md premise 5: half-configured IPv6 is a common cause of rejection, and
// disabling it is a valid answer.
func TestPreflight_IPv6WithoutPtrFailsButDisabledIPv6Passes(t *testing.T) {
deps := healthyDeps()
deps.LookupAddr = func(_ context.Context, ip string) ([]string, error) {
if strings.Contains(ip, ":") {
return nil, errors.New("NXDOMAIN")
}
return []string{"mail.example.test."}, nil
}
withIPv6 := healthyInput()
withIPv6.PublicIPv6 = "2001:db8::1"
report := NewPreflightUseCase(deps).Execute(context.Background(), withIPv6)
if check := findCheck(t, report, "IPv6"); check.Status != CheckFailed {
t.Errorf("check = %+v, want a failure for IPv6 without a PTR", check)
}
report = NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
if check := findCheck(t, report, "IPv6"); check.Status != CheckPassed {
t.Errorf("check = %+v, want a pass when IPv6 is disabled", check)
}
}
// A probe that cannot run is reported as skipped, never silently as a pass.
func TestPreflight_MissingProbesAreSkippedNotPassed(t *testing.T) {
report := NewPreflightUseCase(PreflightDeps{}).Execute(context.Background(), healthyInput())
for _, result := range report.Results {
if result.Status == CheckPassed && result.Name != "IPv6 is either fully configured or disabled" {
t.Errorf("check %q passed with no probe configured", result.Name)
}
}
if report.Blocking() {
t.Error("skipped checks must not block")
}
}
func TestReverseIPv4(t *testing.T) {
if got := reverseIPv4("192.0.2.10"); got != "10.2.0.192" {
t.Errorf("reverseIPv4 = %q, want 10.2.0.192", got)
}
if got := reverseIPv4("2001:db8::1"); got != "" {
t.Errorf("reverseIPv4 of an IPv6 address = %q, want empty", got)
}
}