//go:build test // +build test // 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.) package auth //go:build test // +build test package auth import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "crussell/db" "crussell/mw" "crussell/internal/dav" "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 // ============================================================================= 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) } 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) } }) } } 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()) } } 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 tests all valid UK mobile phone formats 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 tests various invalid phone formats 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()) } }) } } 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, NULL, 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()) } } 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 // ============================================================================= 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") } } 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()) } } 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 // ============================================================================= 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") } } 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 // ============================================================================= 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) } 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 // ============================================================================= 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") } } 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()) } } 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 // ============================================================================= 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) } } 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()) } } 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()) } } 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) } } 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 }