915 lines
28 KiB
Go
915 lines
28 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package auth
|
|
|
|
// Package auth contains tests for authentication and verification endpoints.
|
|
//
|
|
// Test Coverage:
|
|
// - RegisterHandler: POST /api/register - User registration
|
|
// * Validates: required fields, email format, UK phone, age >= 16, duplicate email
|
|
// - LoginHandler: POST /api/login - User login
|
|
// * Validates credentials, returns JWT token
|
|
// - RefreshTokenHandler: POST /api/refresh-token - Refresh JWT token
|
|
// - GenerateVerificationCodeHandler: POST /api/verify/generate - Send verification code
|
|
// * Returns success even for non-existent emails (security)
|
|
// - VerifyCodeHandler: POST /api/verify/check - Verify code and update user role
|
|
// * Validates: correct code, not expired, not already used
|
|
// * Updates user role from unverified_email to verified_email on success
|
|
//
|
|
// Validation: Comprehensive tests for invalid inputs (bad email, bad phone, underage, etc.)
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
|
|
"crussell/internal/dav"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
"crussell/testutils/testdb"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function
|
|
func setupTestDB(t *testing.T) func() {
|
|
t.Helper()
|
|
|
|
pool := testdb.Pool(t)
|
|
testdb.Migrate(t, pool)
|
|
testdb.TruncateTables(t, pool) // Clear data between tests
|
|
|
|
// Replace global db.DB with test pool
|
|
originalDB := db.DB
|
|
db.DB = pool
|
|
|
|
// Initialize JWT for tests
|
|
jwt.Init()
|
|
|
|
// Set up a minimal dav.Service to avoid nil pointer panic
|
|
// The real service is only used in a goroutine in RegisterHandler
|
|
dav.Service = &dav.BaseService{}
|
|
|
|
return func() {
|
|
db.DB = originalDB
|
|
pool.Close()
|
|
}
|
|
}
|
|
|
|
// helper function to make JSON request
|
|
func makeRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// Helper to parse response body
|
|
func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
|
|
return json.Unmarshal(w.Body.Bytes(), dest)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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 := db.DB.QueryRow(context.Background(),
|
|
"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
|
|
db.DB.Exec(context.Background(), "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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", tt.body)
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(RegisterHandler)
|
|
|
|
// Calculate a date that makes them under 16
|
|
under16DOB := time.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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(RegisterHandler)
|
|
|
|
// First create a user with specific email
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
// Create a test user with known email
|
|
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "user@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
body := LoginRequest{
|
|
Email: "user@test.com",
|
|
Password: "testpassword123",
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/login", body)
|
|
|
|
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 := 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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
// Create a test user
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
body := LoginRequest{
|
|
Email: "user@test.com",
|
|
Password: "wrongpassword",
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/login", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
body := LoginRequest{
|
|
Email: "nonexistent@test.com",
|
|
Password: "password123",
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/login", body)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Refresh Token Handler Tests
|
|
// =============================================================================
|
|
|
|
// TestRefreshToken_Success tests that a valid JWT token can be refreshed
|
|
// to obtain a new token with extended expiry.
|
|
func TestRefreshToken_Success(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(RefreshTokenHandler)
|
|
|
|
// Create a test user
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Generate a valid token
|
|
token := jwt.GenerateTestToken(userID, "verified_email")
|
|
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
|
|
// Use the middleware keys to set up context (matching what mw.RequireAuth does)
|
|
ctx := req.Context()
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(ctx)
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := parseResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.Token == "" {
|
|
t.Error("expected new token in response, got empty string")
|
|
}
|
|
}
|
|
|
|
// TestRefreshToken_Unauthorized_NoToken verifies that attempting to refresh
|
|
// a token without providing one results in HTTP 401 Unauthorized.
|
|
func TestRefreshToken_Unauthorized_NoToken(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(RefreshTokenHandler)
|
|
|
|
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// Without proper auth middleware, userID/role won't be in context
|
|
// The handler tries to query DB with empty userID, which should fail
|
|
if w.Code != http.StatusUnauthorized && w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status 401 or 500, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Verify Generate Handler Tests
|
|
// =============================================================================
|
|
|
|
// TestVerifyGenerate_ValidEmail tests that a verification code can be
|
|
// generated for an existing user email. The code is stored in the database
|
|
// for subsequent verification.
|
|
func TestVerifyGenerate_ValidEmail(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
|
|
|
// Create a test user with known email
|
|
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "user@test.com", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
body := VerificationCodeRequest{
|
|
Email: "user@test.com",
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/verify/generate", body)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp VerificationResponse
|
|
if err := 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 = db.DB.QueryRow(context.Background(),
|
|
"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
|
|
db.DB.Exec(context.Background(), "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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
|
|
|
// Security: should return success even if email doesn't exist
|
|
body := VerificationCodeRequest{
|
|
Email: "nonexistent@test.com",
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/verify/generate", body)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp VerificationResponse
|
|
if err := 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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
// Create a test user
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Create a verification code
|
|
var code string
|
|
expiresAt := time.Now().Add(24 * time.Hour)
|
|
err = db.DB.QueryRow(context.Background(),
|
|
`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 db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID)
|
|
|
|
body := VerifyCodeRequest{
|
|
Code: code,
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/verify/check", body)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp VerificationResponse
|
|
if err := 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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
body := VerifyCodeRequest{
|
|
Code: "nonexistent-code-12345",
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/verify/check", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
// Create a test user
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Create an expired verification code
|
|
var code string
|
|
expiresAt := time.Now().Add(-1 * time.Hour) // Expired 1 hour ago
|
|
err = db.DB.QueryRow(context.Background(),
|
|
`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 db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID)
|
|
|
|
body := VerifyCodeRequest{
|
|
Code: code,
|
|
}
|
|
|
|
w := makeRequest(handler, "POST", "/api/verify/check", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(LoginHandler)
|
|
|
|
// Send invalid JSON
|
|
req := httptest.NewRequest("POST", "/api/login", bytes.NewReader([]byte("not json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestRegister_NameTooLong tests that registration fails when the first name
|
|
// exceeds 50 characters (the maximum allowed length).
|
|
func TestRegister_NameTooLong(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
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 := makeRequest(handler, "POST", "/api/register", body)
|
|
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
// Create a test user
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Create a verification code
|
|
var code string
|
|
expiresAt := time.Now().Add(24 * time.Hour)
|
|
err = db.DB.QueryRow(context.Background(),
|
|
`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 db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID)
|
|
|
|
// First verification should succeed
|
|
body := VerifyCodeRequest{
|
|
Code: code,
|
|
}
|
|
w := makeRequest(handler, "POST", "/api/verify/check", body)
|
|
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 = makeRequest(handler, "POST", "/api/verify/check", body)
|
|
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) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
handler := http.HandlerFunc(VerifyCodeHandler)
|
|
|
|
// Create an unverified user
|
|
userID, err := fixtures.CreateTestUnverifiedUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Verify initial role is unverified_email
|
|
var initialRole string
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"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 := time.Now().Add(24 * time.Hour)
|
|
err = db.DB.QueryRow(context.Background(),
|
|
`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 db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID)
|
|
|
|
// Verify the code
|
|
body := VerifyCodeRequest{
|
|
Code: code,
|
|
}
|
|
w := makeRequest(handler, "POST", "/api/verify/check", body)
|
|
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 = db.DB.QueryRow(context.Background(),
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// Ensure test compilation - import pgxpool to avoid unused import
|
|
var _ = func() *pgxpool.Pool { return nil }
|