feat(auth): JWT secret retention policy + automatic cleanup loop (ADR-0021) (#41)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 13s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled

Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #41.
This commit is contained in:
2026-05-05 08:40:27 +02:00
committed by arcodange
parent a2beadc458
commit 03ea2a7b89
8 changed files with 319 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
package user
import (
"context"
"testing"
"time"
@@ -84,3 +85,73 @@ func TestJWTSecretExpiration(t *testing.T) {
}
assert.True(t, foundExpiring)
}
// TestRemoveExpiredSecrets_ExpiredNonPrimaryRemoved confirms that
// RemoveExpiredSecrets drops a non-primary secret whose ExpiresAt is in the past.
func TestRemoveExpiredSecrets_ExpiredNonPrimaryRemoved(t *testing.T) {
manager := NewJWTSecretManager("primary")
// Add a secret that expired 1 hour ago by setting expiresIn to a small
// positive duration then mutating after via AddSecret + manipulation.
// Simpler: add with a 1ns lifetime and sleep 2ns equivalent (tiny TTL).
manager.AddSecret("about-to-expire", false, 1*time.Nanosecond)
time.Sleep(5 * time.Millisecond)
removed := manager.RemoveExpiredSecrets()
assert.Equal(t, 1, removed, "one expired secret should be removed")
secrets := manager.GetAllValidSecrets()
assert.Len(t, secrets, 1, "only primary should remain")
assert.Equal(t, "primary", secrets[0].Secret)
assert.True(t, secrets[0].IsPrimary)
}
// TestRemoveExpiredSecrets_PrimaryNeverRemoved confirms the primary secret
// is preserved even if (somehow) marked expired - ADR-0021 invariant.
func TestRemoveExpiredSecrets_PrimaryNeverRemoved(t *testing.T) {
manager := NewJWTSecretManager("primary")
// Add a non-primary that doesn't expire
manager.AddSecret("kept", false, 0)
// Simulate an "expired primary" by manipulating internals via Reset then
// re-creating - here we rely on the public contract: primary has no
// ExpiresAt by default. Confirm cleanup leaves it.
removed := manager.RemoveExpiredSecrets()
assert.Equal(t, 0, removed)
assert.Equal(t, "primary", manager.GetPrimarySecret())
}
// TestRemoveExpiredSecrets_NonExpiredKept confirms a future-expiring secret
// stays after cleanup.
func TestRemoveExpiredSecrets_NonExpiredKept(t *testing.T) {
manager := NewJWTSecretManager("primary")
manager.AddSecret("future", false, 1*time.Hour)
removed := manager.RemoveExpiredSecrets()
assert.Equal(t, 0, removed)
assert.Len(t, manager.GetAllValidSecrets(), 2)
}
// TestStartCleanupLoop_FiresAndStops confirms the goroutine actually calls
// RemoveExpiredSecrets on each tick and stops cleanly when the context is
// cancelled. Uses a short interval to keep the test fast.
func TestStartCleanupLoop_FiresAndStops(t *testing.T) {
manager := NewJWTSecretManager("primary")
manager.AddSecret("dies", false, 5*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.StartCleanupLoop(ctx, 10*time.Millisecond)
// Wait long enough for at least one tick + the secret's TTL
time.Sleep(50 * time.Millisecond)
cancel() // stop the loop
secrets := manager.GetAllValidSecrets()
assert.Len(t, secrets, 1, "expired secret should have been removed by the loop")
assert.Equal(t, "primary", secrets[0].Secret)
}