267 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|