Files
Crussell/backend/handlers/auth/auth_test.go
T
popertotsandSisyphus e86248b27c refactor: move auth, GDPR, and booking cleanup to centralized scheduler
Convert CleanupRevokedJTIs to return (int, error) and remove StartJTICleanup goroutine. Add CleanupStaleLoginEntries and CleanupGDPRExportCache for centralized scheduler. Add clock.London timezone location.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-07 00:10:00 +01:00

1971 lines
59 KiB
Go

//go:build test
// +build test
package auth
// Package auth contains tests for authentication and verification endpoints.
//
// Test Coverage:
// - RegisterHandler: POST /api/register - User registration
// * Validates: required fields, email format, UK phone, age >= 16, duplicate email
// - LoginHandler: POST /api/login - User login
// * Validates credentials, returns JWT token
// - RefreshTokenHandler: POST /api/refresh-token - Refresh JWT token
// - GenerateVerificationCodeHandler: POST /api/verify/generate - Send verification code
// * Returns success even for non-existent emails (security)
// - VerifyCodeHandler: POST /api/verify/check - Verify code and update user role
// * Validates: correct code, not expired, not already used
// * Updates user role from unverified_email to verified_email on success
//
// Validation: Comprehensive tests for invalid inputs (bad email, bad phone, underage, etc.)
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/auth"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func resetTestData(t *testing.T) (context.Context, db.Querier) {
t.Helper()
ctx, tx := testutils.SetupTestTx(t)
return ctx, tx
}
// =============================================================================
// Register Handler Tests
// =============================================================================
// TestRegister_Success verifies that a new user can successfully register with
// valid credentials. It tests the happy path: valid name, email, password,
// UK phone number, date of birth, and policy agreement. The test confirms
// the user is created in the database with status 201.
func TestRegister_Success(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
body := RegisterRequest{
FirstName: "John",
LastName: "Doe",
Email: "john.doe@test.com",
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify user was created in DB
var userID string
err := tx.QueryRow(ctx,
"SELECT id FROM users WHERE email = $1", "john.doe@test.com").Scan(&userID)
if err != nil {
t.Errorf("failed to find user in DB: %v", err)
}
// Clean up
tx.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
}
// TestRegister_InvalidInput_MissingFields tests that registration fails with
// HTTP 400 when required fields are missing. It covers missing firstName,
// lastName, email, phone, dateOfBirth, and when policy agreement is not given.
func TestRegister_InvalidInput_MissingFields(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
tests := []struct {
name string
body RegisterRequest
}{
{
name: "missing firstName",
body: RegisterRequest{LastName: "Doe", Email: "test@test.com", Password: "pass", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true},
},
{
name: "missing lastName",
body: RegisterRequest{FirstName: "John", Email: "test@test.com", Password: "pass", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true},
},
{
name: "missing email",
body: RegisterRequest{FirstName: "John", LastName: "Doe", Password: "pass", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true},
},
{
name: "missing phone",
body: RegisterRequest{FirstName: "John", LastName: "Doe", Email: "test@test.com", Password: "pass", DateOfBirth: "1990-01-15", AgreedToPolicy: true},
},
{
name: "missing dateOfBirth",
body: RegisterRequest{FirstName: "John", LastName: "Doe", Email: "test@test.com", Password: "pass", Phone: "07123456789", AgreedToPolicy: true},
},
{
name: "did not agree to policy",
body: RegisterRequest{FirstName: "John", LastName: "Doe", Email: "test@test.com", Password: "pass", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: false},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", tt.body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}
}
// TestRegister_InvalidInput_InvalidEmail verifies that registration fails
// with HTTP 400 when an invalid email format is provided (e.g., "not-an-email").
func TestRegister_InvalidInput_InvalidEmail(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
body := RegisterRequest{
FirstName: "John",
LastName: "Doe",
Email: "not-an-email",
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestRegister_InvalidInput_InvalidPhone tests that registration fails
// with HTTP 400 when an invalid UK phone number is provided (e.g., too short).
func TestRegister_InvalidInput_InvalidPhone(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
body := RegisterRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@test.com",
Password: "password123",
Phone: "12345", // Not a valid UK phone
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestRegister_ValidUKPhoneNumbers verifies that registration accepts all
// valid UK mobile phone formats including 07x numbers and E.164 format (+447...).
func TestRegister_ValidUKPhoneNumbers(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// Valid UK mobile numbers (07x numbers)
validPhones := []struct {
name string
phone string
}{
{"07123456789", "07123456789"}, // Standard mobile
{"07234567890", "07234567890"}, // 072
{"07345678901", "07345678901"}, // 073
{"07456789012", "07456789012"}, // 074
{"07567890123", "07567890123"}, // 075
{"07712345678", "07712345678"}, // 077
{"07812345678", "07812345678"}, // 078
{"07912345678", "07912345678"}, // 079
{"+447123456789", "+447123456789"}, // E.164 format
}
for _, tc := range validPhones {
t.Run(tc.name, func(t *testing.T) {
body := RegisterRequest{
FirstName: "John",
LastName: "Doe",
Email: fmt.Sprintf("john.%s@test.com", tc.phone),
Password: "password123",
Phone: tc.phone,
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for %s, got %d. body: %s", tc.phone, w.Code, w.Body.String())
}
})
}
}
// TestRegister_InvalidPhoneNumbers verifies that registration rejects
// invalid phone numbers including too short, invalid formats, US numbers, and
// numbers with special characters.
func TestRegister_InvalidPhoneNumbers(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// Invalid phone numbers - should all be rejected
invalidPhones := []struct {
name string
phone string
}{
{"too_short", "12345"},
{"invalid_07700900000", "07700900000"}, // Invalid number per libphonenumber
{"us_number", "+12025551234"}, // US number - not UK
{"letters", "ABCDEFGHIJK"},
{"empty", ""},
{"special_chars", "+44!@#$%^&*()"},
}
for _, tc := range invalidPhones {
t.Run(tc.name, func(t *testing.T) {
body := RegisterRequest{
FirstName: "John",
LastName: "Doe",
Email: fmt.Sprintf("john.%s@test.com", tc.phone),
Password: "password123",
Phone: tc.phone,
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid phone %s, got %d. body: %s", tc.phone, w.Code, w.Body.String())
}
})
}
}
// TestRegister_InvalidInput_Under16 tests that users under 16 years old cannot
// register. The system enforces a minimum age of 16 for account creation.
func TestRegister_InvalidInput_Under16(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// Calculate a date that makes them under 16
under16DOB := clock.Now().AddDate(-15, 0, 0).Format("2006-01-02")
body := RegisterRequest{
FirstName: "Young",
LastName: "User",
Email: "young@test.com",
Password: "password123",
Phone: "07123456789",
DateOfBirth: under16DOB,
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestRegister_DuplicateEmail verifies that attempting to register with
// an email that already exists returns HTTP 409 Conflict.
func TestRegister_DuplicateEmail(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// First create a user with specific email
_, err := tx.Exec(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'user@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
`)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Now try to register with same email
body := RegisterRequest{
FirstName: "John",
LastName: "Doe",
Email: "user@test.com", // Same as fixture
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Login Handler Tests
// =============================================================================
// TestLogin_Success tests that an existing user can successfully log in
// with correct email and password, receiving a JWT token in the response.
func TestLogin_Success(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Create a test user with known email
userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{
Email: "user@test.com",
Password: "testpassword123",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
}
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Token == "" {
t.Error("expected token in response, got empty string")
}
}
// TestLogin_InvalidCredentials_WrongPassword verifies that login fails with
// HTTP 401 when the correct email exists but the password is incorrect.
func TestLogin_InvalidCredentials_WrongPassword(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{
Email: "user@test.com",
Password: "wrongpassword",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestLogin_InvalidCredentials_NonExistentEmail verifies that login fails
// with HTTP 401 when the email does not exist in the database.
func TestLogin_InvalidCredentials_NonExistentEmail(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
body := LoginRequest{
Email: "nonexistent@test.com",
Password: "password123",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Refresh Token Handler Tests
// =============================================================================
// TestRefreshToken_Success tests that a valid JWT token can be refreshed
// to obtain a new token with extended expiry.
func TestRefreshToken_Success(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(RefreshTokenHandler)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Generate a valid token
token := jwt.GenerateTestToken(userID, "verified_email")
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
// Use the middleware keys to set up context (matching what mw.RequireAuth does)
reqCtx := ctx
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
}
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Token == "" {
t.Error("expected new token in response, got empty string")
}
}
// TestRefreshToken_Unauthorized_NoToken verifies that attempting to refresh
// a token without providing one results in HTTP 401 Unauthorized.
func TestRefreshToken_Unauthorized_NoToken(t *testing.T) {
t.Parallel()
_, _ = resetTestData(t)
handler := http.HandlerFunc(RefreshTokenHandler)
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Without proper auth middleware, userID/role won't be in context
// The handler tries to query DB with empty userID, which should fail
if w.Code != http.StatusUnauthorized && w.Code != http.StatusInternalServerError {
t.Errorf("expected status 401 or 500, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Verify Generate Handler Tests
// =============================================================================
// TestVerifyGenerate_ValidEmail tests that a verification code can be
// generated for an existing user email. The code is stored in the database
// for subsequent verification.
func TestVerifyGenerate_ValidEmail(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
// Create a test user with known email
userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
body := VerificationCodeRequest{
Email: "user@test.com",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp VerificationResponse
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if !resp.Success {
t.Error("expected success=true in response")
}
// Verify a code was created in DB
var codeID string
err = tx.QueryRow(ctx,
"SELECT id FROM verification_codes WHERE user_id = $1", userID).Scan(&codeID)
if err != nil {
t.Errorf("failed to find verification code in DB: %v", err)
}
// Clean up
tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
}
// TestVerifyGenerate_NonExistentEmail verifies that the verification code
// generation endpoint returns HTTP 200 even for non-existent emails. This is
// a security measure to prevent email enumeration attacks.
func TestVerifyGenerate_NonExistentEmail(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
// Security: should return success even if email doesn't exist
body := VerificationCodeRequest{
Email: "nonexistent@test.com",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp VerificationResponse
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
// Should return success for security (don't reveal if email exists)
if !resp.Success {
t.Error("expected success=true in response for non-existent email")
}
}
// =============================================================================
// Verify Check Handler Tests
// =============================================================================
// TestVerifyCheck_ValidCode tests that a valid, non-expired, unused
// verification code successfully verifies a user's email and updates their
// account role from unverified_email to verified_email.
func TestVerifyCheck_ValidCode(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Create a verification code
var code string
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
body := VerifyCodeRequest{
Code: code,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp VerificationResponse
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if !resp.Success {
t.Error("expected success=true in response")
}
// Verify code is marked as used
var usedAt *time.Time
err = tx.QueryRow(ctx,
"SELECT used_at FROM verification_codes WHERE code = $1", code).Scan(&usedAt)
if err != nil || usedAt == nil {
t.Error("expected verification code to be marked as used")
}
}
// TestVerifyCheck_InvalidCode verifies that attempting to verify with
// a non-existent code returns HTTP 400 Bad Request.
func TestVerifyCheck_InvalidCode(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
body := VerifyCodeRequest{
Code: "nonexistent-code-12345",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestVerifyCheck_ExpiredCode tests that verification fails with HTTP 400
// when the code has expired (past its expires_at timestamp).
func TestVerifyCheck_ExpiredCode(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Create an expired verification code
var code string
expiresAt := clock.Now().Add(-1 * time.Hour) // Expired 1 hour ago
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
body := VerifyCodeRequest{
Code: code,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Additional Edge Case Tests
// =============================================================================
// TestLogin_InvalidRequest verifies that sending malformed JSON to the login
// endpoint returns HTTP 400 Bad Request.
func TestLogin_InvalidRequest(t *testing.T) {
_, _ = resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Send invalid JSON
req := httptest.NewRequest("POST", "/api/login", bytes.NewReader([]byte("not json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
// TestRegister_NameTooLong tests that registration fails when the first name
// exceeds 50 characters (the maximum allowed length).
func TestRegister_NameTooLong(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// First name > 50 chars
longName := string(bytes.Repeat([]byte("a"), 51))
body := RegisterRequest{
FirstName: longName,
LastName: "Doe",
Email: "john@test.com",
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestRegister_InvalidNameCharacters verifies that registration fails when
// names contain invalid characters (e.g., numbers).
func TestRegister_InvalidNameCharacters(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// Name with numbers (invalid)
body := RegisterRequest{
FirstName: "John123",
LastName: "Doe",
Email: "john@test.com",
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestVerifyCheck_AlreadyUsed tests that attempting to verify with a code
// that has already been used returns HTTP 403 Forbidden.
func TestVerifyCheck_AlreadyUsed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Create a verification code
var code string
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
// First verification should succeed
body := VerifyCodeRequest{
Code: code,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("first verification: expected status 200, got %d", w.Code)
}
// Second verification with same code should return 403 (already used)
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("second verification: expected status 403, got %d", w.Code)
}
}
// TestVerifyCheck_RoleChangeToVerified confirms that after a successful
// verification, the user's account_role changes from unverified_email to
// verified_email, granting them full account access.
func TestVerifyCheck_RoleChangeToVerified(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
// Create an unverified user
userID, err := fixtures.CreateTestUnverifiedUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Verify initial role is unverified_email
var initialRole string
err = tx.QueryRow(ctx,
"SELECT account_role FROM users WHERE id = $1", userID).Scan(&initialRole)
if err != nil {
t.Fatalf("failed to check initial role: %v", err)
}
if initialRole != "unverified_email" {
t.Errorf("expected initial role 'unverified_email', got %s", initialRole)
}
// Create a verification code
var code string
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
// Verify the code
body := VerifyCodeRequest{
Code: code,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Check that user's role changed to verified_email
var newRole string
err = tx.QueryRow(ctx,
"SELECT account_role FROM users WHERE id = $1", userID).Scan(&newRole)
if err != nil {
t.Errorf("failed to check new role: %v", err)
}
if newRole != "verified_email" {
t.Errorf("expected role to change to 'verified_email', got %s", newRole)
}
}
// =============================================================================
// Password Length Tests (Registration)
// =============================================================================
// TestRegister_PasswordLength_Minimum verifies that registration enforces
// a minimum password length of 6 characters (new requirement from security pass).
// bcrypt handles passwords up to 72 chars internally (truncates longer ones).
func TestRegister_PasswordLength_Minimum(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
tests := []struct {
name string
password string
expectError bool
}{
{
name: "1_char_password_too_short",
password: "x",
expectError: true, // Below 6 char minimum
},
{
name: "5_char_password_too_short",
password: "short",
expectError: true, // Below 6 char minimum
},
{
name: "6_char_password_minimum",
password: "pass12",
expectError: false, // Meets minimum
},
{
name: "72_char_password_exact_bcrypt_limit",
password: strings.Repeat("a", 72),
expectError: false,
},
{
name: "73_char_password_exceeds_limit",
password: strings.Repeat("a", 73),
expectError: true, // Exceeds bcrypt limit
},
{
name: "100_char_password_exceeds_limit",
password: strings.Repeat("a", 100),
expectError: true, // Exceeds bcrypt limit
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := RegisterRequest{
FirstName: "Test",
LastName: "User",
Email: fmt.Sprintf("test-%s@test.com", tt.name),
Password: tt.password,
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if tt.expectError {
if w.Code == http.StatusCreated {
t.Errorf("expected non-201 status for password '%s', got 201", tt.password)
}
} else {
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for password len=%d, got %d. body: %s", len(tt.password), w.Code, w.Body.String())
}
}
})
}
}
// TestRegister_EmptyPassword verifies that an empty password is rejected
// because it's a required field (not because of minimum length).
func TestRegister_EmptyPassword(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
body := RegisterRequest{
FirstName: "Test",
LastName: "User",
Email: "empty@test.com",
Password: "", // Empty - should fail as required field
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
// Empty password fails because it's a required field
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for empty password, got %d", w.Code)
}
}
// TestRegister_WithValidReferralCode verifies that registration succeeds when
// a valid existing referral code is provided, and the referral relationship
// is recorded in the user_referrals table.
func TestRegister_WithValidReferralCode(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// Create a referrer user with a known referral code
referrerID, err := fixtures.CreateTestUserWithEmail(tx, "referrer@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create referrer user: %v", err)
}
defer fixtures.DeleteUser(tx, referrerID)
// Set a known referral code for the referrer
knownCode := "abc123def456"
_, err = tx.Exec(ctx,
"UPDATE users SET referral_code = $1 WHERE id = $2", knownCode, referrerID)
if err != nil {
t.Fatalf("failed to set referral code: %v", err)
}
body := RegisterRequest{
FirstName: "Referred",
LastName: "User",
Email: "referred@test.com",
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
ReferralCode: knownCode,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify referral relationship was created
var referredID string
err = tx.QueryRow(ctx,
"SELECT id FROM users WHERE email = $1", "referred@test.com").Scan(&referredID)
if err != nil {
t.Fatalf("failed to find referred user: %v", err)
}
defer fixtures.DeleteUser(tx, referredID)
var count int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND referred_id = $2",
referrerID, referredID).Scan(&count)
if err != nil {
t.Fatalf("failed to query user_referrals: %v", err)
}
if count != 1 {
t.Errorf("expected 1 referral record, got %d", count)
}
}
// TestRegister_WithInvalidReferralCode verifies that registration fails with
// 400 when a non-existent referral code is provided.
func TestRegister_WithInvalidReferralCode(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
body := RegisterRequest{
FirstName: "Test",
LastName: "User",
Email: "invalid-referral@test.com",
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
ReferralCode: "nonexistent1234", // 12 chars but doesn't exist
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestRegister_WithInvalidReferralCodeFormat verifies that registration fails
// when the referral code is not exactly 12 characters.
func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
tests := []struct {
name string
code string
desc string
}{
{"too_short", "abc123", "less than 12 chars"},
{"too_long", "abc123def456ghi", "more than 12 chars"},
{"special_chars", "abc123def4!!", "contains special chars"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := RegisterRequest{
FirstName: "Test",
LastName: "User",
Email: fmt.Sprintf("format-test-%s@test.com", tt.name),
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
ReferralCode: tt.code,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for %s (%s), got %d. body: %s", tt.name, tt.desc, w.Code, w.Body.String())
}
})
}
}
// TestRegister_ReferralCodeCaseInsensitive verifies that referral codes with
// uppercase letters are accepted and correctly matched against lowercase stored codes.
func TestRegister_ReferralCodeCaseInsensitive(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(RegisterHandler)
// Create a referrer user with a known referral code (lowercase hex)
referrerID, err := fixtures.CreateTestUserWithEmail(tx, "referrer-case@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create referrer user: %v", err)
}
defer fixtures.DeleteUser(tx, referrerID)
knownCode := "abc123def456"
_, err = tx.Exec(ctx,
"UPDATE users SET referral_code = $1 WHERE id = $2", knownCode, referrerID)
if err != nil {
t.Fatalf("failed to set referral code: %v", err)
}
tests := []struct {
name string
inputCode string
wantStatus int
}{
{"lowercase", "abc123def456", http.StatusCreated},
{"uppercase", "ABC123DEF456", http.StatusCreated},
{"mixed_case", "AbC123DeF456", http.StatusCreated},
{"all_caps", "ABC123DEF456", http.StatusCreated},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := RegisterRequest{
FirstName: "Case",
LastName: "Test",
Email: fmt.Sprintf("case-test-%s@test.com", tt.name),
Password: "password123",
Phone: "07123456789",
DateOfBirth: "1990-01-15",
AgreedToPolicy: true,
ReferralCode: tt.inputCode,
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
if w.Code != tt.wantStatus {
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.wantStatus, w.Code, w.Body.String())
}
if tt.wantStatus == http.StatusCreated {
// Verify referral relationship was created
var referredID string
err = tx.QueryRow(ctx,
"SELECT id FROM users WHERE email = $1", fmt.Sprintf("case-test-%s@test.com", tt.name)).Scan(&referredID)
if err != nil {
t.Fatalf("%s: failed to find referred user: %v", tt.name, err)
}
defer fixtures.DeleteUser(tx, referredID)
var count int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND referred_id = $2",
referrerID, referredID).Scan(&count)
if err != nil {
t.Fatalf("%s: failed to query user_referrals: %v", tt.name, err)
}
if count != 1 {
t.Errorf("%s: expected 1 referral record, got %d", tt.name, count)
}
}
})
}
}
// =============================================================================
// Logout Handler Tests
// =============================================================================
// TestLogoutHandler_Success verifies that a valid logout request returns
// 200 OK with {"success": true} and revokes the JTI.
func TestLogoutHandler_Success(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Generate a token and get its JTI
token, jti, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// Create request with JTI in context (simulating RequireAuth middleware)
req := httptest.NewRequest("POST", "/api/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
reqCtx := context.WithValue(ctx, mw.JTIKey, jti)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
}
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if !resp.Success {
t.Error("expected success=true in response")
}
// Verify JTI was revoked
if !auth.IsJTIRevoked(ctx, jti) {
t.Error("expected JTI to be revoked after logout")
}
}
// TestLogoutHandler_RevokesJTI verifies that after logout, the token's JTI is
// revoked and the token can no longer be used with authenticated endpoints.
func TestLogoutHandler_RevokesJTI(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Generate a token and get its JTI
token, jti, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// Call logout with JTI in context
req := httptest.NewRequest("POST", "/api/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
reqCtx := context.WithValue(ctx, mw.JTIKey, jti)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String())
}
// Try to use the same token through RequireAuth middleware
router := chi.NewRouter()
router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req2 := httptest.NewRequest("GET", "/api/protected", nil)
req2 = req2.WithContext(ctx)
req2.Header.Set("Authorization", "Bearer "+token)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for revoked token, got %d", w2.Code)
}
}
// TestLogoutHandler_NoToken verifies that calling logout without an
// Authorization header returns 401.
func TestLogoutHandler_NoToken(t *testing.T) {
t.Parallel()
_, _ = resetTestData(t)
req := httptest.NewRequest("POST", "/api/logout", nil)
w := httptest.NewRecorder()
// Without JTI in context, handler returns 401
LogoutHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestLogoutHandler_InvalidToken verifies that calling logout with an empty
// JTI returns 401.
func TestLogoutHandler_InvalidToken(t *testing.T) {
t.Parallel()
_, _ = resetTestData(t)
req := httptest.NewRequest("POST", "/api/logout", nil)
reqCtx := context.WithValue(req.Context(), mw.JTIKey, "")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Refresh Token JTI Tests
// =============================================================================
// TestRefreshToken_RevokesOldJTI verifies that refreshing a token revokes the
// old JTI and issues a new one. The old token becomes invalid after refresh.
func TestRefreshToken_RevokesOldJTI(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a test user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Generate initial token and JTI
oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate old token: %v", err)
}
// Verify old JTI is not yet revoked
if auth.IsJTIRevoked(ctx, oldJTI) {
t.Fatal("old JTI should not be revoked before refresh")
}
// Call refresh handler with old JTI in context
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
req.Header.Set("Authorization", "Bearer "+oldToken)
reqCtx := ctx
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
reqCtx = context.WithValue(reqCtx, mw.JTIKey, oldJTI)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
RefreshTokenHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
}
// Verify old JTI was revoked
if !auth.IsJTIRevoked(ctx, oldJTI) {
t.Error("expected old JTI to be revoked after refresh")
}
// Verify old token is rejected by middleware
router := chi.NewRouter()
router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req2 := httptest.NewRequest("GET", "/api/protected", nil)
req2 = req2.WithContext(ctx)
req2.Header.Set("Authorization", "Bearer "+oldToken)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for revoked token after refresh, got %d", w2.Code)
}
}
// =============================================================================
// Login Response JTI Tests
// =============================================================================
// TestLoginResponse_IncludesJTI verifies that the login response includes both
// "token" and "jti" fields, both non-empty.
func TestLoginResponse_IncludesJTI(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Create a test user with known email
userID, err := fixtures.CreateTestUserWithEmail(tx, "jti-test@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{
Email: "jti-test@test.com",
Password: "testpassword123",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
JTI string `json:"jti"`
}
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Token == "" {
t.Error("expected non-empty token in login response")
}
if resp.JTI == "" {
t.Error("expected non-empty jti in login response")
}
}
// =============================================================================
// LoginInProgress Rate Limiting Tests
// =============================================================================
// TestLoginInProgress_Cap verifies that when the loginInProgress map is full
// (20 concurrent logins), the 21st attempt returns HTTP 429 Too Many Requests.
func TestLoginInProgress_Cap(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Create a test user
userID, err := fixtures.CreateTestUserWithEmail(tx, "ratelimit@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Fill the loginInProgress map with 20 entries
loginStateMu.Lock()
for i := 0; i < maxLoginInProgress; i++ {
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = clock.Now()
}
loginStateMu.Unlock()
defer func() {
// Clean up
loginStateMu.Lock()
for i := 0; i < maxLoginInProgress; i++ {
delete(loginInProgress, fmt.Sprintf("stale-user-%d", i))
}
loginStateMu.Unlock()
}()
// Attempt login - should get 429
body := LoginRequest{
Email: "ratelimit@test.com",
Password: "testpassword123",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected status 429, got %d. body: %s", w.Code, w.Body.String())
}
}
// ValidateUKPhoneNumber Security Tests
//
// These tests verify that ValidateUKPhoneNumber rejects or sanitises
// injection payloads (SQLi, XSS, command injection, control characters).
func TestValidateUKPhoneNumber_RejectsPureInjectionPayloads(t *testing.T) {
t.Parallel()
payloads := []string{
// SQL injection
"' OR '1'='1",
"admin'--",
`" OR 1=1 --`,
"'; DROP TABLE users;--",
// XSS
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"\"><script>alert(1)</script>",
"javascript:alert(1)",
// Command injection
"; rm -rf /",
"| cat /etc/passwd",
"`id`",
"$(cat /etc/passwd)",
// Control characters
"\n",
"\r\n",
"\x00",
}
for _, p := range payloads {
result, err := ValidateUKPhoneNumber(p)
if err == nil {
t.Errorf("expected injection payload %q to be rejected, got result %q", p, result)
}
}
}
func TestValidateUKPhoneNumber_RejectsMixedInjectionPayloads(t *testing.T) {
t.Parallel()
// When injection characters are interleaved with a valid UK phone number,
// libphonenumber rejects the entire input — it does NOT try to extract
// digits from non-numeric characters. This is MORE secure than naive
// digit-stripping approaches.
payloads := []string{
"' OR '1'='1 OR '+447700900000",
"><script>+447700900000</script>",
"'; rm -rf /; +447700900000",
"\x00\n+447700900000",
}
for _, p := range payloads {
result, err := ValidateUKPhoneNumber(p)
if err == nil {
t.Errorf("expected mixed payload %q to be rejected, got result %q", p, result)
}
}
}
// =============================================================================
// Account Lockout Tests (new from security pass)
// =============================================================================
// TestLogin_AccountLockout_After5Failures verifies that after 5 failed login
// attempts, the account is locked and the next login attempt returns HTTP 429.
func TestLogin_AccountLockout_After5Failures(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
defer tx.Exec(ctx, "UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1", userID)
for i := 0; i < 5; i++ {
body := LoginRequest{
Email: "user@test.com",
Password: "wrongpassword",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: expected 401, got %d", i+1, w.Code)
}
}
body := LoginRequest{
Email: "user@test.com",
Password: "wrongpassword",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after 5 failures, got %d. body: %s", w.Code, w.Body.String())
}
var failedAttempts int
var lockedUntil *time.Time
err = tx.QueryRow(ctx,
"SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil)
if err != nil {
t.Fatalf("failed to query lockout state: %v", err)
}
if failedAttempts < 5 {
t.Errorf("expected >=5 failed attempts, got %d", failedAttempts)
}
if lockedUntil == nil {
t.Error("expected locked_until to be set")
}
}
// TestLogin_AccountLockout_ResetsOnSuccess verifies that a successful login
// resets the failed_attempts counter and clears the locked_until.
func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "lockout-reset@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
_, err = tx.Exec(ctx,
"UPDATE users SET failed_attempts = 3 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set failed_attempts: %v", err)
}
body := LoginRequest{
Email: "lockout-reset@test.com",
Password: "testpassword123",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var failedAttempts int
var lockedUntil *time.Time
err = tx.QueryRow(ctx,
"SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil)
if err != nil {
t.Fatalf("failed to query lockout state: %v", err)
}
if failedAttempts != 0 {
t.Errorf("expected 0 failed_attempts after success, got %d", failedAttempts)
}
if lockedUntil != nil {
t.Error("expected locked_until to be cleared after success")
}
}
// =============================================================================
// JWT Auth Unit Tests (new from security pass)
// =============================================================================
// TestJWT_ExpiryIsOneHour verifies that generated JWTs have a 1-hour expiry
// (changed from 30 days during security pass).
func TestJWT_ExpiryIsOneHour(t *testing.T) {
t.Parallel()
userID := "test-user-id"
role := "verified_email"
token, jti, err := auth.GenerateToken(userID, role)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
if token == "" {
t.Fatal("expected non-empty token")
}
if jti == "" {
t.Fatal("expected non-empty jti")
}
retrievedUserID, retrievedRole, retrievedJTI, err := auth.VerifyToken(token, context.Background())
if err != nil {
t.Fatalf("failed to verify token: %v", err)
}
if retrievedUserID != userID {
t.Errorf("expected userID %q, got %q", userID, retrievedUserID)
}
if retrievedRole != role {
t.Errorf("expected role %q, got %q", role, retrievedRole)
}
if retrievedJTI != jti {
t.Errorf("expected jti %q, got %q", jti, retrievedJTI)
}
}
// TestRefreshToken_Generation verifies that a refresh token can be generated
// and stored in the database. This requires DB access.
func TestRefreshToken_Generation(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate refresh token: %v", err)
}
if refreshToken == "" {
t.Fatal("expected non-empty refresh token")
}
var count int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&count)
if err != nil {
t.Fatalf("failed to query refresh_tokens: %v", err)
}
if count != 1 {
t.Errorf("expected 1 refresh_token, got %d", count)
}
retrievedUserID, retrievedRole, err := auth.VerifyRefreshToken(ctx, refreshToken)
if err != nil {
t.Fatalf("failed to verify refresh token: %v", err)
}
if retrievedUserID != userID {
t.Errorf("expected userID %q, got %q", userID, retrievedUserID)
}
if retrievedRole != "verified_email" {
t.Errorf("expected role 'verified_email', got %q", retrievedRole)
}
_, _, err = auth.VerifyRefreshToken(ctx, refreshToken)
if err == nil {
t.Error("expected error on second refresh token verification (rotated)")
}
}
// TestJTI_Revocation_PostgreSQL verifies that JTI revocation uses the
// PostgreSQL revoked_jtis table and persists across operations.
func TestJTI_Revocation_PostgreSQL(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
token, jti, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
if auth.IsJTIRevoked(ctx, jti) {
t.Fatal("JTI should not be revoked before we revoke it")
}
_, _, _, err = auth.VerifyToken(token, ctx)
if err != nil {
t.Fatalf("token should be valid before revocation: %v", err)
}
auth.RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour))
if !auth.IsJTIRevoked(ctx, jti) {
t.Error("JTI should be revoked after RevokeJTI call")
}
_, _, _, err = auth.VerifyToken(token, ctx)
if err == nil {
t.Error("VerifyToken should fail for revoked JTI")
}
}
// =============================================================================
// Login Response Field Tests (new from security pass)
// =============================================================================
// TestLogin_ResponseIncludesRefreshToken verifies the login response
// includes a refreshToken field alongside the JWT.
func TestLogin_ResponseIncludesRefreshToken(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "refresh-check@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{
Email: "refresh-check@test.com",
Password: "testpassword123",
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
JTI string `json:"jti"`
RefreshToken string `json:"refreshToken"`
}
if err := testutils.ParseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Token == "" {
t.Error("expected non-empty token")
}
if resp.JTI == "" {
t.Error("expected non-empty jti")
}
if resp.RefreshToken == "" {
t.Error("expected non-empty refreshToken in login response")
}
}
// TestRefreshToken_RevokesOldJTI_DBBacked verifies refresh still revokes old
// JTI and the revocation is persisted in the revoked_jtis table.
func TestRefreshToken_RevokesOldJTI_DBBacked(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate old token: %v", err)
}
if auth.IsJTIRevoked(ctx, oldJTI) {
t.Fatal("old JTI should not be revoked before refresh")
}
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
req.Header.Set("Authorization", "Bearer "+oldToken)
reqCtx := ctx
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
reqCtx = context.WithValue(reqCtx, mw.JTIKey, oldJTI)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
RefreshTokenHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
}
if !auth.IsJTIRevoked(ctx, oldJTI) {
t.Error("expected old JTI to be revoked after refresh (DB-backed)")
}
var dbCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM revoked_jtis WHERE jti = $1 AND expires_at > NOW()", oldJTI).Scan(&dbCount)
if err != nil {
t.Fatalf("failed to query revoked_jtis: %v", err)
}
if dbCount != 1 {
t.Errorf("expected 1 revoked_jtis row, got %d", dbCount)
}
}
// ============================================================
// CleanupStaleLoginEntries Tests
// ============================================================
func TestCleanupStaleLoginEntries_Empty(t *testing.T) {
t.Parallel()
loginStateMu.Lock()
saved := loginInProgress
loginInProgress = make(map[string]time.Time)
loginStateMu.Unlock()
defer func() {
loginStateMu.Lock()
loginInProgress = saved
loginStateMu.Unlock()
}()
_, err := CleanupStaleLoginEntries(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
func TestCleanupStaleLoginEntries_RemovesStale(t *testing.T) {
t.Parallel()
loginStateMu.Lock()
saved := loginInProgress
loginInProgress = map[string]time.Time{
"stale-user": clock.Now().Add(-60 * time.Second),
}
loginStateMu.Unlock()
defer func() {
loginStateMu.Lock()
loginInProgress = saved
loginStateMu.Unlock()
}()
_, err := CleanupStaleLoginEntries(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
loginStateMu.Lock()
_, exists := loginInProgress["stale-user"]
deleted := loginInProgress["stale-user"]
loginStateMu.Unlock()
if exists {
t.Errorf("expected stale entry (60s old) to be removed, got %v", deleted)
}
}
func TestCleanupStaleLoginEntries_PreservesRecent(t *testing.T) {
t.Parallel()
loginStateMu.Lock()
saved := loginInProgress
loginInProgress = map[string]time.Time{
"recent-user": clock.Now().Add(-5 * time.Second),
}
loginStateMu.Unlock()
defer func() {
loginStateMu.Lock()
loginInProgress = saved
loginStateMu.Unlock()
}()
_, err := CleanupStaleLoginEntries(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
loginStateMu.Lock()
_, exists := loginInProgress["recent-user"]
loginStateMu.Unlock()
if !exists {
t.Error("expected recent entry (5s old) to be preserved")
}
}
func TestCleanupStaleLoginEntries_Mixed(t *testing.T) {
t.Parallel()
loginStateMu.Lock()
saved := loginInProgress
loginInProgress = map[string]time.Time{
"stale-user": clock.Now().Add(-60 * time.Second),
"recent-user": clock.Now().Add(-5 * time.Second),
"borderline": clock.Now().Add(-29 * time.Second), // Just under 30s threshold
}
loginStateMu.Unlock()
defer func() {
loginStateMu.Lock()
loginInProgress = saved
loginStateMu.Unlock()
}()
_, err := CleanupStaleLoginEntries(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
loginStateMu.Lock()
_, staleExists := loginInProgress["stale-user"]
_, recentExists := loginInProgress["recent-user"]
_, borderlineExists := loginInProgress["borderline"]
loginStateMu.Unlock()
if staleExists {
t.Error("expected stale-user (60s old) to be removed")
}
if !recentExists {
t.Error("expected recent-user (5s old) to be preserved")
}
if !borderlineExists {
t.Error("expected borderline entry (29s old) to be preserved")
}
}
// Ensure test compilation - import pgxpool to avoid unused import
var _ = func() *pgxpool.Pool { return nil }