package cli import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "sync/atomic" "testing" "github.com/bnema/gordon/internal/adapters/dto" climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" "github.com/bnema/gordon/internal/adapters/in/cli/remote" "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" "github.com/bnema/gordon/internal/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTruncateImage(t *testing.T) { tests := []struct { name string image string maxLen int expected string }{ // Basic cases - no truncation needed { name: "short image fits", image: "nginx:latest", maxLen: 20, expected: "nginx:latest", }, { name: "exact fit", image: "nginx:latest", maxLen: 12, expected: "nginx:latest", }, // Regular tag truncation { name: "truncate long tag with ellipsis", image: "registry.test.com/test:v1234567890", maxLen: 30, expected: "registry.test.com/test:v123...", }, { name: "truncate very long image", image: "registry.example.com/organization/project/image:v1.2.3-beta.4", maxLen: 35, expected: "registry.example.com/organizatio...", }, // Digest truncation { name: "digest shortened to 12 chars", image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", maxLen: 50, expected: "myapp@sha256:a3ed95caeb02", }, { name: "digest truncated with ellipsis when too long", image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", maxLen: 35, expected: "registry.example.com/org/app@sha...", }, { name: "short digest fits", image: "app@sha256:abc123", maxLen: 30, expected: "app@sha256:abc123", }, // Edge cases { name: "maxLen zero returns empty", image: "nginx:latest", maxLen: 0, expected: "", }, { name: "maxLen negative returns empty", image: "nginx:latest", maxLen: -1, expected: "", }, { name: "maxLen 3 or less - no ellipsis", image: "nginx:latest", maxLen: 3, expected: "ngi", }, { name: "maxLen 4 - truncate with ellipsis", image: "nginx:latest", maxLen: 4, expected: "n...", }, { name: "empty image", image: "", maxLen: 10, expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := truncateImage(tt.image, tt.maxLen) assert.Equal(t, tt.expected, result) }) } } func TestHTTPHealthToStatus(t *testing.T) { tests := []struct { name string health *remote.RouteHealth expected components.Status }{ { name: "nil health returns unknown", health: nil, expected: components.StatusUnknown, }, { name: "zero status no error returns unknown", health: &remote.RouteHealth{HTTPStatus: 0}, expected: components.StatusUnknown, }, { name: "zero status with error returns error", health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"}, expected: components.StatusError, }, { name: "200 returns success", health: &remote.RouteHealth{HTTPStatus: 200}, expected: components.StatusSuccess, }, { name: "301 returns success", health: &remote.RouteHealth{HTTPStatus: 301}, expected: components.StatusSuccess, }, { name: "500 returns error", health: &remote.RouteHealth{HTTPStatus: 500}, expected: components.StatusError, }, { name: "404 returns error", health: &remote.RouteHealth{HTTPStatus: 404}, expected: components.StatusError, }, { name: "100 informational returns error", health: &remote.RouteHealth{HTTPStatus: 100}, expected: components.StatusError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := httpHealthToStatus(tt.health) assert.Equal(t, tt.expected, result) }) } } func TestGroupRoutesByNetwork(t *testing.T) { routes := []remote.RouteInfo{ {Domain: "alpha.dev", Network: "gordon-alpha-dev"}, {Domain: "beta.dev", Network: "gordon-shared"}, {Domain: "gamma.dev", Network: "gordon-shared"}, {Domain: "delta.dev", Network: "gordon-delta-dev"}, } groups, solo := groupRoutesByNetwork(routes) assert.Len(t, solo, 2) assert.Equal(t, "alpha.dev", solo[0].Domain) assert.Equal(t, "delta.dev", solo[1].Domain) assert.Len(t, groups, 1) assert.Equal(t, "shared", groups[0].name) assert.Len(t, groups[0].routes, 2) } func TestStripNetworkPrefix(t *testing.T) { tests := []struct { network string expected string }{ {"gordon-shared-services", "shared-services"}, {"gordon-my-app-dev", "my-app-dev"}, {"custom-network", "custom-network"}, {"gordon-", ""}, {"", ""}, } for _, tt := range tests { t.Run(tt.network, func(t *testing.T) { assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network)) }) } } func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) { originalRemoteFlag := remoteFlag originalTokenFlag := tokenFlag originalInsecureTLSFlag := insecureTLSFlag t.Cleanup(func() { remoteFlag = originalRemoteFlag tokenFlag = originalTokenFlag insecureTLSFlag = originalInsecureTLSFlag }) remoteFlag = "" tokenFlag = "" insecureTLSFlag = false configHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configHome) t.Setenv("HOME", t.TempDir()) t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com") configPath := filepath.Join(configHome, "gordon", "remotes.toml") require.NoError(t, os.MkdirAll(configPath, 0o755)) resolved, ok, err := resolveRoutesExplicitRemote() require.True(t, ok) require.NoError(t, err) require.NotNil(t, resolved) assert.Equal(t, "https://ad-hoc.example.com", resolved.URL) assert.Empty(t, resolved.Name) } func TestResolveRoutesExplicitRemote_ReturnsErrorForUnreadableNamedRemoteConfig(t *testing.T) { originalRemoteFlag := remoteFlag originalTokenFlag := tokenFlag originalInsecureTLSFlag := insecureTLSFlag t.Cleanup(func() { remoteFlag = originalRemoteFlag tokenFlag = originalTokenFlag insecureTLSFlag = originalInsecureTLSFlag }) remoteFlag = "missing-remote" tokenFlag = "" insecureTLSFlag = false configHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configHome) t.Setenv("HOME", t.TempDir()) t.Setenv("GORDON_REMOTE", "") configPath := filepath.Join(configHome, "gordon", "remotes.toml") require.NoError(t, os.MkdirAll(configPath, 0o755)) resolved, ok, err := resolveRoutesExplicitRemote() require.Error(t, err) require.False(t, ok) require.Nil(t, resolved) assert.Contains(t, err.Error(), "failed to read remotes") } func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) { item := routeStatusItem{ Domain: "app.example.com", Image: "app:latest", ContainerStatus: "running", HTTPStatus: 0, HealthError: "connection refused", } expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " + components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) + " app.example.com" assert.Equal(t, expected, routeStatusTitle(item)) } func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once() var out bytes.Buffer err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true) require.NoError(t, err) var got map[string]any require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, "app.example.com", got["domain"]) assert.Equal(t, "app:latest", got["image"]) assert.Equal(t, "unknown", got["container_status"]) assert.Equal(t, float64(0), got["http_status"]) assert.Equal(t, "probe failed", got["health_error"]) } func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once() var out bytes.Buffer err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false) require.NoError(t, err) text := stripANSI(out.String()) assert.Contains(t, text, "Route: app.example.com") assert.Contains(t, text, "Domain:") assert.Contains(t, text, "Image:") assert.Contains(t, text, "Container:") assert.Contains(t, text, "probe failed") } func TestRunRoutesShow_JSONIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{ "app.example.com": { HTTPStatus: 503, ContainerStatus: "", Error: "probe failed", }, }, nil).Once() var out bytes.Buffer err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true) require.NoError(t, err) var got map[string]any require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, "app.example.com", got["domain"]) assert.Equal(t, "app:latest", got["image"]) assert.Equal(t, "unknown", got["container_status"]) assert.Equal(t, float64(503), got["http_status"]) assert.Equal(t, "probe failed", got["health_error"]) } func TestRunRoutesShow_TextIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{ "app.example.com": { HTTPStatus: 503, ContainerStatus: "", Error: "probe failed", }, }, nil).Once() var out bytes.Buffer err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false) require.NoError(t, err) text := stripANSI(out.String()) assert.Contains(t, text, "Route: app.example.com") assert.Contains(t, text, "Container:") assert.Contains(t, text, "unknown") assert.Contains(t, text, "probe failed") } func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) { testsDeps := routesListDeps{ explicitRemote: func() (*remote.ResolvedRemote, bool, error) { return nil, false, nil }, loadLocal: func(context.Context, string) (routeListSection, error) { return routeListSection{ Kind: "local", Name: "local", Routes: []routeListItem{{ Domain: "app.local", Image: "myapp:latest", }}, }, nil }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { return map[string]remote.RemoteEntry{ "remote-a": {URL: "https://remote-a.example.com"}, "remote-b": {URL: "https://remote-b.example.com"}, }, "remote-a", nil }, loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) { if name == "remote-b" { return routeListSection{ Kind: "remote", Name: name, URL: entry.URL, Error: "remote unavailable", }, nil } return routeListSection{ Kind: "remote", Name: name, URL: entry.URL, Routes: []routeListItem{{ Domain: "dashboard.example.com", Image: "dashboard", }}, }, nil }, } sections, err := collectRoutesListSections(context.Background(), "", testsDeps) require.NoError(t, err) require.Len(t, sections, 3) assert.Equal(t, "local", sections[0].Name) assert.Equal(t, "remote-a", sections[1].Name) assert.Equal(t, "remote-b", sections[2].Name) assert.Equal(t, "dashboard.example.com", sections[1].Routes[0].Domain) assert.Equal(t, "remote unavailable", sections[2].Error) } func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) { testsDeps := routesListDeps{ explicitRemote: func() (*remote.ResolvedRemote, bool, error) { return &remote.ResolvedRemote{ Name: "remote-a", URL: "https://remote-a.example.com", }, true, nil }, loadLocal: func(context.Context, string) (routeListSection, error) { t.Fatal("loadLocal should not be called when an explicit remote is selected") return routeListSection{}, nil }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { t.Fatal("listRemotes should not be called when an explicit remote is selected") return nil, "", nil }, loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) { return routeListSection{ Kind: "remote", Name: name, URL: entry.URL, Routes: []routeListItem{{ Domain: "test.example.com", Image: "hello-app", }}, }, nil }, } sections, err := collectRoutesListSections(context.Background(), "", testsDeps) require.NoError(t, err) require.Len(t, sections, 1) assert.Equal(t, "remote-a", sections[0].Name) assert.Equal(t, "test.example.com", sections[0].Routes[0].Domain) } func TestCollectRoutesSections_ExplicitRemoteResolutionErrorReturnsError(t *testing.T) { t.Run("list", func(t *testing.T) { var loadLocalCalls atomic.Int32 var listRemotesCalls atomic.Int32 var loadRemoteCalls atomic.Int32 deps := routesListDeps{ explicitRemote: func() (*remote.ResolvedRemote, bool, error) { return nil, false, errors.New("failed to read remotes: boom") }, loadLocal: func(context.Context, string) (routeListSection, error) { loadLocalCalls.Add(1) return routeListSection{}, nil }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { listRemotesCalls.Add(1) return map[string]remote.RemoteEntry{}, "", nil }, loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) { loadRemoteCalls.Add(1) return routeListSection{}, nil }, } sections, err := collectRoutesListSections(context.Background(), "", deps) require.Error(t, err) require.Nil(t, sections) assert.Contains(t, err.Error(), "failed to read remotes") assert.Zero(t, loadLocalCalls.Load()) assert.Zero(t, listRemotesCalls.Load()) assert.Zero(t, loadRemoteCalls.Load()) }) t.Run("status", func(t *testing.T) { var loadLocalCalls atomic.Int32 var listRemotesCalls atomic.Int32 var loadRemoteCalls atomic.Int32 deps := routesStatusDeps{ explicitRemote: func() (*remote.ResolvedRemote, bool, error) { return nil, false, errors.New("failed to read remotes: boom") }, loadLocal: func(context.Context, string) (routeStatusSection, error) { loadLocalCalls.Add(1) return routeStatusSection{}, nil }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { listRemotesCalls.Add(1) return map[string]remote.RemoteEntry{}, "", nil }, loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) { loadRemoteCalls.Add(1) return routeStatusSection{}, nil }, } sections, err := collectRoutesStatusSections(context.Background(), "", deps) require.Error(t, err) require.Nil(t, sections) assert.Contains(t, err.Error(), "failed to read remotes") assert.Zero(t, loadLocalCalls.Load()) assert.Zero(t, listRemotesCalls.Load()) assert.Zero(t, loadRemoteCalls.Load()) }) } func TestLoadRoutesListExplicitRemoteSection_PropagatesLoaderError(t *testing.T) { section := loadRoutesListExplicitRemoteSection(context.Background(), routesListDeps{ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) { return routeListSection{}, errors.New("boom") }, }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"}) assert.Equal(t, "remote", section.Kind) assert.Equal(t, "remote-a", section.Name) assert.Equal(t, "https://remote-a.example.com", section.URL) assert.Equal(t, "boom", section.Error) } func TestLoadRoutesStatusExplicitRemoteSection_PropagatesLoaderError(t *testing.T) { section := loadRoutesStatusExplicitRemoteSection(context.Background(), routesStatusDeps{ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) { return routeStatusSection{}, errors.New("boom") }, }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"}) assert.Equal(t, "remote", section.Kind) assert.Equal(t, "remote-a", section.Name) assert.Equal(t, "https://remote-a.example.com", section.URL) assert.Equal(t, "boom", section.Error) } func TestCollectRoutesListAggregateSections_EncodesLoaderErrors(t *testing.T) { t.Run("local loader error", func(t *testing.T) { sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{ loadLocal: func(context.Context, string) (routeListSection, error) { return routeListSection{Kind: "local", Name: "local"}, errors.New("local failed") }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { return map[string]remote.RemoteEntry{}, "", nil }, loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) { return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil }, }) require.NoError(t, err) require.Len(t, sections, 1) assert.Equal(t, "local", sections[0].Kind) assert.Equal(t, "local failed", sections[0].Error) }) t.Run("remote loader error", func(t *testing.T) { sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{ loadLocal: func(context.Context, string) (routeListSection, error) { return routeListSection{Kind: "local", Name: "local"}, nil }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil }, loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) { return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed") }, }) require.NoError(t, err) require.Len(t, sections, 2) assert.Equal(t, "local", sections[0].Kind) assert.Equal(t, "remote", sections[1].Kind) assert.Equal(t, "remote failed", sections[1].Error) }) } func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T) { t.Run("local loader error", func(t *testing.T) { sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{ loadLocal: func(context.Context, string) (routeStatusSection, error) { return routeStatusSection{Kind: "local", Name: "local"}, errors.New("local failed") }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { return map[string]remote.RemoteEntry{}, "", nil }, loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) { return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil }, }) require.NoError(t, err) require.Len(t, sections, 1) assert.Equal(t, "local", sections[0].Kind) assert.Equal(t, "local failed", sections[0].Error) }) t.Run("remote loader error", func(t *testing.T) { sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{ loadLocal: func(context.Context, string) (routeStatusSection, error) { return routeStatusSection{Kind: "local", Name: "local"}, nil }, listRemotes: func() (map[string]remote.RemoteEntry, string, error) { return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil }, loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) { return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed") }, }) require.NoError(t, err) require.Len(t, sections, 2) assert.Equal(t, "local", sections[0].Kind) assert.Equal(t, "remote", sections[1].Kind) assert.Equal(t, "remote failed", sections[1].Error) }) } func TestLoadRoutesStatusLocalSection_DoesNotLeakKernelInitLogs(t *testing.T) { tmpDir := t.TempDir() cfgPath := filepath.Join(tmpDir, "gordon.toml") require.NoError(t, os.WriteFile(cfgPath, []byte(`[server] gordon_domain = "gordon.local" data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+` [auth] enabled = true `), 0o600)) stdout, stderr := captureOutput(t, func() { section, err := loadRoutesStatusLocalSection(context.Background(), cfgPath) require.NoError(t, err) assert.Equal(t, "local", section.Kind) assert.Equal(t, "local", section.Name) assert.Contains(t, section.Error, "failed to initialize local control plane") assert.Contains(t, section.Error, "auth.secrets_backend is required") }) combined := stdout + stderr assert.NotContains(t, combined, "failed to resolve secrets backend") assert.NotContains(t, combined, "local kernel running in minimal mode") } func captureOutput(t *testing.T, fn func()) (string, string) { t.Helper() oldStdout, oldStderr := os.Stdout, os.Stderr outR, outW, err := os.Pipe() require.NoError(t, err) errR, errW, err := os.Pipe() require.NoError(t, err) os.Stdout = outW os.Stderr = errW stdoutCh := make(chan string, 1) stderrCh := make(chan string, 1) go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, outR) stdoutCh <- buf.String() }() go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, errR) stderrCh <- buf.String() }() defer func() { os.Stdout = oldStdout os.Stderr = oldStderr }() fn() require.NoError(t, outW.Close()) require.NoError(t, errW.Close()) return <-stdoutCh, <-stderrCh } func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) { sections := []routeStatusSection{ { Kind: "local", Name: "local", Routes: []routeStatusItem{{ Domain: "app.local", Image: "myapp:latest", ContainerStatus: "running", }}, }, { Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout", }, } var out bytes.Buffer err := renderRoutesStatusSections(&out, sections) require.NoError(t, err) rendered := stripANSI(out.String()) lines := strings.Split(rendered, "\n") lineIndex := func(substr string) int { for i, line := range lines { if strings.Contains(line, substr) { return i } } return -1 } titleIdx := lineIndex("Route Status") require.NotEqual(t, -1, titleIdx) localIdx := lineIndex("Local") require.NotEqual(t, -1, localIdx) routeIdx := lineIndex("app.local") require.NotEqual(t, -1, routeIdx) remoteIdx := lineIndex("Remote: remote-a") require.NotEqual(t, -1, remoteIdx) errorIdx := lineIndex("dial tcp timeout") require.NotEqual(t, -1, errorIdx) assert.Less(t, titleIdx, localIdx) assert.Less(t, localIdx, routeIdx) assert.Less(t, routeIdx, remoteIdx) assert.Less(t, remoteIdx, errorIdx) } func TestRenderRoutesStatusSections_DoesNotAddExtraBlankLineBetweenSections(t *testing.T) { sections := []routeStatusSection{ { Kind: "remote", Name: "remote-b", Routes: []routeStatusItem{{ Domain: "app.example.com", Image: "app:latest", ContainerStatus: "running", }}, }, { Kind: "remote", Name: "remote-a", Routes: []routeStatusItem{{ Domain: "dashboard.example.com", Image: "dashboard", ContainerStatus: "running", }}, }, } var out bytes.Buffer err := renderRoutesStatusSections(&out, sections) require.NoError(t, err) rendered := stripANSI(out.String()) assert.NotContains(t, rendered, "app:latest\n\n\nRemote: remote-a") assert.Contains(t, rendered, "app:latest\n\nRemote: remote-a") } func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) { sections := []routeListSection{ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"}, {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, } var out bytes.Buffer err := renderRoutesListSections(&out, sections) require.NoError(t, err) rendered := stripANSI(out.String()) assert.NotContains(t, rendered, "Local") assert.NotContains(t, rendered, "failed to initialize local control plane") assert.Contains(t, rendered, "Remote: remote-a") assert.Contains(t, rendered, "No routes configured") } func TestLoadRoutesListRemoteSection_UsesInventoryEndpoint(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "": _, _ = w.Write([]byte(`{"routes":[{"domain":"app.example.com","image":"app:latest"}]}`)) case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "detailed=true": w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"detailed route status unavailable"}`)) default: t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery) } })) defer srv.Close() section, err := loadRoutesListRemoteSection(context.Background(), "prod", remote.RemoteEntry{URL: srv.URL}) require.NoError(t, err) require.Empty(t, section.Error) require.Len(t, section.Routes, 1) assert.Equal(t, "app.example.com", section.Routes[0].Domain) assert.Equal(t, "app:latest", section.Routes[0].Image) } func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) { sections := []routeStatusSection{ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"}, {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, } var out bytes.Buffer err := renderRoutesStatusSections(&out, sections) require.NoError(t, err) rendered := stripANSI(out.String()) assert.NotContains(t, rendered, "Local") assert.NotContains(t, rendered, "failed to initialize local control plane") assert.Contains(t, rendered, "Remote: remote-a") assert.Contains(t, rendered, "No routes configured") } func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) { sections := []routeStatusSection{ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"}, {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"}, } var out bytes.Buffer err := renderRoutesStatusSections(&out, sections) require.NoError(t, err) rendered := stripANSI(out.String()) assert.Contains(t, rendered, "Local") assert.Contains(t, rendered, "failed to initialize local control plane") assert.Contains(t, rendered, "Remote: remote-a") assert.Contains(t, rendered, "dial tcp timeout") } func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) { var out bytes.Buffer report := &domain.CleanupReport{ Domain: "app.example.com", RemovedContainers: []domain.CleanupContainer{{ ID: "1234567890abcdef", Name: "gordon-app.example.com", }}, PreservedAttachments: []domain.CleanupAttachment{{ Name: "postgres", ContainerID: "attachment-1", Status: "running", }}, Warnings: []string{"volumes preserved"}, Hints: []string{"run diagnose"}, } err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report)) require.NoError(t, err) rendered := stripANSI(out.String()) assert.Contains(t, rendered, "Route removed: app.example.com") assert.Contains(t, rendered, "Removed containers:") assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)") assert.Contains(t, rendered, "Preserved attachments:") assert.Contains(t, rendered, "postgres (running)") assert.Contains(t, rendered, "volumes preserved") assert.Contains(t, rendered, "run diagnose") } func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { routes := []routeStatusItem{ {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"}, {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"}, {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"}, } var outA bytes.Buffer _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render())) var outB bytes.Buffer reordered := []routeStatusItem{routes[2], routes[0], routes[1]} _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render())) assert.Equal(t, outA.String(), outB.String()) } func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) { attachments := []domain.CleanupAttachment{ {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"}, {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"}, } matched := attachmentsForRouteDiagnosis(attachments, "app.example.com") require.Len(t, matched, 1) assert.Equal(t, "match", matched[0].ContainerID) } func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once() cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ Domain: "example.test", ContainerID: "route-1", ContainerStatus: "running", }}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{ {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}}, {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}}, }, nil).Once() cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once() cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once() diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test") require.NoError(t, err) require.Len(t, diag.Volumes, 1) assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name) } func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/admin/routes/example.test": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"route not found"}`)) case "/admin/routes/example.test/cleanup": _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`)) case "/admin/routes": require.Equal(t, "detailed=true", r.URL.RawQuery) _, _ = w.Write([]byte(`{"routes":[]}`)) case "/admin/health": _, _ = w.Write([]byte(`{"health":{}}`)) case "/admin/volumes": _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`)) case "/admin/attachments/orphans": _, _ = w.Write([]byte(`{"attachments":[]}`)) case "/admin/config": _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`)) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer srv.Close() cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test") require.NoError(t, err) require.Len(t, diag.Volumes, 1) assert.Equal(t, "custom-example-test-data-docs", diag.Volumes[0].Name) } func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once() cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ Domain: "app.example.test", ContainerID: "route-1", ContainerStatus: "running", Attachments: []dto.Attachment{{ Name: "postgres", Image: "postgres:16", ContainerID: "attachment-1", Status: "running", }}, }}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{ {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}}, }, nil).Once() cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once() cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once() diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test") require.NoError(t, err) require.Len(t, diag.Volumes, 1) assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name) } func TestBuildRouteDiagnosis_UsesCleanupPreviewForRemovedRouteRuntimeState(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/admin/routes/example.test": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"route not found"}`)) case "/admin/routes/example.test/cleanup": _, _ = w.Write([]byte(`{"domain":"example.test","orphaned_entities":[{"kind":"route_container","id":"ctr-1","name":"gordon-example.test","status":"running"}],"preserved_attachments":[{"name":"old-postgres","container_id":"att-1","owner":"example.test"}],"preserved_volumes":[{"name":"gordon-example-test-data"}]}`)) case "/admin/routes": require.Equal(t, "detailed=true", r.URL.RawQuery) _, _ = w.Write([]byte(`{"routes":[]}`)) case "/admin/health": _, _ = w.Write([]byte(`{"health":{}}`)) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer srv.Close() cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test") require.NoError(t, err) assert.False(t, diag.Configured) require.Len(t, diag.OrphanedEntities, 1) assert.Equal(t, "route_container", diag.OrphanedEntities[0].Kind) require.Len(t, diag.OrphanedAttachments, 1) assert.Equal(t, "old-postgres", diag.OrphanedAttachments[0].Name) require.Len(t, diag.Volumes, 1) assert.Equal(t, "gordon-example-test-data", diag.Volumes[0].Name) assert.Contains(t, diag.Warnings, "route is not configured but runtime state still exists") } func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/admin/routes/missing.example.test": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"route not found"}`)) case "/admin/routes/missing.example.test/cleanup": _, _ = w.Write([]byte(`{"domain":"missing.example.test"}`)) case "/admin/routes": require.Equal(t, "detailed=true", r.URL.RawQuery) _, _ = w.Write([]byte(`{"routes":[]}`)) case "/admin/health": _, _ = w.Write([]byte(`{"health":{}}`)) case "/admin/volumes": _, _ = w.Write([]byte(`[]`)) case "/admin/attachments/orphans": _, _ = w.Write([]byte(`{"attachments":[]}`)) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer srv.Close() cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) diag, err := buildRouteDiagnosis(context.Background(), cp, "missing.example.test") require.NoError(t, err) assert.False(t, diag.Configured) assert.Empty(t, diag.Warnings) } func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/admin/routes/example.test": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"route not found"}`)) case "/admin/routes/example.test/cleanup": _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`)) case "/admin/routes": require.Equal(t, "detailed=true", r.URL.RawQuery) _, _ = w.Write([]byte(`{"routes":[]}`)) case "/admin/health": _, _ = w.Write([]byte(`{"health":{}}`)) case "/admin/volumes": _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`)) case "/admin/attachments/orphans": _, _ = w.Write([]byte(`{"attachments":[]}`)) case "/admin/config": _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`)) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer srv.Close() cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) report, err := runRoutePurge(context.Background(), cp, "example.test", routePurgeOptions{Volumes: true}) require.NoError(t, err) require.Len(t, report.PreservedVolumes, 1) assert.Equal(t, "custom-example-test-data-docs", report.PreservedVolumes[0].Name) } func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once() cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ Domain: "app.example.test", ContainerID: "route-1", ContainerStatus: "running", }}, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return([]domain.CleanupAttachment{{ Name: "old-postgres", ContainerID: "attachment-1", Owner: "app.example.test", }}, nil).Once() diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test") require.NoError(t, err) assert.Contains(t, diag.Hints, "run 'gordon routes purge app.example.test --attachments' to review orphaned attachment cleanup for this route") } func TestWriteRouteDiagnosisText_UsesNeutralVolumeLabelForConfiguredRoutes(t *testing.T) { var out bytes.Buffer diag := &routeDiagnosis{ Domain: "app.example.test", Configured: true, Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"}, Runtime: &remote.RouteInfo{ Domain: "app.example.test", ContainerID: "route-1", ContainerStatus: "running", }, Volumes: []dto.Volume{{Name: "gordon-app-example-test-data"}}, } err := writeRouteDiagnosisText(&out, diag) require.NoError(t, err) rendered := stripANSI(out.String()) assert.Contains(t, rendered, "Volume: gordon-app-example-test-data") assert.NotContains(t, rendered, "Preserved volume:") } func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) { var out bytes.Buffer diag := &routeDiagnosis{ Domain: "app.example.test", Configured: true, Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"}, Runtime: &remote.RouteInfo{ Domain: "app.example.test", ContainerID: "route-1", ContainerStatus: "running", Attachments: []dto.Attachment{{ Name: "postgres", Image: "postgres:16", ContainerID: "attachment-1", Status: "running", }}, }, OrphanedAttachments: []domain.CleanupAttachment{{ Name: "old-postgres", ContainerID: "attachment-2", Owner: "app.example.test", }}, } err := writeRouteDiagnosisText(&out, diag) require.NoError(t, err) rendered := stripANSI(out.String()) assert.Contains(t, rendered, "Active attachments:") assert.Contains(t, rendered, "postgres (running)") assert.Contains(t, rendered, "Orphaned attachment: old-postgres") } func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) { cpMock := climocks.NewMockControlPlane(t) orphanedAttachments := []domain.CleanupAttachment{ {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"}, {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"}, } cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once() cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once() cpMock.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{ RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}}, }, nil).Once() report, err := runRoutePurge(context.Background(), cpMock, "app.example.com", routePurgeOptions{Force: true, Attachments: true}) require.NoError(t, err) require.NotNil(t, report) require.Len(t, report.RemovedContainers, 1) assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID) }