//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/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/auth" "crussell/clock" "crussell/db" "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 } // ============================================================================= // 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 var code string expiresAt := clock.Now().Add(24 * time.Hour) err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) body := VerifyCodeRequest{ Code: code, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp VerificationResponse if err := testutils.ParseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if !resp.Success { t.Error("expected success=true in response") } // Verify code is marked as used var usedAt *time.Time err = tx.QueryRow(ctx, "SELECT used_at FROM verification_codes WHERE code = $1", code).Scan(&usedAt) if err != nil || usedAt == nil { t.Error("expected verification code to be marked as used") } } // TestVerifyCheck_InvalidCode verifies that attempting to verify with // a non-existent code returns HTTP 400 Bad Request. func TestVerifyCheck_InvalidCode(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) body := VerifyCodeRequest{ Code: "nonexistent-code-12345", } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestVerifyCheck_ExpiredCode tests that verification fails with HTTP 400 // when the code has expired (past its expires_at timestamp). func TestVerifyCheck_ExpiredCode(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) // Create a test user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(tx, userID) // Create an expired verification code var code string expiresAt := clock.Now().Add(-1 * time.Hour) // Expired 1 hour ago err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) body := VerifyCodeRequest{ Code: code, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Additional Edge Case Tests // ============================================================================= // TestLogin_InvalidRequest verifies that sending malformed JSON to the login // endpoint returns HTTP 400 Bad Request. func TestLogin_InvalidRequest(t *testing.T) { _, _ = resetTestData(t) handler := http.HandlerFunc(LoginHandler) // Send invalid JSON req := httptest.NewRequest("POST", "/api/login", bytes.NewReader([]byte("not json"))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } } // 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) err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) // First verification should succeed body := VerifyCodeRequest{ Code: code, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusOK { t.Errorf("first verification: expected status 200, got %d", w.Code) } // Second verification with same code should return 403 (already used) w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusForbidden { t.Errorf("second verification: expected status 403, got %d", w.Code) } } // TestVerifyCheck_RoleChangeToVerified confirms that after a successful // verification, the user's account_role changes from unverified_email to // verified_email, granting them full account access. func TestVerifyCheck_RoleChangeToVerified(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) // Create an unverified user userID, err := fixtures.CreateTestUnverifiedUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(tx, userID) // Verify initial role is unverified_email var initialRole string err = tx.QueryRow(ctx, "SELECT account_role FROM users WHERE id = $1", userID).Scan(&initialRole) if err != nil { t.Fatalf("failed to check initial role: %v", err) } if initialRole != "unverified_email" { t.Errorf("expected initial role 'unverified_email', got %s", initialRole) } // Create a verification code var code string expiresAt := clock.Now().Add(24 * time.Hour) err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) // Verify the code body := VerifyCodeRequest{ Code: code, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Check that user's role changed to verified_email var newRole string err = tx.QueryRow(ctx, "SELECT account_role FROM users WHERE id = $1", userID).Scan(&newRole) if err != nil { t.Errorf("failed to check new role: %v", err) } if newRole != "verified_email" { t.Errorf("expected role to change to 'verified_email', got %s", newRole) } } // ============================================================================= // Password Length Tests (Registration) // ============================================================================= // TestRegister_PasswordLength_Minimum verifies that registration enforces // a minimum password length of 6 characters (new requirement from security pass). // bcrypt handles passwords up to 72 chars internally (truncates longer ones). func TestRegister_PasswordLength_Minimum(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) tests := []struct { name string password string expectError bool }{ { name: "1_char_password_too_short", password: "x", expectError: true, // Below 6 char minimum }, { name: "5_char_password_too_short", password: "short", expectError: true, // Below 6 char minimum }, { name: "6_char_password_minimum", password: "pass12", expectError: false, // Meets minimum }, { name: "72_char_password_exact_bcrypt_limit", password: strings.Repeat("a", 72), expectError: false, }, { name: "73_char_password_exceeds_limit", password: strings.Repeat("a", 73), expectError: true, // Exceeds bcrypt limit }, { name: "100_char_password_exceeds_limit", password: strings.Repeat("a", 100), expectError: true, // Exceeds bcrypt limit }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := RegisterRequest{ FirstName: "Test", LastName: "User", Email: fmt.Sprintf("test-%s@test.com", tt.name), Password: tt.password, Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if tt.expectError { if w.Code == http.StatusCreated { t.Errorf("expected non-201 status for password '%s', got 201", tt.password) } } else { if w.Code != http.StatusCreated { t.Errorf("expected status 201 for password len=%d, got %d. body: %s", len(tt.password), w.Code, w.Body.String()) } } }) } } // TestRegister_EmptyPassword verifies that an empty password is rejected // because it's a required field (not because of minimum length). func TestRegister_EmptyPassword(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) body := RegisterRequest{ FirstName: "Test", LastName: "User", Email: "empty@test.com", Password: "", // Empty - should fail as required field Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) // Empty password fails because it's a required field if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for empty password, got %d", w.Code) } } // TestRegister_WithValidReferralCode verifies that registration succeeds when // a valid existing referral code is provided, and the referral relationship // is recorded in the user_referrals table. func TestRegister_WithValidReferralCode(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) // Create a referrer user with a known referral code referrerID, err := fixtures.CreateTestUserWithEmail(tx, "referrer@test.com", "verified_email") if err != nil { t.Fatalf("failed to create referrer user: %v", err) } defer fixtures.DeleteUser(tx, referrerID) // Set a known referral code for the referrer knownCode := "abc123def456" _, err = tx.Exec(ctx, "UPDATE users SET referral_code = $1 WHERE id = $2", knownCode, referrerID) if err != nil { t.Fatalf("failed to set referral code: %v", err) } body := RegisterRequest{ FirstName: "Referred", LastName: "User", Email: "referred@test.com", Password: "password123", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true, ReferralCode: knownCode, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify referral relationship was created var referredID string err = tx.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", "referred@test.com").Scan(&referredID) if err != nil { t.Fatalf("failed to find referred user: %v", err) } defer fixtures.DeleteUser(tx, referredID) var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND referred_id = $2", referrerID, referredID).Scan(&count) if err != nil { t.Fatalf("failed to query user_referrals: %v", err) } if count != 1 { t.Errorf("expected 1 referral record, got %d", count) } } // TestRegister_WithInvalidReferralCode verifies that registration fails with // 400 when a non-existent referral code is provided. func TestRegister_WithInvalidReferralCode(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) body := RegisterRequest{ FirstName: "Test", LastName: "User", Email: "invalid-referral@test.com", Password: "password123", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true, ReferralCode: "nonexistent1234", // 12 chars but doesn't exist } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestRegister_WithInvalidReferralCodeFormat verifies that registration fails // when the referral code is not exactly 12 characters. func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) tests := []struct { name string code string desc string }{ {"too_short", "abc123", "less than 12 chars"}, {"too_long", "abc123def456ghi", "more than 12 chars"}, {"special_chars", "abc123def4!!", "contains special chars"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := RegisterRequest{ FirstName: "Test", LastName: "User", Email: fmt.Sprintf("format-test-%s@test.com", tt.name), Password: "password123", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true, ReferralCode: tt.code, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for %s (%s), got %d. body: %s", tt.name, tt.desc, w.Code, w.Body.String()) } }) } } // TestRegister_ReferralCodeCaseInsensitive verifies that referral codes with // uppercase letters are accepted and correctly matched against lowercase stored codes. func TestRegister_ReferralCodeCaseInsensitive(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) // Create a referrer user with a known referral code (lowercase hex) referrerID, err := fixtures.CreateTestUserWithEmail(tx, "referrer-case@test.com", "verified_email") if err != nil { t.Fatalf("failed to create referrer user: %v", err) } defer fixtures.DeleteUser(tx, referrerID) knownCode := "abc123def456" _, err = tx.Exec(ctx, "UPDATE users SET referral_code = $1 WHERE id = $2", knownCode, referrerID) if err != nil { t.Fatalf("failed to set referral code: %v", err) } tests := []struct { name string inputCode string wantStatus int }{ {"lowercase", "abc123def456", http.StatusCreated}, {"uppercase", "ABC123DEF456", http.StatusCreated}, {"mixed_case", "AbC123DeF456", http.StatusCreated}, {"all_caps", "ABC123DEF456", http.StatusCreated}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := RegisterRequest{ FirstName: "Case", LastName: "Test", Email: fmt.Sprintf("case-test-%s@test.com", tt.name), Password: "password123", Phone: "07123456789", DateOfBirth: "1990-01-15", AgreedToPolicy: true, ReferralCode: tt.inputCode, } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != tt.wantStatus { t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.wantStatus, w.Code, w.Body.String()) } if tt.wantStatus == http.StatusCreated { // Verify referral relationship was created var referredID string err = tx.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", fmt.Sprintf("case-test-%s@test.com", tt.name)).Scan(&referredID) if err != nil { t.Fatalf("%s: failed to find referred user: %v", tt.name, err) } defer fixtures.DeleteUser(tx, referredID) var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND referred_id = $2", referrerID, referredID).Scan(&count) if err != nil { t.Fatalf("%s: failed to query user_referrals: %v", tt.name, err) } if count != 1 { t.Errorf("%s: expected 1 referral record, got %d", tt.name, count) } } }) } } // ============================================================================= // Logout Handler Tests // ============================================================================= // TestLogoutHandler_Success verifies that a valid logout request returns // 200 OK with {"success": true} and revokes the JTI. func TestLogoutHandler_Success(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a test user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(tx, userID) // Generate a token and get its JTI token, jti, err := auth.GenerateToken(userID, "verified_email") if err != nil { t.Fatalf("failed to generate token: %v", err) } // Create request with JTI in context (simulating RequireAuth middleware) req := httptest.NewRequest("POST", "/api/logout", nil) req.Header.Set("Authorization", "Bearer "+token) reqCtx := context.WithValue(ctx, mw.JTIKey, jti) req = req.WithContext(reqCtx) w := httptest.NewRecorder() LogoutHandler(w, req) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp struct { Success bool `json:"success"` } if err := testutils.ParseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if !resp.Success { t.Error("expected success=true in response") } // Verify JTI was revoked if !auth.IsJTIRevoked(ctx, jti) { t.Error("expected JTI to be revoked after logout") } } // TestLogoutHandler_RevokesJTI verifies that after logout, the token's JTI is // revoked and the token can no longer be used with authenticated endpoints. func TestLogoutHandler_RevokesJTI(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a test user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(tx, userID) // Generate a token and get its JTI token, jti, err := auth.GenerateToken(userID, "verified_email") if err != nil { t.Fatalf("failed to generate token: %v", err) } // Call logout with JTI in context req := httptest.NewRequest("POST", "/api/logout", nil) req.Header.Set("Authorization", "Bearer "+token) reqCtx := context.WithValue(ctx, mw.JTIKey, jti) req = req.WithContext(reqCtx) w := httptest.NewRecorder() LogoutHandler(w, req) if w.Code != http.StatusOK { t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String()) } // Try to use the same token through RequireAuth middleware router := chi.NewRouter() router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) req2 := httptest.NewRequest("GET", "/api/protected", nil) req2 = req2.WithContext(ctx) req2.Header.Set("Authorization", "Bearer "+token) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) if w2.Code != http.StatusUnauthorized { t.Errorf("expected 401 for revoked token, got %d", w2.Code) } } // TestLogoutHandler_NoToken verifies that calling logout without an // Authorization header returns 401. func TestLogoutHandler_NoToken(t *testing.T) { t.Parallel() _, _ = resetTestData(t) req := httptest.NewRequest("POST", "/api/logout", nil) w := httptest.NewRecorder() // Without JTI in context, handler returns 401 LogoutHandler(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } // TestLogoutHandler_InvalidToken verifies that calling logout with an empty // JTI returns 401. func TestLogoutHandler_InvalidToken(t *testing.T) { t.Parallel() _, _ = resetTestData(t) req := httptest.NewRequest("POST", "/api/logout", nil) reqCtx := context.WithValue(req.Context(), mw.JTIKey, "") req = req.WithContext(reqCtx) w := httptest.NewRecorder() LogoutHandler(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } // 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()) } } // 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 "", "", "\">", "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", ">", "'; rm -rf /; +447700900000", "\x00\n+447700900000", } for _, p := range payloads { result, err := ValidateUKPhoneNumber(p) if err == nil { t.Errorf("expected mixed payload %q to be rejected, got result %q", p, result) } } } // ============================================================================= // Account Lockout Tests (new from security pass) // ============================================================================= // TestLogin_AccountLockout_After5Failures verifies that after 5 failed login // attempts, the account is locked and the next login attempt returns HTTP 429. func TestLogin_AccountLockout_After5Failures(t *testing.T) { ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(tx, userID) defer tx.Exec(ctx, "UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1", userID) for i := 0; i < 5; i++ { body := LoginRequest{ Email: "user@test.com", Password: "wrongpassword", } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusUnauthorized { t.Fatalf("attempt %d: expected 401, got %d", i+1, w.Code) } } body := LoginRequest{ Email: "user@test.com", Password: "wrongpassword", } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusTooManyRequests { t.Errorf("expected 429 after 5 failures, got %d. body: %s", w.Code, w.Body.String()) } var failedAttempts int var lockedUntil *time.Time err = tx.QueryRow(ctx, "SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil) if err != nil { t.Fatalf("failed to query lockout state: %v", err) } if failedAttempts < 5 { t.Errorf("expected >=5 failed attempts, got %d", failedAttempts) } if lockedUntil == nil { t.Error("expected locked_until to be set") } } // TestLogin_AccountLockout_ResetsOnSuccess verifies that a successful login // resets the failed_attempts counter and clears the locked_until. func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) { ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) userID, err := fixtures.CreateTestUserWithEmail(tx, "lockout-reset@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(tx, userID) _, err = tx.Exec(ctx, "UPDATE users SET failed_attempts = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set failed_attempts: %v", err) } body := LoginRequest{ Email: "lockout-reset@test.com", Password: "testpassword123", } w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var failedAttempts int var lockedUntil *time.Time err = tx.QueryRow(ctx, "SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil) if err != nil { t.Fatalf("failed to query lockout state: %v", err) } if failedAttempts != 0 { t.Errorf("expected 0 failed_attempts after success, got %d", failedAttempts) } if lockedUntil != nil { t.Error("expected locked_until to be cleared after success") } } // TestLogin_AccountLockout_CappedAt30Min verifies the LOW-6 fix: the lockout // ceiling never exceeds 30 minutes no matter how many failed attempts pile up // (previously 20+ failures locked the account for 2 hours — a persistent, // repeatedly-extendable DoS window for a guessing attacker). func TestLogin_AccountLockout_CappedAt30Min(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 old CASE locked for 2 hours here. _, 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 → 429. w = testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusTooManyRequests { t.Fatalf("expected 429, 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 > 30*time.Minute { t.Errorf("lockout must never exceed the 30-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 }