mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
33e49434ed
Two root causes behind users being logged out: - UA session binding was unconditional and fatal — any User-Agent change (browser/WebView update, DevTools device emulation, mobile rebuild) silently de-authenticated the session. Now log-only across all three enforcement sites (TokenAuth, SessionAuth, and the validateSessionCookie helper used by CLI-auth/account/session-check routes), mirroring the default IP-change handling. (BUG-1815) - Sessions had a fixed absolute TTL with no refresh on activity, so even an active user hit the cliff at 7d (web) / 30d (CLI). Adds sliding renewal: RenewSessionIfStale extends expires_at when past the half-window threshold, capped at created_at + 90d (SessionMaxLifetime), CAS-guarded and only reported when RowsAffected confirms the write. The middleware re-issues the session + CSRF cookies on renewal. New renew_ttl_seconds column (sqlite + pg migrations); legacy rows (0) keep their fixed expiry. (TASK-1816) Reviewed by Codex (clean). Tests: store + server suites pass.
426 lines
12 KiB
Go
426 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
func TestSessionCreateAndValidate(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
// Create session with binding metadata
|
|
token, err := s.CreateSession(u.ID, "cli", "127.0.0.1", "TestAgent/1.0", 30*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession error: %v", err)
|
|
}
|
|
if !strings.HasPrefix(token, "padsess_") {
|
|
t.Errorf("expected token prefix 'padsess_', got %q", token[:8])
|
|
}
|
|
if len(token) != 72 { // "padsess_" (8) + 64 hex chars
|
|
t.Errorf("expected token length 72, got %d", len(token))
|
|
}
|
|
|
|
// Validate session
|
|
session, err := s.ValidateSession(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateSession error: %v", err)
|
|
}
|
|
if session == nil {
|
|
t.Fatal("expected session from valid token")
|
|
}
|
|
if session.User == nil {
|
|
t.Fatal("expected user from valid session")
|
|
}
|
|
if session.User.ID != u.ID {
|
|
t.Errorf("expected user ID %q, got %q", u.ID, session.User.ID)
|
|
}
|
|
if session.User.Email != "test@test.com" {
|
|
t.Errorf("expected email 'test@test.com', got %q", session.User.Email)
|
|
}
|
|
if session.IPAddress != "127.0.0.1" {
|
|
t.Errorf("expected IP '127.0.0.1', got %q", session.IPAddress)
|
|
}
|
|
if session.UAHash == "" {
|
|
t.Error("expected non-empty UA hash")
|
|
}
|
|
}
|
|
|
|
func TestSessionInvalidToken(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
session, err := s.ValidateSession("padsess_invalidtoken")
|
|
if err != nil {
|
|
t.Fatalf("ValidateSession error: %v", err)
|
|
}
|
|
if session != nil {
|
|
t.Error("expected nil for invalid token")
|
|
}
|
|
}
|
|
|
|
func TestSessionExpired(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
// Create session with 0 TTL (already expired)
|
|
token, err := s.CreateSession(u.ID, "cli", "127.0.0.1", "", 0)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession error: %v", err)
|
|
}
|
|
|
|
// Should not validate
|
|
session, err := s.ValidateSession(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateSession error: %v", err)
|
|
}
|
|
if session != nil {
|
|
t.Error("expected nil for expired session")
|
|
}
|
|
}
|
|
|
|
func TestSessionDelete(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
token, _ := s.CreateSession(u.ID, "web", "127.0.0.1", "Browser/1.0", 7*24*time.Hour)
|
|
|
|
// Delete session
|
|
err := s.DeleteSession(token)
|
|
if err != nil {
|
|
t.Fatalf("DeleteSession error: %v", err)
|
|
}
|
|
|
|
// Should no longer validate
|
|
session, _ := s.ValidateSession(token)
|
|
if session != nil {
|
|
t.Error("session should not validate after deletion")
|
|
}
|
|
}
|
|
|
|
func TestDeleteUserSessions(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
token1, _ := s.CreateSession(u.ID, "web", "127.0.0.1", "Browser/1.0", 7*24*time.Hour)
|
|
token2, _ := s.CreateSession(u.ID, "cli", "127.0.0.1", "CLI/1.0", 30*24*time.Hour)
|
|
|
|
// Delete all user sessions
|
|
err := s.DeleteUserSessions(u.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteUserSessions error: %v", err)
|
|
}
|
|
|
|
// Neither should validate
|
|
s1, _ := s.ValidateSession(token1)
|
|
s2, _ := s.ValidateSession(token2)
|
|
if s1 != nil || s2 != nil {
|
|
t.Error("no sessions should validate after DeleteUserSessions")
|
|
}
|
|
}
|
|
|
|
func TestCleanExpiredSessions(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
// Create one expired and one valid session
|
|
s.CreateSession(u.ID, "expired", "", "", 0)
|
|
validToken, _ := s.CreateSession(u.ID, "valid", "127.0.0.1", "", 7*24*time.Hour)
|
|
|
|
// Clean expired
|
|
err := s.CleanExpiredSessions()
|
|
if err != nil {
|
|
t.Fatalf("CleanExpiredSessions error: %v", err)
|
|
}
|
|
|
|
// Valid session should still work
|
|
session, _ := s.ValidateSession(validToken)
|
|
if session == nil {
|
|
t.Error("valid session should survive cleanup")
|
|
}
|
|
}
|
|
|
|
func TestSessionBindingMetadata(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
// Create session without binding metadata
|
|
token, _ := s.CreateSession(u.ID, "cli", "", "", 1*time.Hour)
|
|
session, _ := s.ValidateSession(token)
|
|
if session == nil {
|
|
t.Fatal("expected valid session")
|
|
}
|
|
if session.IPAddress != "" {
|
|
t.Errorf("expected empty IP, got %q", session.IPAddress)
|
|
}
|
|
if session.UAHash != "" {
|
|
t.Errorf("expected empty UA hash, got %q", session.UAHash)
|
|
}
|
|
|
|
// Create session with binding metadata
|
|
token2, _ := s.CreateSession(u.ID, "web", "192.168.1.1", "Mozilla/5.0", 1*time.Hour)
|
|
session2, _ := s.ValidateSession(token2)
|
|
if session2 == nil {
|
|
t.Fatal("expected valid session")
|
|
}
|
|
if session2.IPAddress != "192.168.1.1" {
|
|
t.Errorf("expected IP '192.168.1.1', got %q", session2.IPAddress)
|
|
}
|
|
if session2.UAHash == "" {
|
|
t.Error("expected non-empty UA hash for session with User-Agent")
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceMemberCRUD(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test Workspace")
|
|
u := createTestUser(t, s, "test@test.com", "Test User", "password123")
|
|
|
|
// Add member
|
|
err := s.AddWorkspaceMember(ws.ID, u.ID, "owner")
|
|
if err != nil {
|
|
t.Fatalf("AddWorkspaceMember error: %v", err)
|
|
}
|
|
|
|
// Check membership
|
|
isMember, _ := s.IsWorkspaceMember(ws.ID, u.ID)
|
|
if !isMember {
|
|
t.Error("expected user to be a member")
|
|
}
|
|
|
|
// Get member
|
|
m, err := s.GetWorkspaceMember(ws.ID, u.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetWorkspaceMember error: %v", err)
|
|
}
|
|
if m == nil {
|
|
t.Fatal("expected member, got nil")
|
|
}
|
|
if m.Role != "owner" {
|
|
t.Errorf("expected role 'owner', got %q", m.Role)
|
|
}
|
|
|
|
// List members (with user info join)
|
|
members, err := s.ListWorkspaceMembers(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListWorkspaceMembers error: %v", err)
|
|
}
|
|
if len(members) != 1 {
|
|
t.Errorf("expected 1 member, got %d", len(members))
|
|
}
|
|
if len(members) > 0 {
|
|
if members[0].UserName != "Test User" {
|
|
t.Errorf("expected user name 'Test User', got %q", members[0].UserName)
|
|
}
|
|
if members[0].UserEmail != "test@test.com" {
|
|
t.Errorf("expected user email 'test@test.com', got %q", members[0].UserEmail)
|
|
}
|
|
}
|
|
|
|
// Update role
|
|
err = s.UpdateWorkspaceMemberRole(ws.ID, u.ID, "editor")
|
|
if err != nil {
|
|
t.Fatalf("UpdateWorkspaceMemberRole error: %v", err)
|
|
}
|
|
m, _ = s.GetWorkspaceMember(ws.ID, u.ID)
|
|
if m.Role != "editor" {
|
|
t.Errorf("expected role 'editor', got %q", m.Role)
|
|
}
|
|
|
|
// Remove member
|
|
err = s.RemoveWorkspaceMember(ws.ID, u.ID)
|
|
if err != nil {
|
|
t.Fatalf("RemoveWorkspaceMember error: %v", err)
|
|
}
|
|
isMember, _ = s.IsWorkspaceMember(ws.ID, u.ID)
|
|
if isMember {
|
|
t.Error("user should no longer be a member after removal")
|
|
}
|
|
}
|
|
|
|
func TestGetUserWorkspaces(t *testing.T) {
|
|
s := testStore(t)
|
|
ws1 := createTestWorkspace(t, s, "Workspace A")
|
|
ws2 := createTestWorkspace(t, s, "Workspace B")
|
|
_ = createTestWorkspace(t, s, "Workspace C") // user not a member
|
|
|
|
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
|
|
|
s.AddWorkspaceMember(ws1.ID, u.ID, "owner")
|
|
s.AddWorkspaceMember(ws2.ID, u.ID, "editor")
|
|
|
|
workspaces, err := s.GetUserWorkspaces(u.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserWorkspaces error: %v", err)
|
|
}
|
|
if len(workspaces) != 2 {
|
|
t.Errorf("expected 2 workspaces, got %d", len(workspaces))
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceMemberNotFound(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
m, err := s.GetWorkspaceMember(ws.ID, "nonexistent-user")
|
|
if err != nil {
|
|
t.Fatalf("GetWorkspaceMember error: %v", err)
|
|
}
|
|
if m != nil {
|
|
t.Error("expected nil for nonexistent member")
|
|
}
|
|
|
|
isMember, _ := s.IsWorkspaceMember(ws.ID, "nonexistent-user")
|
|
if isMember {
|
|
t.Error("nonexistent user should not be a member")
|
|
}
|
|
}
|
|
|
|
// Ensure createTestUser helper is usable from other test files
|
|
// by verifying it works correctly with the workspace member pattern
|
|
func TestCreateTestUserHelper(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "helper@test.com", "Helper", "pass")
|
|
|
|
if u.ID == "" {
|
|
t.Error("user ID should not be empty")
|
|
}
|
|
|
|
// Should be reusable for sessions
|
|
token, err := s.CreateSession(u.ID, "test", "127.0.0.1", "TestAgent", 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession with test user error: %v", err)
|
|
}
|
|
|
|
session, err := s.ValidateSession(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateSession error: %v", err)
|
|
}
|
|
if session.User.Email != "helper@test.com" {
|
|
t.Errorf("expected email 'helper@test.com', got %q", session.User.Email)
|
|
}
|
|
}
|
|
|
|
// setSessionTimes backdates a session's expires_at (and optionally created_at)
|
|
// so renewal-threshold and absolute-cap paths can be exercised deterministically
|
|
// without sleeping. Targets by user_id since test users hold a single session.
|
|
func setSessionTimes(t *testing.T, s *Store, userID string, expiresAt, createdAt time.Time) {
|
|
t.Helper()
|
|
if createdAt.IsZero() {
|
|
_, err := s.db.Exec(s.q(`UPDATE sessions SET expires_at = ? WHERE user_id = ?`),
|
|
expiresAt.UTC().Format(time.RFC3339), userID)
|
|
if err != nil {
|
|
t.Fatalf("backdate session expiry: %v", err)
|
|
}
|
|
return
|
|
}
|
|
_, err := s.db.Exec(s.q(`UPDATE sessions SET expires_at = ?, created_at = ? WHERE user_id = ?`),
|
|
expiresAt.UTC().Format(time.RFC3339), createdAt.UTC().Format(time.RFC3339), userID)
|
|
if err != nil {
|
|
t.Fatalf("backdate session times: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenewSessionExtendsWhenStale(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "renew@test.com", "Renew", "password123")
|
|
|
|
token, err := s.CreateSession(u.ID, "web", "127.0.0.1", "UA", 7*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
// Drop remaining lifetime under the half-window threshold.
|
|
setSessionTimes(t, s, u.ID, time.Now().UTC().Add(1*time.Hour), time.Time{})
|
|
|
|
newExpiry, renewed, err := s.RenewSessionIfStale(token)
|
|
if err != nil {
|
|
t.Fatalf("RenewSessionIfStale: %v", err)
|
|
}
|
|
if !renewed {
|
|
t.Fatal("expected stale session to be renewed")
|
|
}
|
|
// Should have been pushed back out to ~now + full window.
|
|
want := time.Now().UTC().Add(7 * 24 * time.Hour)
|
|
if diff := newExpiry.Sub(want); diff > time.Minute || diff < -time.Minute {
|
|
t.Errorf("new expiry %v not within a minute of %v", newExpiry, want)
|
|
}
|
|
if s2, _ := s.ValidateSession(token); s2 == nil {
|
|
t.Error("session should still validate after renewal")
|
|
}
|
|
}
|
|
|
|
func TestRenewSessionSkipsWhenFresh(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "fresh@test.com", "Fresh", "password123")
|
|
|
|
token, err := s.CreateSession(u.ID, "web", "127.0.0.1", "UA", 7*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
// Fresh session has a full window remaining → no renewal.
|
|
_, renewed, err := s.RenewSessionIfStale(token)
|
|
if err != nil {
|
|
t.Fatalf("RenewSessionIfStale: %v", err)
|
|
}
|
|
if renewed {
|
|
t.Error("fresh session should not be renewed")
|
|
}
|
|
}
|
|
|
|
func TestRenewSessionRespectsMaxLifetime(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "cap@test.com", "Cap", "password123")
|
|
|
|
token, err := s.CreateSession(u.ID, "web", "127.0.0.1", "UA", 7*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
// Session created 89 days ago, nearly expired now: renewal must be capped
|
|
// at created_at + SessionMaxLifetime (~1 day out), not now + 7 days.
|
|
created := time.Now().UTC().Add(-89 * 24 * time.Hour)
|
|
setSessionTimes(t, s, u.ID, time.Now().UTC().Add(1*time.Hour), created)
|
|
|
|
newExpiry, renewed, err := s.RenewSessionIfStale(token)
|
|
if err != nil {
|
|
t.Fatalf("RenewSessionIfStale: %v", err)
|
|
}
|
|
if !renewed {
|
|
t.Fatal("expected renewal up to the cap")
|
|
}
|
|
cap := created.Add(SessionMaxLifetime)
|
|
if diff := newExpiry.Sub(cap); diff > time.Minute || diff < -time.Minute {
|
|
t.Errorf("expected expiry capped near %v, got %v", cap, newExpiry)
|
|
}
|
|
}
|
|
|
|
func TestRenewSessionLegacyZeroTTLNotRenewed(t *testing.T) {
|
|
s := testStore(t)
|
|
u := createTestUser(t, s, "legacy@test.com", "Legacy", "password123")
|
|
|
|
token, err := s.CreateSession(u.ID, "web", "127.0.0.1", "UA", 7*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
// Simulate a pre-migration row: stale expiry but no renewal window.
|
|
setSessionTimes(t, s, u.ID, time.Now().UTC().Add(1*time.Hour), time.Time{})
|
|
if _, err := s.db.Exec(s.q(`UPDATE sessions SET renew_ttl_seconds = 0 WHERE user_id = ?`), u.ID); err != nil {
|
|
t.Fatalf("zero renew ttl: %v", err)
|
|
}
|
|
|
|
_, renewed, err := s.RenewSessionIfStale(token)
|
|
if err != nil {
|
|
t.Fatalf("RenewSessionIfStale: %v", err)
|
|
}
|
|
if renewed {
|
|
t.Error("legacy session (renew_ttl_seconds=0) should not be renewed")
|
|
}
|
|
}
|
|
|
|
// Suppress unused import warning — models is used in createTestUser
|
|
var _ = models.UserCreate{}
|