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) } } }