package e2e import ( "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // runEntrypoint runs the selfpost:e2e image's entrypoint standalone (no // compose, no DNS/sink sidecars — the SELFPOST_HOSTNAME gate in // build/entrypoint.sh runs and fails before anything else needs them) with // hostnameEnv substituted for SELFPOST_HOSTNAME, and returns its combined // output and whether it exited zero. func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) { t.Helper() dataDir := t.TempDir() // Entrypoint also chmod 755 /data; mirror that here so a pre-fix image // still gets a traversable bind mount under Go's 0700 TempDir. if err := os.Chmod(dataDir, 0o755); err != nil { t.Fatalf("chmod data dir: %v", err) } args := []string{ "run", "--rm", "-e", "SELFPOST_HOSTNAME=" + hostnameEnv, "-v", dataDir + ":/data", "selfpost:e2e", } cmd := exec.Command("docker", args...) cmd.Env = os.Environ() out, err := cmd.CombinedOutput() return string(out), err == nil } // TestHostnameGate is plan C.4 negative check 7: an empty or syntactically // invalid SELFPOST_HOSTNAME must fail the container fast with an explanatory // message (plan B.3), never a silent bad fallback. It runs the image directly // rather than through the shared stand — the whole point is to exercise // entrypoint.sh's gate before anything else in the container has a chance to // start, so it does not depend on TestE2E's stack at all. func TestHostnameGate(t *testing.T) { cases := []struct { name string hostname string want string }{ {"empty", "", "SELFPOST_HOSTNAME is not set"}, {"scheme_and_port", "https://mail.example.com:465", "must be a bare hostname"}, {"bare_word_no_dot", "localhost", "fully-qualified domain name"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { out, ok := runEntrypoint(t, c.hostname) if ok { t.Fatalf("container started with SELFPOST_HOSTNAME=%q, want a fatal exit\noutput:\n%s", c.hostname, out) } if !strings.Contains(out, c.want) { t.Fatalf("SELFPOST_HOSTNAME=%q: output does not mention %q:\n%s", c.hostname, c.want, out) } }) } t.Run("valid_hostname_starts", func(t *testing.T) { out, ok := runEntrypointBackground(t, "mail.example.test") if !ok { t.Fatalf("container with a valid SELFPOST_HOSTNAME failed to start:\n%s", out) } }) } // runEntrypointBackground starts the container detached and confirms // supervisord came up (rather than crash-looping) within a short timeout, then // removes it — the positive control for the gate cases above: a valid // hostname must not be caught by the same checks. func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) { t.Helper() dataDir := t.TempDir() if err := os.Chmod(dataDir, 0o755); err != nil { t.Fatalf("chmod data dir: %v", err) } certDir := t.TempDir() if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil { t.Fatalf("generate throwaway TLS cert: %v", err) } name := "selfpost-e2e-hostname-check" _ = exec.Command("docker", "rm", "-f", name).Run() defer func() { // Make bind-mounted /data deletable by Go's TempDir cleanup: the panel // leaves setup-token/db/opendkim owned by container UIDs (CI failed the // subtest on testing.go TempDir RemoveAll even when start succeeded). _ = exec.Command("docker", "exec", name, "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data").Run() _ = exec.Command("docker", "rm", "-f", name).Run() }() // #region agent log if fi, err := os.Stat(dataDir); err == nil { agentDebugLog("H1", "hostname_gate_test.go:pre-run", "host dataDir before docker run", map[string]any{ "mode": fmt.Sprintf("%04o", fi.Mode().Perm()), "path": dataDir, }) } imgInspect, _ := exec.Command("docker", "image", "inspect", "selfpost:e2e", "--format", "{{.Id}} created={{.Created}}").CombinedOutput() agentDebugLog("H5", "hostname_gate_test.go:pre-run", "selfpost:e2e image", map[string]any{ "inspect": strings.TrimSpace(string(imgInspect)), }) // #endregion up := exec.Command("docker", "run", "-d", "--name", name, "-e", "SELFPOST_HOSTNAME="+hostnameEnv, "-v", dataDir+":/data", "-v", certDir+":/etc/postfix/tls:ro", "selfpost:e2e") if out, err := up.CombinedOutput(); err != nil { agentDebugLog("H2", "hostname_gate_test.go:run", "docker run -d failed", map[string]any{ "out": string(out), "err": err.Error(), }) return string(out), false } err := waitFor("supervisord to report RUNNING processes", 20*time.Second, 300*time.Millisecond, func() (bool, error) { out, err := exec.Command("docker", "exec", name, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status").CombinedOutput() if strings.Contains(string(out), "RUNNING") { return true, nil } return false, err }) // #region agent log inspect, _ := exec.Command("docker", "inspect", name, "--format", "status={{.State.Status}} exit={{.State.ExitCode}} err={{.State.Error}} oom={{.State.OOMKilled}} started={{.State.StartedAt}} finished={{.State.FinishedAt}}").CombinedOutput() logs, logErr := exec.Command("docker", "logs", name).CombinedOutput() // Exited containers sometimes yield empty `docker logs` in CI; also read // the JSON-file log path docker recorded on the container. logPathOut, _ := exec.Command("docker", "inspect", name, "--format", "{{.LogPath}}").CombinedOutput() logPath := strings.TrimSpace(string(logPathOut)) logFileHead := "" if logPath != "" { if b, e := os.ReadFile(logPath); e == nil { logFileHead = truncateForDebug(string(b), 4000) if strings.TrimSpace(string(logs)) == "" && len(b) > 0 { logs = b } } else { logFileHead = "read LogPath: " + e.Error() } } dataMode, _ := exec.Command("docker", "exec", name, "stat", "-c", "%a %U:%G", "/data").CombinedOutput() opendkimStat, _ := exec.Command("docker", "exec", name, "stat", "-c", "%a %U:%G", "/data/opendkim").CombinedOutput() keyTableStat, _ := exec.Command("docker", "exec", name, "stat", "-c", "%a %U:%G", "/data/opendkim/KeyTable").CombinedOutput() supStatus, _ := exec.Command("docker", "exec", name, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status").CombinedOutput() hostDataListing := "" if entries, e := os.ReadDir(dataDir); e == nil { var parts []string for _, ent := range entries { p := filepath.Join(dataDir, ent.Name()) fi, _ := os.Stat(p) mode := "?" if fi != nil { mode = fmt.Sprintf("%04o", fi.Mode().Perm()) } parts = append(parts, ent.Name()+":"+mode) } hostDataListing = strings.Join(parts, ",") } agentDebugLog("H1", "hostname_gate_test.go:post-wait", "data permissions", map[string]any{ "dataMode": strings.TrimSpace(string(dataMode)), "opendkimStat": strings.TrimSpace(string(opendkimStat)), "keyTableStat": strings.TrimSpace(string(keyTableStat)), "hostDataListing": hostDataListing, }) agentDebugLog("H2", "hostname_gate_test.go:post-wait", "container inspect", map[string]any{ "inspect": strings.TrimSpace(string(inspect)), "waitErr": fmt.Sprintf("%v", err), "logErr": fmt.Sprintf("%v", logErr), "logLen": len(logs), "logPath": logPath, "logFileHead": logFileHead, }) agentDebugLog("H3", "hostname_gate_test.go:post-wait", "supervisor and logs", map[string]any{ "supervisorctl": strings.TrimSpace(string(supStatus)), "logsHead": truncateForDebug(string(logs), 4000), }) // #endregion diag := string(logs) if strings.TrimSpace(diag) == "" { diag = "(empty docker logs)\ninspect: " + strings.TrimSpace(string(inspect)) + "\nlogPath: " + logPath + "\nlogFileHead: " + logFileHead + "\nsupervisorctl: " + strings.TrimSpace(string(supStatus)) + "\n/data mode: " + strings.TrimSpace(string(dataMode)) + "\nhost /data: " + hostDataListing } return diag, err == nil } func truncateForDebug(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }