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