detached 56e2d9e

fix(review): address CodeRabbit findings from PR review

- fix mock RunAndReturn delegates to _c.Call.Return not _c.Run
- remove dead bare ::1 check in IsLocalhostRequest (false positive risk)
- remove matching ::1 unbracketed test cases in httputil and auth tests
- add defer to restore os.Stderr in push_test to prevent leak on panic
- add recover() wrapper on token refresh callback in CLI client
- remove ineffective omitempty on time.Time in tokenstore pass metadata
- skip ExtendToken for permanent tokens (ExpiresAt <= 0) to avoid silent 24h conversion
- add os.TempDir() as last fallback candidate in getInternalCredentialsCandidates
- remove dead t.Setenv in run_internal_creds_test (function ignores env var)
- wrap labels_test.go constant checks in t.Run for isolated filtering
- add debug logs around UpdateAttachments for operator observability
commit
56e2d9e89ef89a751060beac57e0eb3f03dfcb97
author
bnema · · 2026-02-25 01:12 UTC
parent
f6ead37
signature
unsigned
12 files · +32 -14
M internal/adapters/in/cli/push_test.go +4 -0
@@ -369,6 +369,10 @@ func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) {
369369 t.Fatal(err)
370370 }
371371 os.Stderr = w
372+ defer func() {
373+ w.Close()
374+ os.Stderr = origStderr
375+ }()
372376
373377 v := getGitVersion(ctx)
374378
@@ -369,6 +369,10 @@ func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) {
369 t.Fatal(err)369 t.Fatal(err)
370 }370 }
371 os.Stderr = w371 os.Stderr = w
372+ defer func() {
373+ w.Close()
374+ os.Stderr = origStderr
375+ }()
372 376
373 v := getGitVersion(ctx)377 v := getGitVersion(ctx)
374 378
M internal/adapters/in/cli/remote/client.go +5 -1
@@ -160,7 +160,11 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
160160 if newToken := resp.Header.Get("X-Gordon-Token"); newToken != "" && newToken != c.token {
161161 c.token = newToken
162162 if c.onTokenRefreshed != nil {
163- c.onTokenRefreshed(newToken)
163+ // Token persistence is non-fatal: a panic in the callback must not crash the request.
164+ func() {
165+ defer func() { recover() }() //nolint:errcheck
166+ c.onTokenRefreshed(newToken)
167+ }()
164168 }
165169 }
166170
@@ -160,7 +160,11 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
160 if newToken := resp.Header.Get("X-Gordon-Token"); newToken != "" && newToken != c.token {160 if newToken := resp.Header.Get("X-Gordon-Token"); newToken != "" && newToken != c.token {
161 c.token = newToken161 c.token = newToken
162 if c.onTokenRefreshed != nil {162 if c.onTokenRefreshed != nil {
163- c.onTokenRefreshed(newToken)163+ // Token persistence is non-fatal: a panic in the callback must not crash the request.
164+ func() {
165+ defer func() { recover() }() //nolint:errcheck
166+ c.onTokenRefreshed(newToken)
167+ }()
164 }168 }
165 }169 }
166 170
M internal/adapters/in/http/auth/handler_test.go +0 -1
@@ -436,7 +436,6 @@ func TestIsLocalhostRequest(t *testing.T) {
436436 {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
437437 {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
438438 {"::1 is localhost", "[::1]:12345", true},
439- {"::1 without brackets", "::1:12345", true},
440439 {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
441440 {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
442441 {"public IP is not localhost", "8.8.8.8:12345", false},
@@ -436,7 +436,6 @@ func TestIsLocalhostRequest(t *testing.T) {
436 {"127.0.0.1 is localhost", "127.0.0.1:12345", true},436 {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
437 {"127.0.0.2 is localhost", "127.0.0.2:12345", true},437 {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
438 {"::1 is localhost", "[::1]:12345", true},438 {"::1 is localhost", "[::1]:12345", true},
439- {"::1 without brackets", "::1:12345", true},
440 {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},439 {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
441 {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},440 {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
442 {"public IP is not localhost", "8.8.8.8:12345", false},441 {"public IP is not localhost", "8.8.8.8:12345", false},
M internal/adapters/in/http/httputil/localhost.go +3 -3
@@ -7,10 +7,10 @@ import (
77
88 // IsLocalhostRequest reports whether the request originates from localhost.
99 // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
10-// RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345".
10+// Go's net/http always formats RemoteAddr as "host:port" where IPv6 is bracketed:
11+// e.g., "127.0.0.1:12345" or "[::1]:12345".
1112 func IsLocalhostRequest(r *http.Request) bool {
1213 host := r.RemoteAddr
1314 return strings.HasPrefix(host, "127.") ||
14- strings.HasPrefix(host, "[::1]") ||
15- strings.HasPrefix(host, "::1")
15+ strings.HasPrefix(host, "[::1]")
1616 }
@@ -7,10 +7,10 @@ import (
7 7
8 // IsLocalhostRequest reports whether the request originates from localhost.8 // IsLocalhostRequest reports whether the request originates from localhost.
9 // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).9 // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
10-// RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345".10+// Go's net/http always formats RemoteAddr as "host:port" where IPv6 is bracketed:
11+// e.g., "127.0.0.1:12345" or "[::1]:12345".
11 func IsLocalhostRequest(r *http.Request) bool {12 func IsLocalhostRequest(r *http.Request) bool {
12 host := r.RemoteAddr13 host := r.RemoteAddr
13 return strings.HasPrefix(host, "127.") ||14 return strings.HasPrefix(host, "127.") ||
14- strings.HasPrefix(host, "[::1]") ||15+ strings.HasPrefix(host, "[::1]")
15- strings.HasPrefix(host, "::1")
16 }16 }
M internal/adapters/in/http/httputil/localhost_test.go +0 -1
@@ -17,7 +17,6 @@ func TestIsLocalhostRequest(t *testing.T) {
1717 {"ipv4 loopback", "127.0.0.1:12345", true},
1818 {"ipv4 loopback other", "127.0.0.2:9000", true},
1919 {"ipv6 loopback bracketed", "[::1]:12345", true},
20- {"ipv6 loopback plain", "::1:12345", true},
2120 {"external ipv4", "192.168.1.1:12345", false},
2221 {"external ipv6", "[2001:db8::1]:12345", false},
2322 {"empty", "", false},
@@ -17,7 +17,6 @@ func TestIsLocalhostRequest(t *testing.T) {
17 {"ipv4 loopback", "127.0.0.1:12345", true},17 {"ipv4 loopback", "127.0.0.1:12345", true},
18 {"ipv4 loopback other", "127.0.0.2:9000", true},18 {"ipv4 loopback other", "127.0.0.2:9000", true},
19 {"ipv6 loopback bracketed", "[::1]:12345", true},19 {"ipv6 loopback bracketed", "[::1]:12345", true},
20- {"ipv6 loopback plain", "::1:12345", true},
21 {"external ipv4", "192.168.1.1:12345", false},20 {"external ipv4", "192.168.1.1:12345", false},
22 {"external ipv6", "[2001:db8::1]:12345", false},21 {"external ipv6", "[2001:db8::1]:12345", false},
23 {"empty", "", false},22 {"empty", "", false},
M internal/adapters/out/tokenstore/pass.go +1 -1
@@ -85,7 +85,7 @@ type tokenMetadata struct {
8585 IssuedAt time.Time `json:"issued_at"`
8686 ExpiresAt time.Time `json:"expires_at,omitempty"`
8787 Revoked bool `json:"revoked"`
88- LastExtendedAt time.Time `json:"last_extended_at,omitempty"`
88+ LastExtendedAt time.Time `json:"last_extended_at"`
8989 }
9090
9191 // SaveToken stores a token JWT and metadata in pass.
@@ -85,7 +85,7 @@ type tokenMetadata struct {
85 IssuedAt time.Time `json:"issued_at"`85 IssuedAt time.Time `json:"issued_at"`
86 ExpiresAt time.Time `json:"expires_at,omitempty"`86 ExpiresAt time.Time `json:"expires_at,omitempty"`
87 Revoked bool `json:"revoked"`87 Revoked bool `json:"revoked"`
88- LastExtendedAt time.Time `json:"last_extended_at,omitempty"`88+ LastExtendedAt time.Time `json:"last_extended_at"`
89 }89 }
90 90
91 // SaveToken stores a token JWT and metadata in pass.91 // SaveToken stores a token JWT and metadata in pass.
M internal/app/run.go +5 -0
@@ -823,6 +823,7 @@ func cleanupInternalCredentials() {
823823 // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)
824824 // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)
825825 // 3. ~/.gordon/run/ (fallback for non-systemd environments)
826+// 4. os.TempDir() (last resort, matches getInternalCredentialsFile fallback path)
826827 func getInternalCredentialsCandidates() []string {
827828 var candidates []string
828829
@@ -844,6 +845,10 @@ func getInternalCredentialsCandidates() []string {
844845 candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))
845846 }
846847
848+ // 4. os.TempDir() last resort — matches the fallback path in getInternalCredentialsFile,
849+ // ensuring GetInternalCredentials can find credentials even when getSecureRuntimeDir fails.
850+ candidates = append(candidates, filepath.Join(os.TempDir(), "gordon-internal-creds.json"))
851+
847852 return candidates
848853 }
849854
@@ -823,6 +823,7 @@ func cleanupInternalCredentials() {
823 // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)823 // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)
824 // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)824 // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)
825 // 3. ~/.gordon/run/ (fallback for non-systemd environments)825 // 3. ~/.gordon/run/ (fallback for non-systemd environments)
826+// 4. os.TempDir() (last resort, matches getInternalCredentialsFile fallback path)
826 func getInternalCredentialsCandidates() []string {827 func getInternalCredentialsCandidates() []string {
827 var candidates []string828 var candidates []string
828 829
@@ -844,6 +845,10 @@ func getInternalCredentialsCandidates() []string {
844 candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))845 candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))
845 }846 }
846 847
848+ // 4. os.TempDir() last resort — matches the fallback path in getInternalCredentialsFile,
849+ // ensuring GetInternalCredentials can find credentials even when getSecureRuntimeDir fails.
850+ candidates = append(candidates, filepath.Join(os.TempDir(), "gordon-internal-creds.json"))
851+
847 return candidates852 return candidates
848 }853 }
849 854
M internal/app/run_internal_creds_test.go +0 -3
@@ -32,9 +32,6 @@ func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) {
3232 t.Fatal(err)
3333 }
3434
35- // Unset XDG_RUNTIME_DIR so CLI path is taken, but probe gordonRuntime first
36- t.Setenv("XDG_RUNTIME_DIR", "")
37-
3835 creds, err := GetInternalCredentialsFromCandidates([]string{
3936 filepath.Join(gordonRuntime, "internal-creds.json"),
4037 filepath.Join(gordonHome, "internal-creds.json"),
@@ -32,9 +32,6 @@ func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) {
32 t.Fatal(err)32 t.Fatal(err)
33 }33 }
34 34
35- // Unset XDG_RUNTIME_DIR so CLI path is taken, but probe gordonRuntime first
36- t.Setenv("XDG_RUNTIME_DIR", "")
37-
38 creds, err := GetInternalCredentialsFromCandidates([]string{35 creds, err := GetInternalCredentialsFromCandidates([]string{
39 filepath.Join(gordonRuntime, "internal-creds.json"),36 filepath.Join(gordonRuntime, "internal-creds.json"),
40 filepath.Join(gordonHome, "internal-creds.json"),37 filepath.Join(gordonHome, "internal-creds.json"),
M internal/boundaries/in/mocks/mock_container_service.go +1 -1
@@ -829,6 +829,6 @@ func (_c *MockContainerService_UpdateAttachments_Call) Return() *MockContainerSe
829829 }
830830
831831 func (_c *MockContainerService_UpdateAttachments_Call) RunAndReturn(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call {
832- _c.Run(run)
832+ _c.Call.Return(run)
833833 return _c
834834 }
@@ -829,6 +829,6 @@ func (_c *MockContainerService_UpdateAttachments_Call) Return() *MockContainerSe
829 }829 }
830 830
831 func (_c *MockContainerService_UpdateAttachments_Call) RunAndReturn(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call {831 func (_c *MockContainerService_UpdateAttachments_Call) RunAndReturn(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call {
832- _c.Run(run)832+ _c.Call.Return(run)
833 return _c833 return _c
834 }834 }
M internal/domain/labels_test.go +5 -3
@@ -22,8 +22,10 @@ func TestLabelConstantsValues(t *testing.T) {
2222 {domain.LabelCreated, "gordon.created"},
2323 }
2424 for _, tt := range tests {
25- if tt.constant != tt.expected {
26- t.Errorf("constant value changed: got %q, want %q", tt.constant, tt.expected)
27- }
25+ t.Run(tt.expected, func(t *testing.T) {
26+ if tt.constant != tt.expected {
27+ t.Errorf("constant value changed: got %q, want %q", tt.constant, tt.expected)
28+ }
29+ })
2830 }
2931 }
@@ -22,8 +22,10 @@ func TestLabelConstantsValues(t *testing.T) {
22 {domain.LabelCreated, "gordon.created"},22 {domain.LabelCreated, "gordon.created"},
23 }23 }
24 for _, tt := range tests {24 for _, tt := range tests {
25- if tt.constant != tt.expected {25+ t.Run(tt.expected, func(t *testing.T) {
26- t.Errorf("constant value changed: got %q, want %q", tt.constant, tt.expected)26+ if tt.constant != tt.expected {
27- }27+ t.Errorf("constant value changed: got %q, want %q", tt.constant, tt.expected)
28+ }
29+ })
28 }30 }
29 }31 }
M internal/usecase/auth/service.go +6 -0
@@ -470,6 +470,12 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
470470 return tokenString, nil
471471 }
472472
473+ // Skip tokens with no expiry (permanent tokens must not be silently converted to 24h tokens)
474+ if tokenClaims.ExpiresAt <= 0 {
475+ log.Debug().Str("subject", tokenClaims.Subject).Msg("skipping extension for non-expiring token")
476+ return tokenString, nil
477+ }
478+
473479 // Full validation including store checks (not expired, not revoked, exists in store)
474480 if _, err := s.ValidateToken(ctx, tokenString); err != nil {
475481 return "", fmt.Errorf("token validation failed: %w", err)
@@ -470,6 +470,12 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
470 return tokenString, nil470 return tokenString, nil
471 }471 }
472 472
473+ // Skip tokens with no expiry (permanent tokens must not be silently converted to 24h tokens)
474+ if tokenClaims.ExpiresAt <= 0 {
475+ log.Debug().Str("subject", tokenClaims.Subject).Msg("skipping extension for non-expiring token")
476+ return tokenString, nil
477+ }
478+
473 // Full validation including store checks (not expired, not revoked, exists in store)479 // Full validation including store checks (not expired, not revoked, exists in store)
474 if _, err := s.ValidateToken(ctx, tokenString); err != nil {480 if _, err := s.ValidateToken(ctx, tokenString); err != nil {
475 return "", fmt.Errorf("token validation failed: %w", err)481 return "", fmt.Errorf("token validation failed: %w", err)
M internal/usecase/container/events.go +2 -0
@@ -110,7 +110,9 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er
110110 // Propagate updated attachment configuration so new attachments take effect
111111 // without requiring a Gordon restart (fixes issue #87 part 2).
112112 attachments := h.configSvc.GetAllAttachments(ctx)
113+ log.Debug().Int("attachment_groups", len(attachments)).Msg("propagating updated attachment configuration")
113114 h.containerSvc.UpdateAttachments(attachments)
115+ log.Debug().Int("attachment_groups", len(attachments)).Msg("attachment configuration propagated")
114116
115117 currentContainers := h.containerSvc.List(ctx)
116118
@@ -110,7 +110,9 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er
110 // Propagate updated attachment configuration so new attachments take effect110 // Propagate updated attachment configuration so new attachments take effect
111 // without requiring a Gordon restart (fixes issue #87 part 2).111 // without requiring a Gordon restart (fixes issue #87 part 2).
112 attachments := h.configSvc.GetAllAttachments(ctx)112 attachments := h.configSvc.GetAllAttachments(ctx)
113+ log.Debug().Int("attachment_groups", len(attachments)).Msg("propagating updated attachment configuration")
113 h.containerSvc.UpdateAttachments(attachments)114 h.containerSvc.UpdateAttachments(attachments)
115+ log.Debug().Int("attachment_groups", len(attachments)).Msg("attachment configuration propagated")
114 116
115 currentContainers := h.containerSvc.List(ctx)117 currentContainers := h.containerSvc.List(ctx)
116 118

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close