go-crypt/auth/session_store_test.go
Snider 1aeabfd32b feat(auth): add SessionStore interface with SQLite persistence
Extract in-memory session map into SessionStore interface with two
implementations: MemorySessionStore (default, backward-compatible) and
SQLiteSessionStore (persistent via go-store). Add WithSessionStore
option, background cleanup goroutine, and comprehensive tests including
persistence verification and concurrency safety.

Phase 1: Session Persistence — complete.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 01:44:51 +00:00

503 lines
12 KiB
Go

package auth
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-crypt/crypt/lthn"
"forge.lthn.ai/core/go/pkg/io"
)
// --- MemorySessionStore ---
func TestMemorySessionStore_GetSetDelete_Good(t *testing.T) {
store := NewMemorySessionStore()
session := &Session{
Token: "test-token-abc",
UserID: "user-123",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// Set
err := store.Set(session)
require.NoError(t, err)
// Get
got, err := store.Get("test-token-abc")
require.NoError(t, err)
assert.Equal(t, session.Token, got.Token)
assert.Equal(t, session.UserID, got.UserID)
// Delete
err = store.Delete("test-token-abc")
require.NoError(t, err)
// Get after delete should fail
_, err = store.Get("test-token-abc")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_GetNotFound_Bad(t *testing.T) {
store := NewMemorySessionStore()
_, err := store.Get("nonexistent-token")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_DeleteNotFound_Bad(t *testing.T) {
store := NewMemorySessionStore()
err := store.Delete("nonexistent-token")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_DeleteByUser_Good(t *testing.T) {
store := NewMemorySessionStore()
// Create sessions for two users
for i := 0; i < 3; i++ {
err := store.Set(&Session{
Token: fmt.Sprintf("user-a-token-%d", i),
UserID: "user-a",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
}
err := store.Set(&Session{
Token: "user-b-token",
UserID: "user-b",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
// Delete all user-a sessions
err = store.DeleteByUser("user-a")
require.NoError(t, err)
// user-a sessions should be gone
for i := 0; i < 3; i++ {
_, err := store.Get(fmt.Sprintf("user-a-token-%d", i))
assert.ErrorIs(t, err, ErrSessionNotFound)
}
// user-b session should remain
got, err := store.Get("user-b-token")
require.NoError(t, err)
assert.Equal(t, "user-b", got.UserID)
}
func TestMemorySessionStore_Cleanup_Good(t *testing.T) {
store := NewMemorySessionStore()
// Create expired and valid sessions
err := store.Set(&Session{
Token: "expired-1",
UserID: "user",
ExpiresAt: time.Now().Add(-1 * time.Hour),
})
require.NoError(t, err)
err = store.Set(&Session{
Token: "expired-2",
UserID: "user",
ExpiresAt: time.Now().Add(-30 * time.Minute),
})
require.NoError(t, err)
err = store.Set(&Session{
Token: "valid-1",
UserID: "user",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
count, err := store.Cleanup()
require.NoError(t, err)
assert.Equal(t, 2, count)
// Valid session should remain
_, err = store.Get("valid-1")
assert.NoError(t, err)
// Expired sessions should be gone
_, err = store.Get("expired-1")
assert.ErrorIs(t, err, ErrSessionNotFound)
_, err = store.Get("expired-2")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_Concurrent_Good(t *testing.T) {
store := NewMemorySessionStore()
const n = 20
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
token := fmt.Sprintf("concurrent-token-%d", idx)
err := store.Set(&Session{
Token: token,
UserID: fmt.Sprintf("user-%d", idx%5),
ExpiresAt: time.Now().Add(1 * time.Hour),
})
assert.NoError(t, err)
got, err := store.Get(token)
assert.NoError(t, err)
assert.Equal(t, token, got.Token)
}(i)
}
wg.Wait()
}
// --- SQLiteSessionStore ---
func TestSQLiteSessionStore_GetSetDelete_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
session := &Session{
Token: "sqlite-token-abc",
UserID: "user-456",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// Set
err = store.Set(session)
require.NoError(t, err)
// Get
got, err := store.Get("sqlite-token-abc")
require.NoError(t, err)
assert.Equal(t, session.Token, got.Token)
assert.Equal(t, session.UserID, got.UserID)
// Delete
err = store.Delete("sqlite-token-abc")
require.NoError(t, err)
// Get after delete should fail
_, err = store.Get("sqlite-token-abc")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_GetNotFound_Bad(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
_, err = store.Get("nonexistent-token")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_DeleteNotFound_Bad(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
err = store.Delete("nonexistent-token")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
// Create sessions for two users
for i := 0; i < 3; i++ {
err := store.Set(&Session{
Token: fmt.Sprintf("sqlite-user-a-%d", i),
UserID: "user-a",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
}
err = store.Set(&Session{
Token: "sqlite-user-b",
UserID: "user-b",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
// Delete all user-a sessions
err = store.DeleteByUser("user-a")
require.NoError(t, err)
// user-a sessions should be gone
for i := 0; i < 3; i++ {
_, err := store.Get(fmt.Sprintf("sqlite-user-a-%d", i))
assert.ErrorIs(t, err, ErrSessionNotFound)
}
// user-b session should remain
got, err := store.Get("sqlite-user-b")
require.NoError(t, err)
assert.Equal(t, "user-b", got.UserID)
}
func TestSQLiteSessionStore_Cleanup_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
// Create expired and valid sessions
err = store.Set(&Session{
Token: "sqlite-expired-1",
UserID: "user",
ExpiresAt: time.Now().Add(-1 * time.Hour),
})
require.NoError(t, err)
err = store.Set(&Session{
Token: "sqlite-expired-2",
UserID: "user",
ExpiresAt: time.Now().Add(-30 * time.Minute),
})
require.NoError(t, err)
err = store.Set(&Session{
Token: "sqlite-valid-1",
UserID: "user",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
count, err := store.Cleanup()
require.NoError(t, err)
assert.Equal(t, 2, count)
// Valid session should remain
_, err = store.Get("sqlite-valid-1")
assert.NoError(t, err)
// Expired sessions should be gone
_, err = store.Get("sqlite-expired-1")
assert.ErrorIs(t, err, ErrSessionNotFound)
_, err = store.Get("sqlite-expired-2")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_Persistence_Good(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "sessions.db")
// Write a session
store1, err := NewSQLiteSessionStore(dbPath)
require.NoError(t, err)
session := &Session{
Token: "persist-token",
UserID: "persist-user",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
err = store1.Set(session)
require.NoError(t, err)
// Close the store
err = store1.Close()
require.NoError(t, err)
// Reopen and verify data persists
store2, err := NewSQLiteSessionStore(dbPath)
require.NoError(t, err)
defer store2.Close()
got, err := store2.Get("persist-token")
require.NoError(t, err)
assert.Equal(t, "persist-user", got.UserID)
assert.Equal(t, "persist-token", got.Token)
}
func TestSQLiteSessionStore_Concurrent_Good(t *testing.T) {
// Use a temp file — :memory: SQLite has concurrency limitations
dbPath := filepath.Join(t.TempDir(), "concurrent.db")
store, err := NewSQLiteSessionStore(dbPath)
require.NoError(t, err)
defer store.Close()
const n = 20
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
token := fmt.Sprintf("sqlite-concurrent-%d", idx)
err := store.Set(&Session{
Token: token,
UserID: fmt.Sprintf("user-%d", idx%5),
ExpiresAt: time.Now().Add(1 * time.Hour),
})
assert.NoError(t, err)
got, err := store.Get(token)
assert.NoError(t, err)
if got != nil {
assert.Equal(t, token, got.Token)
}
}(i)
}
wg.Wait()
}
// --- Authenticator with SessionStore ---
func TestAuthenticator_WithSessionStore_Good(t *testing.T) {
sqliteStore, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer sqliteStore.Close()
m := io.NewMockMedium()
a := New(m, WithSessionStore(sqliteStore))
// Register user
_, err = a.Register("store-test-user", "pass")
require.NoError(t, err)
userID := lthn.Hash("store-test-user")
// Login creates session in SQLite store
session, err := a.Login(userID, "pass")
require.NoError(t, err)
require.NotNil(t, session)
// Validate session from store
validated, err := a.ValidateSession(session.Token)
require.NoError(t, err)
assert.Equal(t, session.Token, validated.Token)
assert.Equal(t, userID, validated.UserID)
// Refresh session
refreshed, err := a.RefreshSession(session.Token)
require.NoError(t, err)
assert.Equal(t, session.Token, refreshed.Token)
// Revoke session
err = a.RevokeSession(session.Token)
require.NoError(t, err)
// Session should be gone
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestAuthenticator_DefaultStore_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m)
// Default store should be MemorySessionStore
_, ok := a.store.(*MemorySessionStore)
assert.True(t, ok, "default store should be MemorySessionStore")
}
func TestAuthenticator_StartCleanup_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m, WithSessionTTL(1*time.Millisecond))
// Register and login to create a session
_, err := a.Register("cleanup-test", "pass")
require.NoError(t, err)
userID := lthn.Hash("cleanup-test")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
// Wait for session to expire
time.Sleep(5 * time.Millisecond)
// Start cleanup with a short interval
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
a.StartCleanup(ctx, 10*time.Millisecond)
// Wait for at least one cleanup cycle
time.Sleep(50 * time.Millisecond)
// Session should have been cleaned up
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestAuthenticator_StartCleanup_CancelStops_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m)
ctx, cancel := context.WithCancel(context.Background())
a.StartCleanup(ctx, 10*time.Millisecond)
// Cancel should stop the goroutine without panic
cancel()
time.Sleep(50 * time.Millisecond)
}
func TestSQLiteSessionStore_UpdateExisting_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
original := &Session{
Token: "update-token",
UserID: "user-1",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
err = store.Set(original)
require.NoError(t, err)
// Update with new expiry
updated := &Session{
Token: "update-token",
UserID: "user-1",
ExpiresAt: time.Now().Add(2 * time.Hour),
}
err = store.Set(updated)
require.NoError(t, err)
got, err := store.Get("update-token")
require.NoError(t, err)
assert.True(t, got.ExpiresAt.After(original.ExpiresAt),
"updated session should have later expiry")
}
func TestSQLiteSessionStore_TempFile_Good(t *testing.T) {
// Verify we can use a real temp file (not :memory:)
tmpFile := filepath.Join(os.TempDir(), "go-crypt-test-session-store.db")
defer os.Remove(tmpFile)
store, err := NewSQLiteSessionStore(tmpFile)
require.NoError(t, err)
err = store.Set(&Session{
Token: "temp-file-token",
UserID: "user",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
require.NoError(t, err)
got, err := store.Get("temp-file-token")
require.NoError(t, err)
assert.Equal(t, "temp-file-token", got.Token)
err = store.Close()
require.NoError(t, err)
}