mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
The DisableDockerUpdateActions setting was being saved to disk but not updated in h.config, causing the UI toggle to appear to revert on page refresh since the API returned the stale runtime value. Related to #1023
756 lines
20 KiB
Go
756 lines
20 KiB
Go
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) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("Skipping permission test running as root")
|
|
}
|
|
// 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
|
|
}
|