Files
pulse/internal/api/session_store_test.go
T
rcourtman 9e339957c6 fix: Update runtime config when toggling Docker update actions setting
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
2026-01-03 11:14:17 +00:00

729 lines
18 KiB
Go

package api
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestSessionHash(t *testing.T) {
tests := []struct {
name string
token1 string
token2 string
same bool
}{
{
name: "same token produces same hash",
token1: "test-token-123",
token2: "test-token-123",
same: true,
},
{
name: "different tokens produce different hashes",
token1: "token-a",
token2: "token-b",
same: false,
},
{
name: "empty token produces consistent hash",
token1: "",
token2: "",
same: true,
},
{
name: "empty vs non-empty token",
token1: "",
token2: "something",
same: false,
},
{
name: "case sensitive",
token1: "Token",
token2: "token",
same: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hash1 := sessionHash(tc.token1)
hash2 := sessionHash(tc.token2)
if tc.same && hash1 != hash2 {
t.Errorf("sessionHash(%q) = %q, sessionHash(%q) = %q; want same", tc.token1, hash1, tc.token2, hash2)
}
if !tc.same && hash1 == hash2 {
t.Errorf("sessionHash(%q) and sessionHash(%q) produced same hash %q; want different", tc.token1, tc.token2, hash1)
}
})
}
}
func TestSessionHash_Format(t *testing.T) {
hash := sessionHash("test-token")
// SHA256 produces 64 hex characters
if len(hash) != 64 {
t.Errorf("sessionHash length = %d, want 64", len(hash))
}
// Should only contain hex characters
for _, c := range hash {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("sessionHash contains non-hex character: %c", c)
}
}
}
func TestSessionHash_Deterministic(t *testing.T) {
token := "reproducible-token-value"
// Call multiple times
results := make([]string, 100)
for i := range results {
results[i] = sessionHash(token)
}
// All results should be identical
for i := 1; i < len(results); i++ {
if results[i] != results[0] {
t.Errorf("sessionHash call %d returned %q, want %q", i, results[i], results[0])
}
}
}
func TestSessionData_Fields(t *testing.T) {
now := time.Now()
duration := 24 * time.Hour
data := SessionData{
ExpiresAt: now.Add(duration),
CreatedAt: now,
UserAgent: "Mozilla/5.0",
IP: "192.168.1.100",
OriginalDuration: duration,
}
if data.ExpiresAt.Before(data.CreatedAt) {
t.Error("ExpiresAt should be after CreatedAt")
}
if data.UserAgent != "Mozilla/5.0" {
t.Errorf("UserAgent = %q, want Mozilla/5.0", data.UserAgent)
}
if data.IP != "192.168.1.100" {
t.Errorf("IP = %q, want 192.168.1.100", data.IP)
}
if data.OriginalDuration != duration {
t.Errorf("OriginalDuration = %v, want %v", data.OriginalDuration, duration)
}
}
func TestSessionStore_CreateAndValidate(t *testing.T) {
tmpDir := t.TempDir()
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
token := "test-session-token"
duration := time.Hour
// Create session
store.CreateSession(token, duration, "TestAgent", "127.0.0.1")
// Validate session
if !store.ValidateSession(token) {
t.Error("ValidateSession returned false for valid session")
}
// Verify internal state
store.mu.RLock()
key := sessionHash(token)
session, exists := store.sessions[key]
store.mu.RUnlock()
if !exists {
t.Fatal("Session not found in store")
}
if session.UserAgent != "TestAgent" {
t.Errorf("UserAgent = %q, want TestAgent", session.UserAgent)
}
if session.IP != "127.0.0.1" {
t.Errorf("IP = %q, want 127.0.0.1", session.IP)
}
if session.OriginalDuration != duration {
t.Errorf("OriginalDuration = %v, want %v", session.OriginalDuration, duration)
}
}
func TestSessionStore_ValidateSession_NonExistent(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
if store.ValidateSession("nonexistent-token") {
t.Error("ValidateSession returned true for nonexistent session")
}
}
func TestSessionStore_ValidateSession_Expired(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
token := "expired-token"
key := sessionHash(token)
// Add an already-expired session
store.mu.Lock()
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(-time.Hour), // Expired an hour ago
CreatedAt: time.Now().Add(-2 * time.Hour),
}
store.mu.Unlock()
if store.ValidateSession(token) {
t.Error("ValidateSession returned true for expired session")
}
}
func TestSessionStore_DeleteSession(t *testing.T) {
tmpDir := t.TempDir()
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
token := "delete-me-token"
// Create session
store.CreateSession(token, time.Hour, "", "")
// Verify exists
if !store.ValidateSession(token) {
t.Fatal("Session should exist before deletion")
}
// Delete session
store.DeleteSession(token)
// Verify deleted
if store.ValidateSession(token) {
t.Error("Session should not exist after deletion")
}
}
func TestSessionStore_ValidateAndExtendSession(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
token := "extend-me-token"
duration := time.Hour
// Create session
store.CreateSession(token, duration, "", "")
// Get original expiry
store.mu.RLock()
key := sessionHash(token)
originalExpiry := store.sessions[key].ExpiresAt
store.mu.RUnlock()
// Wait a moment
time.Sleep(10 * time.Millisecond)
// Validate and extend
if !store.ValidateAndExtendSession(token) {
t.Error("ValidateAndExtendSession returned false for valid session")
}
// Verify expiry was extended
store.mu.RLock()
newExpiry := store.sessions[key].ExpiresAt
store.mu.RUnlock()
if !newExpiry.After(originalExpiry) {
t.Error("Session expiry was not extended")
}
}
func TestSessionStore_ValidateAndExtendSession_NonExistent(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
if store.ValidateAndExtendSession("nonexistent-token") {
t.Error("ValidateAndExtendSession returned true for nonexistent session")
}
}
func TestSessionStore_ValidateAndExtendSession_Expired(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
token := "expired-extend-token"
key := sessionHash(token)
// Add an already-expired session
store.mu.Lock()
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(-time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
OriginalDuration: time.Hour,
}
store.mu.Unlock()
if store.ValidateAndExtendSession(token) {
t.Error("ValidateAndExtendSession returned true for expired session")
}
}
func TestSessionStore_ValidateAndExtendSession_ZeroDuration(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
token := "zero-duration-token"
key := sessionHash(token)
// Add session with zero original duration
store.mu.Lock()
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
OriginalDuration: 0, // Zero duration - should not extend
}
store.mu.Unlock()
originalExpiry := store.sessions[key].ExpiresAt
// Validate and extend
if !store.ValidateAndExtendSession(token) {
t.Error("ValidateAndExtendSession returned false for valid session")
}
// Expiry should not change (zero duration means no sliding)
store.mu.RLock()
newExpiry := store.sessions[key].ExpiresAt
store.mu.RUnlock()
if !newExpiry.Equal(originalExpiry) {
t.Error("Session with zero OriginalDuration should not have expiry extended")
}
}
func TestSessionStore_MultipleSessions(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
tokens := []string{"token-1", "token-2", "token-3"}
// Create multiple sessions
for _, token := range tokens {
store.CreateSession(token, time.Hour, "Agent-"+token, "")
}
// All should be valid
for _, token := range tokens {
if !store.ValidateSession(token) {
t.Errorf("ValidateSession(%q) = false, want true", token)
}
}
// Delete middle one
store.DeleteSession("token-2")
// First and last should still be valid
if !store.ValidateSession("token-1") {
t.Error("token-1 should still be valid")
}
if store.ValidateSession("token-2") {
t.Error("token-2 should be deleted")
}
if !store.ValidateSession("token-3") {
t.Error("token-3 should still be valid")
}
}
func TestSessionStore_Persistence(t *testing.T) {
tmpDir := t.TempDir()
// Create store and add session
store1 := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
token := "persistent-token"
store1.CreateSession(token, time.Hour, "PersistAgent", "10.0.0.1")
// Verify file was created
sessionsFile := filepath.Join(tmpDir, "sessions.json")
if _, err := os.Stat(sessionsFile); os.IsNotExist(err) {
t.Fatal("sessions.json was not created")
}
// Create new store that should load from disk
store2 := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
store2.load()
// Session should be loaded
if !store2.ValidateSession(token) {
t.Error("Session was not persisted and loaded correctly")
}
// Verify metadata was preserved
store2.mu.RLock()
key := sessionHash(token)
session := store2.sessions[key]
store2.mu.RUnlock()
if session == nil {
t.Fatal("Session not found after load")
}
if session.UserAgent != "PersistAgent" {
t.Errorf("UserAgent = %q, want PersistAgent", session.UserAgent)
}
if session.IP != "10.0.0.1" {
t.Errorf("IP = %q, want 10.0.0.1", session.IP)
}
}
func TestSessionStore_LoadExpiredSessions(t *testing.T) {
tmpDir := t.TempDir()
// Manually create a sessions file with an expired session
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
token := "already-expired"
key := sessionHash(token)
// Add expired session
store.mu.Lock()
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(-time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
}
store.mu.Unlock()
store.save()
// Create new store and load
store2 := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
store2.load()
// Expired session should not be loaded
store2.mu.RLock()
_, exists := store2.sessions[key]
store2.mu.RUnlock()
if exists {
t.Error("Expired session should not be loaded from disk")
}
}
func TestSessionStore_Cleanup(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: t.TempDir(),
stopChan: make(chan bool),
}
// Add a valid and an expired session
validKey := sessionHash("valid-session")
expiredKey := sessionHash("expired-session")
store.mu.Lock()
store.sessions[validKey] = &SessionData{
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
}
store.sessions[expiredKey] = &SessionData{
ExpiresAt: time.Now().Add(-time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
}
store.mu.Unlock()
// Run cleanup
store.cleanup()
// Valid session should remain
store.mu.RLock()
_, validExists := store.sessions[validKey]
_, expiredExists := store.sessions[expiredKey]
store.mu.RUnlock()
if !validExists {
t.Error("Valid session was incorrectly removed during cleanup")
}
if expiredExists {
t.Error("Expired session was not removed during cleanup")
}
}
func TestSessionPersistedStruct_Fields(t *testing.T) {
now := time.Now()
duration := 2 * time.Hour
persisted := sessionPersisted{
Key: "abc123",
ExpiresAt: now.Add(duration),
CreatedAt: now,
UserAgent: "TestUA",
IP: "1.2.3.4",
OriginalDuration: duration,
}
if persisted.Key != "abc123" {
t.Errorf("Key = %q, want abc123", persisted.Key)
}
if persisted.UserAgent != "TestUA" {
t.Errorf("UserAgent = %q, want TestUA", persisted.UserAgent)
}
if persisted.IP != "1.2.3.4" {
t.Errorf("IP = %q, want 1.2.3.4", persisted.IP)
}
if persisted.OriginalDuration != duration {
t.Errorf("OriginalDuration = %v, want %v", persisted.OriginalDuration, duration)
}
}
func TestSessionStore_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 := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: blockedPath, // This is a file, not a directory
stopChan: make(chan bool),
}
// Add a session to save
key := sessionHash("test-token")
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
}
// 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 TestSessionStore_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 := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: readOnlyDir,
stopChan: make(chan bool),
}
// Add a session
key := sessionHash("test-token")
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
}
// 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
sessionsFile := filepath.Join(readOnlyDir, "sessions.json")
if _, err := os.Stat(sessionsFile); err == nil {
t.Error("sessions file should not exist after write failure")
}
}
func TestSessionStore_SaveUnsafe_RenameError(t *testing.T) {
// Create a directory at the target path to cause rename error
tmpDir := t.TempDir()
// Create a directory at the exact path where sessions.json should go
sessionsFilePath := filepath.Join(tmpDir, "sessions.json")
if err := os.Mkdir(sessionsFilePath, 0755); err != nil {
t.Fatalf("failed to create blocking directory: %v", err)
}
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
// Add a session
key := sessionHash("test-token")
store.sessions[key] = &SessionData{
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
}
// saveUnsafe should handle rename error gracefully
store.saveUnsafe()
// The blocking directory should still exist (rename failed)
info, err := os.Stat(sessionsFilePath)
if err != nil {
t.Fatalf("blocking directory was removed: %v", err)
}
if !info.IsDir() {
t.Error("blocking directory was replaced with a file")
}
}
func TestSessionStore_Load_ReadError(t *testing.T) {
tmpDir := t.TempDir()
// Create a directory where the sessions file should be - reading it will fail
sessionsPath := filepath.Join(tmpDir, "sessions.json")
if err := os.Mkdir(sessionsPath, 0755); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
// Should not panic and should log error
store.load()
if len(store.sessions) != 0 {
t.Errorf("store should be empty after read error, got %d sessions", len(store.sessions))
}
}
func TestSessionStore_Load_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
// Write invalid JSON that won't match either format
sessionsFile := filepath.Join(tmpDir, "sessions.json")
if err := os.WriteFile(sessionsFile, []byte("not valid json at all"), 0600); err != nil {
t.Fatalf("failed to write invalid JSON: %v", err)
}
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
// Should not panic
store.load()
if len(store.sessions) != 0 {
t.Errorf("store should be empty after loading invalid JSON, got %d sessions", len(store.sessions))
}
}
func TestSessionStore_Load_LegacyFormat(t *testing.T) {
tmpDir := t.TempDir()
// Create legacy format JSON (map[token]sessionData) with snake_case fields
// The legacy format uses raw tokens as keys
legacyJSON := `{
"raw-token-1": {"expires_at": "2099-12-31T23:59:59Z", "created_at": "2024-01-01T00:00:00Z", "user_agent": "Agent1", "ip": "1.1.1.1"},
"raw-token-2": {"expires_at": "2099-12-31T23:59:59Z", "created_at": "2024-01-01T00:00:00Z", "user_agent": "Agent2", "ip": "2.2.2.2"},
"raw-token-expired": {"expires_at": "2020-01-01T00:00:00Z", "created_at": "2019-01-01T00:00:00Z", "user_agent": "ExpiredAgent", "ip": "3.3.3.3"}
}`
sessionsFile := filepath.Join(tmpDir, "sessions.json")
if err := os.WriteFile(sessionsFile, []byte(legacyJSON), 0600); err != nil {
t.Fatalf("failed to write legacy format JSON: %v", err)
}
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: tmpDir,
stopChan: make(chan bool),
}
store.load()
// Should load the two non-expired sessions (raw-token-1 and raw-token-2)
// The expired one (raw-token-expired) should be skipped
if len(store.sessions) != 2 {
t.Errorf("expected 2 sessions from legacy format, got %d", len(store.sessions))
}
// Verify sessions are hashed and can be validated
// The legacy format stores raw tokens as keys, which get hashed during migration
if !store.ValidateSession("raw-token-1") {
t.Error("should validate session for raw-token-1 after legacy migration")
}
if !store.ValidateSession("raw-token-2") {
t.Error("should validate session for raw-token-2 after legacy migration")
}
// Expired token should not be valid
if store.ValidateSession("raw-token-expired") {
t.Error("expired session should not be loaded from legacy format")
}
}