Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a7b2ab306 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| c5a5476620 | |||
| 06604a24a8 |
@@ -0,0 +1,38 @@
|
|||||||
|
"""Shared pytest fixtures.
|
||||||
|
|
||||||
|
The auth integration tests hit the *real* Postgres database (per
|
||||||
|
``DATABASE_URL``), so they persist data between runs. Without cleanup a
|
||||||
|
second run of the suite fails with 409 conflicts because the users it
|
||||||
|
registered on the first run already exist. The session-scoped fixture below
|
||||||
|
truncates all application tables once before the first test, making the suite
|
||||||
|
re-runnable.
|
||||||
|
|
||||||
|
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
|
||||||
|
keys, but the tables are listed explicitly for clarity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _reset_database():
|
||||||
|
"""Truncate all application tables once per test session."""
|
||||||
|
|
||||||
|
async def _truncate() -> None:
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
|
||||||
|
)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_truncate())
|
||||||
|
yield
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for expired-token rejection across auth endpoints.
|
||||||
|
|
||||||
|
Covers two distinct expiry paths:
|
||||||
|
|
||||||
|
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
|
||||||
|
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
|
||||||
|
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
|
||||||
|
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
|
||||||
|
signature is still valid.
|
||||||
|
|
||||||
|
Requires migrations already applied (alembic upgrade head) before running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from jose import jwt
|
||||||
|
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.models.session import RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mint_access_token(exp: datetime) -> str:
|
||||||
|
"""Mint an RS256 access token with an explicit ``exp`` claim."""
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": "1", "exp": exp},
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _expire_refresh_token_in_db(token: str) -> None:
|
||||||
|
"""Set the persisted refresh token's ``expires_at`` into the past."""
|
||||||
|
|
||||||
|
async def _do():
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.token == token)
|
||||||
|
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_do())
|
||||||
|
|
||||||
|
|
||||||
|
def _register_user(client: TestClient, username: str) -> str:
|
||||||
|
"""Register a user and return its refresh token."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": f"{username}@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "2000-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expired access token (GET /api/auth/me)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExpiredAccessToken:
|
||||||
|
def test_expired_access_token_is_rejected_by_me(self):
|
||||||
|
"""An access JWT with a past exp claim returns 401 on /me."""
|
||||||
|
client = TestClient(app)
|
||||||
|
expired_token = _mint_access_token(
|
||||||
|
datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
|
)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {expired_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Could not validate credentials" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_garbage_access_token_is_rejected_by_me(self):
|
||||||
|
"""A non-JWT bearer token returns 401 on /me."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/me",
|
||||||
|
headers={"Authorization": "Bearer not-a-real-jwt"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_missing_bearer_token_returns_401(self):
|
||||||
|
"""No Authorization header at all returns 401 on /me."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expired refresh token (POST /api/auth/refresh)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExpiredRefreshToken:
|
||||||
|
def test_expired_refresh_token_is_rejected(self):
|
||||||
|
"""A refresh token whose DB expires_at is in the past returns 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
refresh_token = _register_user(client, "expired_rt_user")
|
||||||
|
|
||||||
|
# Force the persisted record to be expired while its JWT signature
|
||||||
|
# is still valid.
|
||||||
|
_expire_refresh_token_in_db(refresh_token)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_expired_refresh_token_jwt_is_rejected(self):
|
||||||
|
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
|
||||||
|
client = TestClient(app)
|
||||||
|
expired_jwt = jwt.encode(
|
||||||
|
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": expired_jwt},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Package Checkpoint 1 (PC1)
|
||||||
|
|
||||||
|
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
|
||||||
|
clone (not a reused agent workspace).
|
||||||
|
|
||||||
|
## Automated validation
|
||||||
|
|
||||||
|
Run with `scripts/run_checkpoint_tests.py` — see
|
||||||
|
`docs/testing/test_inventory.md` for exactly what each suite covers.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Alembic migrations, fresh DB | PASS |
|
||||||
|
| Backend pytest | 68/68 PASS |
|
||||||
|
| Godot smoke tests (4 files) | ALL PASS |
|
||||||
|
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
|
||||||
|
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
|
||||||
|
|
||||||
|
## Problems found during this checkpoint (fixed, tracked as tasks)
|
||||||
|
|
||||||
|
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
|
||||||
|
done — a historical data gap (a review-feedback follow-up task's
|
||||||
|
supersession link was never recorded, predating when that field started
|
||||||
|
being tracked). Backfilled in the PM database; confirmed green.
|
||||||
|
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
|
||||||
|
actually 68; said no integration tests existed, 4 real ones had landed).
|
||||||
|
Fixed via task `398bf1a22a2d` / PR #40.
|
||||||
|
|
||||||
|
## What's actually testable right now
|
||||||
|
|
||||||
|
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
|
||||||
|
the real scope of PC1; don't expect more when playtesting.
|
||||||
|
|
||||||
|
- Register a new account (18+ only, rejected otherwise)
|
||||||
|
- Log out, log back in with the same credentials
|
||||||
|
- Play → Browse Worlds — loads a (currently empty) world list with no errors
|
||||||
|
|
||||||
|
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||||
|
|
||||||
|
Cloudbison's LAN IP: `192.168.58.138`. Pinkbison's: `192.168.58.124`.
|
||||||
|
|
||||||
|
### 1. On cloudbison — start a real (persistent, not throwaway) backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
git clone http://localhost:3000/daddy/agp-game.git agp-game-playtest
|
||||||
|
cd agp-game-playtest/backend
|
||||||
|
docker compose up -d postgres
|
||||||
|
docker compose exec -T postgres pg_isready -U agp_user -d agp_db # wait for this to succeed
|
||||||
|
|
||||||
|
export DATABASE_URL="postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
||||||
|
export JWT_ALGORITHM="RS256"
|
||||||
|
/home/daddy/agp/venv/bin/python3 -m alembic upgrade head
|
||||||
|
nohup /home/daddy/agp/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/agp-backend.log 2>&1 &
|
||||||
|
disown
|
||||||
|
```
|
||||||
|
|
||||||
|
`--host 0.0.0.0` matters — the default `127.0.0.1` only listens on loopback
|
||||||
|
and pinkbison won't be able to reach it. Verify: `curl
|
||||||
|
http://localhost:8000/api/health` should return `{"status":"ok",...}`.
|
||||||
|
|
||||||
|
### 2. On pinkbison — get the game and point it at cloudbison
|
||||||
|
|
||||||
|
Copy `export/windows/agp-game.exe` (or `export/linux/agp-game.x86_64`,
|
||||||
|
whichever matches pinkbison's OS) from cloudbison, or clone the repo there
|
||||||
|
and run `scripts/build.sh` locally. Launch with the backend URL as an env
|
||||||
|
var so you don't have to rebuild to change it:
|
||||||
|
|
||||||
|
- Windows (PowerShell): `$env:AGP_API_BASE_URL="http://192.168.58.138:8000"; .\agp-game.exe`
|
||||||
|
- Linux: `AGP_API_BASE_URL=http://192.168.58.138:8000 ./agp-game.x86_64`
|
||||||
|
|
||||||
|
### 3. Tear down when done
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on cloudbison
|
||||||
|
pkill -f "uvicorn app.main:app"
|
||||||
|
cd ~/agp-game-playtest/backend && docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
|
||||||
|
restricts agent claiming to the 5 PC1-scope features. Agents will not move
|
||||||
|
on to post-PC1 work until that's widened.
|
||||||
Reference in New Issue
Block a user