Add unit tests for RecoveryTokenStore

20 test cases covering:
- Token generation (uniqueness, expiry durations, format)
- Token validation (valid, invalid, expired, used, empty store)
- Concurrent use protection (replay attack prevention)
- Cleanup routine (expired tokens, used tokens, stop signal)
- Persistence (save, load, filter expired on load)
- Edge cases (missing file, invalid JSON)
This commit is contained in:
rcourtman 2025-12-01 00:06:32 +00:00
parent 990a00c5be
commit 51671d2a56

View file

@ -0,0 +1,610 @@
package api
import (
"encoding/hex"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestRecoveryToken_Fields(t *testing.T) {
now := time.Now()
expiry := now.Add(time.Hour)
usedAt := now.Add(30 * time.Minute)
token := RecoveryToken{
Token: "abc123token",
CreatedAt: now,
ExpiresAt: expiry,
Used: true,
UsedAt: usedAt,
IP: "192.168.1.100",
}
if token.Token != "abc123token" {
t.Errorf("Token = %q, want abc123token", token.Token)
}
if !token.CreatedAt.Equal(now) {
t.Errorf("CreatedAt = %v, want %v", token.CreatedAt, now)
}
if !token.ExpiresAt.Equal(expiry) {
t.Errorf("ExpiresAt = %v, want %v", token.ExpiresAt, expiry)
}
if !token.Used {
t.Error("Used = false, want true")
}
if !token.UsedAt.Equal(usedAt) {
t.Errorf("UsedAt = %v, want %v", token.UsedAt, usedAt)
}
if token.IP != "192.168.1.100" {
t.Errorf("IP = %q, want 192.168.1.100", token.IP)
}
}
func newTestRecoveryStore(t *testing.T) *RecoveryTokenStore {
t.Helper()
return &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: t.TempDir(),
stopCleanup: make(chan struct{}),
}
}
func TestRecoveryTokenStore_GenerateRecoveryToken(t *testing.T) {
store := newTestRecoveryStore(t)
token, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
// Token should be 64 hex characters (32 bytes)
if len(token) != 64 {
t.Errorf("token length = %d, want 64", len(token))
}
// Should be valid hex
if _, err := hex.DecodeString(token); err != nil {
t.Errorf("token is not valid hex: %v", err)
}
// Should be stored
store.mu.RLock()
stored, exists := store.tokens[token]
store.mu.RUnlock()
if !exists {
t.Fatal("token not found in store")
}
if stored.Used {
t.Error("new token should not be marked as used")
}
if stored.ExpiresAt.Before(time.Now()) {
t.Error("new token should not be expired")
}
}
func TestRecoveryTokenStore_GenerateRecoveryToken_Uniqueness(t *testing.T) {
store := newTestRecoveryStore(t)
tokens := make(map[string]bool)
for i := 0; i < 100; i++ {
token, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed on iteration %d: %v", i, err)
}
if tokens[token] {
t.Errorf("duplicate token generated: %s", token)
}
tokens[token] = true
}
}
func TestRecoveryTokenStore_GenerateRecoveryToken_ExpiryDurations(t *testing.T) {
tests := []struct {
name string
duration time.Duration
}{
{"1 minute", time.Minute},
{"5 minutes", 5 * time.Minute},
{"1 hour", time.Hour},
{"24 hours", 24 * time.Hour},
{"1 week", 7 * 24 * time.Hour},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := newTestRecoveryStore(t)
beforeGen := time.Now()
token, err := store.GenerateRecoveryToken(tc.duration)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
afterGen := time.Now()
store.mu.RLock()
stored := store.tokens[token]
store.mu.RUnlock()
// ExpiresAt should be approximately beforeGen + duration to afterGen + duration
expectedMin := beforeGen.Add(tc.duration)
expectedMax := afterGen.Add(tc.duration)
if stored.ExpiresAt.Before(expectedMin) || stored.ExpiresAt.After(expectedMax) {
t.Errorf("ExpiresAt = %v, want between %v and %v", stored.ExpiresAt, expectedMin, expectedMax)
}
})
}
}
func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ValidToken(t *testing.T) {
store := newTestRecoveryStore(t)
token, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
// Validate token
if !store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") {
t.Error("ValidateRecoveryTokenConstantTime returned false for valid token")
}
// Check it's marked as used
store.mu.RLock()
stored := store.tokens[token]
store.mu.RUnlock()
if !stored.Used {
t.Error("token should be marked as used after validation")
}
if stored.IP != "10.0.0.1" {
t.Errorf("IP = %q, want 10.0.0.1", stored.IP)
}
if stored.UsedAt.IsZero() {
t.Error("UsedAt should be set")
}
}
func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_InvalidToken(t *testing.T) {
store := newTestRecoveryStore(t)
// Generate a valid token but try to validate with a different one
_, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
if store.ValidateRecoveryTokenConstantTime("nonexistent-token", "10.0.0.1") {
t.Error("ValidateRecoveryTokenConstantTime returned true for invalid token")
}
}
func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ExpiredToken(t *testing.T) {
store := newTestRecoveryStore(t)
// Create an already-expired token directly
expiredToken := "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
store.mu.Lock()
store.tokens[expiredToken] = &RecoveryToken{
Token: expiredToken,
CreatedAt: time.Now().Add(-2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Hour), // Expired
Used: false,
}
store.mu.Unlock()
if store.ValidateRecoveryTokenConstantTime(expiredToken, "10.0.0.1") {
t.Error("ValidateRecoveryTokenConstantTime returned true for expired token")
}
}
func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_UsedToken(t *testing.T) {
store := newTestRecoveryStore(t)
token, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
// Use the token
if !store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") {
t.Fatal("first validation should succeed")
}
// Try to use again
if store.ValidateRecoveryTokenConstantTime(token, "10.0.0.2") {
t.Error("ValidateRecoveryTokenConstantTime returned true for already-used token")
}
}
func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_EmptyStore(t *testing.T) {
store := newTestRecoveryStore(t)
if store.ValidateRecoveryTokenConstantTime("any-token", "10.0.0.1") {
t.Error("ValidateRecoveryTokenConstantTime returned true on empty store")
}
}
func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ConcurrentUse(t *testing.T) {
store := newTestRecoveryStore(t)
token, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
// Try to use token concurrently from multiple goroutines
const numGoroutines = 100
results := make(chan bool, numGoroutines)
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
result := store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1")
results <- result
}(i)
}
wg.Wait()
close(results)
// Count successes - only 1 should succeed
successes := 0
for result := range results {
if result {
successes++
}
}
if successes != 1 {
t.Errorf("concurrent validation successes = %d, want exactly 1", successes)
}
}
func TestRecoveryTokenStore_Cleanup(t *testing.T) {
store := newTestRecoveryStore(t)
// Add tokens: one valid, one expired, one used long ago
validToken := "valid123valid123valid123valid123valid123valid123valid123valid123"
expiredToken := "expired1expired1expired1expired1expired1expired1expired1expired1"
usedOldToken := "usedold1usedold1usedold1usedold1usedold1usedold1usedold1usedold1"
store.mu.Lock()
store.tokens[validToken] = &RecoveryToken{
Token: validToken,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour),
Used: false,
}
store.tokens[expiredToken] = &RecoveryToken{
Token: expiredToken,
CreatedAt: time.Now().Add(-2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Hour),
Used: false,
}
store.tokens[usedOldToken] = &RecoveryToken{
Token: usedOldToken,
CreatedAt: time.Now().Add(-48 * time.Hour),
ExpiresAt: time.Now().Add(-47 * time.Hour),
Used: true,
UsedAt: time.Now().Add(-25 * time.Hour), // Used more than 24 hours ago
}
store.mu.Unlock()
// Run cleanup
store.cleanup()
store.mu.RLock()
_, validExists := store.tokens[validToken]
_, expiredExists := store.tokens[expiredToken]
_, usedOldExists := store.tokens[usedOldToken]
store.mu.RUnlock()
if !validExists {
t.Error("valid token was incorrectly removed during cleanup")
}
if expiredExists {
t.Error("expired token was not removed during cleanup")
}
if usedOldExists {
t.Error("old used token was not removed during cleanup")
}
}
func TestRecoveryTokenStore_Cleanup_RemovesExpiredEvenIfRecentlyUsed(t *testing.T) {
store := newTestRecoveryStore(t)
// Add a token that's expired but was used recently (less than 24 hours ago)
// Per the cleanup logic: removes if expired OR used more than 24 hours ago
// So an expired token will be removed even if recently used
recentlyUsedToken := "recent1recent1recent1recent1recent1recent1recent1recent1recent1r"
store.mu.Lock()
store.tokens[recentlyUsedToken] = &RecoveryToken{
Token: recentlyUsedToken,
CreatedAt: time.Now().Add(-2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Hour), // Expired
Used: true,
UsedAt: time.Now().Add(-time.Hour), // Used 1 hour ago (within 24 hours)
}
store.mu.Unlock()
store.cleanup()
store.mu.RLock()
_, exists := store.tokens[recentlyUsedToken]
store.mu.RUnlock()
// Cleanup removes expired tokens regardless of when they were used
if exists {
t.Error("expired token should be removed during cleanup even if recently used")
}
}
func TestRecoveryTokenStore_Cleanup_KeepsUnexpiredUsedTokens(t *testing.T) {
store := newTestRecoveryStore(t)
// A used token that hasn't expired yet and was used recently should be kept
usedNotExpiredToken := "usednot1usednot1usednot1usednot1usednot1usednot1usednot1usednot1"
store.mu.Lock()
store.tokens[usedNotExpiredToken] = &RecoveryToken{
Token: usedNotExpiredToken,
CreatedAt: time.Now().Add(-time.Hour),
ExpiresAt: time.Now().Add(time.Hour), // Not expired
Used: true,
UsedAt: time.Now().Add(-time.Hour), // Used 1 hour ago
}
store.mu.Unlock()
store.cleanup()
store.mu.RLock()
_, exists := store.tokens[usedNotExpiredToken]
store.mu.RUnlock()
if !exists {
t.Error("used but not expired token should be kept during cleanup")
}
}
func TestRecoveryTokenStore_Persistence_Save(t *testing.T) {
tmpDir := t.TempDir()
store := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
// Generate a token (which triggers save)
token, err := store.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
// Verify file was created
tokensFile := filepath.Join(tmpDir, "recovery_tokens.json")
if _, err := os.Stat(tokensFile); os.IsNotExist(err) {
t.Fatal("recovery_tokens.json was not created")
}
// Read the file and verify content includes the token
data, err := os.ReadFile(tokensFile)
if err != nil {
t.Fatalf("failed to read recovery_tokens.json: %v", err)
}
// Token should appear in the JSON (at least partially)
if len(data) == 0 {
t.Error("recovery_tokens.json is empty")
}
// The token string should appear in the file content
if !containsTokenSubstring(string(data), token[:16]) {
t.Error("token not found in persisted file")
}
}
func TestRecoveryTokenStore_Persistence_Load(t *testing.T) {
tmpDir := t.TempDir()
// Create first store and generate token
store1 := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
token, err := store1.GenerateRecoveryToken(time.Hour)
if err != nil {
t.Fatalf("GenerateRecoveryToken failed: %v", err)
}
// Create second store and load
store2 := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
store2.load()
// Token should be loaded and valid
store2.mu.RLock()
loaded, exists := store2.tokens[token]
store2.mu.RUnlock()
if !exists {
t.Fatal("token was not loaded from disk")
}
if loaded.Used {
t.Error("loaded token should not be marked as used")
}
}
func TestRecoveryTokenStore_Persistence_FilterExpiredOnLoad(t *testing.T) {
tmpDir := t.TempDir()
// Create store with an expired token
store1 := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
expiredToken := "expired1expired1expired1expired1expired1expired1expired1expired1"
store1.mu.Lock()
store1.tokens[expiredToken] = &RecoveryToken{
Token: expiredToken,
CreatedAt: time.Now().Add(-2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Hour), // Expired
Used: false,
}
store1.saveUnsafe()
store1.mu.Unlock()
// Create second store and load
store2 := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
store2.load()
// Expired token should not be loaded
store2.mu.RLock()
_, exists := store2.tokens[expiredToken]
store2.mu.RUnlock()
if exists {
t.Error("expired token should not be loaded from disk")
}
}
func TestRecoveryTokenStore_Persistence_KeepRecentlyUsedOnLoad(t *testing.T) {
tmpDir := t.TempDir()
// Create store with a recently used (but expired) token
store1 := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
recentToken := "recent1recent1recent1recent1recent1recent1recent1recent1recent1r"
store1.mu.Lock()
store1.tokens[recentToken] = &RecoveryToken{
Token: recentToken,
CreatedAt: time.Now().Add(-2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Hour), // Expired
Used: true,
UsedAt: time.Now().Add(-time.Hour), // Used within 24 hours
}
store1.saveUnsafe()
store1.mu.Unlock()
// Create second store and load
store2 := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
store2.load()
// Recently used token should be loaded for audit trail
store2.mu.RLock()
_, exists := store2.tokens[recentToken]
store2.mu.RUnlock()
if !exists {
t.Error("recently used token should be loaded from disk for audit trail")
}
}
func TestRecoveryTokenStore_Load_MissingFile(t *testing.T) {
store := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: t.TempDir(), // Empty directory
stopCleanup: make(chan struct{}),
}
// Should not panic
store.load()
store.mu.RLock()
count := len(store.tokens)
store.mu.RUnlock()
if count != 0 {
t.Errorf("store should be empty after loading missing file, got %d tokens", count)
}
}
func TestRecoveryTokenStore_Load_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
// Write invalid JSON
tokensFile := filepath.Join(tmpDir, "recovery_tokens.json")
if err := os.WriteFile(tokensFile, []byte("{invalid json}"), 0600); err != nil {
t.Fatalf("failed to write invalid JSON: %v", err)
}
store := &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: tmpDir,
stopCleanup: make(chan struct{}),
}
// Should not panic
store.load()
store.mu.RLock()
count := len(store.tokens)
store.mu.RUnlock()
if count != 0 {
t.Errorf("store should be empty after loading invalid JSON, got %d tokens", count)
}
}
func TestRecoveryTokenStore_StopCleanup(t *testing.T) {
store := newTestRecoveryStore(t)
// Start cleanup routine
done := make(chan struct{})
go func() {
store.cleanupRoutine()
close(done)
}()
// Stop it
close(store.stopCleanup)
// Wait for routine to exit (with timeout)
select {
case <-done:
// Success
case <-time.After(time.Second):
t.Error("cleanupRoutine did not stop within timeout")
}
}
// Helper function
func containsTokenSubstring(s, substr string) bool {
if len(substr) == 0 {
return true
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}