Feat v0.6.9 session lifetime config
- Admin-configurable session TTLs (access: 5m–60m, refresh: 1h–90d) stored in global_settings under "session" key - "Keep me logged in" checkbox on login form; unchecked caps refresh token at 24h, checked uses full admin-configured TTL - generateTokens() reads config instead of hardcoded 15m/7d; expires_in and refresh_expires_in in JSON response reflect actual TTLs - Cookie max-age tracks chosen refresh lifetime (not hardcoded 604800) - Optional idle timeout: admin toggle + configurable duration; server rejects refresh if last_activity_at exceeds threshold - Client SDK activity ping (POST /auth/activity, debounced 1/min) - Admin Settings > Session section with dropdowns for all three knobs - 7 new unit tests for duration parsing, clamping, and defaults Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
188
server/handlers/session_config_test.go
Normal file
188
server/handlers/session_config_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"armature/models"
|
||||
"armature/store"
|
||||
)
|
||||
|
||||
// mockGlobalConfig implements store.GlobalConfigStore for tests.
|
||||
type mockGlobalConfig struct {
|
||||
data map[string]models.JSONMap
|
||||
}
|
||||
|
||||
func (m *mockGlobalConfig) Get(_ context.Context, key string) (models.JSONMap, error) {
|
||||
if v, ok := m.data[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockGlobalConfig) Set(_ context.Context, _ string, _ models.JSONMap, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockGlobalConfig) GetAll(_ context.Context) (map[string]models.JSONMap, error) {
|
||||
return m.data, nil
|
||||
}
|
||||
func (m *mockGlobalConfig) SaveOIDCState(_ context.Context, _, _, _ string) error { return nil }
|
||||
func (m *mockGlobalConfig) ConsumeOIDCState(_ context.Context, _ string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
func (m *mockGlobalConfig) CleanupOIDCState(_ context.Context) error { return nil }
|
||||
func (m *mockGlobalConfig) GetString(_ context.Context, _ string) (string, error) { return "", nil }
|
||||
|
||||
func newMockGC(session models.JSONMap) store.GlobalConfigStore {
|
||||
return &mockGlobalConfig{data: map[string]models.JSONMap{"session": session}}
|
||||
}
|
||||
|
||||
// ── parseDurationString tests ──────────────
|
||||
|
||||
func TestParseDurationString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
err bool
|
||||
}{
|
||||
{"15m", 15 * time.Minute, false},
|
||||
{"2h", 2 * time.Hour, false},
|
||||
{"7d", 7 * 24 * time.Hour, false},
|
||||
{"90d", 90 * 24 * time.Hour, false},
|
||||
{"30m", 30 * time.Minute, false},
|
||||
{"1h30m", 90 * time.Minute, false},
|
||||
{"", 0, true},
|
||||
{"abc", 0, true},
|
||||
{"d", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := parseDurationString(tt.input)
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %q, got %v", tt.input, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseDurationString(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── LoadSessionConfig tests ────────────────
|
||||
|
||||
func TestSessionConfigDefaults(t *testing.T) {
|
||||
gc := &mockGlobalConfig{data: map[string]models.JSONMap{}}
|
||||
sc := LoadSessionConfig(context.Background(), gc)
|
||||
|
||||
if sc.AccessTokenTTL != 15*time.Minute {
|
||||
t.Errorf("AccessTokenTTL = %v, want 15m", sc.AccessTokenTTL)
|
||||
}
|
||||
if sc.RefreshTokenTTL != 7*24*time.Hour {
|
||||
t.Errorf("RefreshTokenTTL = %v, want 7d", sc.RefreshTokenTTL)
|
||||
}
|
||||
if sc.IdleTimeout != 0 {
|
||||
t.Errorf("IdleTimeout = %v, want 0", sc.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfigCustomValues(t *testing.T) {
|
||||
gc := newMockGC(models.JSONMap{
|
||||
"access_token_ttl": "30m",
|
||||
"refresh_token_ttl": "30d",
|
||||
"idle_timeout": "1h",
|
||||
})
|
||||
|
||||
sc := LoadSessionConfig(context.Background(), gc)
|
||||
|
||||
if sc.AccessTokenTTL != 30*time.Minute {
|
||||
t.Errorf("AccessTokenTTL = %v, want 30m", sc.AccessTokenTTL)
|
||||
}
|
||||
if sc.RefreshTokenTTL != 30*24*time.Hour {
|
||||
t.Errorf("RefreshTokenTTL = %v, want 30d", sc.RefreshTokenTTL)
|
||||
}
|
||||
if sc.IdleTimeout != 1*time.Hour {
|
||||
t.Errorf("IdleTimeout = %v, want 1h", sc.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfigClampLow(t *testing.T) {
|
||||
gc := newMockGC(models.JSONMap{
|
||||
"access_token_ttl": "1m", // below 5m min
|
||||
"refresh_token_ttl": "30m", // below 1h min
|
||||
"idle_timeout": "1m", // below 5m min
|
||||
})
|
||||
|
||||
sc := LoadSessionConfig(context.Background(), gc)
|
||||
|
||||
if sc.AccessTokenTTL != 5*time.Minute {
|
||||
t.Errorf("AccessTokenTTL clamped = %v, want 5m", sc.AccessTokenTTL)
|
||||
}
|
||||
if sc.RefreshTokenTTL != 1*time.Hour {
|
||||
t.Errorf("RefreshTokenTTL clamped = %v, want 1h", sc.RefreshTokenTTL)
|
||||
}
|
||||
if sc.IdleTimeout != 5*time.Minute {
|
||||
t.Errorf("IdleTimeout clamped = %v, want 5m", sc.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfigClampHigh(t *testing.T) {
|
||||
gc := newMockGC(models.JSONMap{
|
||||
"access_token_ttl": "2h", // above 60m max
|
||||
"refresh_token_ttl": "365d", // above 90d max
|
||||
"idle_timeout": "48h", // above 24h max
|
||||
})
|
||||
|
||||
sc := LoadSessionConfig(context.Background(), gc)
|
||||
|
||||
if sc.AccessTokenTTL != 60*time.Minute {
|
||||
t.Errorf("AccessTokenTTL clamped = %v, want 60m", sc.AccessTokenTTL)
|
||||
}
|
||||
if sc.RefreshTokenTTL != 90*24*time.Hour {
|
||||
t.Errorf("RefreshTokenTTL clamped = %v, want 90d", sc.RefreshTokenTTL)
|
||||
}
|
||||
if sc.IdleTimeout != 24*time.Hour {
|
||||
t.Errorf("IdleTimeout clamped = %v, want 24h", sc.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfigEmptyIdleTimeout(t *testing.T) {
|
||||
gc := newMockGC(models.JSONMap{
|
||||
"access_token_ttl": "15m",
|
||||
"refresh_token_ttl": "7d",
|
||||
"idle_timeout": "",
|
||||
})
|
||||
|
||||
sc := LoadSessionConfig(context.Background(), gc)
|
||||
|
||||
if sc.IdleTimeout != 0 {
|
||||
t.Errorf("IdleTimeout = %v, want 0 (disabled)", sc.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfigInvalidValues(t *testing.T) {
|
||||
gc := newMockGC(models.JSONMap{
|
||||
"access_token_ttl": "not-a-duration",
|
||||
"refresh_token_ttl": "xyz",
|
||||
"idle_timeout": "---",
|
||||
})
|
||||
|
||||
sc := LoadSessionConfig(context.Background(), gc)
|
||||
|
||||
// Should fall back to defaults
|
||||
if sc.AccessTokenTTL != 15*time.Minute {
|
||||
t.Errorf("AccessTokenTTL = %v, want 15m (default)", sc.AccessTokenTTL)
|
||||
}
|
||||
if sc.RefreshTokenTTL != 7*24*time.Hour {
|
||||
t.Errorf("RefreshTokenTTL = %v, want 7d (default)", sc.RefreshTokenTTL)
|
||||
}
|
||||
if sc.IdleTimeout != 0 {
|
||||
t.Errorf("IdleTimeout = %v, want 0 (default)", sc.IdleTimeout)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user