Files
ResolveSpec/pkg/security/providers_test.go
T
Hein c9fa8c60f2 refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
2026-10-01 13:19:44 +02:00

1168 lines
36 KiB
Go

package security
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/bitechdev/ResolveSpec/pkg/cache"
)
// Test parseRoles helper
func TestParseRoles(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "single role",
input: "admin",
expected: []string{"admin"},
},
{
name: "multiple roles",
input: "admin,user,moderator",
expected: []string{"admin", "user", "moderator"},
},
{
name: "empty string",
input: "",
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseRoles(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("expected %d roles, got %d", len(tt.expected), len(result))
return
}
for i, role := range tt.expected {
if result[i] != role {
t.Errorf("expected role[%d] = %s, got %s", i, role, result[i])
}
}
})
}
}
// Test parseIntHeader helper
func TestParseIntHeader(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
t.Run("valid int header", func(t *testing.T) {
req.Header.Set("X-Test-Int", "42")
result := parseIntHeader(req, "X-Test-Int", 0)
if result != 42 {
t.Errorf("expected 42, got %d", result)
}
})
t.Run("missing header returns default", func(t *testing.T) {
result := parseIntHeader(req, "X-Missing", 99)
if result != 99 {
t.Errorf("expected default 99, got %d", result)
}
})
t.Run("invalid int returns default", func(t *testing.T) {
req.Header.Set("X-Invalid-Int", "not-a-number")
result := parseIntHeader(req, "X-Invalid-Int", 10)
if result != 10 {
t.Errorf("expected default 10, got %d", result)
}
})
}
// Test DatabaseAuthenticator caching
func TestDatabaseAuthenticatorCaching(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
// Create a test cache instance
cacheProvider := cache.NewMemoryProvider(&cache.Options{
DefaultTTL: 1 * time.Minute,
MaxSize: 1000,
})
testCache := cache.NewCache(cacheProvider)
// Create authenticator with short cache TTL for testing
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
CacheTTL: 100 * time.Millisecond,
Cache: testCache,
})
t.Run("cache hit avoids database call", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer cached-token-123")
// First call - should hit database
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":1,"user_name":"testuser","session_id":"cached-token-123"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("cached-token-123", "authenticate").
WillReturnRows(rows)
userCtx1, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("first authenticate failed: %v", err)
}
if userCtx1.UserID != 1 {
t.Errorf("expected UserID 1, got %d", userCtx1.UserID)
}
// Second call - should use cache, no database call expected
userCtx2, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("second authenticate failed: %v", err)
}
if userCtx2.UserID != 1 {
t.Errorf("expected UserID 1, got %d", userCtx2.UserID)
}
// Verify no unexpected database calls
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("cache expiration triggers database call", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer expire-token-456")
// First call - populate cache
rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":2,"user_name":"expireuser","session_id":"expire-token-456"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("expire-token-456", "authenticate").
WillReturnRows(rows1)
_, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("first authenticate failed: %v", err)
}
// Wait for cache to expire
time.Sleep(150 * time.Millisecond)
// Second call - cache expired, should hit database again
rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":2,"user_name":"expireuser","session_id":"expire-token-456"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("expire-token-456", "authenticate").
WillReturnRows(rows2)
_, err = authenticateSync(auth, req)
if err != nil {
t.Fatalf("second authenticate after expiration failed: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("logout clears cache", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer logout-token-789")
// First call - populate cache
rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":3,"user_name":"logoutuser","session_id":"logout-token-789"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("logout-token-789", "authenticate").
WillReturnRows(rows1)
_, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("authenticate failed: %v", err)
}
// Logout - should clear cache
logoutRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(true, nil, nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_logout`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(logoutRows)
err = auth.Logout(context.Background(), LogoutRequest{
Token: "logout-token-789",
UserID: 3,
})
if err != nil {
t.Fatalf("logout failed: %v", err)
}
// Next authenticate should hit database again since cache was cleared
rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":3,"user_name":"logoutuser","session_id":"logout-token-789"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("logout-token-789", "authenticate").
WillReturnRows(rows2)
_, err = authenticateSync(auth, req)
if err != nil {
t.Fatalf("authenticate after logout failed: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("manual cache clear", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer manual-clear-token")
// Populate cache
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":4,"user_name":"clearuser","session_id":"manual-clear-token"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("manual-clear-token", "authenticate").
WillReturnRows(rows)
_, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("authenticate failed: %v", err)
}
// Manually clear cache
auth.ClearCache("manual-clear-token")
// Next call should hit database
rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":4,"user_name":"clearuser","session_id":"manual-clear-token"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("manual-clear-token", "authenticate").
WillReturnRows(rows2)
_, err = authenticateSync(auth, req)
if err != nil {
t.Fatalf("authenticate after cache clear failed: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("clear user cache", func(t *testing.T) {
// Populate cache with multiple tokens for the same user
req1 := httptest.NewRequest("GET", "/test", nil)
req1.Header.Set("Authorization", "Bearer user-token-1")
rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":5,"user_name":"multiuser","session_id":"user-token-1"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("user-token-1", "authenticate").
WillReturnRows(rows1)
_, err := authenticateSync(auth, req1)
if err != nil {
t.Fatalf("first authenticate failed: %v", err)
}
req2 := httptest.NewRequest("GET", "/test", nil)
req2.Header.Set("Authorization", "Bearer user-token-2")
rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":5,"user_name":"multiuser","session_id":"user-token-2"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("user-token-2", "authenticate").
WillReturnRows(rows2)
_, err = authenticateSync(auth, req2)
if err != nil {
t.Fatalf("second authenticate failed: %v", err)
}
// Clear all cache entries for user 5
auth.ClearUserCache(5)
// Both tokens should now require database calls
rows3 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":5,"user_name":"multiuser","session_id":"user-token-1"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("user-token-1", "authenticate").
WillReturnRows(rows3)
_, err = authenticateSync(auth, req1)
if err != nil {
t.Fatalf("authenticate after user cache clear failed: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
}
// Test DatabaseAuthenticator
func TestDatabaseAuthenticator(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
auth := NewDatabaseAuthenticator(db)
t.Run("successful login", func(t *testing.T) {
ctx := context.Background()
req := LoginRequest{
Username: "testuser",
Password: "password123",
}
// Mock the stored procedure call
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(true, nil, `{"token":"abc123","user":{"user_id":1,"user_name":"testuser"},"expires_in":86400}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_login`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
resp, err := auth.Login(ctx, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.Token != "abc123" {
t.Errorf("expected token abc123, got %s", resp.Token)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("failed login", func(t *testing.T) {
ctx := context.Background()
req := LoginRequest{
Username: "testuser",
Password: "wrongpass",
}
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(false, "Invalid credentials", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_login`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
_, err := auth.Login(ctx, req)
if err == nil {
t.Fatal("expected error for failed login")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("successful logout", func(t *testing.T) {
ctx := context.Background()
req := LogoutRequest{
Token: "abc123",
UserID: 1,
}
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(true, nil, nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_logout`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
err := auth.Logout(ctx, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("authenticate with bearer token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer test-token-123")
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":1,"user_name":"testuser","session_id":"test-token-123"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("test-token-123", "authenticate").
WillReturnRows(rows)
userCtx, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if userCtx.UserID != 1 {
t.Errorf("expected UserID 1, got %d", userCtx.UserID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("authenticate with cookie", func(t *testing.T) {
cookieAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
EnableCookieSession: true,
})
req := httptest.NewRequest("GET", "/test", nil)
req.AddCookie(&http.Cookie{
Name: "session_token",
Value: "cookie-token-456",
})
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":2,"user_name":"cookieuser","session_id":"cookie-token-456"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("cookie-token-456", "cookie").
WillReturnRows(rows)
userCtx, err := authenticateSync(cookieAuth, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if userCtx.UserID != 2 {
t.Errorf("expected UserID 2, got %d", userCtx.UserID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("authenticate missing token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
_, err := authenticateSync(auth, req)
if err == nil {
t.Fatal("expected error when token is missing")
}
})
t.Run("authenticate with multiple comma-separated tokens", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Token invalid-token, Token valid-token-123")
// First token fails
rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(false, "Invalid token", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("invalid-token", "authenticate").
WillReturnRows(rows1)
// Second token succeeds
rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":3,"user_name":"multitoken","session_id":"valid-token-123"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("valid-token-123", "authenticate").
WillReturnRows(rows2)
userCtx, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if userCtx.UserID != 3 {
t.Errorf("expected UserID 3, got %d", userCtx.UserID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("authenticate with duplicate tokens", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Token 968CA5AE-4F83-4D55-A3C6-51AE4410E03A, Token 968CA5AE-4F83-4D55-A3C6-51AE4410E03A")
// First token succeeds
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":4,"user_name":"duplicateuser","session_id":"968CA5AE-4F83-4D55-A3C6-51AE4410E03A"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("968CA5AE-4F83-4D55-A3C6-51AE4410E03A", "authenticate").
WillReturnRows(rows)
userCtx, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if userCtx.UserID != 4 {
t.Errorf("expected UserID 4, got %d", userCtx.UserID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("authenticate with all tokens failing", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Token bad-token-1, Token bad-token-2")
// First token fails
rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(false, "Invalid token", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("bad-token-1", "authenticate").
WillReturnRows(rows1)
// Second token also fails
rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(false, "Invalid token", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("bad-token-2", "authenticate").
WillReturnRows(rows2)
_, err := authenticateSync(auth, req)
if err == nil {
t.Fatal("expected error when all tokens fail")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("successful registration", func(t *testing.T) {
ctx := context.Background()
req := RegisterRequest{
Username: "newuser",
Password: "password123",
Email: "newuser@example.com",
UserLevel: 1,
Roles: []string{"user"},
}
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(true, nil, `{"token":"abc123","user":{"user_id":1,"user_name":"newuser","email":"newuser@example.com"},"expires_in":86400}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
resp, err := auth.Register(ctx, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.Token != "abc123" {
t.Errorf("expected token abc123, got %s", resp.Token)
}
if resp.User.UserName != "newuser" {
t.Errorf("expected username newuser, got %s", resp.User.UserName)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("registration with duplicate username", func(t *testing.T) {
ctx := context.Background()
req := RegisterRequest{
Username: "existinguser",
Password: "password123",
Email: "new@example.com",
UserLevel: 1,
Roles: []string{"user"},
}
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(false, "Username already exists", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
_, err := auth.Register(ctx, req)
if err == nil {
t.Fatal("expected error for duplicate username")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("registration with duplicate email", func(t *testing.T) {
ctx := context.Background()
req := RegisterRequest{
Username: "newuser2",
Password: "password123",
Email: "existing@example.com",
UserLevel: 1,
Roles: []string{"user"},
}
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(false, "Email already exists", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
_, err := auth.Register(ctx, req)
if err == nil {
t.Fatal("expected error for duplicate email")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
}
// Test DatabaseAuthenticator RefreshToken
func TestDatabaseAuthenticatorRefreshToken(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
auth := NewDatabaseAuthenticator(db)
ctx := context.Background()
t.Run("successful token refresh", func(t *testing.T) {
refreshToken := "refresh-token-123"
// First call to validate refresh token
sessionRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":1,"user_name":"testuser"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs(refreshToken, "refresh").
WillReturnRows(sessionRows)
// Second call to generate new token
refreshRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":1,"user_name":"testuser","session_id":"new-token-456"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_refresh_token`).
WithArgs(refreshToken, sqlmock.AnyArg()).
WillReturnRows(refreshRows)
resp, err := auth.RefreshToken(ctx, refreshToken)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.Token != "new-token-456" {
t.Errorf("expected token new-token-456, got %s", resp.Token)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("invalid refresh token", func(t *testing.T) {
refreshToken := "invalid-token"
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(false, "Invalid refresh token", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs(refreshToken, "refresh").
WillReturnRows(rows)
_, err := auth.RefreshToken(ctx, refreshToken)
if err == nil {
t.Fatal("expected error for invalid refresh token")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
// A resolvespec_refresh_token implementation that rotates its own
// independent refresh token (not just reusing the session/access token)
// has nowhere else to put the new refresh token and real access-token
// expiry than under UserContext.Claims, since UserContext has no
// dedicated fields for either. RefreshToken must surface those claims
// keys into LoginResponse.RefreshToken/ExpiresIn rather than silently
// dropping them (see the "successful token refresh" case above, which
// covers an implementation that has no independent refresh token at all
// and gets the 24h default instead).
t.Run("surfaces rotated refresh token and expiry from claims", func(t *testing.T) {
refreshToken := "refresh-token-abc"
sessionRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":1,"user_name":"testuser"}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs(refreshToken, "refresh").
WillReturnRows(sessionRows)
refreshRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":1,"user_name":"testuser","session_id":"new-access-789","claims":{"refresh_token":"new-refresh-def","expires_in":900}}`)
mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_refresh_token`).
WithArgs(refreshToken, sqlmock.AnyArg()).
WillReturnRows(refreshRows)
resp, err := auth.RefreshToken(ctx, refreshToken)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.Token != "new-access-789" {
t.Errorf("expected token new-access-789, got %s", resp.Token)
}
if resp.RefreshToken != "new-refresh-def" {
t.Errorf("expected rotated refresh token new-refresh-def, got %q", resp.RefreshToken)
}
if resp.ExpiresIn != 900 {
t.Errorf("expected ExpiresIn 900 from claims, got %d", resp.ExpiresIn)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
}
func TestDatabaseAuthenticatorReconnectsClosedDBPaths(t *testing.T) {
newAuthWithReconnect := func(t *testing.T) (*DatabaseAuthenticator, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) {
t.Helper()
primaryDB, primaryMock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create primary mock db: %v", err)
}
reconnectDB, reconnectMock, err := sqlmock.New()
if err != nil {
primaryDB.Close()
t.Fatalf("failed to create reconnect mock db: %v", err)
}
cacheProvider := cache.NewMemoryProvider(&cache.Options{
DefaultTTL: 1 * time.Minute,
MaxSize: 1000,
})
auth := NewDatabaseAuthenticatorWithOptions(primaryDB, DatabaseAuthenticatorOptions{
Cache: cache.NewCache(cacheProvider),
DBFactory: func() (*sql.DB, error) {
return reconnectDB, nil
},
})
cleanup := func() {
_ = primaryDB.Close()
_ = reconnectDB.Close()
}
return auth, primaryMock, reconnectMock, cleanup
}
t.Run("Authenticate reconnects after closed database", func(t *testing.T) {
auth, primaryMock, reconnectMock, cleanup := newAuthWithReconnect(t)
defer cleanup()
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer reconnect-auth-token")
primaryMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("reconnect-auth-token", "authenticate").
WillReturnError(fmt.Errorf("sql: database is closed"))
reconnectRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":7,"user_name":"reconnect-user","session_id":"reconnect-auth-token"}`)
reconnectMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs("reconnect-auth-token", "authenticate").
WillReturnRows(reconnectRows)
userCtx, err := authenticateSync(auth, req)
if err != nil {
t.Fatalf("expected authenticate to reconnect, got %v", err)
}
if userCtx.UserID != 7 {
t.Fatalf("expected user ID 7, got %d", userCtx.UserID)
}
if err := primaryMock.ExpectationsWereMet(); err != nil {
t.Fatalf("primary db expectations not met: %v", err)
}
if err := reconnectMock.ExpectationsWereMet(); err != nil {
t.Fatalf("reconnect db expectations not met: %v", err)
}
})
t.Run("Register reconnects after closed database", func(t *testing.T) {
auth, primaryMock, reconnectMock, cleanup := newAuthWithReconnect(t)
defer cleanup()
req := RegisterRequest{
Username: "reconnect-register",
Password: "password123",
Email: "reconnect@example.com",
UserLevel: 1,
Roles: []string{"user"},
}
primaryMock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`).
WithArgs(sqlmock.AnyArg()).
WillReturnError(fmt.Errorf("sql: database is closed"))
reconnectRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(true, nil, `{"token":"reconnected-register-token","user":{"user_id":8,"user_name":"reconnect-register"},"expires_in":86400}`)
reconnectMock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(reconnectRows)
resp, err := auth.Register(context.Background(), req)
if err != nil {
t.Fatalf("expected register to reconnect, got %v", err)
}
if resp.Token != "reconnected-register-token" {
t.Fatalf("expected refreshed token, got %s", resp.Token)
}
if err := primaryMock.ExpectationsWereMet(); err != nil {
t.Fatalf("primary db expectations not met: %v", err)
}
if err := reconnectMock.ExpectationsWereMet(); err != nil {
t.Fatalf("reconnect db expectations not met: %v", err)
}
})
t.Run("Logout reconnects after closed database", func(t *testing.T) {
auth, primaryMock, reconnectMock, cleanup := newAuthWithReconnect(t)
defer cleanup()
req := LogoutRequest{Token: "logout-reconnect-token", UserID: 9}
primaryMock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_logout`).
WithArgs(sqlmock.AnyArg()).
WillReturnError(fmt.Errorf("sql: database is closed"))
reconnectRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}).
AddRow(true, nil, nil)
reconnectMock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_logout`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(reconnectRows)
if err := auth.Logout(context.Background(), req); err != nil {
t.Fatalf("expected logout to reconnect, got %v", err)
}
if err := primaryMock.ExpectationsWereMet(); err != nil {
t.Fatalf("primary db expectations not met: %v", err)
}
if err := reconnectMock.ExpectationsWereMet(); err != nil {
t.Fatalf("reconnect db expectations not met: %v", err)
}
})
t.Run("RefreshToken reconnects after closed database", func(t *testing.T) {
auth, primaryMock, reconnectMock, cleanup := newAuthWithReconnect(t)
defer cleanup()
refreshToken := "refresh-reconnect-token"
primaryMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs(refreshToken, "refresh").
WillReturnError(fmt.Errorf("sql: database is closed"))
sessionRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":10,"user_name":"refresh-user"}`)
reconnectMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`).
WithArgs(refreshToken, "refresh").
WillReturnRows(sessionRows)
refreshRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":10,"user_name":"refresh-user","session_id":"refreshed-token"}`)
reconnectMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_refresh_token`).
WithArgs(refreshToken, sqlmock.AnyArg()).
WillReturnRows(refreshRows)
resp, err := auth.RefreshToken(context.Background(), refreshToken)
if err != nil {
t.Fatalf("expected refresh token to reconnect, got %v", err)
}
if resp.Token != "refreshed-token" {
t.Fatalf("expected refreshed-token, got %s", resp.Token)
}
if err := primaryMock.ExpectationsWereMet(); err != nil {
t.Fatalf("primary db expectations not met: %v", err)
}
if err := reconnectMock.ExpectationsWereMet(); err != nil {
t.Fatalf("reconnect db expectations not met: %v", err)
}
})
t.Run("updateSessionActivity reconnects after closed database", func(t *testing.T) {
auth, primaryMock, reconnectMock, cleanup := newAuthWithReconnect(t)
defer cleanup()
userCtx := &UserContext{UserID: 11, UserName: "activity-user", SessionID: "activity-token"}
primaryMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session_update`).
WithArgs("activity-token", sqlmock.AnyArg()).
WillReturnError(fmt.Errorf("sql: database is closed"))
reconnectRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, `{"user_id":11,"user_name":"activity-user","session_id":"activity-token"}`)
reconnectMock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session_update`).
WithArgs("activity-token", sqlmock.AnyArg()).
WillReturnRows(reconnectRows)
auth.updateSessionActivity(context.Background(), "activity-token", userCtx)
if err := primaryMock.ExpectationsWereMet(); err != nil {
t.Fatalf("primary db expectations not met: %v", err)
}
if err := reconnectMock.ExpectationsWereMet(); err != nil {
t.Fatalf("reconnect db expectations not met: %v", err)
}
})
}
// Test JWTAuthenticator
func TestJWTAuthenticator(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
auth := NewJWTAuthenticator("secret-key", db)
t.Run("successful login", func(t *testing.T) {
ctx := context.Background()
req := LoginRequest{
Username: "testuser",
Password: "password123",
}
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}).
AddRow(true, nil, []byte(`{"id":1,"username":"testuser","email":"test@example.com","user_level":5,"roles":"admin,user"}`))
mock.ExpectQuery(`SELECT p_success, p_error, p_user FROM resolvespec_jwt_login`).
WithArgs("testuser", "password123").
WillReturnRows(rows)
resp, err := auth.Login(ctx, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.User.UserID != 1 {
t.Errorf("expected UserID 1, got %d", resp.User.UserID)
}
if resp.User.UserName != "testuser" {
t.Errorf("expected UserName testuser, got %s", resp.User.UserName)
}
if len(resp.User.Roles) != 2 {
t.Errorf("expected 2 roles, got %d", len(resp.User.Roles))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("authenticate returns not implemented", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer test-token")
_, err := auth.Authenticate(req)
if err == nil {
t.Fatal("expected error for unimplemented JWT parsing")
}
})
t.Run("authenticate missing bearer token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
_, err := auth.Authenticate(req)
if err == nil {
t.Fatal("expected error when authorization header is missing")
}
})
t.Run("successful logout", func(t *testing.T) {
ctx := context.Background()
req := LogoutRequest{
Token: "token123",
UserID: 1,
}
rows := sqlmock.NewRows([]string{"p_success", "p_error"}).
AddRow(true, nil)
mock.ExpectQuery(`SELECT p_success, p_error FROM resolvespec_jwt_logout`).
WithArgs("token123", 1).
WillReturnRows(rows)
err := auth.Logout(ctx, req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
}
// Test DatabaseColumnSecurityProvider
func TestDatabaseColumnSecurityProvider(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
provider := NewDatabaseColumnSecurityProvider(db)
ctx := context.Background()
t.Run("load column security successfully", func(t *testing.T) {
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_rules"}).
AddRow(true, nil, []byte(`[{"control":"public.users.email","accesstype":"mask","jsonvalue":""}]`))
mock.ExpectQuery(`SELECT p_success, p_error, p_rules FROM resolvespec_column_security`).
WithArgs(1, "public", "users").
WillReturnRows(rows)
rules, err := provider.GetColumnSecurity(ctx, 1, "public", "users")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rules) != 1 {
t.Errorf("expected 1 rule, got %d", len(rules))
}
if rules[0].Accesstype != "mask" {
t.Errorf("expected accesstype mask, got %s", rules[0].Accesstype)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("failed to load column security", func(t *testing.T) {
rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_rules"}).
AddRow(false, "No security rules found", nil)
mock.ExpectQuery(`SELECT p_success, p_error, p_rules FROM resolvespec_column_security`).
WithArgs(1, "public", "orders").
WillReturnRows(rows)
_, err := provider.GetColumnSecurity(ctx, 1, "public", "orders")
if err == nil {
t.Fatal("expected error when loading fails")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
}
// Test DatabaseRowSecurityProvider
func TestDatabaseRowSecurityProvider(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
provider := NewDatabaseRowSecurityProvider(db)
ctx := context.Background()
t.Run("load row security successfully", func(t *testing.T) {
rows := sqlmock.NewRows([]string{"p_template", "p_block"}).
AddRow("user_id = {UserID}", false)
mock.ExpectQuery(`SELECT p_template, p_block FROM resolvespec_row_security`).
WithArgs("public", "orders", 1).
WillReturnRows(rows)
rowSec, err := provider.GetRowSecurity(ctx, 1, "public", "orders")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if rowSec.Template != "user_id = {UserID}" {
t.Errorf("expected template 'user_id = {UserID}', got %s", rowSec.Template)
}
if rowSec.HasBlock {
t.Error("expected HasBlock to be false")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
t.Run("query error", func(t *testing.T) {
mock.ExpectQuery(`SELECT p_template, p_block FROM resolvespec_row_security`).
WithArgs("public", "blocked_table", 1).
WillReturnError(sql.ErrNoRows)
_, err := provider.GetRowSecurity(ctx, 1, "public", "blocked_table")
if err == nil {
t.Fatal("expected error when query fails")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
})
}
// authenticateSync authenticates and waits for the asynchronous session
// activity update so sqlmock expectations are never touched concurrently.
func authenticateSync(auth *DatabaseAuthenticator, req *http.Request) (*UserContext, error) {
userCtx, err := auth.Authenticate(req)
auth.activityWG.Wait()
return userCtx, err
}