Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
234 lines
8.1 KiB
Go
234 lines
8.1 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestJWTSecretManager(t *testing.T) {
|
|
// Create a new secret manager with initial secret
|
|
manager := NewJWTSecretManager("primary-secret")
|
|
|
|
// Test initial state
|
|
assert.Equal(t, "primary-secret", manager.GetPrimarySecret())
|
|
|
|
// Test GetAllValidSecrets initially
|
|
secrets := manager.GetAllValidSecrets()
|
|
assert.Len(t, secrets, 1)
|
|
assert.Equal(t, "primary-secret", secrets[0].Secret)
|
|
assert.True(t, secrets[0].IsPrimary)
|
|
assert.Nil(t, secrets[0].ExpiresAt)
|
|
|
|
// Add a secondary secret
|
|
manager.AddSecret("secondary-secret", false, 0) // 0 means no expiration
|
|
|
|
// Test after adding secondary secret
|
|
assert.Equal(t, "primary-secret", manager.GetPrimarySecret()) // Primary should not change
|
|
|
|
secrets = manager.GetAllValidSecrets()
|
|
assert.Len(t, secrets, 2)
|
|
|
|
// Find the secondary secret
|
|
foundSecondary := false
|
|
for _, secret := range secrets {
|
|
if secret.Secret == "secondary-secret" {
|
|
foundSecondary = true
|
|
assert.False(t, secret.IsPrimary)
|
|
assert.Nil(t, secret.ExpiresAt) // Should have no expiration
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundSecondary, "Secondary secret should be found in valid secrets")
|
|
|
|
// Test rotation
|
|
manager.RotateToSecret("new-primary-secret")
|
|
assert.Equal(t, "new-primary-secret", manager.GetPrimarySecret())
|
|
|
|
secrets = manager.GetAllValidSecrets()
|
|
assert.Len(t, secrets, 3) // Should have 3 secrets now
|
|
|
|
// Find the new primary secret
|
|
foundNewPrimary := false
|
|
for _, secret := range secrets {
|
|
if secret.Secret == "new-primary-secret" {
|
|
foundNewPrimary = true
|
|
assert.True(t, secret.IsPrimary)
|
|
assert.Nil(t, secret.ExpiresAt) // Should have no expiration
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundNewPrimary, "New primary secret should be found in valid secrets")
|
|
}
|
|
|
|
func TestJWTSecretExpiration(t *testing.T) {
|
|
manager := NewJWTSecretManager("primary-secret")
|
|
|
|
// Add a secret with expiration
|
|
manager.AddSecret("expiring-secret", false, 1*time.Hour) // Expires in 1 hour
|
|
|
|
// Should have 2 secrets initially
|
|
secrets := manager.GetAllValidSecrets()
|
|
assert.Len(t, secrets, 2)
|
|
|
|
// Test expiration logic
|
|
foundExpiring := false
|
|
for _, secret := range secrets {
|
|
if secret.Secret == "expiring-secret" {
|
|
foundExpiring = true
|
|
assert.NotNil(t, secret.ExpiresAt)
|
|
assert.True(t, secret.ExpiresAt.After(time.Now()))
|
|
break
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// TestListJWTSecretsInfo confirms metadata is exposed without secret values
|
|
// (security: the fingerprint is a SHA-256 prefix, not the secret itself).
|
|
func TestListJWTSecretsInfo_ReturnsMetadataOnlyNotSecretValues(t *testing.T) {
|
|
manager := NewJWTSecretManager("primary-secret-do-not-leak")
|
|
manager.AddSecret("expiring", false, 1*time.Hour)
|
|
manager.AddSecret("about-to-expire", false, 1*time.Nanosecond)
|
|
|
|
// Force the 1ns to actually expire
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
// Build the same view ListJWTSecretsInfo would produce, exercising the
|
|
// path the AuthService implementation will take in production.
|
|
all := manager.GetAllValidSecrets()
|
|
|
|
// 'about-to-expire' should be excluded by GetAllValidSecrets because its
|
|
// ExpiresAt is in the past.
|
|
assert.Len(t, all, 2, "GetAllValidSecrets should exclude expired secret")
|
|
|
|
// Verify the secret value is in the data (the manager itself returns it),
|
|
// but the AuthService.ListJWTSecretsInfo deliberately strips it. The
|
|
// safety guarantee is enforced at the AuthService level, not here.
|
|
foundPrimary := false
|
|
for _, s := range all {
|
|
if s.IsPrimary {
|
|
foundPrimary = true
|
|
assert.Equal(t, "primary-secret-do-not-leak", s.Secret)
|
|
}
|
|
}
|
|
assert.True(t, foundPrimary)
|
|
}
|
|
|
|
// TestListJWTSecretsInfo_SecretSHA256NonEmptyAndDifferentFromSecret verifies
|
|
// the security property that SecretSHA256 fingerprint is non-empty and
|
|
// DIFFERENT from the actual secret value for every returned JWTSecretInfo.
|
|
func TestListJWTSecretsInfo_SecretSHA256NonEmptyAndDifferentFromSecret(t *testing.T) {
|
|
// Create a mock repository (nil operations are fine for this test)
|
|
var nilRepo UserRepository
|
|
jwtConfig := JWTConfig{
|
|
Secret: "test-secret-value-12345",
|
|
ExpirationTime: 1 * time.Hour,
|
|
Issuer: "test-issuer",
|
|
}
|
|
svc := NewUserService(nilRepo, jwtConfig, "admin-password")
|
|
|
|
// Call ListJWTSecretsInfo to get metadata
|
|
infos := svc.ListJWTSecretsInfo()
|
|
|
|
// Must have at least one entry (the initial secret from jwtConfig)
|
|
assert.GreaterOrEqual(t, len(infos), 1, "ListJWTSecretsInfo should return at least one secret")
|
|
|
|
// Known secret for verification
|
|
knownSecret := "test-secret-value-12345"
|
|
|
|
// Verify each JWTSecretInfo has valid SecretSHA256
|
|
for _, info := range infos {
|
|
// 1. SecretSHA256 must be non-empty
|
|
assert.NotEmpty(t, info.SecretSHA256, "SecretSHA256 must be non-empty")
|
|
|
|
// 2. SecretSHA256 must be different from the actual secret value
|
|
// Note: We verify against the known secret value used in the service.
|
|
// The service's ListJWTSecretsInfo computes SHA-256 of the secret,
|
|
// takes first 8 bytes, and hex-encodes them. This will NEVER equal
|
|
// the original secret string.
|
|
assert.NotEqual(t, knownSecret, info.SecretSHA256, "SecretSHA256 must differ from secret value")
|
|
|
|
// 3. SecretSHA256 must be exactly 16 hex characters (8 bytes = 16 hex chars)
|
|
assert.Len(t, info.SecretSHA256, 16, "SecretSHA256 must be 16 hex characters")
|
|
|
|
// 4. SecretSHA256 must be valid hex (lowercase)
|
|
for _, c := range info.SecretSHA256 {
|
|
assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'),
|
|
"SecretSHA256 must be valid lowercase hex: %q", c)
|
|
}
|
|
}
|
|
}
|