Files
popertots f9e8385d5a fix: auth/2FA security — stdout-log code delivery is dev/test-only, production fails closed until email/SMS; verification-code hashing, lockout recovery, sabredav fail-closed
- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak
- verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle
- sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD
- delete-account re-auth (current_password + fresh 2FA code when enforced)
- prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check
- startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals
- env-docs parity updated (flag removed, 38 vars)
2026-08-22 00:34:50 +01:00

315 lines
9.7 KiB
Go

//go:build test
package user
// Package user contains tests for guest user creation endpoints.
//
// Test Coverage:
// - CreateGuestUserHandler: POST /api/user/guest - Create a new guest user
//
// Edge case tests for validation:
// - Invalid phone number format
// - Empty first name
// - Name exceeds 50 character limit
// - Invalid email format
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestGuestUser_Create_InvalidPhone verifies that an invalid phone number returns 400 Bad Request.
func TestGuestUser_Create_InvalidPhone(t *testing.T) {
t.Parallel()
reqBody := CreateGuestUserRequest{
FirstName: "Test",
LastName: "User",
Email: "test@test.com",
Phone: "not-a-phone",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
}
// TestGuestUser_Create_EmptyFirstName verifies that an empty first name returns 400 Bad Request.
func TestGuestUser_Create_EmptyFirstName(t *testing.T) {
t.Parallel()
reqBody := CreateGuestUserRequest{
FirstName: "",
LastName: "User",
Email: "test@test.com",
Phone: "07123456789",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
}
// TestGuestUser_Create_NameTooLong verifies that a first name exceeding 50 characters returns 400 Bad Request.
func TestGuestUser_Create_NameTooLong(t *testing.T) {
t.Parallel()
reqBody := CreateGuestUserRequest{
FirstName: strings.Repeat("a", 51),
LastName: "User",
Email: "test@test.com",
Phone: "07123456789",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
}
// TestGuestUser_Create_InvalidEmail verifies that an invalid email format returns 400 Bad Request.
func TestGuestUser_Create_InvalidEmail(t *testing.T) {
t.Parallel()
reqBody := CreateGuestUserRequest{
FirstName: "Test",
LastName: "User",
Email: "not-an-email",
Phone: "07123456789",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
}
// TestCheckEmail_NotRegistered verifies that querying a non-existent email
// returns the uniform response {"available": true} and no suggestion breakdown
// (the old "suggestion" field was a user-enumeration/PII-confirmation oracle).
func TestCheckEmail_NotRegistered(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/check-email?email=nobody@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789", nil)
rr := httptest.NewRecorder()
CheckEmailHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if _, ok := resp["suggestion"]; ok {
t.Errorf("expected NO 'suggestion' field (enumeration oracle removed), got %v", resp["suggestion"])
}
available, ok := resp["available"].(bool)
if !ok || !available {
t.Errorf("expected available=true for an unregistered email, got %v", resp["available"])
}
}
// TestCheckEmail_Registered verifies that a registered user's email returns the
// uniform response {"available": false} regardless of whether the caller's
// first/last-name/phone match the account — the detail matching is deliberately
// gone so an unauthenticated caller cannot confirm PII against the database.
func TestCheckEmail_Registered(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUserWithEmail(tx, "jane@example.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET n_first_name = 'Jane', n_last_name = 'Doe' WHERE id = $1
`, userID)
if err != nil {
t.Fatalf("failed to update user name: %v", err)
}
// Matching details AND non-matching details must both yield available=false.
for _, q := range []string{
`/api/check-email?email=jane@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789`,
`/api/check-email?email=jane@example.com&firstName=Wrong&lastName=Doe&phone=%2B447123456789`,
} {
req := httptest.NewRequest(http.MethodGet, q, nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
CheckEmailHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if _, ok := resp["suggestion"]; ok {
t.Errorf("expected NO 'suggestion' field (enumeration oracle removed), got %v", resp["suggestion"])
}
available, ok := resp["available"].(bool)
if !ok || available {
t.Errorf("expected available=false for a registered email (query %s), got %v", q, resp["available"])
}
}
}
// TestCheckEmail_GuestUser verifies that a guest user's email is treated as
// available (the query excludes account_role = 'guest').
func TestCheckEmail_GuestUser(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=guest@test.com&firstName=Guest&lastName=User&phone=%2B447123456789`, nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
CheckEmailHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
available, ok := resp["available"].(bool)
if !ok || !available {
t.Errorf("expected available=true for a guest email, got %v", resp["available"])
}
}
// TestCheckEmail_InvalidEmail verifies that an invalid email format returns 400 Bad Request.
func TestCheckEmail_InvalidEmail(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/check-email?email=not-an-email", nil)
rr := httptest.NewRecorder()
CheckEmailHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
}
// TestCheckEmail_MissingEmail verifies that omitting the email query parameter returns 400 Bad Request
// with the appropriate error message.
func TestCheckEmail_MissingEmail(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/check-email", nil)
rr := httptest.NewRecorder()
CheckEmailHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "email query parameter required") {
t.Errorf("expected 'email query parameter required' in body, got %s", rr.Body.String())
}
}
// TestGuestUser_Create_Success verifies that a valid guest user request
// creates a guest user and returns 201 with the user's details.
func TestGuestUser_Create_Success(t *testing.T) {
// NOT parallel — uses db.Conn state
ctx, tx := testutils.SetupTestTx(t)
reqBody := CreateGuestUserRequest{
FirstName: "Jane",
LastName: "Guest",
Email: "jane.guest.success@example.com",
Phone: "07123456789",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
// Verify user was created in DB
var count int
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "jane.guest.success@example.com").Scan(&count)
if err != nil {
t.Fatalf("failed to query users: %v", err)
}
if count != 1 {
t.Errorf("expected 1 guest user (account_role='guest'), got %d", count)
}
// Verify response body contains ID and role
var resp CreateGuestUserResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.ID == "" {
t.Error("expected non-empty user ID in response")
}
if resp.Role != "guest" {
t.Errorf("expected role 'guest', got '%s'", resp.Role)
}
}