From 433d5d8f9fcca792fd271292746ce5b8f130c782 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:38:39 +0000 Subject: [PATCH 1/6] WIP checkpoint: Enforce 18+ age gate at account registration --- backend/app/models/user.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ff48caa..b893418 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from datetime import date +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -12,6 +13,7 @@ class User(Base): username = Column(String(50), unique=True, index=True, nullable=False) email = Column(String(120), unique=True, index=True, nullable=False) hashed_password = Column(String(255), nullable=False) + date_of_birth = Column(Date, nullable=True) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) -- 2.52.0 From 1d0222d9801a582c7c33049f13ee8f091ba3be65 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:39:29 +0000 Subject: [PATCH 2/6] WIP checkpoint: Enforce 18+ age gate at account registration --- backend/app/models/user.py | 14 +++++++++++++ backend/app/routers/auth.py | 40 +++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index b893418..8ce7f88 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,6 @@ from datetime import date +from typing import Optional + from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -21,3 +23,15 @@ class User(Base): sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") worlds = relationship("World", back_populates="owner") assets = relationship("Asset", back_populates="owner") + + @property + def is_adult(self) -> bool: + """Return True if the user is 18 or older based on date_of_birth.""" + if self.date_of_birth is None: + return False + today = date.today() + age = today.year - self.date_of_birth.year + # Adjust if birthday hasn't occurred yet this year + if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day): + age -= 1 + return age >= 18 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5059174..380e29c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator from jose import jwt from passlib.context import CryptContext @@ -32,12 +32,24 @@ class RegisterRequest(BaseModel): username: str email: EmailStr password: str + date_of_birth: str # ISO format date string (YYYY-MM-DD) + + @field_validator("date_of_birth") + @classmethod + def validate_date_of_birth(cls, v: str) -> str: + """Validate that the date string is a valid date in YYYY-MM-DD format.""" + try: + datetime.strptime(v, "%Y-%m-%d") + except ValueError: + raise ValueError("date_of_birth must be in YYYY-MM-DD format") + return v class UserResponse(BaseModel): id: int username: str email: str + date_of_birth: str | None = None is_active: bool created_at: datetime | None = None @@ -65,11 +77,34 @@ def hash_password(password: str) -> str: return pwd_context.hash(password) +def calculate_age(birth_date: datetime) -> int: + """Calculate age in years from a birth date.""" + 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 + + # --- Endpoints --- @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): - """Register a new user and return an access token.""" + """Register a new user and return an access token. + + Requires date_of_birth and rejects users under 18. + """ + # Parse and validate date_of_birth + dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date() + age = calculate_age(dob) + + if age < 18: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})", + ) + # Check if username already exists result = await db.execute(select(User).where(User.username == req.username)) if result.scalar_one_or_none(): @@ -90,6 +125,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=dob, ) db.add(user) await db.commit() -- 2.52.0 From e4d3fe566b866cbd5c07b08824d5f62ddd05433b Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:39:49 +0000 Subject: [PATCH 3/6] WIP checkpoint: Enforce 18+ age gate at account registration --- .../0002_add_date_of_birth_to_users.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/alembic/versions/0002_add_date_of_birth_to_users.py diff --git a/backend/alembic/versions/0002_add_date_of_birth_to_users.py b/backend/alembic/versions/0002_add_date_of_birth_to_users.py new file mode 100644 index 0000000..7113228 --- /dev/null +++ b/backend/alembic/versions/0002_add_date_of_birth_to_users.py @@ -0,0 +1,30 @@ +"""add date_of_birth column to users table + +Revision ID: 0002_add_date_of_birth_to_users +Revises: 0001_initial +Create Date: 2025-01-01 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0002_add_date_of_birth_to_users" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add date_of_birth column to users table.""" + op.add_column( + "users", + sa.Column("date_of_birth", sa.Date(), nullable=True), + ) + + +def downgrade() -> None: + """Remove date_of_birth column from users table.""" + op.drop_column("users", "date_of_birth") -- 2.52.0 From ef646a8e548101ed7755b4d62274a2399900c51e Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:48:18 +0000 Subject: [PATCH 4/6] WIP checkpoint: Enforce 18+ age gate at account registration --- backend/pytest.ini | 3 ++ backend/tests/__init__.py | 0 backend/tests/test_auth_age_gate.py | 64 +++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 backend/pytest.ini create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_auth_age_gate.py diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_auth_age_gate.py b/backend/tests/test_auth_age_gate.py new file mode 100644 index 0000000..389eaf5 --- /dev/null +++ b/backend/tests/test_auth_age_gate.py @@ -0,0 +1,64 @@ +"""Tests for the 18+ age gate on user registration.""" + +from datetime import date, timedelta, datetime +from unittest.mock import patch + +import pytest + +from app.routers.auth import calculate_age + + +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 = datetime.now().date() + dob = today.replace(year=today.year - 18) + assert calculate_age(dob) == 18 + + def test_just_turned_18(self): + """A person who turned 18 yesterday is 18.""" + today = datetime.now().date() + dob = today.replace(year=today.year - 18) - timedelta(days=1) + assert calculate_age(dob) == 18 + + def test_one_day_short_of_18(self): + """A person born one day after their 18th birthday is 17.""" + today = datetime.now().date() + dob = today.replace(year=today.year - 18) + timedelta(days=1) + assert calculate_age(dob) == 17 + + def test_17_years_old(self): + """A person who is 17 years and 364 days old is 17.""" + today = datetime.now().date() + dob = today.replace(year=today.year - 17) + timedelta(days=1) + assert calculate_age(dob) == 17 + + def test_65_years_old(self): + """A person who is 65 is correctly calculated.""" + today = datetime.now().date() + dob = today.replace(year=today.year - 65) + assert calculate_age(dob) == 65 + + def test_newborn(self): + """A newborn is 0.""" + today = datetime.now().date() + dob = today + assert calculate_age(dob) == 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) + with patch("app.routers.auth.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2025, 1, 1) + mock_dt.now.return_value.date.return_value = date(2025, 1, 1) + assert calculate_age(dob) == 24 + + def test_birthday_today(self): + """Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24.""" + dob = date(2000, 12, 31) + with patch("app.routers.auth.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2024, 12, 31) + mock_dt.now.return_value.date.return_value = date(2024, 12, 31) + assert calculate_age(dob) == 24 -- 2.52.0 From 62889f39f1a6cd12bf128d82b95c366ed8288a14 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 15:32:16 +0000 Subject: [PATCH 5/6] WIP checkpoint: Enforce 18+ age gate at account registration --- backend/app/routers/auth.py | 18 +++++-- backend/tests/test_auth_age_gate.py | 78 ++++++++++++++++++++--------- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 380e29c..28f5848 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ """Authentication router - login, register, token refresh.""" -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 @@ -77,9 +77,19 @@ def hash_password(password: str) -> str: return pwd_context.hash(password) -def calculate_age(birth_date: datetime) -> int: - """Calculate age in years from a birth date.""" - today = datetime.now(timezone.utc).date() +def calculate_age(birth_date: date, today: date | None = None) -> int: + """Calculate age in years from a birth date. + + 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): diff --git a/backend/tests/test_auth_age_gate.py b/backend/tests/test_auth_age_gate.py index 389eaf5..73a9f0a 100644 --- a/backend/tests/test_auth_age_gate.py +++ b/backend/tests/test_auth_age_gate.py @@ -1,7 +1,6 @@ """Tests for the 18+ age gate on user registration.""" -from datetime import date, timedelta, datetime -from unittest.mock import patch +from datetime import date, timedelta import pytest @@ -13,52 +12,83 @@ class TestCalculateAge: def test_exactly_18(self): """A person born exactly 18 years ago today is 18.""" - today = datetime.now().date() + today = date(2025, 6, 15) dob = today.replace(year=today.year - 18) - assert calculate_age(dob) == 18 + assert calculate_age(dob, today=today) == 18 def test_just_turned_18(self): """A person who turned 18 yesterday is 18.""" - today = datetime.now().date() + today = date(2025, 6, 15) dob = today.replace(year=today.year - 18) - timedelta(days=1) - assert calculate_age(dob) == 18 + 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 = datetime.now().date() + today = date(2025, 6, 15) dob = today.replace(year=today.year - 18) + timedelta(days=1) - assert calculate_age(dob) == 17 + assert calculate_age(dob, today=today) == 17 def test_17_years_old(self): - """A person who is 17 years and 364 days old is 17.""" - today = datetime.now().date() - dob = today.replace(year=today.year - 17) + timedelta(days=1) - assert calculate_age(dob) == 17 + """A person who is 17 years old today is 17.""" + today = date(2025, 6, 15) + dob = date(2007, 6, 15) + assert calculate_age(dob, today=today) == 17 def test_65_years_old(self): """A person who is 65 is correctly calculated.""" - today = datetime.now().date() + today = date(2025, 6, 15) dob = today.replace(year=today.year - 65) - assert calculate_age(dob) == 65 + assert calculate_age(dob, today=today) == 65 def test_newborn(self): """A newborn is 0.""" - today = datetime.now().date() + today = date(2025, 6, 15) dob = today - assert calculate_age(dob) == 0 + 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) - with patch("app.routers.auth.datetime") as mock_dt: - mock_dt.now.return_value = datetime(2025, 1, 1) - mock_dt.now.return_value.date.return_value = date(2025, 1, 1) - assert calculate_age(dob) == 24 + 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) - with patch("app.routers.auth.datetime") as mock_dt: - mock_dt.now.return_value = datetime(2024, 12, 31) - mock_dt.now.return_value.date.return_value = date(2024, 12, 31) - assert calculate_age(dob) == 24 + assert calculate_age(dob, today=date(2024, 12, 31)) == 24 + + +class TestRegisterRequestDateValidation: + """Tests for the RegisterRequest date_of_birth field validator.""" + + @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.""" + 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 + + @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.""" + from app.routers.auth import RegisterRequest + + with pytest.raises(ValueError): + RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob) -- 2.52.0 From cb958956126925d1bb36ec123133a76269de908e Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 15:33:32 +0000 Subject: [PATCH 6/6] Recovered orphaned WIP before switching to a new task --- backend/tests/test_auth_age_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_auth_age_gate.py b/backend/tests/test_auth_age_gate.py index 73a9f0a..22a1f2a 100644 --- a/backend/tests/test_auth_age_gate.py +++ b/backend/tests/test_auth_age_gate.py @@ -31,7 +31,7 @@ class TestCalculateAge: def test_17_years_old(self): """A person who is 17 years old today is 17.""" today = date(2025, 6, 15) - dob = date(2007, 6, 15) + dob = date(2008, 6, 15) assert calculate_age(dob, today=today) == 17 def test_65_years_old(self): -- 2.52.0