1package cli
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "sync/atomic"
14 "testing"
15
16 "github.com/bnema/gordon/internal/adapters/dto"
17 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
20 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
21 "github.com/bnema/gordon/internal/domain"
22 "github.com/stretchr/testify/assert"
23 "github.com/stretchr/testify/require"
24)
25
26func TestTruncateImage(t *testing.T) {
27 tests := []struct {
28 name string
29 image string
30 maxLen int
31 expected string
32 }{
33 // Basic cases - no truncation needed
34 {
35 name: "short image fits",
36 image: "nginx:latest",
37 maxLen: 20,
38 expected: "nginx:latest",
39 },
40 {
41 name: "exact fit",
42 image: "nginx:latest",
43 maxLen: 12,
44 expected: "nginx:latest",
45 },
46
47 // Regular tag truncation
48 {
49 name: "truncate long tag with ellipsis",
50 image: "registry.test.com/test:v1234567890",
51 maxLen: 30,
52 expected: "registry.test.com/test:v123...",
53 },
54 {
55 name: "truncate very long image",
56 image: "registry.example.com/organization/project/image:v1.2.3-beta.4",
57 maxLen: 35,
58 expected: "registry.example.com/organizatio...",
59 },
60
61 // Digest truncation
62 {
63 name: "digest shortened to 12 chars",
64 image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
65 maxLen: 50,
66 expected: "myapp@sha256:a3ed95caeb02",
67 },
68 {
69 name: "digest truncated with ellipsis when too long",
70 image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
71 maxLen: 35,
72 expected: "registry.example.com/org/app@sha...",
73 },
74 {
75 name: "short digest fits",
76 image: "app@sha256:abc123",
77 maxLen: 30,
78 expected: "app@sha256:abc123",
79 },
80
81 // Edge cases
82 {
83 name: "maxLen zero returns empty",
84 image: "nginx:latest",
85 maxLen: 0,
86 expected: "",
87 },
88 {
89 name: "maxLen negative returns empty",
90 image: "nginx:latest",
91 maxLen: -1,
92 expected: "",
93 },
94 {
95 name: "maxLen 3 or less - no ellipsis",
96 image: "nginx:latest",
97 maxLen: 3,
98 expected: "ngi",
99 },
100 {
101 name: "maxLen 4 - truncate with ellipsis",
102 image: "nginx:latest",
103 maxLen: 4,
104 expected: "n...",
105 },
106 {
107 name: "empty image",
108 image: "",
109 maxLen: 10,
110 expected: "",
111 },
112 }
113
114 for _, tt := range tests {
115 t.Run(tt.name, func(t *testing.T) {
116 result := truncateImage(tt.image, tt.maxLen)
117 assert.Equal(t, tt.expected, result)
118 })
119 }
120}
121
122func TestHTTPHealthToStatus(t *testing.T) {
123 tests := []struct {
124 name string
125 health *remote.RouteHealth
126 expected components.Status
127 }{
128 {
129 name: "nil health returns unknown",
130 health: nil,
131 expected: components.StatusUnknown,
132 },
133 {
134 name: "zero status no error returns unknown",
135 health: &remote.RouteHealth{HTTPStatus: 0},
136 expected: components.StatusUnknown,
137 },
138 {
139 name: "zero status with error returns error",
140 health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
141 expected: components.StatusError,
142 },
143 {
144 name: "200 returns success",
145 health: &remote.RouteHealth{HTTPStatus: 200},
146 expected: components.StatusSuccess,
147 },
148 {
149 name: "301 returns success",
150 health: &remote.RouteHealth{HTTPStatus: 301},
151 expected: components.StatusSuccess,
152 },
153 {
154 name: "500 returns error",
155 health: &remote.RouteHealth{HTTPStatus: 500},
156 expected: components.StatusError,
157 },
158 {
159 name: "404 returns error",
160 health: &remote.RouteHealth{HTTPStatus: 404},
161 expected: components.StatusError,
162 },
163 {
164 name: "100 informational returns error",
165 health: &remote.RouteHealth{HTTPStatus: 100},
166 expected: components.StatusError,
167 },
168 }
169
170 for _, tt := range tests {
171 t.Run(tt.name, func(t *testing.T) {
172 result := httpHealthToStatus(tt.health)
173 assert.Equal(t, tt.expected, result)
174 })
175 }
176}
177
178func TestGroupRoutesByNetwork(t *testing.T) {
179 routes := []remote.RouteInfo{
180 {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
181 {Domain: "beta.dev", Network: "gordon-shared"},
182 {Domain: "gamma.dev", Network: "gordon-shared"},
183 {Domain: "delta.dev", Network: "gordon-delta-dev"},
184 }
185
186 groups, solo := groupRoutesByNetwork(routes)
187
188 assert.Len(t, solo, 2)
189 assert.Equal(t, "alpha.dev", solo[0].Domain)
190 assert.Equal(t, "delta.dev", solo[1].Domain)
191
192 assert.Len(t, groups, 1)
193 assert.Equal(t, "shared", groups[0].name)
194 assert.Len(t, groups[0].routes, 2)
195}
196
197func TestStripNetworkPrefix(t *testing.T) {
198 tests := []struct {
199 network string
200 expected string
201 }{
202 {"gordon-shared-services", "shared-services"},
203 {"gordon-my-app-dev", "my-app-dev"},
204 {"custom-network", "custom-network"},
205 {"gordon-", ""},
206 {"", ""},
207 }
208
209 for _, tt := range tests {
210 t.Run(tt.network, func(t *testing.T) {
211 assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
212 })
213 }
214}
215
216func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
217 originalRemoteFlag := remoteFlag
218 originalTokenFlag := tokenFlag
219 originalInsecureTLSFlag := insecureTLSFlag
220 t.Cleanup(func() {
221 remoteFlag = originalRemoteFlag
222 tokenFlag = originalTokenFlag
223 insecureTLSFlag = originalInsecureTLSFlag
224 })
225
226 remoteFlag = ""
227 tokenFlag = ""
228 insecureTLSFlag = false
229
230 configHome := t.TempDir()
231 t.Setenv("XDG_CONFIG_HOME", configHome)
232 t.Setenv("HOME", t.TempDir())
233 t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
234
235 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
236 require.NoError(t, os.MkdirAll(configPath, 0o755))
237
238 resolved, ok, err := resolveRoutesExplicitRemote()
239 require.True(t, ok)
240 require.NoError(t, err)
241 require.NotNil(t, resolved)
242 assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
243 assert.Empty(t, resolved.Name)
244}
245
246func TestResolveRoutesExplicitRemote_ReturnsErrorForUnreadableNamedRemoteConfig(t *testing.T) {
247 originalRemoteFlag := remoteFlag
248 originalTokenFlag := tokenFlag
249 originalInsecureTLSFlag := insecureTLSFlag
250 t.Cleanup(func() {
251 remoteFlag = originalRemoteFlag
252 tokenFlag = originalTokenFlag
253 insecureTLSFlag = originalInsecureTLSFlag
254 })
255
256 remoteFlag = "missing-remote"
257 tokenFlag = ""
258 insecureTLSFlag = false
259
260 configHome := t.TempDir()
261 t.Setenv("XDG_CONFIG_HOME", configHome)
262 t.Setenv("HOME", t.TempDir())
263 t.Setenv("GORDON_REMOTE", "")
264
265 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
266 require.NoError(t, os.MkdirAll(configPath, 0o755))
267
268 resolved, ok, err := resolveRoutesExplicitRemote()
269 require.Error(t, err)
270 require.False(t, ok)
271 require.Nil(t, resolved)
272 assert.Contains(t, err.Error(), "failed to read remotes")
273}
274
275func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
276 item := routeStatusItem{
277 Domain: "app.example.com",
278 Image: "app:latest",
279 ContainerStatus: "running",
280 HTTPStatus: 0,
281 HealthError: "connection refused",
282 }
283
284 expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
285 components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
286 " app.example.com"
287
288 assert.Equal(t, expected, routeStatusTitle(item))
289}
290
291func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
292 cpMock := climocks.NewMockControlPlane(t)
293 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
294 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once()
295
296 var out bytes.Buffer
297 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true)
298
299 require.NoError(t, err)
300
301 var got map[string]any
302 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
303 assert.Equal(t, "app.example.com", got["domain"])
304 assert.Equal(t, "app:latest", got["image"])
305 assert.Equal(t, "unknown", got["container_status"])
306 assert.Equal(t, float64(0), got["http_status"])
307 assert.Equal(t, "probe failed", got["health_error"])
308}
309
310func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
311 cpMock := climocks.NewMockControlPlane(t)
312 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
313 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once()
314
315 var out bytes.Buffer
316 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false)
317
318 require.NoError(t, err)
319
320 text := stripANSI(out.String())
321 assert.Contains(t, text, "Route: app.example.com")
322 assert.Contains(t, text, "Domain:")
323 assert.Contains(t, text, "Image:")
324 assert.Contains(t, text, "Container:")
325 assert.Contains(t, text, "probe failed")
326}
327
328func TestRunRoutesShow_JSONIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
329 cpMock := climocks.NewMockControlPlane(t)
330 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
331 cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{
332 "app.example.com": {
333 HTTPStatus: 503,
334 ContainerStatus: "",
335 Error: "probe failed",
336 },
337 }, nil).Once()
338
339 var out bytes.Buffer
340 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true)
341
342 require.NoError(t, err)
343
344 var got map[string]any
345 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
346 assert.Equal(t, "app.example.com", got["domain"])
347 assert.Equal(t, "app:latest", got["image"])
348 assert.Equal(t, "unknown", got["container_status"])
349 assert.Equal(t, float64(503), got["http_status"])
350 assert.Equal(t, "probe failed", got["health_error"])
351}
352
353func TestRunRoutesShow_TextIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
354 cpMock := climocks.NewMockControlPlane(t)
355 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
356 cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{
357 "app.example.com": {
358 HTTPStatus: 503,
359 ContainerStatus: "",
360 Error: "probe failed",
361 },
362 }, nil).Once()
363
364 var out bytes.Buffer
365 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false)
366
367 require.NoError(t, err)
368
369 text := stripANSI(out.String())
370 assert.Contains(t, text, "Route: app.example.com")
371 assert.Contains(t, text, "Container:")
372 assert.Contains(t, text, "unknown")
373 assert.Contains(t, text, "probe failed")
374}
375
376func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
377 testsDeps := routesListDeps{
378 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
379 return nil, false, nil
380 },
381 loadLocal: func(context.Context, string) (routeListSection, error) {
382 return routeListSection{
383 Kind: "local",
384 Name: "local",
385 Routes: []routeListItem{{
386 Domain: "app.local",
387 Image: "myapp:latest",
388 }},
389 }, nil
390 },
391 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
392 return map[string]remote.RemoteEntry{
393 "remote-a": {URL: "https://remote-a.example.com"},
394 "remote-b": {URL: "https://remote-b.example.com"},
395 }, "remote-a", nil
396 },
397 loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
398 if name == "remote-b" {
399 return routeListSection{
400 Kind: "remote",
401 Name: name,
402 URL: entry.URL,
403 Error: "remote unavailable",
404 }, nil
405 }
406 return routeListSection{
407 Kind: "remote",
408 Name: name,
409 URL: entry.URL,
410 Routes: []routeListItem{{
411 Domain: "dashboard.example.com",
412 Image: "dashboard",
413 }},
414 }, nil
415 },
416 }
417
418 sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
419
420 require.NoError(t, err)
421 require.Len(t, sections, 3)
422 assert.Equal(t, "local", sections[0].Name)
423 assert.Equal(t, "remote-a", sections[1].Name)
424 assert.Equal(t, "remote-b", sections[2].Name)
425 assert.Equal(t, "dashboard.example.com", sections[1].Routes[0].Domain)
426 assert.Equal(t, "remote unavailable", sections[2].Error)
427}
428
429func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
430 testsDeps := routesListDeps{
431 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
432 return &remote.ResolvedRemote{
433 Name: "remote-a",
434 URL: "https://remote-a.example.com",
435 }, true, nil
436 },
437 loadLocal: func(context.Context, string) (routeListSection, error) {
438 t.Fatal("loadLocal should not be called when an explicit remote is selected")
439 return routeListSection{}, nil
440 },
441 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
442 t.Fatal("listRemotes should not be called when an explicit remote is selected")
443 return nil, "", nil
444 },
445 loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
446 return routeListSection{
447 Kind: "remote",
448 Name: name,
449 URL: entry.URL,
450 Routes: []routeListItem{{
451 Domain: "test.example.com",
452 Image: "hello-app",
453 }},
454 }, nil
455 },
456 }
457
458 sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
459
460 require.NoError(t, err)
461 require.Len(t, sections, 1)
462 assert.Equal(t, "remote-a", sections[0].Name)
463 assert.Equal(t, "test.example.com", sections[0].Routes[0].Domain)
464}
465
466func TestCollectRoutesSections_ExplicitRemoteResolutionErrorReturnsError(t *testing.T) {
467
468 t.Run("list", func(t *testing.T) {
469 var loadLocalCalls atomic.Int32
470 var listRemotesCalls atomic.Int32
471 var loadRemoteCalls atomic.Int32
472
473 deps := routesListDeps{
474 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
475 return nil, false, errors.New("failed to read remotes: boom")
476 },
477 loadLocal: func(context.Context, string) (routeListSection, error) {
478 loadLocalCalls.Add(1)
479 return routeListSection{}, nil
480 },
481 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
482 listRemotesCalls.Add(1)
483 return map[string]remote.RemoteEntry{}, "", nil
484 },
485 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
486 loadRemoteCalls.Add(1)
487 return routeListSection{}, nil
488 },
489 }
490
491 sections, err := collectRoutesListSections(context.Background(), "", deps)
492
493 require.Error(t, err)
494 require.Nil(t, sections)
495 assert.Contains(t, err.Error(), "failed to read remotes")
496 assert.Zero(t, loadLocalCalls.Load())
497 assert.Zero(t, listRemotesCalls.Load())
498 assert.Zero(t, loadRemoteCalls.Load())
499 })
500
501 t.Run("status", func(t *testing.T) {
502 var loadLocalCalls atomic.Int32
503 var listRemotesCalls atomic.Int32
504 var loadRemoteCalls atomic.Int32
505
506 deps := routesStatusDeps{
507 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
508 return nil, false, errors.New("failed to read remotes: boom")
509 },
510 loadLocal: func(context.Context, string) (routeStatusSection, error) {
511 loadLocalCalls.Add(1)
512 return routeStatusSection{}, nil
513 },
514 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
515 listRemotesCalls.Add(1)
516 return map[string]remote.RemoteEntry{}, "", nil
517 },
518 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
519 loadRemoteCalls.Add(1)
520 return routeStatusSection{}, nil
521 },
522 }
523
524 sections, err := collectRoutesStatusSections(context.Background(), "", deps)
525
526 require.Error(t, err)
527 require.Nil(t, sections)
528 assert.Contains(t, err.Error(), "failed to read remotes")
529 assert.Zero(t, loadLocalCalls.Load())
530 assert.Zero(t, listRemotesCalls.Load())
531 assert.Zero(t, loadRemoteCalls.Load())
532 })
533}
534
535func TestLoadRoutesListExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
536 section := loadRoutesListExplicitRemoteSection(context.Background(), routesListDeps{
537 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
538 return routeListSection{}, errors.New("boom")
539 },
540 }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
541
542 assert.Equal(t, "remote", section.Kind)
543 assert.Equal(t, "remote-a", section.Name)
544 assert.Equal(t, "https://remote-a.example.com", section.URL)
545 assert.Equal(t, "boom", section.Error)
546}
547
548func TestLoadRoutesStatusExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
549 section := loadRoutesStatusExplicitRemoteSection(context.Background(), routesStatusDeps{
550 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
551 return routeStatusSection{}, errors.New("boom")
552 },
553 }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
554
555 assert.Equal(t, "remote", section.Kind)
556 assert.Equal(t, "remote-a", section.Name)
557 assert.Equal(t, "https://remote-a.example.com", section.URL)
558 assert.Equal(t, "boom", section.Error)
559}
560
561func TestCollectRoutesListAggregateSections_EncodesLoaderErrors(t *testing.T) {
562 t.Run("local loader error", func(t *testing.T) {
563 sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
564 loadLocal: func(context.Context, string) (routeListSection, error) {
565 return routeListSection{Kind: "local", Name: "local"}, errors.New("local failed")
566 },
567 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
568 return map[string]remote.RemoteEntry{}, "", nil
569 },
570 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
571 return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
572 },
573 })
574
575 require.NoError(t, err)
576 require.Len(t, sections, 1)
577 assert.Equal(t, "local", sections[0].Kind)
578 assert.Equal(t, "local failed", sections[0].Error)
579 })
580
581 t.Run("remote loader error", func(t *testing.T) {
582 sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
583 loadLocal: func(context.Context, string) (routeListSection, error) {
584 return routeListSection{Kind: "local", Name: "local"}, nil
585 },
586 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
587 return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
588 },
589 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
590 return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
591 },
592 })
593
594 require.NoError(t, err)
595 require.Len(t, sections, 2)
596 assert.Equal(t, "local", sections[0].Kind)
597 assert.Equal(t, "remote", sections[1].Kind)
598 assert.Equal(t, "remote failed", sections[1].Error)
599 })
600}
601
602func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T) {
603 t.Run("local loader error", func(t *testing.T) {
604 sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
605 loadLocal: func(context.Context, string) (routeStatusSection, error) {
606 return routeStatusSection{Kind: "local", Name: "local"}, errors.New("local failed")
607 },
608 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
609 return map[string]remote.RemoteEntry{}, "", nil
610 },
611 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
612 return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
613 },
614 })
615
616 require.NoError(t, err)
617 require.Len(t, sections, 1)
618 assert.Equal(t, "local", sections[0].Kind)
619 assert.Equal(t, "local failed", sections[0].Error)
620 })
621
622 t.Run("remote loader error", func(t *testing.T) {
623 sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
624 loadLocal: func(context.Context, string) (routeStatusSection, error) {
625 return routeStatusSection{Kind: "local", Name: "local"}, nil
626 },
627 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
628 return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
629 },
630 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
631 return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
632 },
633 })
634
635 require.NoError(t, err)
636 require.Len(t, sections, 2)
637 assert.Equal(t, "local", sections[0].Kind)
638 assert.Equal(t, "remote", sections[1].Kind)
639 assert.Equal(t, "remote failed", sections[1].Error)
640 })
641}
642
643func TestLoadRoutesStatusLocalSection_DoesNotLeakKernelInitLogs(t *testing.T) {
644 tmpDir := t.TempDir()
645 cfgPath := filepath.Join(tmpDir, "gordon.toml")
646 require.NoError(t, os.WriteFile(cfgPath, []byte(`[server]
647gordon_domain = "gordon.local"
648data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
649
650[auth]
651enabled = true
652`), 0o600))
653
654 stdout, stderr := captureOutput(t, func() {
655 section, err := loadRoutesStatusLocalSection(context.Background(), cfgPath)
656 require.NoError(t, err)
657 assert.Equal(t, "local", section.Kind)
658 assert.Equal(t, "local", section.Name)
659 assert.Contains(t, section.Error, "failed to initialize local control plane")
660 assert.Contains(t, section.Error, "auth.secrets_backend is required")
661 })
662
663 combined := stdout + stderr
664 assert.NotContains(t, combined, "failed to resolve secrets backend")
665 assert.NotContains(t, combined, "local kernel running in minimal mode")
666}
667
668func captureOutput(t *testing.T, fn func()) (string, string) {
669 t.Helper()
670
671 oldStdout, oldStderr := os.Stdout, os.Stderr
672 outR, outW, err := os.Pipe()
673 require.NoError(t, err)
674 errR, errW, err := os.Pipe()
675 require.NoError(t, err)
676
677 os.Stdout = outW
678 os.Stderr = errW
679
680 stdoutCh := make(chan string, 1)
681 stderrCh := make(chan string, 1)
682 go func() {
683 var buf bytes.Buffer
684 _, _ = io.Copy(&buf, outR)
685 stdoutCh <- buf.String()
686 }()
687 go func() {
688 var buf bytes.Buffer
689 _, _ = io.Copy(&buf, errR)
690 stderrCh <- buf.String()
691 }()
692
693 defer func() {
694 os.Stdout = oldStdout
695 os.Stderr = oldStderr
696 }()
697
698 fn()
699
700 require.NoError(t, outW.Close())
701 require.NoError(t, errW.Close())
702
703 return <-stdoutCh, <-stderrCh
704}
705
706func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
707 sections := []routeStatusSection{
708 {
709 Kind: "local",
710 Name: "local",
711 Routes: []routeStatusItem{{
712 Domain: "app.local",
713 Image: "myapp:latest",
714 ContainerStatus: "running",
715 }},
716 },
717 {
718 Kind: "remote",
719 Name: "remote-a",
720 URL: "https://remote-a.example.com",
721 Error: "dial tcp timeout",
722 },
723 }
724
725 var out bytes.Buffer
726 err := renderRoutesStatusSections(&out, sections)
727
728 require.NoError(t, err)
729 rendered := stripANSI(out.String())
730 lines := strings.Split(rendered, "\n")
731
732 lineIndex := func(substr string) int {
733 for i, line := range lines {
734 if strings.Contains(line, substr) {
735 return i
736 }
737 }
738 return -1
739 }
740
741 titleIdx := lineIndex("Route Status")
742 require.NotEqual(t, -1, titleIdx)
743
744 localIdx := lineIndex("Local")
745 require.NotEqual(t, -1, localIdx)
746
747 routeIdx := lineIndex("app.local")
748 require.NotEqual(t, -1, routeIdx)
749
750 remoteIdx := lineIndex("Remote: remote-a")
751 require.NotEqual(t, -1, remoteIdx)
752
753 errorIdx := lineIndex("dial tcp timeout")
754 require.NotEqual(t, -1, errorIdx)
755
756 assert.Less(t, titleIdx, localIdx)
757 assert.Less(t, localIdx, routeIdx)
758 assert.Less(t, routeIdx, remoteIdx)
759 assert.Less(t, remoteIdx, errorIdx)
760}
761
762func TestRenderRoutesStatusSections_DoesNotAddExtraBlankLineBetweenSections(t *testing.T) {
763 sections := []routeStatusSection{
764 {
765 Kind: "remote",
766 Name: "remote-b",
767 Routes: []routeStatusItem{{
768 Domain: "app.example.com",
769 Image: "app:latest",
770 ContainerStatus: "running",
771 }},
772 },
773 {
774 Kind: "remote",
775 Name: "remote-a",
776 Routes: []routeStatusItem{{
777 Domain: "dashboard.example.com",
778 Image: "dashboard",
779 ContainerStatus: "running",
780 }},
781 },
782 }
783
784 var out bytes.Buffer
785 err := renderRoutesStatusSections(&out, sections)
786
787 require.NoError(t, err)
788 rendered := stripANSI(out.String())
789 assert.NotContains(t, rendered, "app:latest\n\n\nRemote: remote-a")
790 assert.Contains(t, rendered, "app:latest\n\nRemote: remote-a")
791}
792
793func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
794 sections := []routeListSection{
795 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
796 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
797 }
798
799 var out bytes.Buffer
800 err := renderRoutesListSections(&out, sections)
801
802 require.NoError(t, err)
803 rendered := stripANSI(out.String())
804 assert.NotContains(t, rendered, "Local")
805 assert.NotContains(t, rendered, "failed to initialize local control plane")
806 assert.Contains(t, rendered, "Remote: remote-a")
807 assert.Contains(t, rendered, "No routes configured")
808}
809
810func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
811 sections := []routeStatusSection{
812 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
813 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
814 }
815
816 var out bytes.Buffer
817 err := renderRoutesStatusSections(&out, sections)
818
819 require.NoError(t, err)
820 rendered := stripANSI(out.String())
821 assert.NotContains(t, rendered, "Local")
822 assert.NotContains(t, rendered, "failed to initialize local control plane")
823 assert.Contains(t, rendered, "Remote: remote-a")
824 assert.Contains(t, rendered, "No routes configured")
825}
826
827func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
828 sections := []routeStatusSection{
829 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
830 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"},
831 }
832
833 var out bytes.Buffer
834 err := renderRoutesStatusSections(&out, sections)
835
836 require.NoError(t, err)
837 rendered := stripANSI(out.String())
838 assert.Contains(t, rendered, "Local")
839 assert.Contains(t, rendered, "failed to initialize local control plane")
840 assert.Contains(t, rendered, "Remote: remote-a")
841 assert.Contains(t, rendered, "dial tcp timeout")
842}
843
844func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
845 var out bytes.Buffer
846 report := &domain.CleanupReport{
847 Domain: "app.example.com",
848 RemovedContainers: []domain.CleanupContainer{{
849 ID: "1234567890abcdef",
850 Name: "gordon-app.example.com",
851 }},
852 PreservedAttachments: []domain.CleanupAttachment{{
853 Name: "postgres",
854 ContainerID: "attachment-1",
855 Status: "running",
856 }},
857 Warnings: []string{"volumes preserved"},
858 Hints: []string{"run diagnose"},
859 }
860
861 err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report))
862 require.NoError(t, err)
863 rendered := stripANSI(out.String())
864 assert.Contains(t, rendered, "Route removed: app.example.com")
865 assert.Contains(t, rendered, "Removed containers:")
866 assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)")
867 assert.Contains(t, rendered, "Preserved attachments:")
868 assert.Contains(t, rendered, "postgres (running)")
869 assert.Contains(t, rendered, "volumes preserved")
870 assert.Contains(t, rendered, "run diagnose")
871}
872
873func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
874 routes := []routeStatusItem{
875 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
876 {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
877 {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
878 }
879
880 var outA bytes.Buffer
881 _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
882
883 var outB bytes.Buffer
884 reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
885 _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
886
887 assert.Equal(t, outA.String(), outB.String())
888}
889
890func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
891 attachments := []domain.CleanupAttachment{
892 {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"},
893 {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"},
894 }
895
896 matched := attachmentsForRouteDiagnosis(attachments, "app.example.com")
897
898 require.Len(t, matched, 1)
899 assert.Equal(t, "match", matched[0].ContainerID)
900}
901
902func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
903 cpMock := climocks.NewMockControlPlane(t)
904 orphanedAttachments := []domain.CleanupAttachment{
905 {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"},
906 {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"},
907 }
908 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
909 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once()
910 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
911 cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
912 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once()
913 cpMock.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{
914 RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}},
915 }, nil).Once()
916
917 report, err := runRoutePurge(context.Background(), cpMock, "app.example.com", routePurgeOptions{Force: true, Attachments: true})
918
919 require.NoError(t, err)
920 require.NotNil(t, report)
921 require.Len(t, report.RemovedContainers, 1)
922 assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID)
923}