Compare commits

..

14 Commits

Author SHA1 Message Date
coder-bot 72f39bc547 WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:58:20 +00:00
architect-bot ca7924d5e1 WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
architect-bot 758307bbee WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot 478638d4ff WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot 7b074a01ed WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot c68877239f WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot ed6811a438 WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
reviewer-bot f64ffd6422 Merge pull request 'Write headless Godot export CI script' (#32) from agent/a7d462fe1322 into main 2026-08-19 17:16:46 +00:00
reviewer-bot 7e28e2b525 Merge pull request 'Add test inventory + checkpoint test runner' (#31) from docs/test-inventory-and-runner into main 2026-08-19 17:01:42 +00:00
coder-bot 99be8f1dab Write headless Godot export CI script
Adds export_presets.cfg (Linux/X11 + Windows Desktop, x86_64) -- didn't
exist at all before this, which is why the task got stuck: you can't run
godot --export-release without presets already defined, and Godot only
auto-generates this file interactively through the editor. Hand-wrote a
minimal valid one and verified both platforms actually export and produce
real binaries (not just that the command exits 0):
- export/linux/agp-game.x86_64 -- confirmed real ELF 64-bit executable via `file`
- export/windows/agp-game.exe -- 104MB PE binary

scripts/build.sh wraps both exports, documents the exact Godot version +
export template requirement, and was run end-to-end (not just written) to
confirm it works as a real CI entrypoint would invoke it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:54:50 +00:00
reviewer-bot b3e66fc072 Merge pull request 'Architecture: ApiClient autoload (HTTP + JWT + error contract)' (#29) from agent/5b3da417b50c into main 2026-08-19 15:45:01 +00:00
claude-checkpoint-tooling 634b2e39c0 Add test inventory + checkpoint test runner
docs/testing/test_inventory.md: honest, current list of what automated
coverage actually exists (38 backend pytest tests across 2 files, 0 Godot
tests merged yet) and what doesn't (no integration/endpoint tests at all,
no coverage yet for the in-review RS256/moderation work) -- verified
against a genuinely fresh checkout + fresh Postgres, not just read off the
file tree.

scripts/run_checkpoint_tests.py: runs the whole inventory in one shot --
throwaway Postgres, migrations, pytest, then starts the backend server and
runs every scripts/smoke_test_*.gd against it headlessly. Verified working
both with and without a smoke test present (temporarily tested against
PC1's not-yet-merged scripts/api_client.gd + smoke_test_api.gd, removed
before this commit -- that PR carries its own files).

docs/testing/README.md: what running this at a Package Checkpoint review
does and doesn't prove, and how to grow the inventory as real coverage
gets added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:18:49 +00:00
reviewer-bot 1c75367c87 Merge pull request 'Address review feedback: Plan: MVP Vertical Slice: Client-Server Wiring' (#28) from agent/76aa20cd2e45 into main 2026-08-19 15:11:39 +00:00
planner-bot 6238078565 WIP checkpoint: Address review feedback: Plan: MVP Vertical Slice: Client-Server Wiring 2026-08-19 14:12:45 +00:00
14 changed files with 717 additions and 24 deletions
+7 -4
View File
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis # Redis
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://localhost:6379/0
# JWT # JWT / Auth
SECRET_KEY=change-me-to-a-secure-random-string ALGORITHM=RS256
ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=60
ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=30
# Directory where the RSA keypair is loaded/generated at runtime.
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
RSA_KEY_DIR=~/.agp/keys
# App # App
APP_NAME=AGP Backend APP_NAME=AGP Backend
@@ -0,0 +1,36 @@
"""add refresh_tokens table
Revision ID: 0003_add_refresh_tokens_table
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0003_add_refresh_tokens_table"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the refresh_tokens table."""
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
"""Drop the refresh_tokens table."""
op.drop_table("refresh_tokens")
+79 -5
View File
@@ -1,10 +1,56 @@
"""Application configuration via pydantic-settings.""" """Application configuration via pydantic-settings."""
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
generate_private_key,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
load_pem_public_key,
)
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
"""Load existing RSA keys or generate a new pair.
Keys are stored as PEM files in *key_dir*. The directory is created
if it does not exist. Files are named ``private.pem`` and
``public.pem`` and are **not** committed to git.
"""
private_path = key_dir / "private.pem"
public_path = key_dir / "public.pem"
if private_path.exists() and public_path.exists():
private_pem = private_path.read_bytes()
public_pem = public_path.read_bytes()
else:
# Generate a new 2048-bit RSA key pair
private_key = generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.PKCS8,
NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
Encoding.PEM,
PublicFormat.SubjectPublicKeyInfo,
)
key_dir.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
return private_pem, public_pem
class Settings(BaseSettings): class Settings(BaseSettings):
# App # App
APP_NAME: str = "AGP Backend" APP_NAME: str = "AGP Backend"
@@ -18,11 +64,13 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth # JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value ALGORITHM: str = "RS256"
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`) ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
SECRET_KEY: str = "change-me-to-a-secure-random-string" REFRESH_TOKEN_EXPIRE_DAYS: int = 30
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # RS256 key storage. Defaults to a directory outside the repo so the
# generated keypair is never accidentally committed.
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
# CORS # CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000" CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
@@ -42,6 +90,32 @@ class Settings(BaseSettings):
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@property
def rsa_key_dir(self) -> Path:
return Path(self.RSA_KEY_DIR)
@property
def private_pem(self) -> bytes:
"""Return the private key PEM bytes (loads/generates on first call)."""
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
return private_pem
@property
def public_pem(self) -> bytes:
"""Return the public key PEM bytes (loads/generates on first call)."""
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
return public_pem
@property
def private_key(self) -> RSAPrivateKey:
"""Return the deserialized RSA private key object."""
return load_pem_private_key(self.private_pem, password=None)
@property
def public_key(self) -> RSAPublicKey:
"""Return the deserialized RSA public key object."""
return load_pem_public_key(self.public_pem)
@lru_cache() @lru_cache()
def get_settings() -> Settings: def get_settings() -> Settings:
+1 -1
View File
@@ -27,7 +27,7 @@ async def get_current_user(
) )
try: try:
payload = jwt.decode( payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] token, settings.public_pem, algorithms=[settings.ALGORITHM]
) )
user_id: str | None = payload.get("sub") user_id: str | None = payload.get("sub")
if user_id is None: if user_id is None:
+21
View File
@@ -3,6 +3,7 @@ import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.database import Base from app.database import Base
@@ -18,3 +19,23 @@ class Session(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions") user = relationship("User", back_populates="sessions")
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
token = Column(String(512), unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
user = relationship("User", back_populates="refresh_tokens")
+1
View File
@@ -21,6 +21,7 @@ class User(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner") worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner") assets = relationship("Asset", back_populates="owner")
+71 -10
View File
@@ -1,20 +1,21 @@
"""Authentication router - login, register, token refresh.""" """Authentication router - login, register, token refresh."""
import uuid
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt from jose import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database import get_db from app.database import get_db
from app.dependencies import CurrentUser from app.dependencies import CurrentUser
from app.models.session import RefreshToken
from app.models.user import User from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -25,7 +26,9 @@ router = APIRouter()
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
access_token_expires_minutes: int
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
@@ -59,12 +62,36 @@ class UserResponse(BaseModel):
# --- Helpers --- # --- Helpers ---
def _create_access_token(data: dict) -> str: def _create_access_token(data: dict) -> str:
"""Create a signed access token using RS256."""
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta( expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
) )
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _create_refresh_token(data: dict) -> str:
"""Create a signed refresh token using RS256.
Includes a unique ``jti`` claim so that concurrent calls produce
distinct tokens even when issued within the same second.
"""
to_encode = data.copy()
to_encode["jti"] = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -97,11 +124,25 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
return age return age
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
"""Create and persist a refresh token for the given user."""
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user_id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc)
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
db.add(refresh_token_record)
return refresh_token_value
# --- Endpoints --- # --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): 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 access + refresh tokens.
Requires date_of_birth and rejects users under 18. Requires date_of_birth and rejects users under 18.
""" """
@@ -138,16 +179,26 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
date_of_birth=dob, date_of_birth=dob,
) )
db.add(user) db.add(user)
await db.commit() await db.flush() # flush to get user.id before creating refresh token
await db.refresh(user) await db.refresh(user)
# Create access token
access_token = _create_access_token({"sub": str(user.id)}) access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token. """Authenticate user and return access + refresh tokens.
Accepts form-encoded credentials (username/password) as expected by Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly. OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
@@ -167,8 +218,18 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
detail="User account is inactive", detail="User account is inactive",
) )
# Create access token
access_token = _create_access_token({"sub": str(user.id)}) access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
+79
View File
@@ -0,0 +1,79 @@
"""Tests for refresh token uniqueness (jti claim) and model column length."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from jose import jwt
from app.config import settings
from app.routers.auth import _create_refresh_token
from app.models.session import RefreshToken
class TestRefreshTokenJtiUniqueness:
"""Verify that _create_refresh_token produces unique tokens even when
called with identical data in rapid succession (issue #1 from review)."""
def test_two_calls_produce_different_tokens(self):
"""Two calls with the same sub produce different JWTs because of jti."""
token_a = _create_refresh_token({"sub": "42"})
token_b = _create_refresh_token({"sub": "42"})
assert token_a != token_b
def test_jti_is_valid_uuid(self):
"""The jti claim inside the refresh token is a valid UUID."""
token = _create_refresh_token({"sub": "1"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert "jti" in payload
# Verify it parses as a UUID (raises ValueError if not)
uuid.UUID(payload["jti"])
def test_each_token_has_unique_jti(self):
"""Generate 100 tokens for the same user — all jtis must differ."""
jtis = set()
for _ in range(100):
token = _create_refresh_token({"sub": "999"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
jtis.add(payload["jti"])
assert len(jtis) == 100
def test_jti_survives_decode(self):
"""The jti claim is present and correct after decode."""
token = _create_refresh_token({"sub": "7"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert payload["sub"] == "7"
assert "jti" in payload
assert "exp" in payload
class TestRefreshTokenModelColumnLength:
"""Verify the model's token column length matches the migration (issue #2)."""
def test_token_column_has_length_512(self):
"""RefreshToken.token column should be String(512), not unbounded."""
from sqlalchemy import inspect
# Get the column definition from the model's table
column = RefreshToken.__table__.columns["token"]
# The type should be String with length 512
assert column.type.length == 512
def test_token_column_type_is_string(self):
"""The token column should be a String type."""
from sqlalchemy import String as SAString
column = RefreshToken.__table__.columns["token"]
assert isinstance(column.type, SAString)
+8 -4
View File
@@ -23,10 +23,9 @@ explicitly out of scope.
| # | Type | Task | Depends on | | # | Type | Task | Depends on |
|---|------|------|-----------| |---|------|------|-----------|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — | | 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 | | 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 | | 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 | | 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
## Design decisions captured (resolved in task 1) ## Design decisions captured (resolved in task 1)
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left - Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
@@ -35,3 +34,8 @@ explicitly out of scope.
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend. never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`. - Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
- Login is form-encoded; register is JSON — the client helper must support both content types. - Login is form-encoded; register is JSON — the client helper must support both content types.
## Notes
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
- The world-list screen also needs to be **built** (a new scene), not just wired.
+50
View File
@@ -0,0 +1,50 @@
# Testing at Package Checkpoints
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
real milestones, the client and server builds get packaged together and
independently reviewed before anyone calls anything actually done. See
`project_agp` in Claude's memory, or ask, for the full definition; this
doc is just the mechanical "how do I run the tests" part of that review.
## Running the suite
```
python scripts/run_checkpoint_tests.py
```
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
backend pytest suite, starts the backend server, and runs every Godot
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
Postgres container down afterward (`--keep-postgres` to skip that if you
want to poke at the DB after a failure).
If port 5432 is already taken on the machine, pass `--postgres-port` with
something else.
Exit code is 0 only if everything that actually ran passed — suites with
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
reported SKIPPED, not treated as a failure.
## What this does and doesn't prove
Passing this script means: migrations apply cleanly to a genuinely empty
database, the backend's unit-level logic is correct, and whatever Godot
smoke tests exist actually round-trip against a live backend. It does
**not** mean full endpoint/integration coverage exists — see
`test_inventory.md` for the current, honest list of what's tested and what
isn't. A checkpoint review should still involve reading the actual diffs
for anything this script's current coverage doesn't reach, not just running
green and calling it done.
## Growing this over time
As real integration tests get added (endpoint tests hitting the FastAPI app,
more Godot smoke coverage beyond `health_check`), they should land under
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
picks up new files automatically, nothing to register by hand. Update
`test_inventory.md`'s tables when you do, so the inventory stays a source
of truth instead of quietly going stale.
Eventual goal (not implemented yet): gate merges to `main` on this script
passing, the same way a real CI pipeline would. For now it's a manual step
run at checkpoint review time.
+78
View File
@@ -0,0 +1,78 @@
# Test Inventory
What automated coverage actually exists, what it actually verifies, and what
doesn't have coverage yet. Keep this current as tests are added — it's the
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-19 (commit `1c75367`).
## Backend — `backend/tests/` (pytest)
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
reachable Postgres — see the runner script; most of these tests don't
actually touch the DB, but the app's import chain requires a valid URL to
construct).
**Current total: 38 tests, all passing on a clean `main` checkout.**
| File | Tests | What it covers |
|---|---|---|
| `test_auth_age_gate.py` | 10 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
**What these are, honestly:** pure unit tests of standalone functions
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
actually need Postgres reachable at all (the import chain just needs a
syntactically valid `DATABASE_URL`, nothing connects during collection).
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
these yet):
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
`/worlds`, `/assets/*` have zero tests that actually call them through
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
real (or test) database. Everything currently shipped works because it
was manually verified at merge time (checkpoint review), not because a
test asserts it.
- No tests for the RS256 auth upgrade (key generation, refresh-token
issuance/persistence) — PR #30, pending review as of this writing,
manually verified (correct key usage, all 38 existing tests still pass)
but not covered by an automated test of its own yet.
- No tests for the moderation schema/migration (in progress as of this
writing) beyond "the migration runs."
## Godot client — `scripts/smoke_test_*.gd`
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
is the only Godot script on `main` right now, and it's **not a real
test** (a leftover manual probe, returns a hardcoded string, asserts
nothing). Don't count it toward coverage; delete or repurpose it if anyone
actually needs a sandbox probe again.
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
will be the first real one once merged — `ApiClient` autoload's
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
at one), verified by hand end-to-end before opening the PR: exercises the
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
result-dictionary contract), not just that the script compiles. Exits 1 on
failure, so it's CI/script-friendly.
Run (once merged): `godot --headless --path . --import` once (builds the
class cache — skip this and every `class_name`-declared class falsely
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
`godot --headless --path . --script res://scripts/<name>.gd`
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
`login`, `register`, `get_worlds`, `get_me` are implemented (see
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
real user/world fixture in the target backend to test against meaningfully.
## What "full coverage" should eventually mean here
Not defined precisely yet — the honest target as of 2026-08-19 is just
"more than pure-function unit tests." As real integration/endpoint tests get
added, update the tables above rather than letting this doc drift stale;
a wrong inventory is worse than no inventory.
+74
View File
@@ -0,0 +1,74 @@
[preset.0]
name="Linux"
platform="Linux/X11"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="export/linux/agp-game.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="export/windows/agp-game.exe"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
codesign/enable=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
d3d12/agility_sdk_multizip=0
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Headless CI export for Linux and Windows.
#
# Requires:
# - Godot 4.6.2 (the exact version this project + export_presets.cfg were
# built against; a different Godot minor version's export templates are
# not guaranteed compatible)
# - The matching export templates installed at
# ~/.local/share/godot/export_templates/4.6.2.stable/
# (download: https://godotengine.org/download/archive/4.6.2-stable/,
# "Export templates" -- NOT the same as the editor download)
# - godot on PATH (or set GODOT_BIN below)
#
# Usage: scripts/build.sh
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
set -euo pipefail
cd "$(dirname "$0")/.."
GODOT_BIN="${GODOT_BIN:-godot}"
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
if [ -x "$HOME/godot/godot" ]; then
GODOT_BIN="$HOME/godot/godot"
else
echo "godot binary not found (checked PATH and ~/godot/godot)." >&2
exit 1
fi
fi
echo "Godot version: $("$GODOT_BIN" --version)"
mkdir -p export/linux export/windows
echo "=== Exporting Linux/X11 ==="
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
echo "=== Exporting Windows Desktop ==="
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
echo ""
echo "Build artifacts:"
ls -la export/linux export/windows
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Run the full automated test inventory for a Package Checkpoint review.
See docs/testing/test_inventory.md for what each suite actually covers (and
doesn't) -- keep that doc in sync as tests are added, this script just runs
whatever's there.
Usage:
python scripts/run_checkpoint_tests.py [--keep-postgres] [--postgres-port PORT]
Exits 0 if everything that ran passed, 1 otherwise. Suites with nothing to
run (e.g. no scripts/smoke_test_*.gd yet, or no Godot binary found) are
reported as SKIPPED, not counted as a failure.
"""
import argparse
import glob
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BACKEND = ROOT / "backend"
def run(cmd, cwd=None, env=None, capture=False):
print(f"$ {' '.join(cmd)}" + (f" (cwd={cwd})" if cwd else ""))
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=capture, text=True)
def find_godot():
for candidate in ("godot", str(Path.home() / "godot" / "godot")):
if shutil.which(candidate) or Path(candidate).is_file():
return candidate
return None
def wait_for_http(url: str, timeout_s: int = 20) -> bool:
import urllib.request
import urllib.error
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, ConnectionError, OSError):
pass
time.sleep(1)
return False
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--keep-postgres", action="store_true",
help="Don't tear down the checkpoint Postgres container afterward")
parser.add_argument("--postgres-port", default=os.environ.get("CHECKPOINT_POSTGRES_PORT", "5432"),
help="Host port for the checkpoint's own Postgres container (default 5432 -- "
"override if that's already in use on this machine)")
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"),
help="Port to run the backend server on for the Godot smoke tests")
args = parser.parse_args()
results = {}
# --- Backend: bring up Postgres ---
compose_env = os.environ.copy()
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port
compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py
print("=== Backend: bringing up Postgres ===")
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
print("Waiting for Postgres to be ready...")
pg_ready = False
for _ in range(20):
r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"],
cwd=BACKEND, env=compose_env, capture=True)
if r.returncode == 0:
pg_ready = True
break
time.sleep(1)
if not pg_ready:
print("Postgres never became ready -- aborting.")
sys.exit(1)
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
backend_env = os.environ.copy()
backend_env["DATABASE_URL"] = db_url
print("\n=== Backend: alembic upgrade head ===")
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
results["migrations (fresh DB)"] = r.returncode == 0
print("\n=== Backend: pytest ===")
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
results["backend pytest"] = r.returncode == 0
# --- Godot: headless smoke tests (need a live backend server) ---
godot_bin = find_godot()
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd")))
if not godot_bin:
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
results["godot smoke tests"] = None
elif not smoke_tests:
print("\n=== Godot: no scripts/smoke_test_*.gd found yet, nothing to run ===")
results["godot smoke tests"] = None
else:
print(f"\n=== Backend: starting server on :{args.backend_port} for Godot smoke tests ===")
server_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", args.backend_port],
cwd=BACKEND, env=backend_env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
base_url = f"http://127.0.0.1:{args.backend_port}"
if not wait_for_http(f"{base_url}/api/health"):
print("Backend server never became healthy -- skipping Godot smoke tests.")
results["godot smoke tests"] = False
else:
godot_env = os.environ.copy()
godot_env["AGP_API_BASE_URL"] = base_url
print("=== Godot: import pass (builds the class cache) ===")
run([godot_bin, "--headless", "--path", str(ROOT), "--import"], env=godot_env)
run([godot_bin, "--headless", "--path", str(ROOT), "--quit"], env=godot_env)
godot_ok = True
for test in smoke_tests:
rel = os.path.relpath(test, ROOT)
print(f"\n--- {rel} ---")
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
if r.returncode != 0:
godot_ok = False
results["godot smoke tests"] = godot_ok
finally:
server_proc.terminate()
try:
server_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
server_proc.kill()
if not args.keep_postgres:
print("\n=== Backend: tearing down checkpoint Postgres ===")
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
# --- Summary ---
print("\n" + "=" * 50)
print("SUMMARY")
print("=" * 50)
overall_ok = True
for name, ok in results.items():
if ok is None:
print(f" {name}: SKIPPED")
elif ok:
print(f" {name}: PASS")
else:
print(f" {name}: FAIL")
overall_ok = False
print("\nSee docs/testing/test_inventory.md for what each suite actually covers (and doesn't).")
sys.exit(0 if overall_ok else 1)
if __name__ == "__main__":
main()