Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.10.0] - 2026-08-17

### Changed

- An invalid `cache_ttl` (unparseable, zero or negative) now fails the command instead of silently defaulting; the error names the config file, the expected duration syntax and `--refresh`
- An invalid `cache_ttl` (unparseable, zero or negative) now fails the command instead of silently defaulting; the error names the config file and the expected duration syntax

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Custom `SCAAccessService` follows SDK conventions:
## Cache
- Eligibility responses cached in `~/.grant/cache/` as JSON files (e.g., `eligibility_azure.json`, `groups_eligibility_azure.json`)
- Default TTL: 4 hours, configurable via `cache_ttl` in `~/.grant/config.yaml` (Go duration syntax: `2h`, `30m`)
- `config.ParseCacheTTL` returns `(time.Duration, error)`. **Absent** means "use the default"; **any explicitly supplied** value that cannot serve as a TTL — unparseable, zero or negative — is an error. Treating those two the same way is the point: silently defaulting `garbage` while rejecting `0s` would validate one field by two opposite rules. `config.Load` validates it so a bad value surfaces at load, not when some command happens to build a cache. `buildCachedLister` (`cmd/root.go`) therefore returns an error too — its bad-TTL arm is reachable only for a `Config` assembled in memory. Both rejection messages must name a remedy: the unparseable arm names the expected syntax (`must be a positive Go duration such as 4h or 30m`) and still wraps the `time.ParseDuration` error with `%w`; the non-positive arm names `--refresh` as the way to bypass the cache for one command, since `0s` used to work as an accidental kill-switch. Neither may point at `grant configure` (see Config)
- `config.ParseCacheTTL` returns `(time.Duration, error)`. **Absent** means "use the default"; **any explicitly supplied** value that cannot serve as a TTL — unparseable, zero or negative — is an error. Treating those two the same way is the point: silently defaulting `garbage` while rejecting `0s` would validate one field by two opposite rules. `config.Load` validates it so a bad value surfaces at load, not when some command happens to build a cache. `buildCachedLister` (`cmd/root.go`) therefore returns an error too — its bad-TTL arm is reachable only for a `Config` assembled in memory. Both rejection messages must name a remedy: the unparseable arm names the expected syntax (`must be a positive Go duration such as 4h or 30m`) and still wraps the `time.ParseDuration` error with `%w`; the non-positive arm names removing the setting, since `0s` used to work as an accidental kill-switch and `--refresh` does not exist on every cache-consuming command (`status`, `revoke`, `favorites add`) — nor would it help, because `config.Load` rejects the value before any flag is read. Neither may point at `grant configure` (see Config)
- `--refresh` flag on `grant` and `grant env` bypasses cache reads but still writes fresh data
- `internal/cache/cache.go` — generic `Store` with `Get[T]`/`Set[T]`, injectable clock for testing
- `internal/cache/cached_eligibility.go` — `CachedEligibilityLister` decorator implementing `eligibilityLister` + `groupsEligibilityLister`
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func ParseCacheTTL(cfg *Config) (time.Duration, error) {
return 0, fmt.Errorf("invalid cache_ttl %q: must be a positive Go duration such as 4h or 30m: %w", cfg.CacheTTL, err)
}
if d <= 0 {
return 0, fmt.Errorf("invalid cache_ttl %q: must be greater than zero; use --refresh to bypass the cache for a single command", cfg.CacheTTL)
return 0, fmt.Errorf("invalid cache_ttl %q: must be greater than zero; remove the setting to use the default (%s)", cfg.CacheTTL, DefaultCacheTTL)
}
return d, nil
}
Expand Down
8 changes: 4 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,21 +519,21 @@ func TestParseCacheTTL(t *testing.T) {
},
},
{
name: "zero is rejected and names the --refresh alternative",
name: "zero is rejected and names the remedy",
value: "0s",
wantErrContains: []string{
`invalid cache_ttl "0s"`,
"must be greater than zero",
"use --refresh to bypass the cache for a single command",
"remove the setting to use the default",
},
},
{
name: "negative is rejected and names the --refresh alternative",
name: "negative is rejected and names the remedy",
value: "-1h",
wantErrContains: []string{
`invalid cache_ttl "-1h"`,
"must be greater than zero",
"use --refresh to bypass the cache for a single command",
"remove the setting to use the default",
},
},
}
Expand Down
Loading