diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 260042f..61e99ae 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,7 +5,7 @@ from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from jose import jwt +from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel, EmailStr, field_validator from sqlalchemy import select @@ -31,6 +31,10 @@ class TokenResponse(BaseModel): access_token_expires_minutes: int +class RefreshRequest(BaseModel): + refresh_token: str + + class RegisterRequest(BaseModel): username: str email: EmailStr @@ -62,8 +66,15 @@ class UserResponse(BaseModel): # --- Helpers --- def _create_access_token(data: dict) -> str: - """Create a signed access token using RS256.""" + """Create a signed access token using RS256. + + Includes a unique ``jti`` claim so that concurrent/same-second calls + (e.g. back-to-back /refresh requests) still produce distinct tokens — + without it, two tokens minted for the same user within the same + second are byte-identical since ``sub``/``exp`` are the only claims. + """ to_encode = data.copy() + to_encode["jti"] = str(uuid.uuid4()) expire = datetime.now(timezone.utc) + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) @@ -232,6 +243,72 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = ) +@router.post("/refresh", response_model=TokenResponse) +async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)): + """Exchange a valid refresh token for a new access token. + + Validates the refresh token against the database: + - Must exist in the refresh_tokens table + - Must not be expired (expires_at > now) + - Must be active (is_active == true) + + On success, returns a new access token. The refresh token itself is + NOT rotated (the client keeps using the same refresh token). + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + # Step 1: Decode and validate the JWT signature/claims + try: + payload = jwt.decode( + req.refresh_token, + settings.public_pem, + algorithms=[settings.ALGORITHM], + ) + except JWTError: + raise credentials_exception + + user_id_str: str | None = payload.get("sub") + if user_id_str is None: + raise credentials_exception + + try: + user_id = int(user_id_str) + except (ValueError, TypeError): + raise credentials_exception + + # Step 2: Look up the refresh token in the database + result = await db.execute( + select(RefreshToken).where( + RefreshToken.token == req.refresh_token, + RefreshToken.user_id == user_id, + ) + ) + stored_token = result.scalar_one_or_none() + + if stored_token is None: + raise credentials_exception + + # Step 3: Check if the token is expired + if datetime.now(timezone.utc) >= stored_token.expires_at: + raise credentials_exception + + # Step 4: Check if the token is active + if not stored_token.is_active: + raise credentials_exception + + # Step 5: Issue a new access token + access_token = _create_access_token({"sub": str(user_id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=req.refresh_token, # Return the same refresh token + access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, + ) + + @router.get("/me", response_model=UserResponse) async def get_current_user(current_user: CurrentUser): """Get current user profile from the JWT token.""" diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py new file mode 100644 index 0000000..127494a --- /dev/null +++ b/backend/tests/test_auth_endpoints.py @@ -0,0 +1,263 @@ +"""Integration tests for the auth endpoints (register + login). + +These tests hit the real FastAPI app against the real configured database +(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated +in-memory DB), so they exercise the full stack: Pydantic validation → DB → +JWT → response. Requires migrations already applied (alembic upgrade head) +before running. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.main import app +from app.database import get_db +from app.routers.auth import pwd_context + + +# --------------------------------------------------------------------------- +# Test fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _override_db(): + """Override get_db so the app uses a fresh session per test. + + Deliberately does NOT reuse app.database's shared engine: FastAPI's + (sync) TestClient runs each request through anyio's blocking-portal + machinery, which can hand different requests different event loops. + asyncpg connections are bound to the loop they were created on, so + reusing one from the shared engine's connection pool across requests + raises "cannot perform operation: another operation is in progress" the + moment a test makes more than one request (register-then-login, + register-twice, etc.) -- confirmed this is the actual cause, not a bug + in the endpoints themselves. NullPool sidesteps it: every checkout is a + brand-new connection, never handed to a different loop than the one + that opened it. + """ + test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False) + + async def _get_db(): + async_session = test_session_factory() + try: + yield async_session + await async_session.commit() + except Exception: + await async_session.rollback() + raise + finally: + await async_session.close() + + app.dependency_overrides[get_db] = _get_db + yield + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Register endpoint +# --------------------------------------------------------------------------- + +class TestRegister: + """Tests for POST /api/auth/register.""" + + def test_register_success(self): + """A valid registration returns 201 with a token.""" + client = TestClient(app) + resp = client.post( + "/api/auth/register", + json={ + "username": "newuser", + "email": "new@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + assert resp.status_code == 201 + data = resp.json() + 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.""" + client = TestClient(app) + resp = client.post( + "/api/auth/register", + json={ + "username": "kid", + "email": "kid@example.com", + "password": "secret123", + "date_of_birth": "2010-06-15", # ~15 years old + }, + ) + assert resp.status_code == 400 + assert "18 or older" in resp.json()["detail"] + + def test_register_rejects_duplicate_username(self): + """Registering the same username twice returns 409.""" + client = TestClient(app) + # First registration + client.post( + "/api/auth/register", + json={ + "username": "duplicate", + "email": "dup1@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + # Second registration with same username, different email + resp = client.post( + "/api/auth/register", + json={ + "username": "duplicate", + "email": "dup2@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + assert resp.status_code == 409 + assert "Username already registered" in resp.json()["detail"] + + def test_register_rejects_duplicate_email(self): + """Registering the same email twice returns 409.""" + client = TestClient(app) + client.post( + "/api/auth/register", + json={ + "username": "user1", + "email": "same@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + resp = client.post( + "/api/auth/register", + json={ + "username": "user2", + "email": "same@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + 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.""" + client = TestClient(app) + resp = client.post( + "/api/auth/register", + json={ + "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 +# --------------------------------------------------------------------------- + +class TestLogin: + """Tests for POST /api/auth/login.""" + + def _create_user(self, username: str, password: str, email: str = None): + """Helper to create a user via register.""" + client = TestClient(app) + if email is None: + email = f"{username}@example.com" + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": email, + "password": password, + "date_of_birth": "2000-01-01", + }, + ) + assert resp.status_code == 201 + return resp.json()["access_token"] + + def test_login_success(self): + """Correct credentials return a token.""" + self._create_user("loginuser", "correctpass") + client = TestClient(app) + resp = client.post( + "/api/auth/login", + data={"username": "loginuser", "password": "correctpass"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_login_wrong_password(self): + """Wrong password returns 401.""" + # Distinct username from test_login_success -- there's no per-test + # DB rollback/isolation here (real Postgres via the app's own + # dependency-overridden session), so reusing "loginuser" collided + # with the user that test already created (409 on register instead + # of the intended 401-on-login assertion this test is actually for). + self._create_user("loginuser_wrongpass", "correctpass") + client = TestClient(app) + resp = client.post( + "/api/auth/login", + data={"username": "loginuser_wrongpass", "password": "wrongpass"}, + ) + assert resp.status_code == 401 + assert "Incorrect username or password" in resp.json()["detail"] + + def test_login_nonexistent_user(self): + """Non-existent user returns 401 (not 404).""" + client = TestClient(app) + resp = client.post( + "/api/auth/login", + data={"username": "nobody", "password": "any"}, + ) + assert resp.status_code == 401 + assert "Incorrect username or password" in resp.json()["detail"] + + def test_login_accepts_form_encoded(self): + """Login accepts application/x-www-form-urlencoded (OAuth2).""" + self._create_user("formuser", "formpass") + client = TestClient(app) + resp = client.post( + "/api/auth/login", + data={"username": "formuser", "password": "formpass"}, + ) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_login_token_is_valid_jwt(self): + """The returned token is a valid JWT that can be decoded.""" + self._create_user("jwttoken", "jwt123") + client = TestClient(app) + resp = client.post( + "/api/auth/login", + data={"username": "jwttoken", "password": "jwt123"}, + ) + token = resp.json()["access_token"] + # Verify it's a 3-part JWT + parts = token.split(".") + assert len(parts) == 3 diff --git a/backend/tests/test_refresh_endpoint.py b/backend/tests/test_refresh_endpoint.py new file mode 100644 index 0000000..92e4322 --- /dev/null +++ b/backend/tests/test_refresh_endpoint.py @@ -0,0 +1,201 @@ +"""Tests for the POST /api/auth/refresh endpoint. + +These tests exercise the full stack: register → get refresh token → +refresh → new access token. Requires migrations already applied +(alembic upgrade head) before running. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.main import app +from app.database import get_db + + +# --------------------------------------------------------------------------- +# Test fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _override_db(): + """Override get_db so the app uses a fresh session per test.""" + test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False) + + async def _get_db(): + async_session = test_session_factory() + try: + yield async_session + await async_session.commit() + except Exception: + await async_session.rollback() + raise + finally: + await async_session.close() + + app.dependency_overrides[get_db] = _get_db + yield + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Refresh endpoint tests +# --------------------------------------------------------------------------- + +class TestRefresh: + """Tests for POST /api/auth/refresh.""" + + def _register_user(self, username: str, password: str, email: str = None): + """Helper to register a user and return the refresh token.""" + client = TestClient(app) + if email is None: + email = f"{username}@example.com" + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": email, + "password": password, + "date_of_birth": "2000-01-01", + }, + ) + assert resp.status_code == 201 + return resp.json()["refresh_token"] + + def test_refresh_success(self): + """A valid refresh token returns a new access token.""" + refresh_token = self._register_user("refep_refreshuser", "secret123") + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + # The refresh token in the response is the same one we sent + assert data["refresh_token"] == refresh_token + + def test_refresh_returns_different_access_token(self): + """Refreshing produces a new access token (different from any previous).""" + refresh_token = self._register_user("refep_refreshtoken", "secret123") + client = TestClient(app) + + # First refresh + resp1 = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp1.status_code == 200 + access_token_1 = resp1.json()["access_token"] + + # Second refresh + resp2 = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp2.status_code == 200 + access_token_2 = resp2.json()["access_token"] + + # Each refresh should produce a new access token + assert access_token_1 != access_token_2 + + def test_refresh_invalid_token(self): + """An invalid (garbage) refresh token returns 401.""" + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": "not.a.jwt.token"}, + ) + assert resp.status_code == 401 + assert "Invalid or expired refresh token" in resp.json()["detail"] + + def test_refresh_empty_body(self): + """Missing refresh_token field returns 422 validation error.""" + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={}, + ) + assert resp.status_code == 422 + + def test_refresh_missing_field(self): + """No body at all returns 422.""" + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + content="{}", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 422 + + def test_refresh_with_login_token(self): + """A refresh token obtained from login also works for /refresh.""" + client = TestClient(app) + # Register first + client.post( + "/api/auth/register", + json={ + "username": "refep_loginrefuser", + "email": "loginref@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + # Login to get a refresh token + login_resp = client.post( + "/api/auth/login", + data={"username": "refep_loginrefuser", "password": "secret123"}, + ) + assert login_resp.status_code == 200 + refresh_token = login_resp.json()["refresh_token"] + + # Use it to refresh + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_refresh_access_token_is_valid_jwt(self): + """The access token returned by /refresh is a valid JWT.""" + from jose import jwt + + refresh_token = self._register_user("refep_jwttoken", "secret123") + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + access_token = resp.json()["access_token"] + + # Verify it's a 3-part JWT + parts = access_token.split(".") + assert len(parts) == 3 + + # Verify it decodes with the public key + payload = jwt.decode( + access_token, + settings.public_pem, + algorithms=[settings.ALGORITHM], + ) + assert "sub" in payload + assert "exp" in payload + + def test_refresh_expires_minutes_in_response(self): + """The response includes access_token_expires_minutes.""" + refresh_token = self._register_user("refep_expiretest", "secret123") + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + assert "access_token_expires_minutes" in resp.json() + assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES diff --git a/scripts/smoke_test_auth.gd b/scripts/smoke_test_auth.gd new file mode 100644 index 0000000..63dfc22 --- /dev/null +++ b/scripts/smoke_test_auth.gd @@ -0,0 +1,133 @@ +extends SceneTree +## Headless smoke test for the AuthPanel login/register flow. +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_auth.gd +## +## Requires the backend to be running (default base URL +## `application/agp/api_base_url` -> http://127.0.0.1:8000). +## +## Tests: +## 1. Register a unique user → 201 + token stored. +## 2. Register the same user again → 409 "Username already registered". +## 3. Register an under-18 user → 400 "Must be 18 or older". +## 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". +## +## Exits 0 on full pass, 1 on any failure. + +const API_BASE := "http://127.0.0.1:8000" +const TEST_USERNAME := "smoke_test_user_%d" +const TEST_EMAIL := "smoke_%d@test.example.com" +const TEST_PASSWORD := "SmokeTest123!" +const TEST_DOB_ADULT := "2000-01-01" +const TEST_DOB_CHILD := "2020-01-01" # ~5 years old + + +func _initialize() -> void: + _run() + + +func _run() -> void: + # Wait for autoloads to be ready. + await process_frame + + var api := root.get_node("ApiClient") as ApiClient + assert(api != null, "ApiClient autoload not found") + + var passed: int = 0 + var failed: int = 0 + + # --- Test 1: Register a unique user --- + var unique_id := Time.get_ticks_msec() + var username := "smoke_%d" % unique_id + 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, TEST_DOB_ADULT) + if _check("register returns ok", result.ok) \ + and _check("register status is 201", result.status == 201) \ + and _check("register stores token", api.has_token()): + passed += 1 + else: + failed += 1 + print(" detail: ", result.detail) + + # --- Test 2: Register same user again (duplicate) --- + print("\n=== Test 2: Duplicate username ===") + result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT) + if _check("duplicate register not ok", not result.ok) \ + and _check("duplicate status is 409", result.status == 409) \ + and _check("duplicate detail mentions username", + "username" in result.detail.to_lower()): + passed += 1 + else: + failed += 1 + print(" detail: ", result.detail) + + # --- Test 3: Register under-18 user --- + 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, TEST_DOB_CHILD) + if _check("under-18 register not ok", not result.ok) \ + and _check("under-18 status is 400", result.status == 400) \ + and _check("under-18 detail mentions age", + "18" in result.detail): + passed += 1 + else: + failed += 1 + print(" detail: ", result.detail) + + # --- Test 4: Login with correct credentials --- + print("\n=== Test 4: Correct login ===") + result = await api.login(username, TEST_PASSWORD) + if _check("login returns ok", result.ok) \ + and _check("login status is 200", result.status == 200) \ + and _check("login stores token", api.has_token()): + passed += 1 + else: + failed += 1 + print(" detail: ", result.detail) + + # --- Test 5: Login with wrong password --- + print("\n=== Test 5: Wrong password ===") + result = await api.login(username, "WrongPassword!") + if _check("wrong pwd login not ok", not result.ok) \ + and _check("wrong pwd status is 401", result.status == 401) \ + and _check("wrong pwd detail", + "Incorrect" in result.detail or "incorrect" in result.detail): + passed += 1 + else: + failed += 1 + print(" detail: ", result.detail) + + # --- Test 6: Login with non-existent user --- + print("\n=== Test 6: Non-existent user ===") + result = await api.login("nonexistent_user_xyz", TEST_PASSWORD) + if _check("missing user login not ok", not result.ok) \ + and _check("missing user status is 401", result.status == 401) \ + and _check("missing user detail", + "Incorrect" in result.detail or "incorrect" in result.detail): + passed += 1 + else: + failed += 1 + print(" detail: ", result.detail) + + # --- Summary --- + print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) + if failed > 0: + quit(1) + else: + print("SMOKE RESULT: ALL PASS") + quit(0) + + +func _check(description: String, condition: bool) -> bool: + if condition: + print(" PASS: ", description) + return true + else: + printerr(" FAIL: ", description) + return false