Registration: replace date-of-birth field with a simple 18+ confirmation
The register form collected and validated an actual date of birth just to throw it away (backend never stored it beyond a nullable column). Per the roadmap note: don't collect/store DOB right now, just get a client-side "I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now over_18 (bool); the endpoint rejects over_18=false with 400 same as before. calculate_age() is kept (unused by /register now) since real DOB collection is coming back in a future pass and deleting it broke test_auth_age_gate.py's import for no benefit. Frontend: DOBEdit LineEdit replaced with an Over18Check CheckBox; api_client.gd's register() now takes a bool instead of a date string. All Godot callers (smoke_test_auth/e2e/world_list/main_menu_ui/main_menu_full_flow) and backend test files updated to match. Full suite verified against a live backend: 61/61 pytest, 6/6 Godot smoke tests (33 individual assertions), all passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Authentication router - login, register, token refresh."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
@@ -105,6 +105,36 @@ def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
"""Calculate age in years from a birth date.
|
||||
|
||||
Not called by /register anymore (that endpoint now just trusts the
|
||||
client's over_18 boolean, see RegisterRequest) -- kept as a real,
|
||||
tested utility since date-of-birth storage/verification is coming
|
||||
back in a future pass (real age math, not just a client-side claim,
|
||||
the user explicitly wants to revisit this design). Removing it broke
|
||||
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
|
||||
on `from app.routers.auth import calculate_age`) for zero benefit --
|
||||
restoring it is the lower-risk path over rewriting or deleting 17
|
||||
tests for logic this codebase will need again.
|
||||
|
||||
Args:
|
||||
birth_date: The person's date of birth.
|
||||
today: The reference date. Defaults to today's date (UTC).
|
||||
|
||||
Returns:
|
||||
Age in whole years as of the reference date.
|
||||
"""
|
||||
if today is None:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
age = today.year - birth_date.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
|
||||
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
"""Create and persist a refresh token for the given user."""
|
||||
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||
|
||||
@@ -57,38 +57,38 @@ class TestCalculateAge:
|
||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
||||
|
||||
|
||||
class TestRegisterRequestDateValidation:
|
||||
"""Tests for the RegisterRequest date_of_birth field validator."""
|
||||
class TestRegisterRequestOver18Field:
|
||||
"""Tests for RegisterRequest's over_18 field.
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dob",
|
||||
[
|
||||
"2000-01-01",
|
||||
"1990-12-31",
|
||||
"2004-02-29", # leap day
|
||||
],
|
||||
)
|
||||
def test_valid_dates(self, dob):
|
||||
"""Valid YYYY-MM-DD dates pass validation."""
|
||||
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
|
||||
field_validator were removed from RegisterRequest 2026-08-20 (interim
|
||||
simplification: a plain client-side over_18 confirmation instead of
|
||||
collecting/validating an actual date of birth; see calculate_age's
|
||||
own docstring in app/routers/auth.py for why that function is still
|
||||
here even though this endpoint no longer calls it). There's no date
|
||||
format left to validate, so the equivalent coverage is just: the
|
||||
field exists, is a real bool, and is required."""
|
||||
|
||||
def test_over_18_true_accepted(self):
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||
assert req.date_of_birth == dob
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
|
||||
assert req.over_18 is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dob",
|
||||
[
|
||||
"01-01-2000",
|
||||
"2000/01/01",
|
||||
"2000-13-01",
|
||||
"2000-00-10",
|
||||
"not-a-date",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_invalid_dates_rejected(self, dob):
|
||||
"""Invalid dates are rejected by the validator."""
|
||||
def test_over_18_false_accepted(self):
|
||||
"""False is a valid value at the schema level -- the endpoint,
|
||||
not the schema, is what rejects it (see
|
||||
TestRegister::test_register_rejects_not_over_18 in
|
||||
test_auth_endpoints.py)."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
|
||||
assert req.over_18 is False
|
||||
|
||||
def test_over_18_missing_rejected(self):
|
||||
"""over_18 has no default -- omitting it entirely is a validation error."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestRegister:
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -81,8 +81,14 @@ class TestRegister:
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_register_rejects_underage(self):
|
||||
"""Users under 18 get a 400 with a detail message."""
|
||||
def test_register_rejects_not_over_18(self):
|
||||
"""over_18=false gets a 400 with a detail message.
|
||||
|
||||
The interim design (2026-08-20) trusts a client-side boolean
|
||||
confirmation instead of collecting/validating an actual date of
|
||||
birth -- this only checks that the flag is respected, not real
|
||||
age math (there's no date to verify against anymore). Real DOB
|
||||
collection/verification may come back in a future pass."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
@@ -90,7 +96,7 @@ class TestRegister:
|
||||
"username": "kid",
|
||||
"email": "kid@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2010-06-15", # ~15 years old
|
||||
"over_18": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -106,7 +112,7 @@ class TestRegister:
|
||||
"username": "duplicate",
|
||||
"email": "dup1@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
# Second registration with same username, different email
|
||||
@@ -116,7 +122,7 @@ class TestRegister:
|
||||
"username": "duplicate",
|
||||
"email": "dup2@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
@@ -131,7 +137,7 @@ class TestRegister:
|
||||
"username": "user1",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
@@ -140,14 +146,19 @@ class TestRegister:
|
||||
"username": "user2",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_invalid_date_format(self):
|
||||
"""Invalid date format returns 422 validation error."""
|
||||
def test_register_missing_over_18(self):
|
||||
"""Missing the required over_18 field returns 422.
|
||||
|
||||
Replaces the old test_register_invalid_date_format -- there's no
|
||||
date format to get wrong anymore (no date field at all), so the
|
||||
closest equivalent "malformed request" case is the required
|
||||
over_18 boolean being absent entirely."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
@@ -155,25 +166,10 @@ class TestRegister:
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "01-01-2000", # wrong format
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_register_exactly_18(self):
|
||||
"""A user who just turned 18 can register."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "just18",
|
||||
"email": "just18@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2007-06-15",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login endpoint
|
||||
@@ -193,7 +189,7 @@ class TestLogin:
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestRefresh:
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -143,7 +143,7 @@ class TestRefresh:
|
||||
"username": "refep_loginrefuser",
|
||||
"email": "loginref@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
# Login to get a refresh token
|
||||
|
||||
Reference in New Issue
Block a user