Files
kandev/apps/backend/internal/backendapp/binds_before_startup_test.go
T
2026-08-24 19:35:07 +00:00

264 lines
9.3 KiB
Go

package backendapp
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"testing"
"time"
"github.com/kandev/kandev/internal/common/config"
"go.uber.org/goleak"
)
// getJSON dials host:port+path over HTTP and decodes a JSON object body.
// Keep-alives are disabled so no idle client connection lingers for goleak.
func getJSON(host string, port int, path string) (int, map[string]any, error) {
return requestJSON(host, port, http.MethodGet, path)
}
func requestJSON(host string, port int, method string, path string) (int, map[string]any, error) {
url := fmt.Sprintf("http://%s%s", net.JoinHostPort(host, fmt.Sprint(port)), path)
client := &http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{DisableKeepAlives: true},
}
defer client.CloseIdleConnections()
req, err := http.NewRequest(method, url, nil)
if err != nil {
return 0, nil, err
}
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer func() { _ = resp.Body.Close() }()
var body map[string]any
if decodeErr := json.NewDecoder(resp.Body).Decode(&body); decodeErr != nil {
return resp.StatusCode, nil, decodeErr
}
return resp.StatusCode, body, nil
}
// TestBindBootstrapListenersServesLivenessBeforeSwap is the regression test
// for docs/plans/startup-listener-before-recovery/task-01-bind-before-
// startup.md: a caller must be able to bind the socket and get a 200 on the
// launcher's liveness path (GET /health) before the real router exists.
//
// Expected pre-fix failure: bindBootstrapListeners does not exist yet, so
// this fails to compile.
func TestBindBootstrapListenersServesLivenessBeforeSwap(t *testing.T) {
defer goleak.VerifyNone(t)
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
log := testLogger(t)
handler, server, listeners, err := bindBootstrapListeners(cfg, log, "1.2.3-test")
if err != nil {
t.Fatalf("bindBootstrapListeners: %v", err)
}
defer shutdown(t, listeners, server)
status, body, err := getJSON("127.0.0.1", cfg.Server.Port, "/health")
if err != nil {
t.Fatalf("GET /health before swap: %v", err)
}
if status != http.StatusOK {
t.Fatalf("bootstrap /health status = %d, want %d (launcher liveness probe requires 200)", status, http.StatusOK)
}
if got := body[versionFieldKey]; got != "1.2.3-test" {
t.Fatalf("bootstrap /health version = %v, want 1.2.3-test", got)
}
if got := body[statusKey]; got != "ok" {
t.Fatalf("bootstrap /health status field = %v, want %q (must match healthHandler's contract)", got, "ok")
}
if got := body["mode"]; got != "websocket+http" {
t.Fatalf("bootstrap /health mode = %v, want %q (must match healthHandler's contract)", got, "websocket+http")
}
status, body, err = requestJSON("127.0.0.1", cfg.Server.Port, http.MethodPost, "/health")
if err != nil {
t.Fatalf("POST /health before swap: %v", err)
}
if status != http.StatusServiceUnavailable {
t.Fatalf("bootstrap POST /health status = %d, want %d", status, http.StatusServiceUnavailable)
}
if body[statusKey] != startingStatus {
t.Fatalf("bootstrap POST /health body = %v, want status=%q", body, startingStatus)
}
// Every other path must answer deterministically, not hang or 404.
status, body, err = getJSON("127.0.0.1", cfg.Server.Port, "/api/v1/whatever")
if err != nil {
t.Fatalf("GET non-health path before swap: %v", err)
}
if status != http.StatusServiceUnavailable {
t.Fatalf("bootstrap non-health status = %d, want %d", status, http.StatusServiceUnavailable)
}
if body["status"] != startingStatus {
t.Fatalf("bootstrap non-health body = %v, want status=%q", body, startingStatus)
}
if handler == nil {
t.Fatal("bindBootstrapListeners returned a nil handler switch")
}
}
// TestBindBootstrapListenersEchoesDesktopHealthToken guards against the
// bootstrap handler poisoning the launcher's health probe: probeHealth
// (internal/launcher/health.go) treats a 200 with a missing/mismatched
// X-Kandev-Desktop-Health-Token as a foreign process and permanently sticks
// that verdict for the whole wait window.
func TestBindBootstrapListenersEchoesDesktopHealthToken(t *testing.T) {
defer goleak.VerifyNone(t)
t.Setenv(desktopHealthTokenEnv, "bootstrap-token")
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
log := testLogger(t)
_, server, listeners, err := bindBootstrapListeners(cfg, log, "1.2.3-test")
if err != nil {
t.Fatalf("bindBootstrapListeners: %v", err)
}
defer shutdown(t, listeners, server)
url := fmt.Sprintf("http://%s/health", net.JoinHostPort("127.0.0.1", fmt.Sprint(cfg.Server.Port)))
client := &http.Client{Timeout: 500 * time.Millisecond, Transport: &http.Transport{DisableKeepAlives: true}}
defer client.CloseIdleConnections()
resp, err := client.Get(url)
if err != nil {
t.Fatalf("GET /health: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get(desktopHealthTokenHeader); got != "bootstrap-token" {
t.Fatalf("bootstrap /health token header = %q, want bootstrap-token", got)
}
}
// TestHandlerSwitchSwapsWithoutRebind is the regression test for the "no
// window where the socket is closed and reopened" acceptance criterion: the
// same listeners must serve the bootstrap handler and then the real handler
// with no gap and no second bind.
func TestHandlerSwitchSwapsWithoutRebind(t *testing.T) {
defer goleak.VerifyNone(t)
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
log := testLogger(t)
handler, server, listeners, err := bindBootstrapListeners(cfg, log, "1.2.3-test")
if err != nil {
t.Fatalf("bindBootstrapListeners: %v", err)
}
defer shutdown(t, listeners, server)
boundBefore := listeners.boundAddrs()
status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/api/v1/whatever")
if err != nil {
t.Fatalf("GET before swap: %v", err)
}
if status != http.StatusServiceUnavailable {
t.Fatalf("status before swap = %d, want %d", status, http.StatusServiceUnavailable)
}
handler.Store(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
status, err = getStatus("127.0.0.1", cfg.Server.Port)
if err != nil {
t.Fatalf("GET after swap: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status after swap = %d, want %d", status, http.StatusOK)
}
if got := listeners.boundAddrs(); len(got) != len(boundBefore) || got[0] != boundBefore[0] {
t.Fatalf("bound addresses changed across swap: before=%v after=%v (want no rebind)", boundBefore, got)
}
}
// TestBindBootstrapListenersFailsWhenPortUnavailable pins today's bind-
// failure behaviour: bindBootstrapListeners returns errServerBindFailed
// (already logged by startHTTPServers) rather than a generic error, so the
// caller can skip the misleading "Failed to start orchestrator" log.
func TestBindBootstrapListenersFailsWhenPortUnavailable(t *testing.T) {
defer goleak.VerifyNone(t)
blocker := listenOnFreePort(t)
t.Cleanup(func() { _ = blocker.Close() })
port := listenerPort(t, blocker)
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
log := testLogger(t)
handler, server, listeners, err := bindBootstrapListeners(cfg, log, "1.2.3-test")
if !errors.Is(err, errServerBindFailed) {
t.Fatalf("bindBootstrapListeners error = %v, want %v", err, errServerBindFailed)
}
if handler != nil || server != nil || listeners != nil {
t.Fatalf("bindBootstrapListeners returned non-nil results on bind failure: handler=%v server=%v listeners=%v",
handler, server, listeners)
}
}
// TestCloseBoundListenersReleasesPortOnStartupFailure is the regression test
// for the listener-leak flagged in Build round 3 review: a step that fails
// after bindBootstrapListeners already bound the socket (orchestrator start,
// office services, storage composition, buildHTTPServer) must release the
// port before returning, not just stop the background bind-retry loop.
func TestCloseBoundListenersReleasesPortOnStartupFailure(t *testing.T) {
defer goleak.VerifyNone(t)
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
log := testLogger(t)
_, server, listeners, err := bindBootstrapListeners(cfg, log, "1.2.3-test")
if err != nil {
t.Fatalf("bindBootstrapListeners: %v", err)
}
closeBoundListeners(server, listeners, log)
// The port must become free: a later startup failure that leaks the
// listener would leave a same-process retry (or even a fresh process
// racing a restart) unable to bind here. server.Close() closing the
// listener's fd and the OS releasing the address aren't guaranteed to be
// observable in the same instant, so poll briefly rather than asserting
// on the very next syscall.
deadline := time.Now().Add(2 * time.Second)
var lastErr error
for time.Now().Before(deadline) {
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprint(cfg.Server.Port)))
if err == nil {
if closeErr := ln.Close(); closeErr != nil {
t.Fatalf("close probe listener: %v", closeErr)
}
return
}
lastErr = err
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("port still held after closeBoundListeners: %v", lastErr)
}
// TestCloseBoundListenersNoopWhenListenersNil covers the pure-bind-failure
// case: bindBootstrapListeners returns a nil listeners/server pair when the
// bind itself failed, so closeBoundListeners must not dereference server.
func TestCloseBoundListenersNoopWhenListenersNil(t *testing.T) {
closeBoundListeners(nil, nil, testLogger(t))
}