PC3 Phase 3.2: real age verification (date of birth, not a checkbox)

POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD).
The server computes age (auth.calculate_age) and rejects under-18s with 403;
a future/nonsensical date is 400. The DOB is stored and surfaced only to
that user (/api/auth/me).

Still self-attested (a user can enter a false DOB) -- but a concrete,
stored, legally-meaningful data point and the seam for a future ID-check
pass. Encryption-at-rest for the column and the Tier-2 hook are not done;
the limitation is stated plainly in docs/architecture/content_moderation.md 5.2.

- auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register()
  computes + enforces; UserResponse.date_of_birth typed `date | None`
  (the old `str | None` would have 500'd /me once a DOB was actually stored).
- api_client.gd / main_menu.gd: the Over18 checkbox is now a
  "Date of birth (YYYY-MM-DD)" field with a client-side sanity check.
- tests: test_auth_age_gate.py rewritten around the endpoint + age math
  (adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across
  6 backend test files + 6 game smoke tests updated. Backend 74 passed;
  game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.);
  fresh-clone boot PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 22:18:38 +00:00
parent 855311e731
commit 7bd8124199
19 changed files with 194 additions and 150 deletions
+16 -24
View File
@@ -73,7 +73,7 @@ class TestRegister:
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
@@ -81,26 +81,23 @@ class TestRegister:
assert "access_token" in data
assert data["token_type"] == "bearer"
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."""
def test_register_rejects_under_18(self):
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
for the age-math coverage)."""
from datetime import date
client = TestClient(app)
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
resp = client.post(
"/api/auth/register",
json={
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"over_18": False,
"date_of_birth": minor_dob,
},
)
assert resp.status_code == 400
assert "18 or older" in resp.json()["detail"]
assert resp.status_code == 403
assert "at least 18" in resp.json()["detail"]
def test_register_rejects_duplicate_username(self):
"""Registering the same username twice returns 409."""
@@ -112,7 +109,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
# Second registration with same username, different email
@@ -122,7 +119,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 409
@@ -137,7 +134,7 @@ class TestRegister:
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
resp = client.post(
@@ -146,19 +143,14 @@ class TestRegister:
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
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."""
def test_register_missing_dob(self):
"""Missing the required date_of_birth field returns 422."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -189,7 +181,7 @@ class TestLogin:
"username": username,
"email": email,
"password": password,
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201