go-crypt/auth/auth_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

853 lines
22 KiB
Go

package auth
import (
"encoding/json"
"fmt"
"strings"
"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-crypt/crypt/pgp"
"forge.lthn.ai/core/go/pkg/io"
)
// helper creates a fresh Authenticator backed by MockMedium.
func newTestAuth(opts ...Option) (*Authenticator, *io.MockMedium) {
m := io.NewMockMedium()
a := New(m, opts...)
return a, m
}
// --- Register ---
func TestRegister_Good(t *testing.T) {
a, m := newTestAuth()
user, err := a.Register("alice", "hunter2")
require.NoError(t, err)
require.NotNil(t, user)
userID := lthn.Hash("alice")
// Verify public key is stored
assert.True(t, m.IsFile(userPath(userID, ".pub")))
assert.True(t, m.IsFile(userPath(userID, ".key")))
assert.True(t, m.IsFile(userPath(userID, ".rev")))
assert.True(t, m.IsFile(userPath(userID, ".json")))
assert.True(t, m.IsFile(userPath(userID, ".lthn")))
// Verify user fields
assert.NotEmpty(t, user.PublicKey)
assert.Equal(t, userID, user.KeyID)
assert.NotEmpty(t, user.Fingerprint)
assert.Equal(t, lthn.Hash("hunter2"), user.PasswordHash)
assert.False(t, user.Created.IsZero())
}
func TestRegister_Bad(t *testing.T) {
a, _ := newTestAuth()
// Register first time succeeds
_, err := a.Register("bob", "pass1")
require.NoError(t, err)
// Duplicate registration should fail
_, err = a.Register("bob", "pass2")
assert.Error(t, err)
assert.Contains(t, err.Error(), "user already exists")
}
func TestRegister_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Empty username/password should still work (PGP allows it)
user, err := a.Register("", "")
require.NoError(t, err)
require.NotNil(t, user)
}
// --- CreateChallenge ---
func TestCreateChallenge_Good(t *testing.T) {
a, _ := newTestAuth()
user, err := a.Register("charlie", "pass")
require.NoError(t, err)
challenge, err := a.CreateChallenge(user.KeyID)
require.NoError(t, err)
require.NotNil(t, challenge)
assert.Len(t, challenge.Nonce, nonceBytes)
assert.NotEmpty(t, challenge.Encrypted)
assert.True(t, challenge.ExpiresAt.After(time.Now()))
}
func TestCreateChallenge_Bad(t *testing.T) {
a, _ := newTestAuth()
// Challenge for non-existent user
_, err := a.CreateChallenge("nonexistent-user-id")
assert.Error(t, err)
assert.Contains(t, err.Error(), "user not found")
}
func TestCreateChallenge_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Empty userID
_, err := a.CreateChallenge("")
assert.Error(t, err)
}
// --- ValidateResponse (full challenge-response flow) ---
func TestValidateResponse_Good(t *testing.T) {
a, m := newTestAuth()
// Register user
_, err := a.Register("dave", "password123")
require.NoError(t, err)
userID := lthn.Hash("dave")
// Create challenge
challenge, err := a.CreateChallenge(userID)
require.NoError(t, err)
// Client-side: decrypt nonce, then sign it
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "password123")
require.NoError(t, err)
assert.Equal(t, challenge.Nonce, decryptedNonce)
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "password123")
require.NoError(t, err)
// Validate response
session, err := a.ValidateResponse(userID, signedNonce)
require.NoError(t, err)
require.NotNil(t, session)
assert.NotEmpty(t, session.Token)
assert.Equal(t, userID, session.UserID)
assert.True(t, session.ExpiresAt.After(time.Now()))
}
func TestValidateResponse_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("eve", "pass")
require.NoError(t, err)
userID := lthn.Hash("eve")
// No pending challenge
_, err = a.ValidateResponse(userID, []byte("fake-signature"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "no pending challenge")
}
func TestValidateResponse_Ugly(t *testing.T) {
a, m := newTestAuth(WithChallengeTTL(1 * time.Millisecond))
_, err := a.Register("frank", "pass")
require.NoError(t, err)
userID := lthn.Hash("frank")
// Create challenge and let it expire
challenge, err := a.CreateChallenge(userID)
require.NoError(t, err)
time.Sleep(5 * time.Millisecond)
// Sign with valid key but expired challenge
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
signedNonce, err := pgp.Sign(challenge.Nonce, privKey, "pass")
require.NoError(t, err)
_, err = a.ValidateResponse(userID, signedNonce)
assert.Error(t, err)
assert.Contains(t, err.Error(), "challenge expired")
}
// --- ValidateSession ---
func TestValidateSession_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("grace", "pass")
require.NoError(t, err)
userID := lthn.Hash("grace")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
validated, err := a.ValidateSession(session.Token)
require.NoError(t, err)
assert.Equal(t, session.Token, validated.Token)
assert.Equal(t, userID, validated.UserID)
}
func TestValidateSession_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.ValidateSession("nonexistent-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestValidateSession_Ugly(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
_, err := a.Register("heidi", "pass")
require.NoError(t, err)
userID := lthn.Hash("heidi")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
time.Sleep(5 * time.Millisecond)
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session expired")
}
// --- RefreshSession ---
func TestRefreshSession_Good(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Hour))
_, err := a.Register("ivan", "pass")
require.NoError(t, err)
userID := lthn.Hash("ivan")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
originalExpiry := session.ExpiresAt
// Small delay to ensure time moves forward
time.Sleep(2 * time.Millisecond)
refreshed, err := a.RefreshSession(session.Token)
require.NoError(t, err)
assert.True(t, refreshed.ExpiresAt.After(originalExpiry))
}
func TestRefreshSession_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.RefreshSession("nonexistent-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestRefreshSession_Ugly(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
_, err := a.Register("judy", "pass")
require.NoError(t, err)
userID := lthn.Hash("judy")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
time.Sleep(5 * time.Millisecond)
_, err = a.RefreshSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session expired")
}
// --- RevokeSession ---
func TestRevokeSession_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("karl", "pass")
require.NoError(t, err)
userID := lthn.Hash("karl")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
err = a.RevokeSession(session.Token)
require.NoError(t, err)
// Token should no longer be valid
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
}
func TestRevokeSession_Bad(t *testing.T) {
a, _ := newTestAuth()
err := a.RevokeSession("nonexistent-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestRevokeSession_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Revoke empty token
err := a.RevokeSession("")
assert.Error(t, err)
}
// --- DeleteUser ---
func TestDeleteUser_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("larry", "pass")
require.NoError(t, err)
userID := lthn.Hash("larry")
// Also create a session that should be cleaned up
session, err := a.Login(userID, "pass")
require.NoError(t, err)
err = a.DeleteUser(userID)
require.NoError(t, err)
// All files should be gone
assert.False(t, m.IsFile(userPath(userID, ".pub")))
assert.False(t, m.IsFile(userPath(userID, ".key")))
assert.False(t, m.IsFile(userPath(userID, ".rev")))
assert.False(t, m.IsFile(userPath(userID, ".json")))
assert.False(t, m.IsFile(userPath(userID, ".lthn")))
// Session should be gone (validate returns error)
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestDeleteUser_Bad(t *testing.T) {
a, _ := newTestAuth()
// Protected user "server" cannot be deleted
err := a.DeleteUser("server")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot delete protected user")
}
func TestDeleteUser_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Non-existent user
err := a.DeleteUser("nonexistent-user-id")
assert.Error(t, err)
assert.Contains(t, err.Error(), "user not found")
}
// --- Login ---
func TestLogin_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("mallory", "secret")
require.NoError(t, err)
userID := lthn.Hash("mallory")
session, err := a.Login(userID, "secret")
require.NoError(t, err)
require.NotNil(t, session)
assert.NotEmpty(t, session.Token)
assert.Equal(t, userID, session.UserID)
assert.True(t, session.ExpiresAt.After(time.Now()))
}
func TestLogin_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("nancy", "correct-password")
require.NoError(t, err)
userID := lthn.Hash("nancy")
// Wrong password
_, err = a.Login(userID, "wrong-password")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid password")
}
func TestLogin_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Login for non-existent user
_, err := a.Login("nonexistent-user-id", "pass")
assert.Error(t, err)
assert.Contains(t, err.Error(), "user not found")
}
// --- WriteChallengeFile / ReadResponseFile (Air-Gapped) ---
func TestAirGappedFlow_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("oscar", "airgap-pass")
require.NoError(t, err)
userID := lthn.Hash("oscar")
// Write challenge to file
challengePath := "transfer/challenge.json"
err = a.WriteChallengeFile(userID, challengePath)
require.NoError(t, err)
assert.True(t, m.IsFile(challengePath))
// Read challenge file to get the encrypted nonce (simulating courier)
challengeData, err := m.Read(challengePath)
require.NoError(t, err)
var challenge Challenge
err = json.Unmarshal([]byte(challengeData), &challenge)
require.NoError(t, err)
// Client-side: decrypt nonce and sign it
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "airgap-pass")
require.NoError(t, err)
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "airgap-pass")
require.NoError(t, err)
// Write signed response to file
responsePath := "transfer/response.sig"
err = m.Write(responsePath, string(signedNonce))
require.NoError(t, err)
// Server reads response file
session, err := a.ReadResponseFile(userID, responsePath)
require.NoError(t, err)
require.NotNil(t, session)
assert.NotEmpty(t, session.Token)
assert.Equal(t, userID, session.UserID)
}
func TestWriteChallengeFile_Bad(t *testing.T) {
a, _ := newTestAuth()
// Challenge for non-existent user
err := a.WriteChallengeFile("nonexistent-user", "challenge.json")
assert.Error(t, err)
}
func TestReadResponseFile_Bad(t *testing.T) {
a, _ := newTestAuth()
// Response file does not exist
_, err := a.ReadResponseFile("some-user", "nonexistent-file.sig")
assert.Error(t, err)
}
func TestReadResponseFile_Ugly(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("peggy", "pass")
require.NoError(t, err)
userID := lthn.Hash("peggy")
// Create a challenge
_, err = a.CreateChallenge(userID)
require.NoError(t, err)
// Write garbage to response file
responsePath := "transfer/bad-response.sig"
err = m.Write(responsePath, "not-a-valid-signature")
require.NoError(t, err)
_, err = a.ReadResponseFile(userID, responsePath)
assert.Error(t, err)
}
// --- Options ---
func TestWithChallengeTTL_Good(t *testing.T) {
ttl := 30 * time.Second
a, _ := newTestAuth(WithChallengeTTL(ttl))
assert.Equal(t, ttl, a.challengeTTL)
}
func TestWithSessionTTL_Good(t *testing.T) {
ttl := 2 * time.Hour
a, _ := newTestAuth(WithSessionTTL(ttl))
assert.Equal(t, ttl, a.sessionTTL)
}
// --- Full Round-Trip (Online Flow) ---
func TestFullRoundTrip_Good(t *testing.T) {
a, m := newTestAuth()
// 1. Register
user, err := a.Register("quinn", "roundtrip-pass")
require.NoError(t, err)
require.NotNil(t, user)
userID := lthn.Hash("quinn")
// 2. Create challenge
challenge, err := a.CreateChallenge(userID)
require.NoError(t, err)
// 3. Client decrypts + signs
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
nonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "roundtrip-pass")
require.NoError(t, err)
sig, err := pgp.Sign(nonce, privKey, "roundtrip-pass")
require.NoError(t, err)
// 4. Server validates, issues session
session, err := a.ValidateResponse(userID, sig)
require.NoError(t, err)
require.NotNil(t, session)
// 5. Validate session
validated, err := a.ValidateSession(session.Token)
require.NoError(t, err)
assert.Equal(t, session.Token, validated.Token)
// 6. Refresh session
refreshed, err := a.RefreshSession(session.Token)
require.NoError(t, err)
assert.Equal(t, session.Token, refreshed.Token)
// 7. Revoke session
err = a.RevokeSession(session.Token)
require.NoError(t, err)
// 8. Session should be invalid now
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
}
// --- Concurrent Access ---
func TestConcurrentSessions_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("ruth", "pass")
require.NoError(t, err)
userID := lthn.Hash("ruth")
// Create multiple sessions concurrently
const n = 10
sessions := make(chan *Session, n)
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
s, err := a.Login(userID, "pass")
if err != nil {
errs <- err
return
}
sessions <- s
}()
}
for i := 0; i < n; i++ {
select {
case s := <-sessions:
require.NotNil(t, s)
// Validate each session
_, err := a.ValidateSession(s.Token)
assert.NoError(t, err)
case err := <-errs:
t.Fatalf("concurrent login failed: %v", err)
}
}
}
// --- Phase 0 Additions ---
// TestConcurrentSessionCreation_Good verifies that 10 goroutines creating
// sessions simultaneously do not produce data races or errors.
func TestConcurrentSessionCreation_Good(t *testing.T) {
a, _ := newTestAuth()
// Register 10 distinct users to avoid contention on a single user record
const n = 10
userIDs := make([]string, n)
for i := 0; i < n; i++ {
username := fmt.Sprintf("concurrent-user-%d", i)
_, err := a.Register(username, "pass")
require.NoError(t, err)
userIDs[i] = lthn.Hash(username)
}
var wg sync.WaitGroup
wg.Add(n)
sessions := make([]*Session, n)
errs := make([]error, n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
s, err := a.Login(userIDs[idx], "pass")
sessions[idx] = s
errs[idx] = err
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
require.NoError(t, errs[i], "goroutine %d failed", i)
require.NotNil(t, sessions[i], "goroutine %d returned nil session", i)
// Each session token must be valid
_, err := a.ValidateSession(sessions[i].Token)
assert.NoError(t, err, "session from goroutine %d should be valid", i)
}
}
// TestSessionTokenUniqueness_Good generates 1000 tokens and verifies no collisions.
func TestSessionTokenUniqueness_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("uniqueness-test", "pass")
require.NoError(t, err)
userID := lthn.Hash("uniqueness-test")
const n = 1000
tokens := make(map[string]bool, n)
for i := 0; i < n; i++ {
session, err := a.Login(userID, "pass")
require.NoError(t, err)
require.NotNil(t, session)
if tokens[session.Token] {
t.Fatalf("duplicate token detected at iteration %d: %s", i, session.Token)
}
tokens[session.Token] = true
}
assert.Len(t, tokens, n, "all 1000 tokens should be unique")
}
// TestChallengeExpiryBoundary_Ugly tests validation right at the 5-minute boundary.
// The challenge should still be valid just before expiry and rejected after.
func TestChallengeExpiryBoundary_Ugly(t *testing.T) {
// Use a very short TTL to test the boundary without sleeping 5 minutes
ttl := 50 * time.Millisecond
a, m := newTestAuth(WithChallengeTTL(ttl))
_, err := a.Register("boundary-user", "pass")
require.NoError(t, err)
userID := lthn.Hash("boundary-user")
// Create a challenge and respond immediately (should succeed)
challenge, err := a.CreateChallenge(userID)
require.NoError(t, err)
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "pass")
require.NoError(t, err)
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "pass")
require.NoError(t, err)
session, err := a.ValidateResponse(userID, signedNonce)
require.NoError(t, err)
assert.NotNil(t, session)
// Now create another challenge and let it expire
challenge2, err := a.CreateChallenge(userID)
require.NoError(t, err)
// Wait past the TTL
time.Sleep(ttl + 10*time.Millisecond)
decryptedNonce2, err := pgp.Decrypt([]byte(challenge2.Encrypted), privKey, "pass")
require.NoError(t, err)
signedNonce2, err := pgp.Sign(decryptedNonce2, privKey, "pass")
require.NoError(t, err)
_, err = a.ValidateResponse(userID, signedNonce2)
assert.Error(t, err)
assert.Contains(t, err.Error(), "challenge expired")
}
// TestEmptyPasswordRegistration_Good verifies that empty password registration works.
// PGP key is generated unencrypted in this case.
func TestEmptyPasswordRegistration_Good(t *testing.T) {
a, m := newTestAuth()
user, err := a.Register("no-password-user", "")
require.NoError(t, err)
require.NotNil(t, user)
userID := lthn.Hash("no-password-user")
// Verify all files are stored
assert.True(t, m.IsFile(userPath(userID, ".pub")))
assert.True(t, m.IsFile(userPath(userID, ".key")))
assert.True(t, m.IsFile(userPath(userID, ".json")))
// Login with empty password should work
session, err := a.Login(userID, "")
require.NoError(t, err)
assert.NotNil(t, session)
// Challenge-response flow should also work with empty password
challenge, err := a.CreateChallenge(userID)
require.NoError(t, err)
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "")
require.NoError(t, err)
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "")
require.NoError(t, err)
crSession, err := a.ValidateResponse(userID, signedNonce)
require.NoError(t, err)
assert.NotNil(t, crSession)
}
// TestVeryLongUsername_Ugly verifies behaviour with a 10K character username.
func TestVeryLongUsername_Ugly(t *testing.T) {
a, _ := newTestAuth()
longUsername := strings.Repeat("a", 10000)
user, err := a.Register(longUsername, "pass")
require.NoError(t, err)
require.NotNil(t, user)
// The LTHN hash of the long username should still be a fixed-length identifier
userID := lthn.Hash(longUsername)
assert.Len(t, userID, 64, "LTHN hash should always be 64 hex chars (SHA-256)")
// Login should work
session, err := a.Login(userID, "pass")
require.NoError(t, err)
assert.NotNil(t, session)
}
// TestUnicodeUsernamePassword_Good verifies registration and login with Unicode characters.
func TestUnicodeUsernamePassword_Good(t *testing.T) {
a, _ := newTestAuth()
// Japanese + emoji + Chinese + Arabic
username := "\u65e5\u672c\u8a9e\u30c6\u30b9\u30c8\U0001F680\u4e2d\u6587\u0627\u0644\u0639\u0631\u0628\u064a\u0629"
password := "\u00fc\u00f1\u00ee\u00e7\u00f6\u00f0\u00ea\u2603\u2764"
user, err := a.Register(username, password)
require.NoError(t, err)
require.NotNil(t, user)
userID := lthn.Hash(username)
// Login with correct Unicode password
session, err := a.Login(userID, password)
require.NoError(t, err)
assert.NotNil(t, session)
// Login with wrong Unicode password should fail
_, err = a.Login(userID, "wrong-\u00fc\u00f1\u00ee")
assert.Error(t, err)
}
// TestAirGappedRoundTrip_Good tests the full air-gapped flow:
// WriteChallengeFile -> client signs offline -> ReadResponseFile
func TestAirGappedRoundTrip_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("airgap-roundtrip", "courier-pass")
require.NoError(t, err)
userID := lthn.Hash("airgap-roundtrip")
// Step 1: Server writes challenge file
challengePath := "airgap/challenge.json"
err = a.WriteChallengeFile(userID, challengePath)
require.NoError(t, err)
assert.True(t, m.IsFile(challengePath))
// Step 2: Client reads challenge file (simulating courier transport)
challengeData, err := m.Read(challengePath)
require.NoError(t, err)
var challenge Challenge
err = json.Unmarshal([]byte(challengeData), &challenge)
require.NoError(t, err)
assert.NotEmpty(t, challenge.Encrypted)
assert.True(t, challenge.ExpiresAt.After(time.Now()))
// Step 3: Client decrypts nonce, signs it, writes response
privKey, err := m.Read(userPath(userID, ".key"))
require.NoError(t, err)
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "courier-pass")
require.NoError(t, err)
assert.Equal(t, challenge.Nonce, decryptedNonce)
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "courier-pass")
require.NoError(t, err)
responsePath := "airgap/response.sig"
err = m.Write(responsePath, string(signedNonce))
require.NoError(t, err)
// Step 4: Server reads response file and validates
session, err := a.ReadResponseFile(userID, responsePath)
require.NoError(t, err)
require.NotNil(t, session)
assert.NotEmpty(t, session.Token)
assert.Equal(t, userID, session.UserID)
assert.True(t, session.ExpiresAt.After(time.Now()))
// Step 5: Session should be valid
validated, err := a.ValidateSession(session.Token)
require.NoError(t, err)
assert.Equal(t, session.Token, validated.Token)
}
// TestRefreshExpiredSession_Bad verifies that refreshing an already-expired session fails.
func TestRefreshExpiredSession_Bad(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
_, err := a.Register("expired-refresh", "pass")
require.NoError(t, err)
userID := lthn.Hash("expired-refresh")
session, err := a.Login(userID, "pass")
require.NoError(t, err)
// Wait for session to expire
time.Sleep(10 * time.Millisecond)
// Refresh should fail
_, err = a.RefreshSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session expired")
// The expired session should now be cleaned up (removed from map)
_, err = a.ValidateSession(session.Token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}