From 31b5b8d1cf71d76fccf3546286f956f9bdb3cd36 Mon Sep 17 00:00:00 2001 From: Zeno Belli Date: Fri, 11 Sep 2026 23:51:57 +0200 Subject: [PATCH 1/2] refactor(jwtinfo): add sentinel and typed errors for Is/As Expose stable jwtinfo failure conditions via sentinels and typed errors, and route CLI/MCP display through errdisp domain leaves. Co-authored-by: Cursor --- CHANGELOG.md | 2 + internal/cmd/jwtinfo.go | 15 +- internal/errdisp/errdisp.go | 60 +++++++- internal/errdisp/errdisp_test.go | 29 ++++ internal/jwtinfo/errors.go | 183 +++++++++++++++++++++++ internal/jwtinfo/errors_test.go | 148 ++++++++++++++++++ internal/jwtinfo/jwtinfo.go | 52 +++---- internal/jwtinfo/jwtinfo_refresh_test.go | 31 ++-- internal/jwtinfo/jwtinfo_test.go | 105 ++++++++----- 9 files changed, 535 insertions(+), 90 deletions(-) create mode 100644 internal/jwtinfo/errors.go create mode 100644 internal/jwtinfo/errors_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce2eb0..4abae17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Requests: pin Go 1.27 ML-KEM hybrid CurvePreferences (including P-521 fallback) and print the negotiated key exchange. + Jwtinfo: add sentinel and typed errors for errors.Is/As, and route CLI/MCP display through errdisp domain leaves. + ### Fix Devenv: prefer httpbin on 127.0.0.1:8081 and proxy nginx upstreams through the allocated httpbin port so `devenv test` keeps working when the preferred port is already taken; fail fast in request integration tests with `set -e`, enable `pipefail` on success-case request leaf pipelines, and assert request exit status separately from expected error text. diff --git a/internal/cmd/jwtinfo.go b/internal/cmd/jwtinfo.go index 0d4606c..cb85c4b 100644 --- a/internal/cmd/jwtinfo.go +++ b/internal/cmd/jwtinfo.go @@ -15,6 +15,7 @@ import ( "github.com/MicahParks/keyfunc/v3" "github.com/spf13/cobra" + "github.com/xenos76/https-wrench/internal/errdisp" "github.com/xenos76/https-wrench/internal/jwtinfo" "github.com/xenos76/https-wrench/internal/style" ) @@ -123,7 +124,7 @@ Examples: if err != nil { cmd.Printf( "error while reading token value from file: %s", - err, + errdisp.FormatCause(err), ) return @@ -153,7 +154,7 @@ Examples: } if err != nil { - cmd.Printf("error processing %s: %s\n", step.kind, err) + cmd.Printf("error processing %s: %s\n", step.kind, errdisp.FormatCause(err)) return } } @@ -166,7 +167,7 @@ Examples: io.ReadAll, ) if err != nil { - cmd.Printf("error while requesting token data: %s\n", err) + cmd.Printf("error while requesting token data: %s\n", errdisp.FormatCause(err)) return } } @@ -174,21 +175,21 @@ Examples: if tokenData != nil && tokenData.AccessTokenRaw != "" { err = tokenData.DecodeBase64() if err != nil { - cmd.Printf("DecodeBase64 error: %s\n", err) + cmd.Printf("DecodeBase64 error: %s\n", errdisp.FormatCause(err)) return } if jwksURL != "" { err = tokenData.ParseWithJWKS(cmd.Context(), jwksURL, keyfuncDefOverride) if err != nil { - cmd.Printf("error while parsing token data: %s\n", err) + cmd.Printf("error while parsing token data: %s\n", errdisp.FormatCause(err)) return } } err = jwtinfo.PrintTokenInfo(tokenData, cmd.OutOrStdout()) if err != nil { - cmd.Printf("error while printing token data: %s\n", err) + cmd.Printf("error while printing token data: %s\n", errdisp.FormatCause(err)) return } @@ -222,7 +223,7 @@ Examples: cmd.OutOrStdout(), ) if err != nil { - cmd.Printf("Refresh loop exited with error: %s\n", err) + cmd.Printf("Refresh loop exited with error: %s\n", errdisp.FormatCause(err)) } else { cmd.Printf("Refresh loop stopped gracefully.\n") } diff --git a/internal/errdisp/errdisp.go b/internal/errdisp/errdisp.go index d80c0ae..0c0678f 100644 --- a/internal/errdisp/errdisp.go +++ b/internal/errdisp/errdisp.go @@ -3,7 +3,8 @@ Copyright © 2025 Zeno Belli xeno@os76.xyz */ // Package errdisp formats errors for CLI and MCP user boundaries. -// It holds no sentinels; domain identity stays in packages such as certinfo. +// It holds no sentinels; domain identity stays in packages such as certinfo +// and jwtinfo. package errdisp import ( @@ -11,6 +12,7 @@ import ( "strings" "github.com/xenos76/https-wrench/internal/certinfo" + "github.com/xenos76/https-wrench/internal/jwtinfo" ) // Cause returns the deepest single-cause unwrap of err. @@ -29,8 +31,8 @@ func Cause(err error) error { } // FormatCause returns a short message for callers that already print an -// operation prefix. Prefer certinfo domain leaves via Is/As; otherwise the -// deepest cause. +// operation prefix. Prefer domain leaves via Is/As; otherwise the deepest +// cause. func FormatCause(err error) string { if err == nil { return "" @@ -44,8 +46,8 @@ func FormatCause(err error) string { } // Format returns a user-facing message when the caller has no operation prefix. -// Prefer certinfo domain leaves via Is/As; otherwise top wrap label + deepest -// cause, skipping intermediate layers. +// Prefer domain leaves via Is/As; otherwise top wrap label + deepest cause, +// skipping intermediate layers. func Format(err error) string { if err == nil { return "" @@ -69,7 +71,7 @@ func Format(err error) string { return label + ": " + cause.Error() } -// domainLeaf returns a certinfo leaf message when err matches a known domain failure. +// domainLeaf returns a domain leaf message when err matches a known failure. func domainLeaf(err error) (string, bool) { if empty, ok := errors.AsType[*certinfo.EmptyArgError](err); ok { return empty.Error(), true @@ -83,6 +85,38 @@ func domainLeaf(err error) (string, bool) { return keyType.Error(), true } + if empty, ok := errors.AsType[*jwtinfo.EmptyArgError](err); ok { + return empty.Error(), true + } + + if jwtFmt, ok := errors.AsType[*jwtinfo.InvalidJWTFormatError](err); ok { + return jwtFmt.Error(), true + } + + if jsonPart, ok := errors.AsType[*jwtinfo.InvalidJSONPartError](err); ok { + return jsonPart.Error(), true + } + + if claim, ok := errors.AsType[*jwtinfo.ClaimError](err); ok { + return claim.Error(), true + } + + if status, ok := errors.AsType[*jwtinfo.TokenStatusError](err); ok { + return status.Error(), true + } + + if kv, ok := errors.AsType[*jwtinfo.InvalidKVError](err); ok { + return kv.Error(), true + } + + if param, ok := errors.AsType[*jwtinfo.EmptyParamNameError](err); ok { + return param.Error(), true + } + + if thr, ok := errors.AsType[*jwtinfo.InvalidRenewThresholdError](err); ok { + return thr.Error(), true + } + for _, s := range []error{ certinfo.ErrNilReader, certinfo.ErrPEMDecode, @@ -93,6 +127,20 @@ func domainLeaf(err error) (string, bool) { certinfo.ErrEmptyArg, certinfo.ErrNoCertsInFile, certinfo.ErrUnrecognizedKeyType, + jwtinfo.ErrNilBodyReader, + jwtinfo.ErrEmptyRequestValues, + jwtinfo.ErrEmptyArg, + jwtinfo.ErrInvalidJWTFormat, + jwtinfo.ErrInvalidHeaderJSON, + jwtinfo.ErrInvalidClaimsJSON, + jwtinfo.ErrEmptyClaims, + jwtinfo.ErrClaimMissing, + jwtinfo.ErrClaimNotNumeric, + jwtinfo.ErrInvalidKV, + jwtinfo.ErrEmptyParamName, + jwtinfo.ErrInvalidRenewThreshold, + jwtinfo.ErrTokenLifetimeInvalid, + jwtinfo.ErrTokenRequestStatus, } { if errors.Is(err, s) { return s.Error(), true diff --git a/internal/errdisp/errdisp_test.go b/internal/errdisp/errdisp_test.go index 0df15bb..e65ae17 100644 --- a/internal/errdisp/errdisp_test.go +++ b/internal/errdisp/errdisp_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/xenos76/https-wrench/internal/certinfo" + "github.com/xenos76/https-wrench/internal/jwtinfo" ) func TestCause(t *testing.T) { @@ -57,6 +58,27 @@ func TestFormatCause(t *testing.T) { err := fmt.Errorf("wrap: %w", &certinfo.EmptyArgError{Name: "caBundlePath"}) require.Equal(t, "empty string provided as caBundlePath", FormatCause(err)) }) + + t.Run("jwtinfo empty arg", func(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrap: %w", &jwtinfo.EmptyArgError{Name: "request URL"}) + require.Equal(t, "empty string provided as request URL", FormatCause(err)) + }) + + t.Run("jwtinfo token status", func(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("request: %w", &jwtinfo.TokenStatusError{Code: 401}) + require.Equal(t, "token request returned the following status code: 401", FormatCause(err)) + }) + + t.Run("jwtinfo claim missing", func(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("claims: %w", &jwtinfo.ClaimError{Claim: "exp", Kind: jwtinfo.ClaimMissing}) + require.Equal(t, "exp claim missing", FormatCause(err)) + }) } func TestFormat(t *testing.T) { @@ -87,4 +109,11 @@ func TestFormat(t *testing.T) { require.Equal(t, "boom", Format(errors.New("boom"))) }) + + t.Run("jwtinfo sentinel leaf only", func(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("failed to request refreshed token: %w", jwtinfo.ErrEmptyRequestValues) + require.Equal(t, jwtinfo.ErrEmptyRequestValues.Error(), Format(err)) + }) } diff --git a/internal/jwtinfo/errors.go b/internal/jwtinfo/errors.go new file mode 100644 index 0000000..a34a2cc --- /dev/null +++ b/internal/jwtinfo/errors.go @@ -0,0 +1,183 @@ +//nolint:revive // max-public-structs: typed domain errors for errors.Is/As +package jwtinfo + +import ( + "errors" + "fmt" +) + +// Package-level sentinels for stable jwtinfo failure conditions. +// Match them with errors.Is after wrapping; Error() strings stay human-facing. +var ( + ErrNilBodyReader = errors.New("nil body reader function") + ErrEmptyRequestValues = errors.New("empty map provided as request values") + ErrEmptyArg = errors.New("empty string provided as argument") + ErrInvalidJWTFormat = errors.New("invalid three dotted JWT format") + ErrInvalidHeaderJSON = errors.New("invalid JSON found in header") + ErrInvalidClaimsJSON = errors.New("invalid JSON found in claims") + ErrEmptyClaims = errors.New("access token claims are empty") + ErrClaimMissing = errors.New("required claim missing") + ErrClaimNotNumeric = errors.New("claim is not a numeric timestamp") + ErrInvalidKV = errors.New("invalid key-value pair") + ErrEmptyParamName = errors.New("empty request parameter name") + ErrInvalidRenewThreshold = errors.New("renewThreshold must be between 0 and 100") + ErrTokenLifetimeInvalid = errors.New("token lifetime is zero or negative") + ErrTokenRequestStatus = errors.New("token request returned non-OK status") +) + +// EmptyArgError is returned when a required string argument is empty. +// errors.Is(err, ErrEmptyArg) is true. +type EmptyArgError struct { + Name string +} + +// Error returns a message naming the empty argument. +func (e *EmptyArgError) Error() string { + return fmt.Sprintf("empty string provided as %s", e.Name) +} + +// Is reports whether target is ErrEmptyArg. +func (*EmptyArgError) Is(target error) bool { + return target == ErrEmptyArg +} + +// InvalidJWTFormatError is returned when a token is not three dotted JWT parts. +// errors.Is(err, ErrInvalidJWTFormat) is true. +type InvalidJWTFormatError struct { + Name string +} + +// Error returns a message naming the malformed token. +func (e *InvalidJWTFormatError) Error() string { + return fmt.Sprintf("invalid three dotted JWT format in %s", e.Name) +} + +// Is reports whether target is ErrInvalidJWTFormat. +func (*InvalidJWTFormatError) Is(target error) bool { + return target == ErrInvalidJWTFormat +} + +// InvalidJSONPartError is returned when a JWT header or claims part is not JSON. +// errors.Is matches ErrInvalidHeaderJSON or ErrInvalidClaimsJSON from Part. +type InvalidJSONPartError struct { + Name string + Part string // "header" or "claims" +} + +// Error returns a message naming the invalid JSON part. +func (e *InvalidJSONPartError) Error() string { + return fmt.Sprintf("invalid JSON found in %s from %s", e.Part, e.Name) +} + +// Is reports whether target matches the header or claims sentinel for Part. +func (e *InvalidJSONPartError) Is(target error) bool { + switch e.Part { + case "header": + return target == ErrInvalidHeaderJSON + case "claims": + return target == ErrInvalidClaimsJSON + default: + return false + } +} + +// ClaimKind distinguishes missing vs non-numeric claim failures. +type ClaimKind int + +const ( + // ClaimMissing means the claim key is absent. + ClaimMissing ClaimKind = iota + // ClaimNotNumeric means the claim exists but is not a numeric timestamp. + ClaimNotNumeric +) + +// ClaimError is returned for iat/exp claim validation failures. +// errors.Is matches ErrClaimMissing or ErrClaimNotNumeric from Kind. +type ClaimError struct { + Claim string + Kind ClaimKind +} + +// Error returns a human-facing claim failure message. +func (e *ClaimError) Error() string { + switch e.Kind { + case ClaimNotNumeric: + return fmt.Sprintf("%s claim is not a numeric timestamp", e.Claim) + default: + return fmt.Sprintf("%s claim missing", e.Claim) + } +} + +// Is reports whether target matches the sentinel for Kind. +func (e *ClaimError) Is(target error) bool { + switch e.Kind { + case ClaimNotNumeric: + return target == ErrClaimNotNumeric + default: + return target == ErrClaimMissing + } +} + +// TokenStatusError is returned when the token HTTP request is not StatusOK. +// errors.Is(err, ErrTokenRequestStatus) is true. +type TokenStatusError struct { + Code int +} + +// Error returns a message including the HTTP status code. +func (e *TokenStatusError) Error() string { + return fmt.Sprintf("token request returned the following status code: %d", e.Code) +} + +// Is reports whether target is ErrTokenRequestStatus. +func (*TokenStatusError) Is(target error) bool { + return target == ErrTokenRequestStatus +} + +// InvalidKVError is returned when a key-value string is not key=value. +// errors.Is(err, ErrInvalidKV) is true. +type InvalidKVError struct { + Value string +} + +// Error returns a message including the invalid value. +func (e *InvalidKVError) Error() string { + return fmt.Sprintf("invalid key-value pair: %s (expected key=value)", e.Value) +} + +// Is reports whether target is ErrInvalidKV. +func (*InvalidKVError) Is(target error) bool { + return target == ErrInvalidKV +} + +// EmptyParamNameError is returned when the key side of key=value is empty. +// errors.Is(err, ErrEmptyParamName) is true. +type EmptyParamNameError struct { + KV string +} + +// Error returns a message including the original key-value string. +func (e *EmptyParamNameError) Error() string { + return fmt.Sprintf("empty request parameter name in: %s", e.KV) +} + +// Is reports whether target is ErrEmptyParamName. +func (*EmptyParamNameError) Is(target error) bool { + return target == ErrEmptyParamName +} + +// InvalidRenewThresholdError is returned when renewThreshold is out of range. +// errors.Is(err, ErrInvalidRenewThreshold) is true. +type InvalidRenewThresholdError struct { + Value float64 +} + +// Error returns a message including the invalid threshold. +func (e *InvalidRenewThresholdError) Error() string { + return fmt.Sprintf("renewThreshold must be between 0 and 100, got %.2f", e.Value) +} + +// Is reports whether target is ErrInvalidRenewThreshold. +func (*InvalidRenewThresholdError) Is(target error) bool { + return target == ErrInvalidRenewThreshold +} diff --git a/internal/jwtinfo/errors_test.go b/internal/jwtinfo/errors_test.go new file mode 100644 index 0000000..2355647 --- /dev/null +++ b/internal/jwtinfo/errors_test.go @@ -0,0 +1,148 @@ +package jwtinfo + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +//nolint:revive // function-length: table-driven sentinel coverage +func TestJwtinfo_errorSentinels_Is(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + target error + }{ + { + name: "EmptyArgError", + err: &EmptyArgError{Name: "request URL"}, + target: ErrEmptyArg, + }, + { + name: "EmptyArgError wrapped", + err: fmt.Errorf("load: %w", &EmptyArgError{Name: "JWKS url"}), + target: ErrEmptyArg, + }, + { + name: "InvalidJWTFormatError", + err: &InvalidJWTFormatError{Name: "AccessToken"}, + target: ErrInvalidJWTFormat, + }, + { + name: "InvalidJSONPartError header", + err: &InvalidJSONPartError{Name: "AccessToken", Part: "header"}, + target: ErrInvalidHeaderJSON, + }, + { + name: "InvalidJSONPartError claims", + err: &InvalidJSONPartError{Name: "RefreshToken", Part: "claims"}, + target: ErrInvalidClaimsJSON, + }, + { + name: "ClaimError missing", + err: &ClaimError{Claim: "iat", Kind: ClaimMissing}, + target: ErrClaimMissing, + }, + { + name: "ClaimError not numeric", + err: &ClaimError{Claim: "exp", Kind: ClaimNotNumeric}, + target: ErrClaimNotNumeric, + }, + { + name: "TokenStatusError", + err: &TokenStatusError{Code: 503}, + target: ErrTokenRequestStatus, + }, + { + name: "InvalidKVError", + err: &InvalidKVError{Value: "nope"}, + target: ErrInvalidKV, + }, + { + name: "EmptyParamNameError", + err: &EmptyParamNameError{KV: "=value"}, + target: ErrEmptyParamName, + }, + { + name: "InvalidRenewThresholdError", + err: &InvalidRenewThresholdError{Value: 150}, + target: ErrInvalidRenewThreshold, + }, + { + name: "ErrNilBodyReader wrapped", + err: fmt.Errorf("op: %w", ErrNilBodyReader), + target: ErrNilBodyReader, + }, + { + name: "ErrEmptyRequestValues", + err: ErrEmptyRequestValues, + target: ErrEmptyRequestValues, + }, + { + name: "ErrEmptyClaims", + err: ErrEmptyClaims, + target: ErrEmptyClaims, + }, + { + name: "ErrTokenLifetimeInvalid", + err: ErrTokenLifetimeInvalid, + target: ErrTokenLifetimeInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.ErrorIs(t, tt.err, tt.target) + }) + } +} + +func TestJwtinfo_errorTypes_AsType(t *testing.T) { + t.Parallel() + + empty := fmt.Errorf("wrap: %w", &EmptyArgError{Name: "request URL"}) + gotEmpty, ok := errors.AsType[*EmptyArgError](empty) + require.True(t, ok) + require.Equal(t, "request URL", gotEmpty.Name) + + jwtFmt := fmt.Errorf("wrap: %w", &InvalidJWTFormatError{Name: "AccessToken"}) + gotFmt, ok := errors.AsType[*InvalidJWTFormatError](jwtFmt) + require.True(t, ok) + require.Equal(t, "AccessToken", gotFmt.Name) + + jsonPart := fmt.Errorf("wrap: %w", &InvalidJSONPartError{Name: "AccessToken", Part: "header"}) + gotPart, ok := errors.AsType[*InvalidJSONPartError](jsonPart) + require.True(t, ok) + require.Equal(t, "header", gotPart.Part) + + claim := fmt.Errorf("wrap: %w", &ClaimError{Claim: "exp", Kind: ClaimMissing}) + gotClaim, ok := errors.AsType[*ClaimError](claim) + require.True(t, ok) + require.Equal(t, "exp", gotClaim.Claim) + require.Equal(t, ClaimMissing, gotClaim.Kind) + + status := fmt.Errorf("wrap: %w", &TokenStatusError{Code: 401}) + gotStatus, ok := errors.AsType[*TokenStatusError](status) + require.True(t, ok) + require.Equal(t, 401, gotStatus.Code) + + kv := fmt.Errorf("wrap: %w", &InvalidKVError{Value: "x"}) + gotKV, ok := errors.AsType[*InvalidKVError](kv) + require.True(t, ok) + require.Equal(t, "x", gotKV.Value) + + param := fmt.Errorf("wrap: %w", &EmptyParamNameError{KV: "=v"}) + gotParam, ok := errors.AsType[*EmptyParamNameError](param) + require.True(t, ok) + require.Equal(t, "=v", gotParam.KV) + + thr := fmt.Errorf("wrap: %w", &InvalidRenewThresholdError{Value: -1}) + gotThr, ok := errors.AsType[*InvalidRenewThresholdError](thr) + require.True(t, ok) + require.InDelta(t, -1.0, gotThr.Value, 0.001) +} diff --git a/internal/jwtinfo/jwtinfo.go b/internal/jwtinfo/jwtinfo.go index 41cf0a9..d9e4101 100644 --- a/internal/jwtinfo/jwtinfo.go +++ b/internal/jwtinfo/jwtinfo.go @@ -5,7 +5,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "maps" @@ -60,15 +59,15 @@ type AllReader func(io.Reader) ([]byte, error) //nolint:revive func RequestToken(ctx context.Context, reqURL string, reqValues map[string]string, client *http.Client, readAll AllReader) (*JwtTokenData, error) { if readAll == nil { - return nil, errors.New("nil body reader function") + return nil, ErrNilBodyReader } if reqURL == emptyString { - return nil, errors.New("empty string provided as request URL") + return nil, &EmptyArgError{Name: "request URL"} } if len(reqValues) == 0 { - return nil, errors.New("empty map provided as request values") + return nil, ErrEmptyRequestValues } t := &JwtTokenData{} @@ -103,10 +102,7 @@ func RequestToken(ctx context.Context, reqURL string, reqValues map[string]strin defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf( - "token request returned the following status code: %d", - resp.StatusCode, - ) + return nil, &TokenStatusError{Code: resp.StatusCode} } bodyBytes, errBodyRead := readAll(resp.Body) @@ -181,7 +177,7 @@ func ParseRequestJSONValues( error, ) { if reqValues == "" { - return nil, errors.New("empty string provided as JSON encoded request values") + return nil, &EmptyArgError{Name: "JSON encoded request values"} } var objmap map[string]string @@ -272,7 +268,7 @@ func (jtd *JwtTokenData) DecodeBase64() error { func decodeToken(name, raw string) (header []byte, claims []byte, err error) { tokenB64Elements := strings.Split(raw, ".") if len(tokenB64Elements) != 3 { - return nil, nil, fmt.Errorf("invalid three dotted JWT format in %s", name) + return nil, nil, &InvalidJWTFormatError{Name: name} } header, err = base64.RawURLEncoding.DecodeString(tokenB64Elements[0]) @@ -281,7 +277,7 @@ func decodeToken(name, raw string) (header []byte, claims []byte, err error) { } if !isValidJSON(header) { - return nil, nil, fmt.Errorf("invalid JSON found in header from %s", name) + return nil, nil, &InvalidJSONPartError{Name: name, Part: "header"} } claims, err = base64.RawURLEncoding.DecodeString(tokenB64Elements[1]) @@ -290,7 +286,7 @@ func decodeToken(name, raw string) (header []byte, claims []byte, err error) { } if !isValidJSON(claims) { - return nil, nil, fmt.Errorf("invalid JSON found in claims from %s", name) + return nil, nil, &InvalidJSONPartError{Name: name, Part: "claims"} } return header, claims, nil @@ -318,7 +314,7 @@ func (jtd *JwtTokenData) ParseUnverified() error { // provided at the given URL. func (jtd *JwtTokenData) ParseWithJWKS(ctx context.Context, jwksURL string, keyfuncOverride keyfunc.Override) error { if jwksURL == emptyString { - return errors.New("emptyString string provided as JWKS url") + return &EmptyArgError{Name: "JWKS url"} } jwks, err := keyfunc.NewDefaultOverrideCtx( @@ -454,19 +450,19 @@ func unmarshalTokenTimeClaims(claims []byte) (map[string]string, error) { } if _, ok := genericClaims["iat"]; !ok { - return nil, errors.New("unable to find Issued At (iat) in token Claims") + return nil, &ClaimError{Claim: "iat", Kind: ClaimMissing} } if _, ok := genericClaims["iat"].(float64); !ok { - return nil, errors.New("Issued At (iat) claim is not a numeric timestamp") + return nil, &ClaimError{Claim: "iat", Kind: ClaimNotNumeric} } if _, ok := genericClaims["exp"]; !ok { - return nil, errors.New("unable to find Expiration Time (exp) in token Claims") + return nil, &ClaimError{Claim: "exp", Kind: ClaimMissing} } if _, ok := genericClaims["exp"].(float64); !ok { - return nil, errors.New("Expiration Time (exp) claim is not a numeric timestamp") + return nil, &ClaimError{Claim: "exp", Kind: ClaimNotNumeric} } for k, v := range genericClaims { @@ -486,7 +482,7 @@ func unmarshalTokenTimeClaims(claims []byte) (map[string]string, error) { // GetExpiration extracts the expiration time (exp) from the token claims. func (jtd *JwtTokenData) GetExpiration() (time.Time, error) { if jtd.AccessTokenClaims == nil { - return time.Time{}, errors.New("access token claims are empty") + return time.Time{}, ErrEmptyClaims } var genericClaims map[string]any @@ -499,16 +495,16 @@ func (jtd *JwtTokenData) GetExpiration() (time.Time, error) { return time.Unix(int64(vf), 0), nil } - return time.Time{}, errors.New("exp claim is not a numeric timestamp") + return time.Time{}, &ClaimError{Claim: "exp", Kind: ClaimNotNumeric} } - return time.Time{}, errors.New("exp claim missing") + return time.Time{}, &ClaimError{Claim: "exp", Kind: ClaimMissing} } // GetIssuedAt extracts the issued at time (iat) from the token claims. func (jtd *JwtTokenData) GetIssuedAt() (time.Time, error) { if jtd.AccessTokenClaims == nil { - return time.Time{}, errors.New("access token claims are empty") + return time.Time{}, ErrEmptyClaims } var genericClaims map[string]any @@ -521,10 +517,10 @@ func (jtd *JwtTokenData) GetIssuedAt() (time.Time, error) { return time.Unix(int64(vf), 0), nil } - return time.Time{}, errors.New("iat claim is not a numeric timestamp") + return time.Time{}, &ClaimError{Claim: "iat", Kind: ClaimNotNumeric} } - return time.Time{}, errors.New("iat claim missing") + return time.Time{}, &ClaimError{Claim: "iat", Kind: ClaimMissing} } // Refresh attempts to acquire a new token either by using the refresh token or the original request values. @@ -613,7 +609,7 @@ func (jtd *JwtTokenData) RefreshLoop( // based on the expiration time and the renewal threshold. func (jtd *JwtTokenData) calculateWaitDuration(renewThreshold float64) (time.Duration, error) { if renewThreshold < 0 || renewThreshold > 100 { - return 0, fmt.Errorf("renewThreshold must be between 0 and 100, got %.2f", renewThreshold) + return 0, &InvalidRenewThresholdError{Value: renewThreshold} } exp, err := jtd.GetExpiration() @@ -639,7 +635,7 @@ func (jtd *JwtTokenData) calculateWaitDuration(renewThreshold float64) (time.Dur } if lifetime <= 0 { - return 0, errors.New("token lifetime is zero or negative") + return 0, ErrTokenLifetimeInvalid } sleepFor := time.Until(wakeTime) @@ -719,17 +715,17 @@ func ParseKVValue( error, ) { if kv == "" { - return nil, errors.New("empty string provided as key-value pair") + return nil, &EmptyArgError{Name: "key-value pair"} } parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { - return nil, fmt.Errorf("invalid key-value pair: %s (expected key=value)", kv) + return nil, &InvalidKVError{Value: kv} } key := strings.TrimSpace(parts[0]) if key == "" { - return nil, fmt.Errorf("empty request parameter name in: %s", kv) + return nil, &EmptyParamNameError{KV: kv} } newMap := maps.Clone(reqValuesMap) diff --git a/internal/jwtinfo/jwtinfo_refresh_test.go b/internal/jwtinfo/jwtinfo_refresh_test.go index acfe6ad..994c934 100644 --- a/internal/jwtinfo/jwtinfo_refresh_test.go +++ b/internal/jwtinfo/jwtinfo_refresh_test.go @@ -157,27 +157,28 @@ func TestJwtTokenData_TimingMethods_Errors(t *testing.T) { tests := []struct { name string jtd *JwtTokenData - wantErr string + wantErr error + wantMsg string }{ { name: "nil_claims", jtd: &JwtTokenData{AccessTokenClaims: nil}, - wantErr: "access token claims are empty", + wantErr: ErrEmptyClaims, }, { name: "invalid_json", jtd: &JwtTokenData{AccessTokenClaims: []byte(`{invalid}`)}, - wantErr: "unable to unmarshal claims", + wantMsg: "unable to unmarshal claims", }, { name: "missing_exp", jtd: &JwtTokenData{AccessTokenClaims: []byte(`{"iat":123}`)}, - wantErr: "exp claim missing", + wantErr: ErrClaimMissing, }, { name: "non_numeric_exp", jtd: &JwtTokenData{AccessTokenClaims: []byte(`{"exp":"not-a-number"}`)}, - wantErr: "exp claim is not a numeric timestamp", + wantErr: ErrClaimNotNumeric, }, } @@ -185,12 +186,22 @@ func TestJwtTokenData_TimingMethods_Errors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { _, err := tt.jtd.GetExpiration() require.Error(t, err) - require.Contains(t, err.Error(), tt.wantErr) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.Contains(t, err.Error(), tt.wantMsg) + } _, err = tt.jtd.GetIssuedAt() if tt.name == "nil_claims" || tt.name == "invalid_json" { require.Error(t, err) - require.Contains(t, err.Error(), tt.wantErr) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.Contains(t, err.Error(), tt.wantMsg) + } } }) } @@ -239,10 +250,8 @@ func TestJwtTokenData_CalculateWaitDuration_Validation(t *testing.T) { jtd := &JwtTokenData{} _, err := jtd.calculateWaitDuration(-1.0) - require.Error(t, err) - require.Contains(t, err.Error(), "renewThreshold must be between 0 and 100") + require.ErrorIs(t, err, ErrInvalidRenewThreshold) _, err = jtd.calculateWaitDuration(101.0) - require.Error(t, err) - require.Contains(t, err.Error(), "renewThreshold must be between 0 and 100") + require.ErrorIs(t, err, ErrInvalidRenewThreshold) } diff --git a/internal/jwtinfo/jwtinfo_test.go b/internal/jwtinfo/jwtinfo_test.go index cbe034d..859ae49 100644 --- a/internal/jwtinfo/jwtinfo_test.go +++ b/internal/jwtinfo/jwtinfo_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "errors" "fmt" "io" "maps" @@ -105,6 +106,7 @@ func TestParseRequestJSONValues(t *testing.T) { jsonStr string jsonRefMap map[string]string requireError bool + errorIs error errorMsg string }{ { @@ -125,7 +127,7 @@ func TestParseRequestJSONValues(t *testing.T) { jsonStr: "", jsonRefMap: mapToValidJSON, requireError: true, - errorMsg: "empty string provided as JSON encoded request values", + errorIs: ErrEmptyArg, }, } for _, tc := range tests { @@ -140,7 +142,12 @@ func TestParseRequestJSONValues(t *testing.T) { if tt.requireError { require.Error(t, err) - require.ErrorContains(t, err, tt.errorMsg) + + if tt.errorIs != nil { + require.ErrorIs(t, err, tt.errorIs) + } else { + require.ErrorContains(t, err, tt.errorMsg) + } return } @@ -268,7 +275,7 @@ func TestRequestToken_nilReadAll(t *testing.T) { nil, ) require.Error(t, err) - require.ErrorContains(t, err, "nil body reader") + require.ErrorIs(t, err, ErrNilBodyReader) } type requestTokenTestCase struct { @@ -301,6 +308,16 @@ func runRequestTokenSubtest(t *testing.T, tt requestTokenTestCase) { if tt.expError { require.Error(t, err, "RequestToken - expected error: %s", err) + + switch tt.scope { + case "emptyValuesMap": + require.ErrorIs(t, err, ErrEmptyRequestValues) + case "emptyReqUrl": + require.ErrorIs(t, err, ErrEmptyArg) + default: + // Other failure modes assert only that an error occurred. + } + return } @@ -565,11 +582,10 @@ func TestParseWithJWKS_Errors(t *testing.T) { "", keyfunc.Override{}, ) - require.ErrorContains( - t, - err, - "emptyString string provided as JWKS url", - ) + require.ErrorIs(t, err, ErrEmptyArg) + got, ok := errors.AsType[*EmptyArgError](err) + require.True(t, ok) + require.Equal(t, "JWKS url", got.Name) }) t.Run("WrongJwksURL", func(t *testing.T) { @@ -649,34 +665,35 @@ func TestDecodeBase64(t *testing.T) { tests := []struct { name string tokenString string - errMsg string + errorIs error + errorMsg string }{ { name: "not three dotted string", tokenString: b64notThreeDotted, - errMsg: "invalid three dotted JWT format in", + errorIs: ErrInvalidJWTFormat, }, { name: "invalid base64 header", tokenString: invalidB64HeaderTokenString, - errMsg: "unable to decode base64 header from", + errorMsg: "unable to decode base64 header from", }, { name: "invalid base64 claims", tokenString: invalidB64ClaimsTokenString, - errMsg: "unable to decode base64 claims from", + errorMsg: "unable to decode base64 claims from", }, { name: "invalid JSON header", tokenString: invalidJSONHeaderTokenString, - errMsg: "invalid JSON found in header from", + errorIs: ErrInvalidHeaderJSON, }, { name: "invalid JSON claims", tokenString: invalidJSONClaimsTokenString, - errMsg: "invalid JSON found in claims from", + errorIs: ErrInvalidClaimsJSON, }, } @@ -698,8 +715,14 @@ func TestDecodeBase64(t *testing.T) { tdAccessTokenTest := td tdAccessTokenTest.AccessTokenRaw = tt.tokenString + err = tdAccessTokenTest.DecodeBase64() - require.ErrorContains(t, err, tt.errMsg) + + if tt.errorIs != nil { + require.ErrorIs(t, err, tt.errorIs) + } else { + require.ErrorContains(t, err, tt.errorMsg) + } refreshTokenRaw, err := createToken("demo") require.NoError(t, err) @@ -716,8 +739,10 @@ func TestDecodeBase64(t *testing.T) { // It only fails if it *looks* like a JWT (3 parts) but is invalid. if strings.Count(tt.tokenString, ".") != 2 { require.NoError(t, err) + } else if tt.errorIs != nil { + require.ErrorIs(t, err, tt.errorIs) } else { - require.ErrorContains(t, err, tt.errMsg) + require.ErrorContains(t, err, tt.errorMsg) } }) } @@ -785,9 +810,10 @@ func TestUnmarshalTokenTimeClaims_MapErrors(t *testing.T) { expStringClaims := "{\"exp\":\"now\", \"iat\":1}" tests := []struct { - name string - claims []byte - errMsg string + name string + claims []byte + errorIs error + errMsg string }{ { name: "invalid JSON", @@ -795,25 +821,25 @@ func TestUnmarshalTokenTimeClaims_MapErrors(t *testing.T) { errMsg: "unable to unmarshal claims", }, { - name: "missing Issued At", - claims: []byte(noIatClaims), - errMsg: "unable to find Issued At (iat) in token Claims", + name: "missing Issued At", + claims: []byte(noIatClaims), + errorIs: ErrClaimMissing, }, { - name: "not numeric Issued At", - claims: []byte(iatStringClaims), - errMsg: "Issued At (iat) claim is not a numeric timestamp", + name: "not numeric Issued At", + claims: []byte(iatStringClaims), + errorIs: ErrClaimNotNumeric, }, { - name: "claims no Expiration Time", - claims: []byte(noExpClaims), - errMsg: "unable to find Expiration Time (exp) in token Claims", + name: "claims no Expiration Time", + claims: []byte(noExpClaims), + errorIs: ErrClaimMissing, }, { - name: "not numeric Expiration Time", - claims: []byte(expStringClaims), - errMsg: "Expiration Time (exp) claim is not a numeric timestamp", + name: "not numeric Expiration Time", + claims: []byte(expStringClaims), + errorIs: ErrClaimNotNumeric, }, } @@ -823,7 +849,13 @@ func TestUnmarshalTokenTimeClaims_MapErrors(t *testing.T) { t.Parallel() _, err := unmarshalTokenTimeClaims(tt.claims) - require.ErrorContains(t, err, tt.errMsg) + require.Error(t, err) + + if tt.errorIs != nil { + require.ErrorIs(t, err, tt.errorIs) + } else { + require.ErrorContains(t, err, tt.errMsg) + } //nolint:revive }) //nolint:revive @@ -1050,23 +1082,20 @@ func TestParseKVValue(t *testing.T) { t.Parallel() _, err := ParseKVValue("invalid", nil) - require.Error(t, err) - require.ErrorContains(t, err, "expected key=value") + require.ErrorIs(t, err, ErrInvalidKV) }) t.Run("Error_Empty", func(t *testing.T) { t.Parallel() _, err := ParseKVValue("", nil) - require.Error(t, err) - require.ErrorContains(t, err, "empty string provided") + require.ErrorIs(t, err, ErrEmptyArg) }) t.Run("Error_EmptyKey", func(t *testing.T) { t.Parallel() _, err := ParseKVValue("=value", nil) - require.Error(t, err) - require.ErrorContains(t, err, "empty request parameter name") + require.ErrorIs(t, err, ErrEmptyParamName) }) } From ddde668c8ca43cb8134c7662eecb035834b22fb3 Mon Sep 17 00:00:00 2001 From: Zeno Belli Date: Sat, 12 Sep 2026 01:00:28 +0200 Subject: [PATCH 2/2] doc: increase docstring coverage --- internal/errdisp/errdisp_test.go | 2 ++ internal/jwtinfo/errors_test.go | 3 +++ internal/jwtinfo/jwtinfo_refresh_test.go | 2 ++ internal/jwtinfo/jwtinfo_test.go | 11 +++++++++-- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/errdisp/errdisp_test.go b/internal/errdisp/errdisp_test.go index e65ae17..3d09e99 100644 --- a/internal/errdisp/errdisp_test.go +++ b/internal/errdisp/errdisp_test.go @@ -26,6 +26,7 @@ func TestCause(t *testing.T) { require.Equal(t, leaf, Cause(wrapped)) } +// TestFormatCause checks FormatCause prefers domain leaves over wrap text. func TestFormatCause(t *testing.T) { t.Parallel() @@ -81,6 +82,7 @@ func TestFormatCause(t *testing.T) { }) } +// TestFormat checks Format surfaces domain leaves or top label plus cause. func TestFormat(t *testing.T) { t.Parallel() diff --git a/internal/jwtinfo/errors_test.go b/internal/jwtinfo/errors_test.go index 2355647..6d5924f 100644 --- a/internal/jwtinfo/errors_test.go +++ b/internal/jwtinfo/errors_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/require" ) +// TestJwtinfo_errorSentinels_Is checks errors.Is against jwtinfo sentinels. +// //nolint:revive // function-length: table-driven sentinel coverage func TestJwtinfo_errorSentinels_Is(t *testing.T) { t.Parallel() @@ -102,6 +104,7 @@ func TestJwtinfo_errorSentinels_Is(t *testing.T) { } } +// TestJwtinfo_errorTypes_AsType checks errors.AsType for typed jwtinfo errors. func TestJwtinfo_errorTypes_AsType(t *testing.T) { t.Parallel() diff --git a/internal/jwtinfo/jwtinfo_refresh_test.go b/internal/jwtinfo/jwtinfo_refresh_test.go index 994c934..6c7acec 100644 --- a/internal/jwtinfo/jwtinfo_refresh_test.go +++ b/internal/jwtinfo/jwtinfo_refresh_test.go @@ -153,6 +153,7 @@ func TestJwtTokenData_WriteTokenToFile(t *testing.T) { require.Contains(t, buf.String(), "Token persisted to") } +// TestJwtTokenData_TimingMethods_Errors checks GetExpiration/GetIssuedAt claim failures. func TestJwtTokenData_TimingMethods_Errors(t *testing.T) { tests := []struct { name string @@ -246,6 +247,7 @@ func TestJwtTokenData_RefreshLoop_ErrorRetry(t *testing.T) { require.Contains(t, buf.String(), "network error") } +// TestJwtTokenData_CalculateWaitDuration_Validation checks renewThreshold bounds. func TestJwtTokenData_CalculateWaitDuration_Validation(t *testing.T) { jtd := &JwtTokenData{} diff --git a/internal/jwtinfo/jwtinfo_test.go b/internal/jwtinfo/jwtinfo_test.go index 859ae49..e3936bf 100644 --- a/internal/jwtinfo/jwtinfo_test.go +++ b/internal/jwtinfo/jwtinfo_test.go @@ -90,6 +90,8 @@ func TestReadRequestValuesFile(t *testing.T) { }) } +// TestParseRequestJSONValues checks JSON request-value parsing and merge errors. +// //nolint:revive func TestParseRequestJSONValues(t *testing.T) { inputMap := map[string]string{ @@ -264,6 +266,7 @@ func TestRequestToken(t *testing.T) { //nolint:revive } +// TestRequestToken_nilReadAll checks RequestToken rejects a nil body reader. func TestRequestToken_nilReadAll(t *testing.T) { t.Parallel() @@ -286,6 +289,7 @@ type requestTokenTestCase struct { expError bool } +// runRequestTokenSubtest runs one RequestToken table case against the test server. func runRequestTokenSubtest(t *testing.T, tt requestTokenTestCase) { t.Parallel() @@ -568,6 +572,7 @@ func TestParseUnverified(t *testing.T) { }) } +// TestParseWithJWKS_Errors checks ParseWithJWKS argument and JWKS failure paths. func TestParseWithJWKS_Errors(t *testing.T) { t.Run("EmpryJwksURL", func(t *testing.T) { t.Parallel() @@ -631,8 +636,8 @@ func TestParseWithJWKS_Errors(t *testing.T) { //nolint:revive } -//nolint:revive - +// TestDecodeBase64 checks DecodeBase64 format and JSON part failures. +// //nolint:revive func TestDecodeBase64(t *testing.T) { notThreeDotted := "notThreeDottedBase64CompliantString" @@ -800,6 +805,7 @@ func TestUnmarshalTokenTimeClaims(t *testing.T) { }) } +// TestUnmarshalTokenTimeClaims_MapErrors checks claim map validation failures. func TestUnmarshalTokenTimeClaims_MapErrors(t *testing.T) { invalidJSONClaims := "can not unmarshal" @@ -1058,6 +1064,7 @@ func TestPrintTokenInfo_Errors(t *testing.T) { }) } +// TestParseKVValue checks key=value parsing and empty-name errors. func TestParseKVValue(t *testing.T) { t.Run("Success", func(t *testing.T) { t.Parallel()