Files
dance-lessons-coach/pkg/config/config_hot_reload_test.go
Gabriel Radureau 405a9fc937 feat(auth): JWT TTL hot-reload + fix hardcoded 24h bug (ADR-0023 Phase 2)
Two changes in one diff because they share the same surface (JWTConfig
plumbing):

1. **Bug fix** : pkg/server/server.go was hardcoding ExpirationTime to
   24h, ignoring the auth.jwt.ttl config value entirely (default 1h).
   Production has been signing tokens with 24h TTL regardless of config
   since the config field was added.

2. **Hot-reload (ADR-0023 Phase 2)** : extends JWTConfig with a GetTTL
   func() time.Duration callback. effectiveTTL() prefers GetTTL when
   set, falls back to ExpirationTime otherwise (test-friendly). server.go
   wires GetTTL = cfg.GetJWTTTL — a method value that captures the
   *Config, so when WatchAndApply re-unmarshals, the next token
   generation reads the new TTL automatically. Tokens already issued
   keep their original expiry.

WatchAndApply now also logs the new jwt_ttl on every reload event.

Tests:
- New TestWatchAndApply_JWTTTL in pkg/config/config_hot_reload_test.go
  rewrites the config file and asserts the in-memory ttl flips within
  2s. Polling (no fixed sleep), race-clean.
- Existing pkg/user tests (including JWT manager + cleanup loop) all
  pass with -race.
- Full BDD suite (auth/config/greet/health/info/jwt) green.

ADR-0023 status: Phase 1+2 Implemented. Phase 3 (telemetry sampler)
and Phase 4 (api.v2_enabled — needs router refactor) remain Proposed.
2026-05-05 09:08:19 +02:00

117 lines
3.6 KiB
Go

package config
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// loadFromFile is a helper that mimics LoadConfig() for a specific file path
// without going through the env-prefix and singleton machinery — keeps the
// test hermetic.
func loadFromFile(t *testing.T, path string) *Config {
t.Helper()
v := viper.New()
v.SetConfigFile(path)
v.SetConfigType("yaml")
v.SetDefault("logging.level", "info")
v.SetDefault("auth.jwt.ttl", time.Hour)
require.NoError(t, v.ReadInConfig())
c := &Config{viper: v}
require.NoError(t, v.Unmarshal(c))
return c
}
// TestWatchAndApply_LoggingLevel proves the hot-reload pipe end-to-end:
// write a new logging.level to the watched file, the OnConfigChange handler
// re-unmarshals, and the in-memory Config reflects the new value.
func TestWatchAndApply_LoggingLevel(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: info\n"), 0644))
c := loadFromFile(t, path)
assert.Equal(t, "info", c.GetLogLevel())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx)
// Mutate the file. fsnotify needs a real write event; rewrite atomically.
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: debug\n"), 0644))
// Poll for up to 2s waiting for the in-memory level to flip.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
c.reloadMu.RLock()
level := c.GetLogLevel()
c.reloadMu.RUnlock()
if level == "debug" {
return
}
time.Sleep(20 * time.Millisecond)
}
c.reloadMu.RLock()
defer c.reloadMu.RUnlock()
t.Fatalf("logging level did not hot-reload to debug: still %q", c.GetLogLevel())
}
// TestWatchAndApply_NoFileNoOp confirms the watcher is a safe no-op when no
// config file is in use (env-only / defaults) — important so production
// containers without a mounted config.yaml don't crash.
func TestWatchAndApply_NoFileNoOp(t *testing.T) {
c := &Config{viper: viper.New()}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx) // should return without panicking
}
// TestWatchAndApply_NilViperNoOp confirms the watcher tolerates a Config
// constructed without the viper field (e.g. tests that build a Config{}
// manually — same defensive code path as production but exercised explicitly).
func TestWatchAndApply_NilViperNoOp(t *testing.T) {
c := &Config{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx)
}
// TestWatchAndApply_JWTTTL proves Phase 2 of ADR-0023: the JWT TTL is
// re-read on every token generation via the GetJWTTTL method value, so
// after a config-file change the new TTL takes effect without restart.
func TestWatchAndApply_JWTTTL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("auth:\n jwt:\n ttl: 1h\n"), 0644))
c := loadFromFile(t, path)
assert.Equal(t, time.Hour, c.GetJWTTTL())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx)
require.NoError(t, os.WriteFile(path, []byte("auth:\n jwt:\n ttl: 30m\n"), 0644))
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
c.reloadMu.RLock()
ttl := c.GetJWTTTL()
c.reloadMu.RUnlock()
if ttl == 30*time.Minute {
return
}
time.Sleep(20 * time.Millisecond)
}
c.reloadMu.RLock()
defer c.reloadMu.RUnlock()
t.Fatalf("auth.jwt.ttl did not hot-reload to 30m: still %s", c.GetJWTTTL())
}