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>
503 lines
12 KiB
Go
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)
|
|
}
|