- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak - verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle - sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD - delete-account re-auth (current_password + fresh 2FA code when enforced) - prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check - startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals - env-docs parity updated (flag removed, 38 vars)
2705 lines
87 KiB
Go
2705 lines
87 KiB
Go
//go: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"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/auth"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/twofa"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"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
|
|
}
|
|
|
|
// insertVerificationCode creates a verification_codes row storing only the
|
|
// digest of a fresh random code (mirroring GenerateVerificationCodeHandler —
|
|
// the code column holds twofa.Hash(plaintext), never the plaintext) and
|
|
// returns the plaintext so the test can submit it to VerifyCodeHandler exactly
|
|
// like a delivered code would be used.
|
|
func insertVerificationCode(ctx context.Context, q db.Querier, userID, purpose string, expiresAt time.Time) (string, error) {
|
|
code, err := generateVerificationCode()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, err = q.Exec(ctx,
|
|
`INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`,
|
|
userID, purpose, twofa.Hash(code), expiresAt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return code, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 refresh token can be exchanged
|
|
// for a new access token + a rotated refresh token (B5).
|
|
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)
|
|
|
|
// B5: the endpoint REQUIRES the opaque refresh token in the Authorization
|
|
// header — the access token alone can no longer self-renew.
|
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate refresh token: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer "+refreshToken)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
|
|
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"`
|
|
JTI string `json:"jti"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
}
|
|
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")
|
|
}
|
|
if resp.JTI == "" {
|
|
t.Error("expected new jti in response, got empty string")
|
|
}
|
|
if resp.RefreshToken == "" {
|
|
t.Error("expected rotated refresh token in response, got empty string")
|
|
}
|
|
}
|
|
|
|
// TestRefreshToken_Unauthorized_NoToken verifies that attempting to refresh
|
|
// without providing a refresh token 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)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRefreshToken_Unauthorized_InvalidRefreshToken verifies that a bogus or
|
|
// unknown refresh token is rejected with 401.
|
|
func TestRefreshToken_Unauthorized_InvalidRefreshToken(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(RefreshTokenHandler)
|
|
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer not-a-real-refresh-token")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRefreshToken_AccessTokenRejected verifies the B5 fix: a stolen ACCESS
|
|
// token must NOT self-renew. Presenting it to /api/refresh-token is rejected
|
|
// because only a valid opaque refresh token can mint a new session.
|
|
func TestRefreshToken_AccessTokenRejected(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)
|
|
|
|
accessToken, _, err := auth.GenerateToken(userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate access token: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
|
|
RefreshTokenHandler(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401 when an access token is presented to refresh-token, 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 (only the digest is stored — see
|
|
// insertVerificationCode); the test submits the plaintext code.
|
|
var code string
|
|
expiresAt := clock.Now().Add(24 * time.Hour)
|
|
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
|
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", twofa.Hash(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
|
|
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestLogin_EmptyFields verifies that sending login with empty email/password
|
|
// returns HTTP 400 with a JSON error body containing an error field.
|
|
func TestLogin_EmptyFields(t *testing.T) {
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
body := map[string]string{
|
|
"email": "",
|
|
"password": "",
|
|
}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, context.Background())
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d", w.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("expected JSON error body, got: %s (parse error: %v)", w.Body.String(), err)
|
|
}
|
|
if _, ok := resp["error"]; !ok {
|
|
t.Errorf("expected JSON response with 'error' field, got: %v", resp)
|
|
}
|
|
}
|
|
|
|
// TestLogin_MissingEmail verifies that login without email field returns 400.
|
|
func TestLogin_MissingEmail(t *testing.T) {
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
body := map[string]string{
|
|
"password": "secret123",
|
|
}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, context.Background())
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d", w.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("expected JSON error body, got: %s (parse error: %v)", w.Body.String(), err)
|
|
}
|
|
if _, ok := resp["error"]; !ok {
|
|
t.Errorf("expected JSON response with 'error' field, got: %v", resp)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
|
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)
|
|
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestLogoutHandler_RevokesPresentedFamilyOnly pins the LOW-1 fix: logout must
|
|
// revoke the refresh tokens of the PRESENTED access token's rotation family
|
|
// (family_id claim), NOT every refresh token the user holds on other devices —
|
|
// a stolen access token must not be able to wipe all sessions. A second,
|
|
// unrelated rotation family for the same user survives the logout.
|
|
func TestLogoutHandler_RevokesPresentedFamilyOnly(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)
|
|
}
|
|
|
|
// Two independent rotation families for the same user: A (the one being
|
|
// logged out) and B (a session on another device that must survive).
|
|
_, familyA, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate refresh token A: %v", err)
|
|
}
|
|
_, familyB, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate refresh token B: %v", err)
|
|
}
|
|
if familyA == familyB {
|
|
t.Fatal("expected two distinct rotation families")
|
|
}
|
|
|
|
// The presented access token is bound to family A (as LoginHandler and
|
|
// RefreshTokenHandler mint it).
|
|
token, jti, err := auth.GenerateTokenForFamily(userID, "verified_email", familyA)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate family-bound access token: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/logout", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
reqCtx := context.WithValue(ctx, mw.JTIKey, jti)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
|
|
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())
|
|
}
|
|
|
|
countFamily := func(familyID string) int {
|
|
var n int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count family rows: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
if got := countFamily(familyA); got != 0 {
|
|
t.Errorf("expected family A (presented token) to be revoked after logout, got %d rows", got)
|
|
}
|
|
if got := countFamily(familyB); got != 1 {
|
|
t.Errorf("expected family B (other device session) to survive logout, got %d rows", got)
|
|
}
|
|
}
|
|
|
|
// TestLogoutHandler_UnboundToken_RevokesUserWide pins the LOW-1 fallback: an
|
|
// access token WITHOUT a family_id claim (test/legacy minting via
|
|
// GenerateToken) cannot be scoped, so logout falls back to the historical
|
|
// user-wide refresh-token delete.
|
|
func TestLogoutHandler_UnboundToken_RevokesUserWide(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)
|
|
}
|
|
if _, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email"); err != nil {
|
|
t.Fatalf("failed to generate refresh token: %v", err)
|
|
}
|
|
|
|
token, jti, err := auth.GenerateToken(userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate unbound access token: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/logout", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
reqCtx := context.WithValue(ctx, mw.JTIKey, jti)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
|
|
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())
|
|
}
|
|
|
|
var n int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1`, userID).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count refresh tokens: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected all refresh tokens revoked for an unbound token, got %d rows", n)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Refresh Token JTI Tests
|
|
// =============================================================================
|
|
|
|
// TestRefreshToken_RotatesRefreshToken verifies that a successful refresh
|
|
// CONSUMES the presented refresh token (rotation): replaying the same token
|
|
// afterwards is rejected 401, and the newly issued access token verifies.
|
|
func TestRefreshToken_RotatesRefreshToken(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)
|
|
|
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate refresh token: %v", err)
|
|
}
|
|
|
|
// First refresh succeeds and rotates the token.
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer "+refreshToken)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
RefreshTokenHandler(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The used refresh token was consumed: replaying it fails 401.
|
|
req2 := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req2.Header.Set("Authorization", "Bearer "+refreshToken)
|
|
req2 = req2.WithContext(ctx)
|
|
w2 := httptest.NewRecorder()
|
|
RefreshTokenHandler(w2, req2)
|
|
if w2.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401 for replayed (rotated) refresh token, got %d", w2.Code)
|
|
}
|
|
|
|
// The freshly issued access token is a real, verifiable JWT.
|
|
var resp struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := testutils.ParseResponseBody(w, &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if _, _, _, err := auth.VerifyToken(resp.Token, ctx); err != nil {
|
|
t.Errorf("refreshed access token must verify: %v", err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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())
|
|
}
|
|
}
|
|
|
|
// TestLoginInProgress_SameUserReentry_429 pins finding 5: a second login for
|
|
// the same account while one is mid-flight is rejected 429 (previously 409 — a
|
|
// Conflict response leaks that a login is in progress for this account and is
|
|
// semantically wrong for "try again in a moment").
|
|
func TestLoginInProgress_SameUserReentry_429(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
userID, err := fixtures.CreateTestUserWithEmail(tx, "inprogress@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Simulate a login already in flight for this account.
|
|
loginStateMu.Lock()
|
|
loginInProgress[userID] = clock.Now()
|
|
loginStateMu.Unlock()
|
|
defer func() {
|
|
loginStateMu.Lock()
|
|
delete(loginInProgress, userID)
|
|
loginStateMu.Unlock()
|
|
}()
|
|
|
|
body := LoginRequest{
|
|
Email: "inprogress@test.com",
|
|
Password: "testpassword123",
|
|
}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 for a re-entry into an in-progress login, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestLoginInProgress_StaleEntriesEvictedBeforeCap pins finding 5b: stale
|
|
// loginInProgress entries are evicted BEFORE the map cap is consulted, so a
|
|
// single attacker holding many fake (stale) entries can no longer trip the
|
|
// global "server busy" 429 for legitimate users — only genuinely concurrent
|
|
// in-flight logins occupy the budget.
|
|
func TestLoginInProgress_StaleEntriesEvictedBeforeCap(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
userID, err := fixtures.CreateTestUserWithEmail(tx, "evict-before-cap@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Fill the map to the cap with STALE entries (older than the 30s window).
|
|
loginStateMu.Lock()
|
|
for i := 0; i < maxLoginInProgress; i++ {
|
|
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = clock.Now().Add(-loginInProgressWindow - time.Second)
|
|
}
|
|
loginStateMu.Unlock()
|
|
defer func() {
|
|
loginStateMu.Lock()
|
|
for i := 0; i < maxLoginInProgress; i++ {
|
|
delete(loginInProgress, fmt.Sprintf("stale-user-%d", i))
|
|
}
|
|
loginStateMu.Unlock()
|
|
}()
|
|
|
|
body := LoginRequest{
|
|
Email: "evict-before-cap@test.com",
|
|
Password: "testpassword123",
|
|
}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
|
|
if w.Code == http.StatusTooManyRequests {
|
|
t.Error("stale entries must be evicted before the cap check — a legitimate login must not get the global 429")
|
|
}
|
|
}
|
|
|
|
// TestVerifyCheck_AttemptBudget_LocksOutAfterFive verifies finding 8: POST
|
|
// /verify/check now bounds guesses per submitted code — the 6th failed attempt
|
|
// for a code is rejected 429, mirroring the 2FA attempt pattern — and a
|
|
// successful verify clears the budget.
|
|
func TestVerifyCheck_AttemptBudget_LocksOutAfterFive(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
// Create a user + a real code so the success path is exercised.
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
var realCode string
|
|
expiresAt := clock.Now().Add(24 * time.Hour)
|
|
realCode, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
|
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)
|
|
|
|
// 4 wrong guesses for a code that does not exist → 400 each (the 2FA
|
|
// pattern: the 5th failure is the lockout).
|
|
guess := "000000000000"
|
|
for i := 0; i < 4; i++ {
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: guess}, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("wrong guess %d: expected 400, got %d. body: %s", i+1, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
// The 5th failed attempt exhausts the budget → 429.
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: guess}, ctx)
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 on the 5th failed attempt for the same code, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
// A further attempt is rejected before any DB work.
|
|
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: guess}, ctx)
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 for a spent budget, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// A DIFFERENT code (the real one) is unaffected by the spent miss-path
|
|
// budget and verifies successfully: the miss-path budget is keyed per
|
|
// submitted code value (a guess cannot resolve a user), while the real
|
|
// code resolves to the user and uses the (fresh) per-user budget.
|
|
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: realCode}, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("a valid code must still verify after another code's budget was spent, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestVerifyGenerate_StoresDigestNotPlaintext pins the MEDIUM finding fix:
|
|
// the verification_codes.code column stores ONLY the twofa.Hash digest (a
|
|
// 64-char hex SHA-256), never the 12-hex-char plaintext the old schema stored.
|
|
func TestVerifyGenerate_StoresDigestNotPlaintext(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
|
|
|
userID, err := fixtures.CreateTestUserWithEmail(tx, "hash-at-rest@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate",
|
|
VerificationCodeRequest{Email: "hash-at-rest@test.com"}, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var stored string
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT code FROM verification_codes WHERE user_id = $1 AND purpose = 'email_verify'`, userID).Scan(&stored)
|
|
if err != nil {
|
|
t.Fatalf("failed to read stored code: %v", err)
|
|
}
|
|
if len(stored) != 64 {
|
|
t.Errorf("expected a 64-char hex digest at rest, got %q (len %d)", stored, len(stored))
|
|
}
|
|
if _, err := hex.DecodeString(stored); err != nil {
|
|
t.Errorf("stored code %q is not hex: %v", stored, err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyCheck_PasswordReset_ClearsLoginLockout pins the HIGH finding fix:
|
|
// verifying a password_reset code clears a locked-out account's
|
|
// failed_attempts/locked_until, so the owner can log in again and change their
|
|
// password — the self-service recovery for the repeatable login-DoS.
|
|
func TestVerifyCheck_PasswordReset_ClearsLoginLockout(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
userID, err := fixtures.CreateTestUserWithEmail(tx, "reset@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Simulate the attacker-lockout state (7 failures → 30-minute lock).
|
|
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 7, locked_until = NOW() + INTERVAL '30 minutes' WHERE id = $1`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to lock account: %v", err)
|
|
}
|
|
|
|
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposePasswordReset, clock.Now().Add(24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create password_reset code: %v", err)
|
|
}
|
|
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for a valid password_reset code, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var failed int
|
|
var locked *time.Time
|
|
err = tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failed, &locked)
|
|
if err != nil {
|
|
t.Fatalf("failed to read lockout state: %v", err)
|
|
}
|
|
if failed != 0 {
|
|
t.Errorf("expected failed_attempts reset to 0, got %d", failed)
|
|
}
|
|
if locked != nil {
|
|
t.Errorf("expected locked_until cleared, got %v", locked)
|
|
}
|
|
|
|
// The unlocked account can log in again with the correct password.
|
|
login := http.HandlerFunc(LoginHandler)
|
|
w = testutils.MakeRequestNoAuth(login, "POST", "/api/login",
|
|
LoginRequest{Email: "reset@test.com", Password: "testpassword123"}, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected login to succeed after lockout cleared, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestVerifyCheck_AttemptBudget_KeyedPerUser pins the MEDIUM finding fix: once
|
|
// a submitted code resolves to a user (an existing-but-expired row), the
|
|
// brute-force budget follows the USER, so draining it with one code value
|
|
// exhausts it for every other code value of that user — and a different user's
|
|
// budget stays independent.
|
|
func TestVerifyCheck_AttemptBudget_KeyedPerUser(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
userID, err := fixtures.CreateTestUserWithEmail(tx, "budget-user@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
otherID, err := fixtures.CreateTestUserWithEmail(tx, "budget-other@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create second test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, otherID)
|
|
|
|
// Five expired codes for the same user, each burned on the expired path.
|
|
for i := 0; i < 5; i++ {
|
|
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, clock.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired code: %v", err)
|
|
}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
|
|
if i < 4 {
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("attempt %d: expected 400 for an expired code, got %d. body: %s", i+1, w.Code, w.Body.String())
|
|
}
|
|
} else {
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("attempt 5: expected 429, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// A SIXTH expired code for the SAME user must be rejected 429 up front: the
|
|
// budget followed the user, not each distinct code value.
|
|
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, clock.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create sixth expired code: %v", err)
|
|
}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 for the same user's next code (per-user budget), got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// A DIFFERENT user's fresh valid code still verifies — budgets are per user.
|
|
otherCode, err := insertVerificationCode(ctx, tx, otherID, verificationCodePurposeEmailVerify, clock.Now().Add(24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user's code: %v", err)
|
|
}
|
|
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: otherCode}, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected the other user's valid code to verify, 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 401 —
|
|
// INDISTINGUISHABLE from a wrong password (F5.5): the old 429 lockout response
|
|
// revealed account existence and lockout state, so an attacker could probe
|
|
// whether their lockout DoS of a victim account was in effect. The DB-side
|
|
// lockout state (failed_attempts >= 5, locked_until set) is unchanged.
|
|
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.StatusUnauthorized {
|
|
t.Errorf("expected 401 on a locked account (F5.5 — indistinguishable from a wrong password), 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")
|
|
}
|
|
}
|
|
|
|
// TestLogin_AccountLockout_CappedAt60Min verifies the LOW-6 follow-up: the
|
|
// escalating lockout ceiling never exceeds 60 minutes no matter how many failed
|
|
// attempts pile up (the 15/30/60-minute tiers replaced the old flat 30-minute
|
|
// cap; the earliest pre-cap lockout locked 20+ failures for 2 hours — a
|
|
// repeatedly-extendable DoS window for a guessing attacker).
|
|
func TestLogin_AccountLockout_CappedAt60Min(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
userID, err := fixtures.CreateTestUserWithEmail(tx, "cap-test@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)
|
|
|
|
// Simulate 20 prior failures — the escalation CASE (15/30/60min tiers)
|
|
// caps the lock here instead of growing without bound.
|
|
_, err = tx.Exec(ctx, "UPDATE users SET failed_attempts = 20 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed failed_attempts: %v", err)
|
|
}
|
|
|
|
body := LoginRequest{
|
|
Email: "cap-test@test.com",
|
|
Password: "wrongpassword",
|
|
}
|
|
// First wrong attempt computes and sets the (now capped) lock.
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 on the lock-setting attempt, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
// Second attempt hits the active lock → 401 (F5.5: indistinguishable from
|
|
// a wrong password; the old 429 leaked the lockout state).
|
|
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 on the active lock, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var lockedUntil *time.Time
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT locked_until FROM users WHERE id = $1", userID).Scan(&lockedUntil)
|
|
if err != nil {
|
|
t.Fatalf("failed to query locked_until: %v", err)
|
|
}
|
|
if lockedUntil == nil {
|
|
t.Fatal("expected locked_until to be set")
|
|
}
|
|
if until := lockedUntil.Sub(clock.Now()); until > 60*time.Minute {
|
|
t.Errorf("lockout must never exceed the 60-minute ceiling, got %v", until)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)
|
|
}
|
|
|
|
if err := auth.RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour)); err != nil {
|
|
t.Fatalf("RevokeJTI() failed: %v", err)
|
|
}
|
|
|
|
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 carries
|
|
// the opaque refresh token (B5 contract): the SPA must be able to persist it
|
|
// and present it to POST /api/refresh-token. The refresh token is hashed at
|
|
// rest in refresh_tokens and single-use (rotated on every refresh).
|
|
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 (B5)")
|
|
}
|
|
|
|
// The issued refresh token must be stored (hashed) in refresh_tokens.
|
|
var dbCount int
|
|
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&dbCount); err != nil {
|
|
t.Fatalf("failed to query refresh_tokens: %v", err)
|
|
}
|
|
if dbCount != 1 {
|
|
t.Errorf("expected 1 refresh_token row after login, got %d", dbCount)
|
|
}
|
|
|
|
// And it must actually be usable: exchange it for a new access token.
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer "+resp.RefreshToken)
|
|
req = req.WithContext(ctx)
|
|
rr := httptest.NewRecorder()
|
|
RefreshTokenHandler(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("the login-issued refresh token must refresh successfully, got %d. body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRefreshToken_RotatesRefreshToken_DBBacked verifies refresh-token
|
|
// rotation is DB-backed: after a successful refresh the used token's row is
|
|
// deleted and exactly one (new) refresh token row remains for the user.
|
|
func TestRefreshToken_RotatesRefreshToken_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)
|
|
|
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to generate refresh token: %v", err)
|
|
}
|
|
|
|
var before int
|
|
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&before); err != nil {
|
|
t.Fatalf("failed to count refresh tokens: %v", err)
|
|
}
|
|
if before != 1 {
|
|
t.Fatalf("expected 1 refresh token before refresh, got %d", before)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer "+refreshToken)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
RefreshTokenHandler(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Rotation is DB-backed: the presented token was consumed (marked used, so a
|
|
// replay can be detected and the whole family revoked) and a fresh descendant
|
|
// minted in the same family. The used row is RETAINED for reuse detection, so
|
|
// 2 rows now exist for the user (used original + new descendant), and the
|
|
// consumed token no longer verifies.
|
|
var after int
|
|
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&after); err != nil {
|
|
t.Fatalf("failed to count refresh tokens after refresh: %v", err)
|
|
}
|
|
if after != 2 {
|
|
t.Errorf("expected 2 refresh tokens after rotation (used original retained + descendant), got %d", after)
|
|
}
|
|
|
|
if _, _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil {
|
|
t.Error("the consumed refresh token must no longer verify (rotated)")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Verification Generate - Invalid Input Tests
|
|
// =============================================================================
|
|
|
|
// TestVerifyGenerate_InvalidJSON verifies that malformed JSON body returns 400.
|
|
func TestVerifyGenerate_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
|
|
|
req := httptest.NewRequest("POST", "/api/verify/generate", 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 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestVerifyGenerate_ValidationError verifies that invalid email format
|
|
// is rejected by the struct validator and returns 400.
|
|
func TestVerifyGenerate_ValidationError(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
|
|
|
req := VerificationCodeRequest{Email: "not-an-email"}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", req, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestVerifyGenerate_EmptyEmail verifies that a whitespace-only email is
|
|
// trimmed and rejected as empty with 400.
|
|
func TestVerifyGenerate_EmptyEmail(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
|
|
|
req := VerificationCodeRequest{Email: " "}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", req, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Verify Check - Invalid Input Tests
|
|
// =============================================================================
|
|
|
|
// TestVerifyCheck_InvalidJSON verifies that malformed JSON body returns 400.
|
|
func TestVerifyCheck_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
req := httptest.NewRequest("POST", "/api/verify/check", 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 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestVerifyCheck_ValidationError verifies that an empty code field fails
|
|
// struct validation and returns 400.
|
|
func TestVerifyCheck_ValidationError(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
req := VerifyCodeRequest{Code: ""}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", req, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestVerifyCheck_EmptyCode verifies that a whitespace-only code is trimmed
|
|
// to empty and rejected with 400.
|
|
func TestVerifyCheck_EmptyCode(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
req := VerifyCodeRequest{Code: " "}
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", req, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Register - Additional Validation Tests
|
|
// =============================================================================
|
|
|
|
// TestRegister_AgreedToPolicyFalse verifies that registration fails when
|
|
// AgreedToPolicy is false with a valid password. The existing test for
|
|
// "did not agree to policy" uses Password: "pass" (4 chars) which gets
|
|
// caught by the validator's min=6 before reaching the AgreedToPolicy check.
|
|
// This test uses a valid password to exercise the actual policy check.
|
|
func TestRegister_AgreedToPolicyFalse(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(RegisterHandler)
|
|
|
|
body := RegisterRequest{
|
|
FirstName: "Test",
|
|
LastName: "User",
|
|
Email: "test-policy@test.com",
|
|
Password: "validpassword123",
|
|
Phone: "07123456789",
|
|
DateOfBirth: "1990-01-15",
|
|
AgreedToPolicy: false,
|
|
}
|
|
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRegister_InvalidDateFormat verifies that registration fails with 400
|
|
// when DateOfBirth is not a valid date format. The struct validator only
|
|
// enforces required, so an invalid format like "not-a-date" passes validation
|
|
// but is caught by time.Parse in the handler.
|
|
func TestRegister_InvalidDateFormat(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(RegisterHandler)
|
|
|
|
body := RegisterRequest{
|
|
FirstName: "Test",
|
|
LastName: "User",
|
|
Email: "test-date@test.com",
|
|
Password: "validpassword123",
|
|
Phone: "07123456789",
|
|
DateOfBirth: "not-a-date",
|
|
AgreedToPolicy: true,
|
|
}
|
|
|
|
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Ensure test compilation - import pgxpool to avoid unused import
|
|
var _ = func() *pgxpool.Pool { return nil }
|