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_Load_ReadError(t *testing.T) { tmpDir := t.TempDir() // Create a directory where the tokens file should be - reading it will fail tokensPath := filepath.Join(tmpDir, "recovery_tokens.json") if err := os.Mkdir(tokensPath, 0755); err != nil { t.Fatalf("failed to create directory: %v", err) } store := &RecoveryTokenStore{ tokens: make(map[string]*RecoveryToken), dataPath: tmpDir, stopCleanup: make(chan struct{}), } // Should not panic and should log error store.load() store.mu.RLock() count := len(store.tokens) store.mu.RUnlock() if count != 0 { t.Errorf("store should be empty after read error, 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") } } func TestRecoveryTokenStore_SaveUnsafe_MkdirAllError(t *testing.T) { // Create a file where the directory should be - MkdirAll will fail tmpDir := t.TempDir() blockedPath := filepath.Join(tmpDir, "blocked") // Create a file at the path where dataPath should be if err := os.WriteFile(blockedPath, []byte("blocking file"), 0644); err != nil { t.Fatalf("failed to create blocking file: %v", err) } store := &RecoveryTokenStore{ tokens: make(map[string]*RecoveryToken), dataPath: blockedPath, // This is a file, not a directory stopCleanup: make(chan struct{}), } // Add a token to save store.tokens["testtoken"] = &RecoveryToken{ Token: "testtoken", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Used: false, } // saveUnsafe should handle error gracefully (logs but doesn't panic) store.saveUnsafe() // Verify the blocking file still exists (wasn't overwritten) data, err := os.ReadFile(blockedPath) if err != nil { t.Fatalf("blocking file was removed: %v", err) } if string(data) != "blocking file" { t.Error("blocking file was modified") } } func TestRecoveryTokenStore_SaveUnsafe_WriteFileError(t *testing.T) { // Create a read-only directory to prevent file creation tmpDir := t.TempDir() readOnlyDir := filepath.Join(tmpDir, "readonly") if err := os.Mkdir(readOnlyDir, 0755); err != nil { t.Fatalf("failed to create readonly dir: %v", err) } store := &RecoveryTokenStore{ tokens: make(map[string]*RecoveryToken), dataPath: readOnlyDir, stopCleanup: make(chan struct{}), } // Add a token store.tokens["testtoken"] = &RecoveryToken{ Token: "testtoken", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Used: false, } // Make directory read-only after it exists (MkdirAll succeeds, WriteFile fails) if err := os.Chmod(readOnlyDir, 0555); err != nil { t.Fatalf("failed to make dir readonly: %v", err) } t.Cleanup(func() { os.Chmod(readOnlyDir, 0755) // Restore for cleanup }) // saveUnsafe should handle error gracefully (logs but doesn't panic) store.saveUnsafe() // Verify no file was created tokensFile := filepath.Join(readOnlyDir, "recovery_tokens.json") if _, err := os.Stat(tokensFile); err == nil { t.Error("tokens file should not exist after write failure") } } func TestRecoveryTokenStore_SaveUnsafe_RenameError(t *testing.T) { // This test ensures rename errors are handled. // Creating a rename error is tricky - we'll create a directory at the target path tmpDir := t.TempDir() // Create a directory at the exact path where recovery_tokens.json should go tokensFilePath := filepath.Join(tmpDir, "recovery_tokens.json") if err := os.Mkdir(tokensFilePath, 0755); err != nil { t.Fatalf("failed to create blocking directory: %v", err) } store := &RecoveryTokenStore{ tokens: make(map[string]*RecoveryToken), dataPath: tmpDir, stopCleanup: make(chan struct{}), } // Add a token store.tokens["testtoken"] = &RecoveryToken{ Token: "testtoken", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Used: false, } // saveUnsafe should handle rename error gracefully store.saveUnsafe() // The blocking directory should still exist (rename failed) info, err := os.Stat(tokensFilePath) if err != nil { t.Fatalf("blocking directory was removed: %v", err) } if !info.IsDir() { t.Error("blocking directory was replaced with a file") } } // 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 }