Compare commits

..

8 Commits

Author SHA1 Message Date
claude-checkpoint-tooling 3a7b2ab306 Add PC1 checkpoint record + playtest instructions
Validation results from the 2026-08-20 checkpoint run (fresh clone,
commit 927f9bd: migrations, 68/68 pytest, all 4 Godot smoke tests, both
platform exports) plus manual playtest instructions for backend on
cloudbison / client on pinkbison. First of docs/checkpoints/PC<N>.md,
one per Package Checkpoint going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:17:18 +00:00
reviewer-bot 927f9bd95e Merge pull request 'End-to-end headless smoke test: register -> login -> fetch worlds' (#39) from agent/775379a9bedc into main 2026-08-20 00:26:58 +00:00
reviewer-bot 2d1f0111c3 Merge pull request 'Write auth endpoint tests (pytest)' (#38) from agent/daf820da4497 into main 2026-08-19 23:33:04 +00:00
tester-bot fcfb767c49 End-to-end headless smoke test: register -> login -> fetch worlds
Tester had already written a complete, correct test (4/4 steps pass
against a real backend) but stalled investigating DB/migration setup
after its own verification run silently exited with no result -- the
task spec's suggested invocation included --quit, which kills a --script
SceneTree test before its awaited HTTP calls finish (same Godot
--script-mode gotcha documented elsewhere this session).

Renamed smoke_test.gd -> smoke_test_e2e.gd: the task spec named it
without the checkpoint runner's expected smoke_test_*.gd suffix, so
scripts/run_checkpoint_tests.py's glob would have silently never picked
it up. Also fixed the --quit flag in the file's own usage comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:30:52 +00:00
tester-bot e2e771d2f8 WIP checkpoint: End-to-end headless smoke test + manual verify 2026-08-19 23:15:22 +00:00
reviewer-bot 8a5d3aabea Merge pull request 'Wire browse-worlds screen to GET /api/worlds' (#36) from agent/1de295a0da42 into main 2026-08-19 23:12:27 +00:00
tester-bot c5a5476620 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:50:22 +00:00
tester-bot 06604a24a8 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:49:19 +00:00
4 changed files with 441 additions and 0 deletions
+38
View File
@@ -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
+171
View File
@@ -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"]
+84
View File
@@ -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.
+148
View File
@@ -0,0 +1,148 @@
extends SceneTree
## End-to-end headless smoke test: register -> login -> fetch worlds list.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_e2e.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
## the AGP_API_BASE_URL environment variable).
##
## Steps:
## 1. Health check GET /api/health -> 200, {"status": "ok"}
## 2. Register POST /api/auth/register -> 201, access_token present
## 3. Login POST /api/auth/login -> 200, access_token present
## 4. Worlds list GET /api/worlds -> 200, JSON array
##
## Exits 0 if all steps pass, 1 otherwise. If the backend is unreachable the
## health check fails with a clear message and the script exits 1 immediately.
# Fixed credentials from the task spec. The username gets a unique numeric
# suffix so the test stays re-runnable (a second run would otherwise hit
# "Username already registered" on step 2).
const TEST_USERNAME_BASE := "smoke_test_user"
const TEST_EMAIL := "smoke@test.com"
const TEST_PASSWORD := "TestPass123"
const TEST_DOB := "2000-01-01"
func _initialize() -> void:
_run()
func _run() -> void:
# _initialize() fires before the first frame; wait one frame so freshly
# created HTTPRequest nodes are reliably inside the tree.
await process_frame
# In --script mode the autoload is reachable via get_node(), not as a
# bare identifier (bare identifiers fail to resolve at compile time here).
var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found in the scene tree")
var unique_id := int(Time.get_unix_time_from_system() * 1000)
var username := "%s_%d" % [TEST_USERNAME_BASE, unique_id]
var email := "smoke_%d@test.com" % unique_id
var results := {}
# --- Step 1: Health check -------------------------------------------------
print("\n=== Step 1: Health check (GET /api/health) ===")
var health: Dictionary = await api.health_check()
var health_ok: bool = (
health.ok
and health.status == 200
and health.data is Dictionary
and str((health.data as Dictionary).get("status", "")) == "ok"
)
_record(results, "health", health_ok, "status=%d data=%s" % [
health.status, health.data
])
if not health.ok or health.status == 0:
printerr("\nERROR: Backend is not reachable at the configured base URL.")
printerr(" detail: %s" % health.detail)
printerr(" Start the backend first (see backend/README), then re-run:")
printerr(" godot --headless --path . --script res://scripts/smoke_test_e2e.gd")
_summarize(results)
quit(1)
return
# --- Step 2: Register -----------------------------------------------------
print("\n=== Step 2: Register (POST /api/auth/register) ===")
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
var reg_token := _extract_token(reg)
var reg_ok: bool = (
reg.ok
and reg.status == 201
and reg_token != ""
)
_record(results, "register", reg_ok, "status=%d token=%s detail=%s" % [
reg.status, "yes" if reg_token != "" else "no", reg.detail
])
# --- Step 3: Login --------------------------------------------------------
print("\n=== Step 3: Login (POST /api/auth/login) ===")
var login: Dictionary = await api.login(username, TEST_PASSWORD)
var login_token := _extract_token(login)
var login_ok: bool = (
login.ok
and login.status == 200
and login_token != ""
)
_record(results, "login", login_ok, "status=%d token=%s detail=%s" % [
login.status, "yes" if login_token != "" else "no", login.detail
])
# --- Step 4: Worlds list ---------------------------------------------------
print("\n=== Step 4: Worlds list (GET /api/worlds) ===")
# login/register already stored the token; make the auth header explicit by
# re-applying the extracted token so step 4 is independent of storage order.
if login_token != "":
api.set_token(login_token)
var worlds: Dictionary = await api.get_worlds()
var worlds_ok: bool = (
worlds.ok
and worlds.status == 200
and worlds.data is Array
)
_record(results, "worlds", worlds_ok, "status=%d is_array=%s count=%d detail=%s" % [
worlds.status,
"yes" if (worlds.data is Array) else "no",
(worlds.data as Array).size() if (worlds.data is Array) else -1,
worlds.detail,
])
# --- Summary ---------------------------------------------------------------
_summarize(results)
func _extract_token(result: Dictionary) -> String:
if result.ok and result.data is Dictionary:
return str((result.data as Dictionary).get("access_token", ""))
return ""
func _record(results: Dictionary, name: String, ok: bool, detail: String) -> void:
results[name] = {"ok": ok, "detail": detail}
if ok:
print(" PASS: %s" % detail)
else:
printerr(" FAIL: %s" % detail)
func _summarize(results: Dictionary) -> void:
var all_pass := true
print("\n=== Smoke test summary ===")
for step in ["health", "register", "login", "worlds"]:
var entry: Dictionary = results.get(step, {"ok": false, "detail": "not run"})
if not entry["ok"]:
all_pass = false
print(" %-8s : %s" % [step, "PASS" if entry["ok"] else "FAIL"])
if all_pass:
print("SMOKE RESULT: ALL PASS (4/4)")
quit(0)
else:
printerr("SMOKE RESULT: FAIL")
quit(1)