1package cli
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "sync/atomic"
16 "testing"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
20 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
21 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
22 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
23 "github.com/bnema/gordon/internal/domain"
24 "github.com/stretchr/testify/assert"
25 "github.com/stretchr/testify/require"
26)
27
28func TestTruncateImage(t *testing.T) {
29 tests := []struct {
30 name string
31 image string
32 maxLen int
33 expected string
34 }{
35 // Basic cases - no truncation needed
36 {
37 name: "short image fits",
38 image: "nginx:latest",
39 maxLen: 20,
40 expected: "nginx:latest",
41 },
42 {
43 name: "exact fit",
44 image: "nginx:latest",
45 maxLen: 12,
46 expected: "nginx:latest",
47 },
48
49 // Regular tag truncation
50 {
51 name: "truncate long tag with ellipsis",
52 image: "registry.test.com/test:v1234567890",
53 maxLen: 30,
54 expected: "registry.test.com/test:v123...",
55 },
56 {
57 name: "truncate very long image",
58 image: "registry.example.com/organization/project/image:v1.2.3-beta.4",
59 maxLen: 35,
60 expected: "registry.example.com/organizatio...",
61 },
62
63 // Digest truncation
64 {
65 name: "digest shortened to 12 chars",
66 image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
67 maxLen: 50,
68 expected: "myapp@sha256:a3ed95caeb02",
69 },
70 {
71 name: "digest truncated with ellipsis when too long",
72 image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
73 maxLen: 35,
74 expected: "registry.example.com/org/app@sha...",
75 },
76 {
77 name: "short digest fits",
78 image: "app@sha256:abc123",
79 maxLen: 30,
80 expected: "app@sha256:abc123",
81 },
82
83 // Edge cases
84 {
85 name: "maxLen zero returns empty",
86 image: "nginx:latest",
87 maxLen: 0,
88 expected: "",
89 },
90 {
91 name: "maxLen negative returns empty",
92 image: "nginx:latest",
93 maxLen: -1,
94 expected: "",
95 },
96 {
97 name: "maxLen 3 or less - no ellipsis",
98 image: "nginx:latest",
99 maxLen: 3,
100 expected: "ngi",
101 },
102 {
103 name: "maxLen 4 - truncate with ellipsis",
104 image: "nginx:latest",
105 maxLen: 4,
106 expected: "n...",
107 },
108 {
109 name: "empty image",
110 image: "",
111 maxLen: 10,
112 expected: "",
113 },
114 }
115
116 for _, tt := range tests {
117 t.Run(tt.name, func(t *testing.T) {
118 result := truncateImage(tt.image, tt.maxLen)
119 assert.Equal(t, tt.expected, result)
120 })
121 }
122}
123
124func TestHTTPHealthToStatus(t *testing.T) {
125 tests := []struct {
126 name string
127 health *remote.RouteHealth
128 expected components.Status
129 }{
130 {
131 name: "nil health returns unknown",
132 health: nil,
133 expected: components.StatusUnknown,
134 },
135 {
136 name: "zero status no error returns unknown",
137 health: &remote.RouteHealth{HTTPStatus: 0},
138 expected: components.StatusUnknown,
139 },
140 {
141 name: "zero status with error returns error",
142 health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
143 expected: components.StatusError,
144 },
145 {
146 name: "200 returns success",
147 health: &remote.RouteHealth{HTTPStatus: 200},
148 expected: components.StatusSuccess,
149 },
150 {
151 name: "301 returns success",
152 health: &remote.RouteHealth{HTTPStatus: 301},
153 expected: components.StatusSuccess,
154 },
155 {
156 name: "500 returns error",
157 health: &remote.RouteHealth{HTTPStatus: 500},
158 expected: components.StatusError,
159 },
160 {
161 name: "404 returns error",
162 health: &remote.RouteHealth{HTTPStatus: 404},
163 expected: components.StatusError,
164 },
165 {
166 name: "100 informational returns error",
167 health: &remote.RouteHealth{HTTPStatus: 100},
168 expected: components.StatusError,
169 },
170 }
171
172 for _, tt := range tests {
173 t.Run(tt.name, func(t *testing.T) {
174 result := httpHealthToStatus(tt.health)
175 assert.Equal(t, tt.expected, result)
176 })
177 }
178}
179
180func TestGroupRoutesByNetwork(t *testing.T) {
181 routes := []remote.RouteInfo{
182 {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
183 {Domain: "beta.dev", Network: "gordon-shared"},
184 {Domain: "gamma.dev", Network: "gordon-shared"},
185 {Domain: "delta.dev", Network: "gordon-delta-dev"},
186 }
187
188 groups, solo := groupRoutesByNetwork(routes)
189
190 assert.Len(t, solo, 2)
191 assert.Equal(t, "alpha.dev", solo[0].Domain)
192 assert.Equal(t, "delta.dev", solo[1].Domain)
193
194 assert.Len(t, groups, 1)
195 assert.Equal(t, "shared", groups[0].name)
196 assert.Len(t, groups[0].routes, 2)
197}
198
199func TestStripNetworkPrefix(t *testing.T) {
200 tests := []struct {
201 network string
202 expected string
203 }{
204 {"gordon-shared-services", "shared-services"},
205 {"gordon-my-app-dev", "my-app-dev"},
206 {"custom-network", "custom-network"},
207 {"gordon-", ""},
208 {"", ""},
209 }
210
211 for _, tt := range tests {
212 t.Run(tt.network, func(t *testing.T) {
213 assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
214 })
215 }
216}
217
218func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
219 originalRemoteFlag := remoteFlag
220 originalTokenFlag := tokenFlag
221 originalInsecureTLSFlag := insecureTLSFlag
222 t.Cleanup(func() {
223 remoteFlag = originalRemoteFlag
224 tokenFlag = originalTokenFlag
225 insecureTLSFlag = originalInsecureTLSFlag
226 })
227
228 remoteFlag = ""
229 tokenFlag = ""
230 insecureTLSFlag = false
231
232 configHome := t.TempDir()
233 t.Setenv("XDG_CONFIG_HOME", configHome)
234 t.Setenv("HOME", t.TempDir())
235 t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
236
237 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
238 require.NoError(t, os.MkdirAll(configPath, 0o755))
239
240 resolved, ok, err := resolveRoutesExplicitRemote()
241 require.True(t, ok)
242 require.NoError(t, err)
243 require.NotNil(t, resolved)
244 assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
245 assert.Empty(t, resolved.Name)
246}
247
248func TestResolveRoutesExplicitRemote_ReturnsErrorForUnreadableNamedRemoteConfig(t *testing.T) {
249 originalRemoteFlag := remoteFlag
250 originalTokenFlag := tokenFlag
251 originalInsecureTLSFlag := insecureTLSFlag
252 t.Cleanup(func() {
253 remoteFlag = originalRemoteFlag
254 tokenFlag = originalTokenFlag
255 insecureTLSFlag = originalInsecureTLSFlag
256 })
257
258 remoteFlag = "missing-remote"
259 tokenFlag = ""
260 insecureTLSFlag = false
261
262 configHome := t.TempDir()
263 t.Setenv("XDG_CONFIG_HOME", configHome)
264 t.Setenv("HOME", t.TempDir())
265 t.Setenv("GORDON_REMOTE", "")
266
267 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
268 require.NoError(t, os.MkdirAll(configPath, 0o755))
269
270 resolved, ok, err := resolveRoutesExplicitRemote()
271 require.Error(t, err)
272 require.False(t, ok)
273 require.Nil(t, resolved)
274 assert.Contains(t, err.Error(), "failed to read remotes")
275}
276
277func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
278 item := routeStatusItem{
279 Domain: "app.example.com",
280 Image: "app:latest",
281 ContainerStatus: "running",
282 HTTPStatus: 0,
283 HealthError: "connection refused",
284 }
285
286 expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
287 components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
288 " app.example.com"
289
290 assert.Equal(t, expected, routeStatusTitle(item))
291}
292
293func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
294 cpMock := climocks.NewMockControlPlane(t)
295 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
296 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once()
297
298 var out bytes.Buffer
299 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true)
300
301 require.NoError(t, err)
302
303 var got map[string]any
304 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
305 assert.Equal(t, "app.example.com", got["domain"])
306 assert.Equal(t, "app:latest", got["image"])
307 assert.Equal(t, "unknown", got["container_status"])
308 assert.Equal(t, float64(0), got["http_status"])
309 assert.Equal(t, "probe failed", got["health_error"])
310}
311
312func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
313 cpMock := climocks.NewMockControlPlane(t)
314 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
315 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once()
316
317 var out bytes.Buffer
318 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false)
319
320 require.NoError(t, err)
321
322 text := stripANSI(out.String())
323 assert.Contains(t, text, "Route: app.example.com")
324 assert.Contains(t, text, "Domain:")
325 assert.Contains(t, text, "Image:")
326 assert.Contains(t, text, "Container:")
327 assert.Contains(t, text, "probe failed")
328}
329
330func TestRunRoutesShow_JSONIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
331 cpMock := climocks.NewMockControlPlane(t)
332 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
333 cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{
334 "app.example.com": {
335 HTTPStatus: 503,
336 ContainerStatus: "",
337 Error: "probe failed",
338 },
339 }, nil).Once()
340
341 var out bytes.Buffer
342 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true)
343
344 require.NoError(t, err)
345
346 var got map[string]any
347 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
348 assert.Equal(t, "app.example.com", got["domain"])
349 assert.Equal(t, "app:latest", got["image"])
350 assert.Equal(t, "unknown", got["container_status"])
351 assert.Equal(t, float64(503), got["http_status"])
352 assert.Equal(t, "probe failed", got["health_error"])
353}
354
355func TestRunRoutesShow_TextIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
356 cpMock := climocks.NewMockControlPlane(t)
357 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
358 cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{
359 "app.example.com": {
360 HTTPStatus: 503,
361 ContainerStatus: "",
362 Error: "probe failed",
363 },
364 }, nil).Once()
365
366 var out bytes.Buffer
367 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false)
368
369 require.NoError(t, err)
370
371 text := stripANSI(out.String())
372 assert.Contains(t, text, "Route: app.example.com")
373 assert.Contains(t, text, "Container:")
374 assert.Contains(t, text, "unknown")
375 assert.Contains(t, text, "probe failed")
376}
377
378func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
379 testsDeps := routesListDeps{
380 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
381 return nil, false, nil
382 },
383 loadLocal: func(context.Context, string) (routeListSection, error) {
384 return routeListSection{
385 Kind: "local",
386 Name: "local",
387 Routes: []routeListItem{{
388 Domain: "app.local",
389 Image: "myapp:latest",
390 }},
391 }, nil
392 },
393 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
394 return map[string]remote.RemoteEntry{
395 "remote-a": {URL: "https://remote-a.example.com"},
396 "remote-b": {URL: "https://remote-b.example.com"},
397 }, "remote-a", nil
398 },
399 loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
400 if name == "remote-b" {
401 return routeListSection{
402 Kind: "remote",
403 Name: name,
404 URL: entry.URL,
405 Error: "remote unavailable",
406 }, nil
407 }
408 return routeListSection{
409 Kind: "remote",
410 Name: name,
411 URL: entry.URL,
412 Routes: []routeListItem{{
413 Domain: "dashboard.example.com",
414 Image: "dashboard",
415 }},
416 }, nil
417 },
418 }
419
420 sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
421
422 require.NoError(t, err)
423 require.Len(t, sections, 3)
424 assert.Equal(t, "local", sections[0].Name)
425 assert.Equal(t, "remote-a", sections[1].Name)
426 assert.Equal(t, "remote-b", sections[2].Name)
427 assert.Equal(t, "dashboard.example.com", sections[1].Routes[0].Domain)
428 assert.Equal(t, "remote unavailable", sections[2].Error)
429}
430
431func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
432 testsDeps := routesListDeps{
433 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
434 return &remote.ResolvedRemote{
435 Name: "remote-a",
436 URL: "https://remote-a.example.com",
437 }, true, nil
438 },
439 loadLocal: func(context.Context, string) (routeListSection, error) {
440 t.Fatal("loadLocal should not be called when an explicit remote is selected")
441 return routeListSection{}, nil
442 },
443 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
444 t.Fatal("listRemotes should not be called when an explicit remote is selected")
445 return nil, "", nil
446 },
447 loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
448 return routeListSection{
449 Kind: "remote",
450 Name: name,
451 URL: entry.URL,
452 Routes: []routeListItem{{
453 Domain: "test.example.com",
454 Image: "hello-app",
455 }},
456 }, nil
457 },
458 }
459
460 sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
461
462 require.NoError(t, err)
463 require.Len(t, sections, 1)
464 assert.Equal(t, "remote-a", sections[0].Name)
465 assert.Equal(t, "test.example.com", sections[0].Routes[0].Domain)
466}
467
468func TestCollectRoutesSections_ExplicitRemoteResolutionErrorReturnsError(t *testing.T) {
469
470 t.Run("list", func(t *testing.T) {
471 var loadLocalCalls atomic.Int32
472 var listRemotesCalls atomic.Int32
473 var loadRemoteCalls atomic.Int32
474
475 deps := routesListDeps{
476 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
477 return nil, false, errors.New("failed to read remotes: boom")
478 },
479 loadLocal: func(context.Context, string) (routeListSection, error) {
480 loadLocalCalls.Add(1)
481 return routeListSection{}, nil
482 },
483 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
484 listRemotesCalls.Add(1)
485 return map[string]remote.RemoteEntry{}, "", nil
486 },
487 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
488 loadRemoteCalls.Add(1)
489 return routeListSection{}, nil
490 },
491 }
492
493 sections, err := collectRoutesListSections(context.Background(), "", deps)
494
495 require.Error(t, err)
496 require.Nil(t, sections)
497 assert.Contains(t, err.Error(), "failed to read remotes")
498 assert.Zero(t, loadLocalCalls.Load())
499 assert.Zero(t, listRemotesCalls.Load())
500 assert.Zero(t, loadRemoteCalls.Load())
501 })
502
503 t.Run("status", func(t *testing.T) {
504 var loadLocalCalls atomic.Int32
505 var listRemotesCalls atomic.Int32
506 var loadRemoteCalls atomic.Int32
507
508 deps := routesStatusDeps{
509 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
510 return nil, false, errors.New("failed to read remotes: boom")
511 },
512 loadLocal: func(context.Context, string) (routeStatusSection, error) {
513 loadLocalCalls.Add(1)
514 return routeStatusSection{}, nil
515 },
516 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
517 listRemotesCalls.Add(1)
518 return map[string]remote.RemoteEntry{}, "", nil
519 },
520 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
521 loadRemoteCalls.Add(1)
522 return routeStatusSection{}, nil
523 },
524 }
525
526 sections, err := collectRoutesStatusSections(context.Background(), "", deps)
527
528 require.Error(t, err)
529 require.Nil(t, sections)
530 assert.Contains(t, err.Error(), "failed to read remotes")
531 assert.Zero(t, loadLocalCalls.Load())
532 assert.Zero(t, listRemotesCalls.Load())
533 assert.Zero(t, loadRemoteCalls.Load())
534 })
535}
536
537func TestLoadRoutesListExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
538 section := loadRoutesListExplicitRemoteSection(context.Background(), routesListDeps{
539 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
540 return routeListSection{}, errors.New("boom")
541 },
542 }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
543
544 assert.Equal(t, "remote", section.Kind)
545 assert.Equal(t, "remote-a", section.Name)
546 assert.Equal(t, "https://remote-a.example.com", section.URL)
547 assert.Equal(t, "boom", section.Error)
548}
549
550func TestLoadRoutesStatusExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
551 section := loadRoutesStatusExplicitRemoteSection(context.Background(), routesStatusDeps{
552 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
553 return routeStatusSection{}, errors.New("boom")
554 },
555 }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
556
557 assert.Equal(t, "remote", section.Kind)
558 assert.Equal(t, "remote-a", section.Name)
559 assert.Equal(t, "https://remote-a.example.com", section.URL)
560 assert.Equal(t, "boom", section.Error)
561}
562
563func TestCollectRoutesListAggregateSections_EncodesLoaderErrors(t *testing.T) {
564 t.Run("local loader error", func(t *testing.T) {
565 sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
566 loadLocal: func(context.Context, string) (routeListSection, error) {
567 return routeListSection{Kind: "local", Name: "local"}, errors.New("local failed")
568 },
569 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
570 return map[string]remote.RemoteEntry{}, "", nil
571 },
572 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
573 return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
574 },
575 })
576
577 require.NoError(t, err)
578 require.Len(t, sections, 1)
579 assert.Equal(t, "local", sections[0].Kind)
580 assert.Equal(t, "local failed", sections[0].Error)
581 })
582
583 t.Run("remote loader error", func(t *testing.T) {
584 sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
585 loadLocal: func(context.Context, string) (routeListSection, error) {
586 return routeListSection{Kind: "local", Name: "local"}, nil
587 },
588 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
589 return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
590 },
591 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
592 return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
593 },
594 })
595
596 require.NoError(t, err)
597 require.Len(t, sections, 2)
598 assert.Equal(t, "local", sections[0].Kind)
599 assert.Equal(t, "remote", sections[1].Kind)
600 assert.Equal(t, "remote failed", sections[1].Error)
601 })
602}
603
604func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T) {
605 t.Run("local loader error", func(t *testing.T) {
606 sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
607 loadLocal: func(context.Context, string) (routeStatusSection, error) {
608 return routeStatusSection{Kind: "local", Name: "local"}, errors.New("local failed")
609 },
610 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
611 return map[string]remote.RemoteEntry{}, "", nil
612 },
613 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
614 return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
615 },
616 })
617
618 require.NoError(t, err)
619 require.Len(t, sections, 1)
620 assert.Equal(t, "local", sections[0].Kind)
621 assert.Equal(t, "local failed", sections[0].Error)
622 })
623
624 t.Run("remote loader error", func(t *testing.T) {
625 sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
626 loadLocal: func(context.Context, string) (routeStatusSection, error) {
627 return routeStatusSection{Kind: "local", Name: "local"}, nil
628 },
629 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
630 return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
631 },
632 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
633 return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
634 },
635 })
636
637 require.NoError(t, err)
638 require.Len(t, sections, 2)
639 assert.Equal(t, "local", sections[0].Kind)
640 assert.Equal(t, "remote", sections[1].Kind)
641 assert.Equal(t, "remote failed", sections[1].Error)
642 })
643}
644
645func TestLoadRoutesStatusLocalSection_DoesNotLeakKernelInitLogs(t *testing.T) {
646 tmpDir := t.TempDir()
647 cfgPath := filepath.Join(tmpDir, "gordon.toml")
648 require.NoError(t, os.WriteFile(cfgPath, []byte(`[server]
649gordon_domain = "gordon.local"
650data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
651
652[auth]
653enabled = true
654`), 0o600))
655
656 stdout, stderr := captureOutput(t, func() {
657 section, err := loadRoutesStatusLocalSection(context.Background(), cfgPath)
658 require.NoError(t, err)
659 assert.Equal(t, "local", section.Kind)
660 assert.Equal(t, "local", section.Name)
661 assert.Contains(t, section.Error, "failed to initialize local control plane")
662 assert.Contains(t, section.Error, "auth.secrets_backend is required")
663 })
664
665 combined := stdout + stderr
666 assert.NotContains(t, combined, "failed to resolve secrets backend")
667 assert.NotContains(t, combined, "local kernel running in minimal mode")
668}
669
670func captureOutput(t *testing.T, fn func()) (string, string) {
671 t.Helper()
672
673 oldStdout, oldStderr := os.Stdout, os.Stderr
674 outR, outW, err := os.Pipe()
675 require.NoError(t, err)
676 errR, errW, err := os.Pipe()
677 require.NoError(t, err)
678
679 os.Stdout = outW
680 os.Stderr = errW
681
682 stdoutCh := make(chan string, 1)
683 stderrCh := make(chan string, 1)
684 go func() {
685 var buf bytes.Buffer
686 _, _ = io.Copy(&buf, outR)
687 stdoutCh <- buf.String()
688 }()
689 go func() {
690 var buf bytes.Buffer
691 _, _ = io.Copy(&buf, errR)
692 stderrCh <- buf.String()
693 }()
694
695 defer func() {
696 os.Stdout = oldStdout
697 os.Stderr = oldStderr
698 }()
699
700 fn()
701
702 require.NoError(t, outW.Close())
703 require.NoError(t, errW.Close())
704
705 return <-stdoutCh, <-stderrCh
706}
707
708func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
709 sections := []routeStatusSection{
710 {
711 Kind: "local",
712 Name: "local",
713 Routes: []routeStatusItem{{
714 Domain: "app.local",
715 Image: "myapp:latest",
716 ContainerStatus: "running",
717 }},
718 },
719 {
720 Kind: "remote",
721 Name: "remote-a",
722 URL: "https://remote-a.example.com",
723 Error: "dial tcp timeout",
724 },
725 }
726
727 var out bytes.Buffer
728 err := renderRoutesStatusSections(&out, sections)
729
730 require.NoError(t, err)
731 rendered := stripANSI(out.String())
732 lines := strings.Split(rendered, "\n")
733
734 lineIndex := func(substr string) int {
735 for i, line := range lines {
736 if strings.Contains(line, substr) {
737 return i
738 }
739 }
740 return -1
741 }
742
743 titleIdx := lineIndex("Route Status")
744 require.NotEqual(t, -1, titleIdx)
745
746 localIdx := lineIndex("Local")
747 require.NotEqual(t, -1, localIdx)
748
749 routeIdx := lineIndex("app.local")
750 require.NotEqual(t, -1, routeIdx)
751
752 remoteIdx := lineIndex("Remote: remote-a")
753 require.NotEqual(t, -1, remoteIdx)
754
755 errorIdx := lineIndex("dial tcp timeout")
756 require.NotEqual(t, -1, errorIdx)
757
758 assert.Less(t, titleIdx, localIdx)
759 assert.Less(t, localIdx, routeIdx)
760 assert.Less(t, routeIdx, remoteIdx)
761 assert.Less(t, remoteIdx, errorIdx)
762}
763
764func TestRenderRoutesStatusSections_DoesNotAddExtraBlankLineBetweenSections(t *testing.T) {
765 sections := []routeStatusSection{
766 {
767 Kind: "remote",
768 Name: "remote-b",
769 Routes: []routeStatusItem{{
770 Domain: "app.example.com",
771 Image: "app:latest",
772 ContainerStatus: "running",
773 }},
774 },
775 {
776 Kind: "remote",
777 Name: "remote-a",
778 Routes: []routeStatusItem{{
779 Domain: "dashboard.example.com",
780 Image: "dashboard",
781 ContainerStatus: "running",
782 }},
783 },
784 }
785
786 var out bytes.Buffer
787 err := renderRoutesStatusSections(&out, sections)
788
789 require.NoError(t, err)
790 rendered := stripANSI(out.String())
791 assert.NotContains(t, rendered, "app:latest\n\n\nRemote: remote-a")
792 assert.Contains(t, rendered, "app:latest\n\nRemote: remote-a")
793}
794
795func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
796 sections := []routeListSection{
797 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
798 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
799 }
800
801 var out bytes.Buffer
802 err := renderRoutesListSections(&out, sections)
803
804 require.NoError(t, err)
805 rendered := stripANSI(out.String())
806 assert.NotContains(t, rendered, "Local")
807 assert.NotContains(t, rendered, "failed to initialize local control plane")
808 assert.Contains(t, rendered, "Remote: remote-a")
809 assert.Contains(t, rendered, "No routes configured")
810}
811
812func TestLoadRoutesListRemoteSection_UsesInventoryEndpoint(t *testing.T) {
813 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
814 w.Header().Set("Content-Type", "application/json")
815 switch {
816 case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "":
817 _, _ = w.Write([]byte(`{"routes":[{"domain":"app.example.com","image":"app:latest"}]}`))
818 case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "detailed=true":
819 w.WriteHeader(http.StatusInternalServerError)
820 _, _ = w.Write([]byte(`{"error":"detailed route status unavailable"}`))
821 default:
822 t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery)
823 }
824 }))
825 defer srv.Close()
826
827 section, err := loadRoutesListRemoteSection(context.Background(), "prod", remote.RemoteEntry{URL: srv.URL})
828
829 require.NoError(t, err)
830 require.Empty(t, section.Error)
831 require.Len(t, section.Routes, 1)
832 assert.Equal(t, "app.example.com", section.Routes[0].Domain)
833 assert.Equal(t, "app:latest", section.Routes[0].Image)
834}
835
836func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
837 sections := []routeStatusSection{
838 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
839 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
840 }
841
842 var out bytes.Buffer
843 err := renderRoutesStatusSections(&out, sections)
844
845 require.NoError(t, err)
846 rendered := stripANSI(out.String())
847 assert.NotContains(t, rendered, "Local")
848 assert.NotContains(t, rendered, "failed to initialize local control plane")
849 assert.Contains(t, rendered, "Remote: remote-a")
850 assert.Contains(t, rendered, "No routes configured")
851}
852
853func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
854 sections := []routeStatusSection{
855 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
856 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"},
857 }
858
859 var out bytes.Buffer
860 err := renderRoutesStatusSections(&out, sections)
861
862 require.NoError(t, err)
863 rendered := stripANSI(out.String())
864 assert.Contains(t, rendered, "Local")
865 assert.Contains(t, rendered, "failed to initialize local control plane")
866 assert.Contains(t, rendered, "Remote: remote-a")
867 assert.Contains(t, rendered, "dial tcp timeout")
868}
869
870func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
871 var out bytes.Buffer
872 report := &domain.CleanupReport{
873 Domain: "app.example.com",
874 RemovedContainers: []domain.CleanupContainer{{
875 ID: "1234567890abcdef",
876 Name: "gordon-app.example.com",
877 }},
878 PreservedAttachments: []domain.CleanupAttachment{{
879 Name: "postgres",
880 ContainerID: "attachment-1",
881 Status: "running",
882 }},
883 Warnings: []string{"volumes preserved"},
884 Hints: []string{"run diagnose"},
885 }
886
887 err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report))
888 require.NoError(t, err)
889 rendered := stripANSI(out.String())
890 assert.Contains(t, rendered, "Route removed: app.example.com")
891 assert.Contains(t, rendered, "Removed containers:")
892 assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)")
893 assert.Contains(t, rendered, "Preserved attachments:")
894 assert.Contains(t, rendered, "postgres (running)")
895 assert.Contains(t, rendered, "volumes preserved")
896 assert.Contains(t, rendered, "run diagnose")
897}
898
899func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
900 routes := []routeStatusItem{
901 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
902 {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
903 {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
904 }
905
906 var outA bytes.Buffer
907 _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
908
909 var outB bytes.Buffer
910 reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
911 _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
912
913 assert.Equal(t, outA.String(), outB.String())
914}
915
916func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
917 attachments := []domain.CleanupAttachment{
918 {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"},
919 {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"},
920 }
921
922 matched := attachmentsForRouteDiagnosis(attachments, "app.example.com")
923
924 require.Len(t, matched, 1)
925 assert.Equal(t, "match", matched[0].ContainerID)
926}
927
928func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) {
929 cpMock := climocks.NewMockControlPlane(t)
930 cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once()
931 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
932 Domain: "example.test",
933 ContainerID: "route-1",
934 ContainerStatus: "running",
935 }}, nil).Once()
936 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
937 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
938 {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},
939 {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
940 }, nil).Once()
941 cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
942 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
943
944 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
945
946 require.NoError(t, err)
947 require.Len(t, diag.Volumes, 1)
948 assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
949}
950
951func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *testing.T) {
952 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
953 w.Header().Set("Content-Type", "application/json")
954 switch r.URL.Path {
955 case "/admin/routes/example.test":
956 w.WriteHeader(http.StatusNotFound)
957 _, _ = w.Write([]byte(`{"error":"route not found"}`))
958 case "/admin/routes/example.test/cleanup":
959 _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
960 case "/admin/routes":
961 require.Equal(t, "detailed=true", r.URL.RawQuery)
962 _, _ = w.Write([]byte(`{"routes":[]}`))
963 case "/admin/health":
964 _, _ = w.Write([]byte(`{"health":{}}`))
965 case "/admin/volumes":
966 _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`))
967 case "/admin/attachments/orphans":
968 _, _ = w.Write([]byte(`{"attachments":[]}`))
969 case "/admin/config":
970 _, _ = 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":[]}`))
971 default:
972 t.Fatalf("unexpected path: %s", r.URL.Path)
973 }
974 }))
975 defer srv.Close()
976
977 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
978 diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
979
980 require.NoError(t, err)
981 require.Len(t, diag.Volumes, 1)
982 assert.Equal(t, "custom-example-test-data-docs", diag.Volumes[0].Name)
983}
984
985func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
986 cpMock := climocks.NewMockControlPlane(t)
987 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
988 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
989 Domain: "app.example.test",
990 ContainerID: "route-1",
991 ContainerStatus: "running",
992 Attachments: []dto.Attachment{{
993 Name: "postgres",
994 Image: "postgres:16",
995 ContainerID: "attachment-1",
996 Status: "running",
997 }},
998 }}, nil).Once()
999 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1000 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
1001 {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
1002 }, nil).Once()
1003 cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
1004 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
1005
1006 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
1007
1008 require.NoError(t, err)
1009 require.Len(t, diag.Volumes, 1)
1010 assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
1011}
1012
1013func TestBuildRouteDiagnosis_UsesCleanupPreviewForRemovedRouteRuntimeState(t *testing.T) {
1014 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1015 w.Header().Set("Content-Type", "application/json")
1016 switch r.URL.Path {
1017 case "/admin/routes/example.test":
1018 w.WriteHeader(http.StatusNotFound)
1019 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1020 case "/admin/routes/example.test/cleanup":
1021 _, _ = 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"}]}`))
1022 case "/admin/routes":
1023 require.Equal(t, "detailed=true", r.URL.RawQuery)
1024 _, _ = w.Write([]byte(`{"routes":[]}`))
1025 case "/admin/health":
1026 _, _ = w.Write([]byte(`{"health":{}}`))
1027 default:
1028 t.Fatalf("unexpected path: %s", r.URL.Path)
1029 }
1030 }))
1031 defer srv.Close()
1032
1033 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1034 diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
1035
1036 require.NoError(t, err)
1037 assert.False(t, diag.Configured)
1038 require.Len(t, diag.OrphanedEntities, 1)
1039 assert.Equal(t, "route_container", diag.OrphanedEntities[0].Kind)
1040 require.Len(t, diag.OrphanedAttachments, 1)
1041 assert.Equal(t, "old-postgres", diag.OrphanedAttachments[0].Name)
1042 require.Len(t, diag.Volumes, 1)
1043 assert.Equal(t, "gordon-example-test-data", diag.Volumes[0].Name)
1044 assert.Contains(t, diag.Warnings, "route is not configured but runtime state still exists")
1045}
1046
1047func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) {
1048 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1049 w.Header().Set("Content-Type", "application/json")
1050 switch r.URL.Path {
1051 case "/admin/routes/missing.example.test":
1052 w.WriteHeader(http.StatusNotFound)
1053 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1054 case "/admin/routes/missing.example.test/cleanup":
1055 _, _ = w.Write([]byte(`{"domain":"missing.example.test"}`))
1056 case "/admin/routes":
1057 require.Equal(t, "detailed=true", r.URL.RawQuery)
1058 _, _ = w.Write([]byte(`{"routes":[]}`))
1059 case "/admin/health":
1060 _, _ = w.Write([]byte(`{"health":{}}`))
1061 case "/admin/volumes":
1062 _, _ = w.Write([]byte(`[]`))
1063 case "/admin/attachments/orphans":
1064 _, _ = w.Write([]byte(`{"attachments":[]}`))
1065 default:
1066 t.Fatalf("unexpected path: %s", r.URL.Path)
1067 }
1068 }))
1069 defer srv.Close()
1070
1071 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1072 diag, err := buildRouteDiagnosis(context.Background(), cp, "missing.example.test")
1073
1074 require.NoError(t, err)
1075 assert.False(t, diag.Configured)
1076 assert.Empty(t, diag.Warnings)
1077}
1078
1079func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) {
1080 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1081 w.Header().Set("Content-Type", "application/json")
1082 switch r.URL.Path {
1083 case "/admin/routes/example.test":
1084 w.WriteHeader(http.StatusNotFound)
1085 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1086 case "/admin/routes/example.test/cleanup":
1087 _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
1088 case "/admin/routes":
1089 require.Equal(t, "detailed=true", r.URL.RawQuery)
1090 _, _ = w.Write([]byte(`{"routes":[]}`))
1091 case "/admin/health":
1092 _, _ = w.Write([]byte(`{"health":{}}`))
1093 case "/admin/volumes":
1094 _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`))
1095 case "/admin/attachments/orphans":
1096 _, _ = w.Write([]byte(`{"attachments":[]}`))
1097 case "/admin/config":
1098 _, _ = 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":[]}`))
1099 default:
1100 t.Fatalf("unexpected path: %s", r.URL.Path)
1101 }
1102 }))
1103 defer srv.Close()
1104
1105 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1106 report, err := runRoutePurge(context.Background(), cp, "example.test", routePurgeOptions{Volumes: true})
1107
1108 require.NoError(t, err)
1109 require.Len(t, report.PreservedVolumes, 1)
1110 assert.Equal(t, "custom-example-test-data-docs", report.PreservedVolumes[0].Name)
1111}
1112
1113func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) {
1114 cpMock := climocks.NewMockControlPlane(t)
1115 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
1116 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
1117 Domain: "app.example.test",
1118 ContainerID: "route-1",
1119 ContainerStatus: "running",
1120 }}, nil).Once()
1121 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1122 cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
1123 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return([]domain.CleanupAttachment{{
1124 Name: "old-postgres",
1125 ContainerID: "attachment-1",
1126 Owner: "app.example.test",
1127 }}, nil).Once()
1128
1129 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
1130
1131 require.NoError(t, err)
1132 assert.Contains(t, diag.Hints, "run 'gordon routes purge app.example.test --attachments' to review orphaned attachment cleanup for this route")
1133}
1134
1135func TestWriteRouteDiagnosisText_UsesNeutralVolumeLabelForConfiguredRoutes(t *testing.T) {
1136 var out bytes.Buffer
1137 diag := &routeDiagnosis{
1138 Domain: "app.example.test",
1139 Configured: true,
1140 Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"},
1141 Runtime: &remote.RouteInfo{
1142 Domain: "app.example.test",
1143 ContainerID: "route-1",
1144 ContainerStatus: "running",
1145 },
1146 Volumes: []dto.Volume{{Name: "gordon-app-example-test-data"}},
1147 }
1148
1149 err := writeRouteDiagnosisText(&out, diag)
1150 require.NoError(t, err)
1151 rendered := stripANSI(out.String())
1152 assert.Contains(t, rendered, "Volume: gordon-app-example-test-data")
1153 assert.NotContains(t, rendered, "Preserved volume:")
1154}
1155
1156func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) {
1157 var out bytes.Buffer
1158 diag := &routeDiagnosis{
1159 Domain: "app.example.test",
1160 Configured: true,
1161 Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"},
1162 Runtime: &remote.RouteInfo{
1163 Domain: "app.example.test",
1164 ContainerID: "route-1",
1165 ContainerStatus: "running",
1166 Attachments: []dto.Attachment{{
1167 Name: "postgres",
1168 Image: "postgres:16",
1169 ContainerID: "attachment-1",
1170 Status: "running",
1171 }},
1172 },
1173 OrphanedAttachments: []domain.CleanupAttachment{{
1174 Name: "old-postgres",
1175 ContainerID: "attachment-2",
1176 Owner: "app.example.test",
1177 }},
1178 }
1179
1180 err := writeRouteDiagnosisText(&out, diag)
1181 require.NoError(t, err)
1182 rendered := stripANSI(out.String())
1183 assert.Contains(t, rendered, "Active attachments:")
1184 assert.Contains(t, rendered, "postgres (running)")
1185 assert.Contains(t, rendered, "Orphaned attachment: old-postgres")
1186}
1187
1188func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
1189 cpMock := climocks.NewMockControlPlane(t)
1190 orphanedAttachments := []domain.CleanupAttachment{
1191 {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"},
1192 {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"},
1193 }
1194 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
1195 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once()
1196 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1197 cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
1198 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once()
1199 cpMock.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{
1200 RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}},
1201 }, nil).Once()
1202
1203 report, err := runRoutePurge(context.Background(), cpMock, "app.example.com", routePurgeOptions{Force: true, Attachments: true})
1204
1205 require.NoError(t, err)
1206 require.NotNil(t, report)
1207 require.Len(t, report.RemovedContainers, 1)
1208 assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID)
1209}