diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b1e1dd5..b5afe97 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -21,6 +21,9 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") router = APIRouter() +## Minimum age to register. This platform is adults-only. +MIN_AGE = 18 + # --- Pydantic schemas --- @@ -39,14 +42,19 @@ class RegisterRequest(BaseModel): username: str email: EmailStr password: str - over_18: bool # Simple boolean confirmation instead of date_of_birth + # Real date of birth -- the server computes age from it and rejects + # anyone under MIN_AGE. Still self-attested (no document check yet), but + # a concrete, stored, legally-meaningful data point rather than a + # throwaway checkbox, and the seam for a future hard ID-verification + # pass. See docs/architecture/content_moderation.md 5.2. + date_of_birth: date class UserResponse(BaseModel): id: int username: str email: str - date_of_birth: str | None = None + date_of_birth: date | None = None is_active: bool created_at: datetime | None = None @@ -108,15 +116,9 @@ def hash_password(password: str) -> str: 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. + Used by /register (PC3 Phase 3) to compute age from the submitted + date_of_birth and enforce the 18+ gate. See + docs/architecture/content_moderation.md 5.2. Args: birth_date: The person's date of birth. @@ -155,12 +157,20 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str: async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): """Register a new user and return access + refresh tokens. - Requires over_18 boolean confirmation; rejects users who are not 18+. + Requires a date of birth; the server computes age and rejects anyone + under MIN_AGE (18). See docs/architecture/content_moderation.md for the + verification design and its self-attestation limitation. """ - if not req.over_18: + age = calculate_age(req.date_of_birth) + if age < 0 or req.date_of_birth > date.today(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Must be 18 or older to register", + detail="Invalid date of birth", + ) + if age < MIN_AGE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"You must be at least {MIN_AGE} to use this platform", ) # Check if username already exists @@ -183,7 +193,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): username=req.username, email=req.email, hashed_password=hash_password(req.password), - date_of_birth=None, # No longer collecting DOB + date_of_birth=req.date_of_birth, ) db.add(user) await db.flush() # flush to get user.id before creating refresh token diff --git a/backend/tests/test_auth_age_gate.py b/backend/tests/test_auth_age_gate.py index 3b372b9..264c7f4 100644 --- a/backend/tests/test_auth_age_gate.py +++ b/backend/tests/test_auth_age_gate.py @@ -3,92 +3,102 @@ from datetime import date, timedelta import pytest +from fastapi.testclient import TestClient -from app.routers.auth import calculate_age +from app.main import app +from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest class TestCalculateAge: """Unit tests for the calculate_age helper.""" def test_exactly_18(self): - """A person born exactly 18 years ago today is 18.""" - today = date(2025, 6, 15) + today = date(2026, 6, 15) dob = today.replace(year=today.year - 18) assert calculate_age(dob, today=today) == 18 def test_just_turned_18(self): - """A person who turned 18 yesterday is 18.""" - today = date(2025, 6, 15) + today = date(2026, 6, 15) dob = today.replace(year=today.year - 18) - timedelta(days=1) assert calculate_age(dob, today=today) == 18 def test_one_day_short_of_18(self): - """A person born one day after their 18th birthday is 17.""" - today = date(2025, 6, 15) + today = date(2026, 6, 15) dob = today.replace(year=today.year - 18) + timedelta(days=1) assert calculate_age(dob, today=today) == 17 - def test_17_years_old(self): - """A person who is 17 years old today is 17.""" - today = date(2025, 6, 15) - dob = date(2008, 6, 15) - assert calculate_age(dob, today=today) == 17 + def test_leap_day_birthday_before(self): + assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23 - def test_65_years_old(self): - """A person who is 65 is correctly calculated.""" - today = date(2025, 6, 15) - dob = today.replace(year=today.year - 65) - assert calculate_age(dob, today=today) == 65 - - def test_newborn(self): - """A newborn is 0.""" - today = date(2025, 6, 15) - dob = today - assert calculate_age(dob, today=today) == 0 - - def test_birthday_not_yet_this_year(self): - """Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24.""" - dob = date(2000, 12, 31) - assert calculate_age(dob, today=date(2025, 1, 1)) == 24 - - def test_birthday_today(self): - """Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24.""" - dob = date(2000, 12, 31) - assert calculate_age(dob, today=date(2024, 12, 31)) == 24 + def test_leap_day_birthday_after(self): + assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24 -class TestRegisterRequestOver18Field: - """Tests for RegisterRequest's over_18 field. +class TestRegisterRequestDob: + def test_dob_parsed_from_iso_string(self): + req = RegisterRequest(username="u", email="u@example.com", password="p", + date_of_birth="1990-01-01") + assert req.date_of_birth == date(1990, 1, 1) - 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", over_18=True) - assert req.over_18 is True - - 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 - - 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): + def test_dob_required(self): + with pytest.raises(Exception): RegisterRequest(username="u", email="u@example.com", password="p") + + def test_bad_dob_string_rejected(self): + with pytest.raises(Exception): + RegisterRequest(username="u", email="u@example.com", password="p", + date_of_birth="not-a-date") + + +class TestRegisterAgeGate: + """End-to-end: the /register endpoint computes age from the DOB.""" + + def _dob_for_age(self, years: int) -> str: + return (date.today().replace(year=date.today().year - years)).isoformat() + + def test_adult_accepted(self): + client = TestClient(app) + r = client.post("/api/auth/register", json={ + "username": "gate_adult", "email": "gate_adult@e.com", + "password": "secret123", "date_of_birth": self._dob_for_age(25), + }) + assert r.status_code == 201, r.text + + def test_minor_rejected(self): + client = TestClient(app) + r = client.post("/api/auth/register", json={ + "username": "gate_minor", "email": "gate_minor@e.com", + "password": "secret123", "date_of_birth": self._dob_for_age(15), + }) + assert r.status_code == 403 + assert str(MIN_AGE) in r.json()["detail"] + + def test_exactly_18_accepted(self): + client = TestClient(app) + r = client.post("/api/auth/register", json={ + "username": "gate_18", "email": "gate_18@e.com", + "password": "secret123", + "date_of_birth": (date.today().replace(year=date.today().year - 18) - timedelta(days=1)).isoformat(), + }) + assert r.status_code == 201, r.text + + def test_future_dob_rejected(self): + client = TestClient(app) + r = client.post("/api/auth/register", json={ + "username": "gate_future", "email": "gate_future@e.com", + "password": "secret123", "date_of_birth": "2999-01-01", + }) + assert r.status_code == 400 + + def test_dob_is_stored(self): + client = TestClient(app) + dob = self._dob_for_age(30) + reg = client.post("/api/auth/register", json={ + "username": "gate_stored", "email": "gate_stored@e.com", + "password": "secret123", "date_of_birth": dob, + }) + assert reg.status_code == 201 + me = client.get("/api/auth/me", headers={ + "Authorization": f"Bearer {reg.json()['access_token']}" + }) + assert me.json()["date_of_birth"] == dob diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py index afe949e..9305bb7 100644 --- a/backend/tests/test_auth_endpoints.py +++ b/backend/tests/test_auth_endpoints.py @@ -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 diff --git a/backend/tests/test_auth_expired_token.py b/backend/tests/test_auth_expired_token.py index 960afb6..25f919e 100644 --- a/backend/tests/test_auth_expired_token.py +++ b/backend/tests/test_auth_expired_token.py @@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str: "username": username, "email": f"{username}@example.com", "password": "secret123", - "over_18": True, + "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201 diff --git a/backend/tests/test_avatar_loadout.py b/backend/tests/test_avatar_loadout.py index 3090917..d4944b7 100644 --- a/backend/tests/test_avatar_loadout.py +++ b/backend/tests/test_avatar_loadout.py @@ -18,7 +18,7 @@ def _auth_headers(client: TestClient, username: str) -> dict: "username": username, "email": f"{username}@example.com", "password": "secret123", - "over_18": True, + "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201, resp.text diff --git a/backend/tests/test_refresh_endpoint.py b/backend/tests/test_refresh_endpoint.py index 3da8e47..7c66001 100644 --- a/backend/tests/test_refresh_endpoint.py +++ b/backend/tests/test_refresh_endpoint.py @@ -59,7 +59,7 @@ class TestRefresh: "username": username, "email": email, "password": password, - "over_18": True, + "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201 @@ -143,7 +143,7 @@ class TestRefresh: "username": "refep_loginrefuser", "email": "loginref@example.com", "password": "secret123", - "over_18": True, + "date_of_birth": "1990-01-01", }, ) # Login to get a refresh token diff --git a/backend/tests/test_world_join.py b/backend/tests/test_world_join.py index 6ca32d5..566dc40 100644 --- a/backend/tests/test_world_join.py +++ b/backend/tests/test_world_join.py @@ -13,7 +13,7 @@ from app.main import app def _auth(client: TestClient, name: str) -> tuple[dict, int]: r = client.post("/api/auth/register", json={ - "username": name, "email": f"{name}@e.com", "password": "secret123", "over_18": True, + "username": name, "email": f"{name}@e.com", "password": "secret123", "date_of_birth": "1990-01-01", }) assert r.status_code == 201, r.text headers = {"Authorization": f"Bearer {r.json()['access_token']}"} diff --git a/docs/architecture/content_moderation.md b/docs/architecture/content_moderation.md index 66f5ae0..d5c7952 100644 --- a/docs/architecture/content_moderation.md +++ b/docs/architecture/content_moderation.md @@ -227,18 +227,33 @@ An account has an **age verification status**: ### 5.2 Tier 1 — Self-Declaration Flow +> **Implemented, PC3 Phase 3.** `POST /api/auth/register` now takes a real +> `date_of_birth` (ISO `YYYY-MM-DD`), not the throwaway `over_18` boolean it +> had before. The server computes age (`auth.calculate_age`) and rejects +> anyone under `auth.MIN_AGE` (18) with a **403** — this platform is +> adults-only, so there is no "minor, restricted" tier here (the 13+ rows +> below are the general design, not the current gate). A future-in-the-past +> or nonsensical date is a **400**. The DOB is stored on the user row and +> surfaced only back to that user (`/api/auth/me`), never to other players. +> +> **Limitation, stated plainly:** this is still self-attested — a determined +> user can enter a false DOB. It is a concrete, stored, legally-meaningful +> data point (matching how most platforms gate before adding hard checks) +> and the seam for Tier 2. Encryption-at-rest for the DOB column and the +> Tier 2 ID-check hook below are **not yet implemented**. + ``` User submits { date_of_birth } │ ▼ -Server validates format & plausibility (age 13–120, not in future) +Server validates format & plausibility (real date, not in future) │ ▼ Compute age from DOB │ ▼ -age >= 18 ──► status = self_declared (adult) -age >= 13 ──► status = self_declared (minor, restricted) +age >= 18 ──► status = self_declared (adult) ← the current gate +age >= 13 ──► status = self_declared (minor, restricted) ← design only age < 13 ──► status = rejected (below minimum platform age) ``` diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn index 65b3cac..550c2fa 100644 --- a/scenes/main_menu.tscn +++ b/scenes/main_menu.tscn @@ -83,11 +83,11 @@ visible = false placeholder_text = "Email" size_flags_horizontal = 3 -[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] layout_mode = 1 visible = false -text = "I am 18 or older" size_flags_horizontal = 3 +placeholder_text = "Date of birth (YYYY-MM-DD)" [node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] layout_mode = 1 diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 99c8f8b..f9efc33 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -178,18 +178,19 @@ func login(username: String, password: String) -> Dictionary: ## Register a new user. On success stores the returned access token. -## @param over_18 Must be true; the backend rejects registration otherwise. +## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it +## and rejects under-18s (403). func register( username: String, email: String, password: String, - over_18: bool + date_of_birth: String ) -> Dictionary: var payload := { "username": username, "email": email, "password": password, - "over_18": over_18, + "date_of_birth": date_of_birth, } var result := await request( HTTPClient.METHOD_POST, diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index 824411d..617623c 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -35,7 +35,7 @@ var _auth_mode_label: Label var _username_edit: LineEdit var _password_edit: LineEdit var _email_edit: LineEdit -var _over_18_check: CheckBox +var _dob_edit: LineEdit var _auth_button: Button var _status_label: Label var _welcome_label: Label @@ -59,7 +59,7 @@ func _ready() -> void: _username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit _password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit _email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit - _over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check + _dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit _auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel @@ -115,7 +115,7 @@ func _show_authenticated(username: String) -> void: _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false - _over_18_check.visible = false + _dob_edit.visible = false _auth_button.visible = false _auth_mode_label.visible = false _play_button.disabled = false @@ -131,8 +131,8 @@ func _reset_to_logged_out() -> void: _status_label.visible = false _welcome_label.visible = false _email_edit.visible = false - _over_18_check.visible = false - _over_18_check.button_pressed = false + _dob_edit.visible = false + _dob_edit.text = "" _play_button.disabled = true # Gate play behind successful auth. _customize_button.disabled = true _update_auth_mode_ui() @@ -158,6 +158,19 @@ func _on_quit_button_pressed() -> void: get_tree().quit() +## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the +## future. The backend does the authoritative age computation. +func _is_valid_dob(dob: String) -> bool: + var m := RegEx.new() + m.compile("^\\d{4}-\\d{2}-\\d{2}$") + if m.search(dob) == null: + return false + var parts := dob.split("-") + var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0} + var unix := Time.get_unix_time_from_datetime_dict(d) + return unix > 0 and unix < Time.get_unix_time_from_system() + + # ─── Auth panel toggle ──────────────────────────────────────────────────────── func _on_login_button_pressed() -> void: @@ -184,12 +197,12 @@ func _update_auth_mode_ui() -> void: _auth_mode_label.text = "Register" _auth_button.text = "Register" _email_edit.visible = true - _over_18_check.visible = true + _dob_edit.visible = true else: _auth_mode_label.text = "Login" _auth_button.text = "Login" _email_edit.visible = false - _over_18_check.visible = false + _dob_edit.visible = false # ─── Auth submission ────────────────────────────────────────────────────────── @@ -216,8 +229,9 @@ func _on_auth_button_pressed() -> void: if email == "": _show_status("Email is required.") return - if not _over_18_check.button_pressed: - _show_status("You must confirm you are 18 or older to register.") + var dob := _dob_edit.text.strip_edges() + if not _is_valid_dob(dob): + _show_status("Enter your date of birth as YYYY-MM-DD.") return _auth_button.disabled = true @@ -225,7 +239,7 @@ func _on_auth_button_pressed() -> void: _is_authenticating = true _clear_status() - var result := await ApiClient.register(username, email, password, true) + var result := await ApiClient.register(username, email, password, dob) await _on_auth_complete(result) else: _auth_button.disabled = true @@ -251,7 +265,7 @@ func _on_auth_complete(result: Dictionary) -> void: _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false - _over_18_check.visible = false + _dob_edit.visible = false _auth_button.visible = false _auth_mode_label.visible = false _status_label.visible = false diff --git a/scripts/mp_game_flow_client.gd b/scripts/mp_game_flow_client.gd index 68d716f..36fe0ff 100644 --- a/scripts/mp_game_flow_client.gd +++ b/scripts/mp_game_flow_client.gd @@ -30,7 +30,7 @@ func _run() -> void: var session := root.get_node("Session") var uname := "mpflow_%s_%d" % [role, int(Time.get_unix_time_from_system() * 1000) % 1000000] - var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", true) + var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", "1990-01-01") if not reg.ok: print("RESULT %s register_failed %s" % [role, reg.detail]) quit(1); return diff --git a/scripts/smoke_test_auth.gd b/scripts/smoke_test_auth.gd index cc9182f..cfdde54 100644 --- a/scripts/smoke_test_auth.gd +++ b/scripts/smoke_test_auth.gd @@ -10,7 +10,7 @@ extends SceneTree ## Tests: ## 1. Register a unique user → 201 + token stored. ## 2. Register the same user again → 409 "Username already registered". -## 3. Register with over_18=false → 400 "Must be 18 or older". +## 3. Register with an under-18 DOB → 403 "at least 18". ## 4. Login with correct credentials → 200 + token stored. ## 5. Login with wrong password → 401 "Incorrect username or password". ## 6. Login with non-existent user → 401 "Incorrect username or password". @@ -44,7 +44,7 @@ func _run() -> void: var email := "smoke_%d@test.example.com" % unique_id print("\n=== Test 1: Register unique user ===") - var result := await api.register(username, email, TEST_PASSWORD, true) + var result := await api.register(username, email, TEST_PASSWORD, "1990-01-01") if _check("register returns ok", result.ok) \ and _check("register status is 201", result.status == 201) \ and _check("register stores token", api.has_token()): @@ -55,7 +55,7 @@ func _run() -> void: # --- Test 2: Register same user again (duplicate) --- print("\n=== Test 2: Duplicate username ===") - result = await api.register(username, email, TEST_PASSWORD, true) + result = await api.register(username, email, TEST_PASSWORD, "1990-01-01") if _check("duplicate register not ok", not result.ok) \ and _check("duplicate status is 409", result.status == 409) \ and _check("duplicate detail mentions username", @@ -65,14 +65,16 @@ func _run() -> void: failed += 1 print(" detail: ", result.detail) - # --- Test 3: Register with over_18=false --- - print("\n=== Test 3: over_18=false registration ===") + # --- Test 3: Register with an under-18 date of birth --- + print("\n=== Test 3: under-18 registration ===") var child_username := "child_%d" % unique_id var child_email := "child_%d@test.example.com" % unique_id - result = await api.register(child_username, child_email, TEST_PASSWORD, false) - if _check("over_18=false register not ok", not result.ok) \ - and _check("over_18=false status is 400", result.status == 400) \ - and _check("over_18=false detail mentions age", + var now := Time.get_datetime_dict_from_system() + var minor_dob := "%04d-01-01" % (int(now["year"]) - 14) + result = await api.register(child_username, child_email, TEST_PASSWORD, minor_dob) + if _check("under-18 register not ok", not result.ok) \ + and _check("under-18 status is 403", result.status == 403) \ + and _check("under-18 detail mentions age", "18" in result.detail): passed += 1 else: diff --git a/scripts/smoke_test_e2e.gd b/scripts/smoke_test_e2e.gd index 692aec9..e61fc78 100644 --- a/scripts/smoke_test_e2e.gd +++ b/scripts/smoke_test_e2e.gd @@ -69,7 +69,7 @@ func _run() -> void: # --- Step 2: Register ----------------------------------------------------- print("\n=== Step 2: Register (POST /api/auth/register) ===") - var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true) + var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, "1990-01-01") var reg_token := _extract_token(reg) var reg_ok: bool = ( reg.ok diff --git a/scripts/smoke_test_enter_world.gd b/scripts/smoke_test_enter_world.gd index d0377d8..7ab0ca6 100644 --- a/scripts/smoke_test_enter_world.gd +++ b/scripts/smoke_test_enter_world.gd @@ -35,7 +35,7 @@ func _run() -> void: var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000 var username := "enterworld_%d" % unique_id var email := "enterworld_%d@test.example.com" % unique_id - var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true) + var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01") if not _check("setup: register succeeded", reg_result.ok): print(" detail: ", reg_result.detail) _finish() diff --git a/scripts/smoke_test_main_menu_back.gd b/scripts/smoke_test_main_menu_back.gd index 6313c8d..99aa42b 100644 --- a/scripts/smoke_test_main_menu_back.gd +++ b/scripts/smoke_test_main_menu_back.gd @@ -49,7 +49,7 @@ func _run() -> void: var username := USERNAME % unique_id var email := EMAIL % unique_id print("\n=== Setup: register ", username, " ===") - var register_result: Dictionary = await api.register(username, email, PASSWORD, true) + var register_result: Dictionary = await api.register(username, email, PASSWORD, "1990-01-01") if not _check("register succeeds", register_result.ok): print(" detail: ", register_result.detail) _finish() diff --git a/scripts/smoke_test_main_menu_full_flow.gd b/scripts/smoke_test_main_menu_full_flow.gd index 71cc34d..9a23105 100644 --- a/scripts/smoke_test_main_menu_full_flow.gd +++ b/scripts/smoke_test_main_menu_full_flow.gd @@ -25,7 +25,7 @@ func _run() -> void: var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit") var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit") var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit") - var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check") + var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit") var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton") var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel") var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel") @@ -46,7 +46,7 @@ func _run() -> void: username_edit.text = "mainmenu_e2e_%d" % unique_id password_edit.text = "TestPass123!" email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id - over_18_check.button_pressed = true + dob_edit.text = "1990-01-01" # Call the submit handler directly (awaited) rather than via # auth_button.pressed.emit() -- a synthetic .emit() in --script/ @@ -68,7 +68,7 @@ func _run() -> void: _check("Username edit hidden after auth", not username_edit.visible) _check("Password edit hidden after auth", not password_edit.visible) _check("Email edit hidden after auth", not email_edit.visible) - _check("Over18 checkbox hidden after auth", not over_18_check.visible) + _check("DOB field hidden after auth", not dob_edit.visible) _check("Auth button hidden after auth", not auth_button.visible) _check("Auth mode label hidden after auth", not auth_mode_label.visible) _check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_")) diff --git a/scripts/smoke_test_main_menu_ui.gd b/scripts/smoke_test_main_menu_ui.gd index f5d62ab..248ceb0 100644 --- a/scripts/smoke_test_main_menu_ui.gd +++ b/scripts/smoke_test_main_menu_ui.gd @@ -31,7 +31,7 @@ func _run() -> void: var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel") var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel") var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit") - var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check") + var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit") # --- Test 1: auth panel starts hidden --- if _check("auth panel starts hidden", not auth_panel.visible): @@ -58,7 +58,7 @@ func _run() -> void: else: failed += 1 - if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible): + if _check("DOB field hidden in Login mode", not dob_edit.visible): passed += 1 else: failed += 1 @@ -82,7 +82,7 @@ func _run() -> void: else: failed += 1 - if _check("Over18 checkbox visible in Register mode", over_18_check.visible): + if _check("DOB field visible in Register mode", dob_edit.visible): passed += 1 else: failed += 1 @@ -101,7 +101,7 @@ func _run() -> void: else: failed += 1 - if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible): + if _check("DOB field hidden after switching back to Login", not dob_edit.visible): passed += 1 else: failed += 1 diff --git a/scripts/smoke_test_world_list.gd b/scripts/smoke_test_world_list.gd index b0b6231..1414ebb 100644 --- a/scripts/smoke_test_world_list.gd +++ b/scripts/smoke_test_world_list.gd @@ -58,7 +58,7 @@ func _run() -> void: var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000 var username := "worldlist_smoke_%d" % unique_id var email := "worldlist_smoke_%d@test.example.com" % unique_id - var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true) + var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01") if not _check("setup: register succeeded", reg_result.ok): print(" detail: ", reg_result.detail) quit(1)