From e6cb21c65a82b0994a1ed5144aa1bc84f354b6d1 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Tue, 4 Aug 2026 00:11:56 +0200 Subject: [PATCH] feat(config): app-config registry, env-var renames, wire session.duration from DB Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +-- backend/cmd/geniusrund/main.go | 15 +++-- backend/internal/config/appconfig.go | 80 +++++++++++++++++++++++ backend/internal/config/appconfig_test.go | 79 ++++++++++++++++++++++ backend/internal/config/config.go | 53 ++++++++------- backend/internal/config/config_test.go | 18 ++--- 6 files changed, 210 insertions(+), 43 deletions(-) create mode 100644 backend/internal/config/appconfig.go create mode 100644 backend/internal/config/appconfig_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 5ff6c20..d917f02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty ## Commands Backend (from `backend/`): -- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed). +- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed). - Build/vet: `go build ./...` && `go vet ./...` - Format: `gofmt -l .` must report nothing before committing. - All tests: `go test ./...` @@ -37,7 +37,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install. -Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer). +Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`. Session duration is application configuration, not an env var: `session.duration` (hours, default `720`), editable on the `/config` page and applied on the next backend restart. See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer). ## Repo layout @@ -80,7 +80,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c - Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array). - Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`. - **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable. -- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default from `GENIUSRUN_MIN_CONFIDENCE`, 0.6). All three populate `Candidates` for the review UI. +- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (hard-coded `classify.DefaultMinConfidence`, 0.6). All three populate `Candidates` for the review UI. - **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance. - **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery. - Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring. @@ -92,7 +92,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c - **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching. - **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason). -- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped. +- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, this token-store root (`config.GarminTokenStoreRoot`, read from `GENIUSRUN_TOKENSTORE_PATH`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped. - **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`). diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index 6a757f7..b664e4a 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -35,6 +35,15 @@ func main() { } defer db.Close() + overrides, err := db.ConfigValues(context.Background()) + if err != nil { + log.Fatalf("app config: %v", err) + } + appCfg, err := config.LoadApp(overrides) + if err != nil { + log.Fatalf("app config: %v", err) + } + authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ IssuerURL: cfg.OIDCIssuerURL, ClientID: cfg.OIDCClientID, @@ -49,11 +58,9 @@ func main() { server := api.NewServer(db, garmin.NewClient, garmin.Config{ PythonPath: cfg.GarminPythonPath, TokenStorePath: cfg.GarminTokenStoreRoot, - }, appsync.Config{ - MinConfidence: cfg.MinConfidence, - }, authVerifier, api.SessionConfig{ + }, appsync.Config{}, authVerifier, api.SessionConfig{ Secret: cfg.SessionSecret, - Duration: cfg.SessionDuration, + Duration: appCfg.SessionDuration, Secure: cfg.SessionSecure, BackendURL: cfg.BackendURL, FrontendURL: cfg.FrontendURL, diff --git a/backend/internal/config/appconfig.go b/backend/internal/config/appconfig.go new file mode 100644 index 0000000..db38918 --- /dev/null +++ b/backend/internal/config/appconfig.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "strconv" + "time" +) + +// AppKey describes one application-configuration key: instance-global, +// stored (as an override) in the DB `config` table, read once at startup. +// Cold -- a changed value applies on the next backend restart. +type AppKey struct { + Key string + Default string + Description string + Validate func(value string) error +} + +// KeySessionDuration is the session cookie lifetime, in integer hours. +const KeySessionDuration = "session.duration" + +var appRegistry = []AppKey{ + { + Key: KeySessionDuration, + Default: "720", + Description: "Session cookie lifetime in hours", + Validate: validatePositiveInt, + }, +} + +// AppRegistry returns every known application-configuration key, in +// display order. +func AppRegistry() []AppKey { return appRegistry } + +func validatePositiveInt(v string) error { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return fmt.Errorf("must be a positive integer, got %q", v) + } + return nil +} + +// ValidateAppValue rejects unknown keys and invalid values -- every write +// path's guard, so the config table can never accumulate junk. +func ValidateAppValue(key, value string) error { + for _, k := range appRegistry { + if k.Key == key { + if err := k.Validate(value); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + return nil + } + } + return fmt.Errorf("unknown configuration key %q", key) +} + +// AppConfig is the typed result of merging DB overrides over registry +// defaults. +type AppConfig struct { + SessionDuration time.Duration +} + +// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure +// -- it takes the raw map instead of a *store.DB so this package needs no +// store dependency and the merge logic tests without a database. A bad +// stored value fails fast, same posture as Load() for env vars. +func LoadApp(overrides map[string]string) (AppConfig, error) { + merged := map[string]string{} + for _, k := range appRegistry { + merged[k.Key] = k.Default + } + for key, value := range overrides { + if err := ValidateAppValue(key, value); err != nil { + return AppConfig{}, fmt.Errorf("app config: %w", err) + } + merged[key] = value + } + hours, _ := strconv.Atoi(merged[KeySessionDuration]) + return AppConfig{SessionDuration: time.Duration(hours) * time.Hour}, nil +} diff --git a/backend/internal/config/appconfig_test.go b/backend/internal/config/appconfig_test.go new file mode 100644 index 0000000..819bbd8 --- /dev/null +++ b/backend/internal/config/appconfig_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func TestLoadApp(t *testing.T) { + tests := []struct { + name string + overrides map[string]string + want time.Duration + wantErr string + }{ + {name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour}, + {name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour}, + {name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"}, + {name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := LoadApp(tt.overrides) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("LoadApp: %v", err) + } + if got.SessionDuration != tt.want { + t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want) + } + }) + } +} + +func TestValidateAppValue(t *testing.T) { + if err := ValidateAppValue("session.duration", "24"); err != nil { + t.Fatalf("valid value rejected: %v", err) + } + if err := ValidateAppValue("session.duration", "-1"); err == nil { + t.Fatal("negative hours accepted") + } + if err := ValidateAppValue("session.duration", "1.5"); err == nil { + t.Fatal("non-integer accepted") + } + if err := ValidateAppValue("nope", "1"); err == nil { + t.Fatal("unknown key accepted") + } +} + +func TestDisplayEnv_MasksSecrets(t *testing.T) { + cfg := Config{ + Addr: ":8080", OIDCClientSecret: "hunter2", + SessionSecret: []byte("0123456789abcdef0123456789abcdef"), + } + entries := map[string]string{} + for _, e := range cfg.DisplayEnv() { + entries[e.Name] = e.Value + } + if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" { + t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"]) + } + if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" { + t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"]) + } + if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" { + t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"]) + } + empty := Config{} + for _, e := range empty.DisplayEnv() { + if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" { + t.Errorf("unset secret = %q, want (unset)", e.Value) + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b578a91..41fb74e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -8,9 +8,7 @@ package config import ( "fmt" "os" - "strconv" "strings" - "time" ) // Config holds geniusrund's process-level configuration. @@ -26,7 +24,7 @@ type Config struct { GarminPythonPath string // GarminTokenStoreRoot is the root directory under which each user's // Garmin session cache lives (one subdirectory per user id, e.g. - // "/3"). Read from the GARMIN_TOKENSTORE env var, configured + // "/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured // independently of DBPath -- if unset, it defaults to a ".garmin" // directory relative to the working directory the process is started // from, not derived from DBPath in any way, so every deployment gets @@ -35,7 +33,6 @@ type Config struct { // api.Server.garminFor), so it can never be silently left empty. GarminTokenStoreRoot string - MinConfidence float64 // LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error"). LogLevel string @@ -62,7 +59,6 @@ type Config struct { OIDCRedirectURL string OIDCRequiredRole string SessionSecret []byte - SessionDuration time.Duration SessionSecure bool } @@ -70,18 +66,16 @@ type Config struct { // for anything optional. Returns an error if a required variable is unset. func Load() (Config, error) { cfg := Config{ - Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"), + Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), - GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"), - GarminTokenStoreRoot: getEnvDefault("GARMIN_TOKENSTORE", ".garmin"), - MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), + GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"), + GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"), LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"), BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"), OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"), OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"), OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), - SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour), } if cfg.BackendURL == "" { @@ -117,20 +111,35 @@ func getEnvDefault(key, def string) string { return def } -func getEnvFloat(key string, def float64) float64 { - if v := os.Getenv(key); v != "" { - if f, err := strconv.ParseFloat(v, 64); err == nil { - return f - } - } - return def +// EnvEntry is one environment-configuration variable as displayed on the +// config page. Display-only: secrets are masked here, so raw values never +// leave the process. +type EnvEntry struct { + Name string + Value string } -func getEnvDuration(key string, def time.Duration) time.Duration { - if v := os.Getenv(key); v != "" { - if d, err := time.ParseDuration(v); err == nil { - return d +// DisplayEnv returns the environment configuration as a display-safe +// list, in stable order, secrets masked. +func (c Config) DisplayEnv() []EnvEntry { + mask := func(set bool) string { + if set { + return "•••• (set)" } + return "(unset)" + } + return []EnvEntry{ + {Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr}, + {Name: "GENIUSRUN_DB_PATH", Value: c.DBPath}, + {Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath}, + {Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot}, + {Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel}, + {Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL}, + {Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL}, + {Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL}, + {Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID}, + {Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")}, + {Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole}, + {Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)}, } - return def } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2717af9..60c5a47 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -2,7 +2,6 @@ package config import ( "testing" - "time" ) func setRequiredEnv(t *testing.T) { @@ -30,9 +29,6 @@ func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) { if cfg.OIDCRequiredRole != "geniusrun-user" { t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole) } - if cfg.SessionDuration != 720*time.Hour { - t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration) - } } func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { @@ -80,7 +76,7 @@ func TestLoad_SessionSecretTooShort(t *testing.T) { func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) { setRequiredEnv(t) - t.Setenv("GARMIN_TOKENSTORE", "") + t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "") t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db") cfg, err := Load() @@ -94,7 +90,7 @@ func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) { func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) { setRequiredEnv(t) - t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores") + t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores") t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db") cfg, err := Load() @@ -134,7 +130,7 @@ func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) { func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) { setRequiredEnv(t) - t.Setenv("GARMIN_WRAPPER_PYTHON", "") + t.Setenv("GENIUSRUN_PYTHON_PATH", "") cfg, err := Load() if err != nil { @@ -147,7 +143,7 @@ func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) { func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) { setRequiredEnv(t) - t.Setenv("GARMIN_WRAPPER_PYTHON", "/opt/venv/bin/python3") + t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3") cfg, err := Load() if err != nil { @@ -184,10 +180,9 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) { } } -func TestLoad_CustomRoleAndDuration(t *testing.T) { +func TestLoad_CustomRole(t *testing.T) { setRequiredEnv(t) t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin") - t.Setenv("GENIUSRUN_SESSION_DURATION", "24h") cfg, err := Load() if err != nil { @@ -196,7 +191,4 @@ func TestLoad_CustomRoleAndDuration(t *testing.T) { if cfg.OIDCRequiredRole != "admin" { t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole) } - if cfg.SessionDuration != 24*time.Hour { - t.Errorf("SessionDuration = %v", cfg.SessionDuration) - } }