Skip to content

Commit 8d12458

Browse files
authored
fix: 2026.06 polish batch (#137, #138, #139, #140, #143) (#241)
* fix: 2026.06 polish batch (#137, #138, #139, #140, #143) - #137: document the always-re-sign dispatch contract in ActionHandler.DispatchAction and drop the dead lint stubs. - #138: change indexer health-addr default from :8082 to :8090 so it no longer collides with control's InternalListenAddr default on single-host dev setups. - #139: distinguish jwt.ErrTokenExpired from other ValidateToken failures so the web client can pick refresh-and-retry vs forced-relogin instead of treating every failure as expired. - #140: ToSnakeCase now keeps acronyms intact via SDK update; bumps SDK pin to 16f6ede929e7 and updates the validator-error assertion from "i_d must be a valid ULID" to "id must be a valid ULID". - #143: errors_parity_test strips // and /* */ comments before scanning sdk/ts/errors.ts so a commented-out export no longer counts as live and skews the parity check. * test(auth): align interceptor tests with #139 expired-vs-invalid split The "invalid or expired token" wording was a single message for both JWT failure modes — the #139 split swapped it for distinct "token expired" / "invalid token" messages so the web client can pick refresh-and-retry vs forced-relogin instead of treating every 401 as expired. Update the existing tests to: - TestAuthInterceptor_InvalidToken: assert "invalid token" + assert the error does NOT contain "expired" (locks the disjoint behavior). - TestAuthInterceptor_ExpiredToken: assert "token expired" so the refresh-and-retry path stays covered. - TestAuthInterceptor_WrongSecret: assert "invalid token" + assert not "expired" — signature-invalid is a hard reject, not refreshable. CI caught the stale assertion on PR #241 (Unit Tests job) — fix applied as a follow-up so the polish bundle stays single-PR.
1 parent 090bbdb commit 8d12458

8 files changed

Lines changed: 92 additions & 23 deletions

File tree

cmd/indexer/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,11 @@ func parseFlags() *Config {
189189
flag.StringVar(&cfg.LogFormat, "log-format", "text", "Log format (text, json)")
190190
flag.DurationVar(&cfg.ReconcileInterval, "reconcile-interval", time.Hour, "Interval for periodic full rebuild (0 to disable)")
191191
flag.IntVar(&cfg.Concurrency, "concurrency", 5, "Number of concurrent Asynq workers")
192-
flag.StringVar(&cfg.HealthAddr, "health-addr", ":8082", "Health check endpoint address")
192+
// :8090 was previously :8082, which collided with the control
193+
// server's InternalListenAddr default on single-host dev setups
194+
// (#138). Operators on multi-host deploys can still pin either
195+
// via CONTROL_INTERNAL_LISTEN_ADDR + INDEXER_HEALTH_ADDR.
196+
flag.StringVar(&cfg.HealthAddr, "health-addr", ":8090", "Health check endpoint address")
193197

194198
flag.Parse()
195199

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,4 @@ require (
106106
// whatever happens to be in a local ../sdk checkout. Developers who
107107
// want to iterate against a local SDK override this with a per-dev
108108
// go.work at their workspace root — see server/README.md for setup.
109-
replace github.com/manchtools/power-manage/sdk => github.com/manchtools/power-manage-sdk v0.4.1-0.20260512183037-dda53a00b743
109+
replace github.com/manchtools/power-manage/sdk => github.com/manchtools/power-manage-sdk v0.4.1-0.20260512213837-16f6ede929e7

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
111111
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
112112
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
113113
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
114-
github.com/manchtools/power-manage-sdk v0.4.1-0.20260512183037-dda53a00b743 h1:ANagVtDVc6PmBoFXVcT2gx23tYVrRHAE3OiQoK2sdms=
115-
github.com/manchtools/power-manage-sdk v0.4.1-0.20260512183037-dda53a00b743/go.mod h1:hG7NcdIGAG0VzhLL/W12FDAgVQJxeK+jHFtHb2eF3A8=
114+
github.com/manchtools/power-manage-sdk v0.4.1-0.20260512213837-16f6ede929e7 h1:vBwzVqiiOJamAvF6TPhmhJ80DeMj2ofvwB6qCP8iB0I=
115+
github.com/manchtools/power-manage-sdk v0.4.1-0.20260512213837-16f6ede929e7/go.mod h1:hG7NcdIGAG0VzhLL/W12FDAgVQJxeK+jHFtHb2eF3A8=
116116
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
117117
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
118118
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=

internal/api/action_dispatch.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,23 @@ func (h *ActionHandler) DispatchAction(ctx context.Context, req *connect.Request
109109
}
110110
inputs.timeoutSeconds = action.TimeoutSeconds
111111
inputs.actionID = &source.ActionId
112-
// NOTE: action.Signature and action.ParamsCanonical are
113-
// loaded from the stored row in the original PR-1 shape but
114-
// were unconditionally overwritten by the re-sign call below.
115-
// The contract decision (re-sign every dispatch vs. respect
116-
// stored signature) is tracked in #137. Until that lands,
117-
// drop the dead loads — keeping them only as //lint:ignore
118-
// stubs trips ineffassign in golangci-lint and adds noise
119-
// without addressing the underlying ambiguity. The comment
120-
// stays here so a future contributor sees why these fields
121-
// are conspicuously absent on the stored-action branch.
112+
// Contract (closes #137 audit F002): always re-sign every
113+
// dispatch. action.Signature + action.ParamsCanonical on the
114+
// stored row are NOT consumed here — the re-sign step below
115+
// overwrites both with a fresh signature against the current
116+
// signing key. Reasons:
117+
// 1. The signing key may have rotated since the action was
118+
// originally created. A stored signature would become
119+
// unverifiable against the current public key the agent
120+
// has cached.
121+
// 2. The agent's verification path doesn't accept "old"
122+
// signatures — it always checks against whatever the
123+
// current control-cert chain says.
124+
// 3. The cost of re-signing is one HMAC, dwarfed by the
125+
// Asynq enqueue and the bidi-stream RTT.
126+
// The columns stay on the row as audit-history (the original
127+
// signature at create-time) but never round-trip into a
128+
// dispatch.
122129

123130
case *pm.DispatchActionRequest_InlineAction:
124131
action := source.InlineAction

internal/api/errors_parity_test.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,21 @@ func extractSDKCodes(t *testing.T) []string {
118118
return nil
119119
}
120120

121+
// Strip comments before matching so a commented-out export (left
122+
// as a TODO or for documentation) doesn't get counted as live and
123+
// skew the parity check (#143). Removes:
124+
// - `// ...` line comments (until end-of-line)
125+
// - `/* ... */` block comments (single-line; multi-line block
126+
// comments containing export lines are rare in this file but
127+
// handled with the non-greedy `(?s)` form below)
128+
src := stripTSComments(string(data))
129+
121130
// Accept single, double, or backtick-quoted string literals plus
122131
// an optional `: string` type annotation so a future refactor to
123132
// `export const ErrFoo: string = "…"` or a template literal
124133
// doesn't silently hide the code from the parity check.
125134
re := regexp.MustCompile(`export\s+const\s+Err\w+(?:\s*:\s*string)?\s*=\s*['"` + "`" + `]([a-z][a-z0-9_]*)['"` + "`" + `]`)
126-
matches := re.FindAllStringSubmatch(string(data), -1)
135+
matches := re.FindAllStringSubmatch(src, -1)
127136
seen := make(map[string]struct{}, len(matches))
128137
for _, m := range matches {
129138
seen[m[1]] = struct{}{}
@@ -136,6 +145,21 @@ func extractSDKCodes(t *testing.T) []string {
136145
return out
137146
}
138147

148+
// stripTSComments removes `// ...` line comments and `/* ... */`
149+
// block comments from a TypeScript source string before regex
150+
// matching. Without this, a commented-out export (left as a TODO or
151+
// for documentation) would be counted as a live export by the
152+
// parity check (#143). Imperfect — a `//` inside a string literal
153+
// would also be eaten — but the parity-check input is the SDK's
154+
// curated errors.ts file, which doesn't carry such literals.
155+
func stripTSComments(src string) string {
156+
// Block comments first (greedy across newlines via (?s) inline flag).
157+
src = regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(src, "")
158+
// Then line comments (until end of line).
159+
src = regexp.MustCompile(`//[^\n]*`).ReplaceAllString(src, "")
160+
return src
161+
}
162+
139163
// diff returns elements in a that are not in b, sorted.
140164
func diff(a, b []string) []string {
141165
present := make(map[string]struct{}, len(b))

internal/api/validator_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func TestValidate_InvalidULID(t *testing.T) {
5555
ID: "not-a-ulid",
5656
})
5757
require.Error(t, err)
58-
assert.Contains(t, err.Error(), "i_d must be a valid ULID")
58+
assert.Contains(t, err.Error(), "id must be a valid ULID")
5959
}
6060

6161
func TestValidate_ValidULID(t *testing.T) {
@@ -89,13 +89,23 @@ func TestValidate_Optional_TooShort(t *testing.T) {
8989
}
9090

9191
func TestToSnakeCase(t *testing.T) {
92+
// Acronym handling pinned by #140 — the previous shape split
93+
// contiguous uppercase letters one at a time (`UserID` →
94+
// `user_i_d`), which leaked nonsense into operator-facing
95+
// validation error messages. New rule: `_` is inserted before an
96+
// uppercase letter only at word boundaries (lowercase→upper or
97+
// end-of-acronym), so acronyms ride together.
9298
tests := map[string]string{
9399
"Name": "name",
94-
"UserID": "user_i_d",
95-
"ActionSetID": "action_set_i_d",
100+
"UserID": "user_id",
101+
"ActionSetID": "action_set_id",
96102
"createdAt": "created_at",
97103
"simple": "simple",
98-
"HTTPStatusCode": "h_t_t_p_status_code",
104+
"HTTPStatusCode": "http_status_code",
105+
"IDOnly": "id_only", // acronym at start
106+
"ID": "id", // pure acronym
107+
"a": "a", // single lowercase
108+
"": "", // empty
99109
}
100110
for input, expected := range tests {
101111
assert.Equal(t, expected, sdkvalidate.ToSnakeCase(input), "ToSnakeCase(%q)", input)

internal/auth/interceptor.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"connectrpc.com/connect"
11+
"github.com/golang-jwt/jwt/v5"
1112

1213
pm "github.com/manchtools/power-manage/sdk/gen/go/pm/v1"
1314
"github.com/manchtools/power-manage/server/internal/middleware"
@@ -223,10 +224,18 @@ func (i *AuthInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
223224
return nil, authErrorCtx(ctx, errNotAuthenticated, connect.CodeUnauthenticated, "missing authentication credentials")
224225
}
225226

226-
// Validate token
227+
// Validate token. Distinguish "expired" from "malformed /
228+
// signature-invalid / wrong-type" so the web client can show
229+
// the right UX (silent refresh vs forced re-login). Falls back
230+
// to errNotAuthenticated for non-expiry failures so the web
231+
// error mapping doesn't trigger refresh-and-retry on a token
232+
// that can never become valid (#139, audit Bundle A).
227233
claims, err := i.jwtManager.ValidateToken(tokenString, TokenTypeAccess)
228234
if err != nil {
229-
return nil, authErrorCtx(ctx, errTokenExpired, connect.CodeUnauthenticated, "invalid or expired token")
235+
if errors.Is(err, jwt.ErrTokenExpired) {
236+
return nil, authErrorCtx(ctx, errTokenExpired, connect.CodeUnauthenticated, "token expired")
237+
}
238+
return nil, authErrorCtx(ctx, errNotAuthenticated, connect.CodeUnauthenticated, "invalid token")
230239
}
231240

232241
// Add user context with permissions from JWT

internal/auth/interceptor_test.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,11 @@ func TestAuthInterceptor_MissingHeader(t *testing.T) {
189189
assert.Contains(t, err.Error(), "missing authentication credentials")
190190
}
191191

192-
// TestAuthInterceptor_InvalidToken verifies that an invalid JWT is rejected.
192+
// TestAuthInterceptor_InvalidToken verifies that a malformed JWT is
193+
// rejected with the "invalid token" message — distinct from the
194+
// "token expired" wording covered by TestAuthInterceptor_ExpiredToken.
195+
// The split (#139) lets the web client choose refresh-and-retry vs
196+
// forced-relogin instead of reflexively retrying on every 401.
193197
func TestAuthInterceptor_InvalidToken(t *testing.T) {
194198
serverURL, _ := setupInterceptorTest(t)
195199

@@ -203,7 +207,9 @@ func TestAuthInterceptor_InvalidToken(t *testing.T) {
203207
_, err := client.CallUnary(context.Background(), req)
204208
require.Error(t, err)
205209
assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
206-
assert.Contains(t, err.Error(), "invalid or expired token")
210+
assert.Contains(t, err.Error(), "invalid token")
211+
assert.NotContains(t, err.Error(), "expired",
212+
"malformed-token failures must not surface as 'expired' — keeps the web error-mapping branches disjoint")
207213
}
208214

209215
// TestAuthInterceptor_CookieNoLongerAccepted verifies that cookie-based auth
@@ -297,6 +303,11 @@ func TestAuthInterceptor_ExpiredToken(t *testing.T) {
297303
_, err = client.CallUnary(context.Background(), req)
298304
require.Error(t, err)
299305
assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
306+
// #139: expired tokens specifically must surface the "token
307+
// expired" wording so the web client can pick refresh-and-retry.
308+
// Malformed-token failures use "invalid token" instead — see
309+
// TestAuthInterceptor_InvalidToken.
310+
assert.Contains(t, err.Error(), "token expired")
300311
}
301312

302313
// TestAuthInterceptor_WrongSecret verifies that a token signed with a different
@@ -321,6 +332,10 @@ func TestAuthInterceptor_WrongSecret(t *testing.T) {
321332
_, err = client.CallUnary(context.Background(), req)
322333
require.Error(t, err)
323334
assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
335+
// #139: signature-invalid is a hard "invalid", not an expiry —
336+
// the web client must NOT treat this as a refreshable failure.
337+
assert.Contains(t, err.Error(), "invalid token")
338+
assert.NotContains(t, err.Error(), "expired")
324339
}
325340

326341
// setupRateLimitedInterceptorTest stands up an httptest server that

0 commit comments

Comments
 (0)