Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc0c133af8 | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| 3dd5416e81 | |||
| c5a5476620 | |||
| 06604a24a8 | |||
| 5c21e5051c | |||
| 3cb4d01073 | |||
| 3e81d60268 | |||
| b3d2d71789 | |||
| b3227f5b78 | |||
| 2ba6bc6b9f | |||
| 7d89e860fd | |||
| 03da22aa9e | |||
| 55a65bb7b1 | |||
| 62e15d0c87 | |||
| cbe5e992dd | |||
| d4998d7359 | |||
| 87575eec43 | |||
| 72f39bc547 | |||
| ca7924d5e1 | |||
| 758307bbee | |||
| 478638d4ff | |||
| 7b074a01ed | |||
| c68877239f | |||
| ed6811a438 |
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ class World(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
visibility = Column(
|
visibility = Column(
|
||||||
ENUM(WorldVisibility, name="world_visibility", create_type=False),
|
ENUM(
|
||||||
|
WorldVisibility,
|
||||||
|
name="world_visibility",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||||
|
),
|
||||||
default=WorldVisibility.PUBLIC,
|
default=WorldVisibility.PUBLIC,
|
||||||
)
|
)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
+149
-11
@@ -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 JWTError, 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,13 @@ 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 RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
@@ -59,12 +66,43 @@ 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.
|
||||||
|
|
||||||
|
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 = data.copy()
|
||||||
|
to_encode["jti"] = str(uuid.uuid4())
|
||||||
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 +135,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 +190,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 +229,84 @@ 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.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)
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Worlds router - CRUD for game worlds."""
|
"""Worlds router - CRUD for game worlds."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@@ -40,7 +41,7 @@ class WorldResponse(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
visibility: WorldVisibility
|
visibility: WorldVisibility
|
||||||
owner_id: int
|
owner_id: int
|
||||||
created_at: str | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ pydantic==2.10.3
|
|||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
# passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
|
||||||
|
# backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed --
|
||||||
|
# hashing/verifying a password then raises "password cannot be longer than
|
||||||
|
# 72 bytes" (a misleading downstream symptom of the real AttributeError:
|
||||||
|
# module 'bcrypt' has no attribute '__about__') on every call. Without this
|
||||||
|
# pin, `pip install -r requirements.txt` silently grabs whatever the latest
|
||||||
|
# bcrypt is and breaks register/login outright. Pin below 4.1 explicitly.
|
||||||
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||||
|
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||||
|
|
||||||
|
### 1. On cloudbison — the AGP dashboard
|
||||||
|
|
||||||
|
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||||
|
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||||
|
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||||
|
the API server, all in one step. The page shows the LAN address it's
|
||||||
|
reachable at once it's up (under a minute).
|
||||||
|
|
||||||
|
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||||
|
it tears down everything that step started (API server, Postgres
|
||||||
|
container, its volume and network). Nothing is left running or lying
|
||||||
|
around afterward, even if the dashboard itself gets restarted in between.
|
||||||
|
|
||||||
|
### 2. On pinkbison — download and run the .exe
|
||||||
|
|
||||||
|
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||||
|
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||||
|
binary with cloudbison's backend address already baked in. Click
|
||||||
|
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||||
|
environment variable, no config file, no build step on pinkbison at all.
|
||||||
|
|
||||||
|
### 3. Tear down when done
|
||||||
|
|
||||||
|
**Stop & Clean Up** on the dashboard page. That's it.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -7,72 +7,98 @@ to know what "run the tests" actually checked.
|
|||||||
|
|
||||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||||
|
|
||||||
Last verified against `main`: 2026-08-19 (commit `1c75367`).
|
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
|
||||||
|
migrations + pytest + all 6 Godot smoke tests + both platform exports, all
|
||||||
|
clean. Two real bugs found and fixed via real playtesting this same day
|
||||||
|
(not caught by the checkpoint suite as it stood before today): the main
|
||||||
|
menu had no way to actually open the login/register form, and the world
|
||||||
|
list's cards rendered at ~1px wide once real data existed. Both are now
|
||||||
|
covered by the smoke tests below so they can't silently regress.
|
||||||
|
|
||||||
## Backend — `backend/tests/` (pytest)
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
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
|
reachable Postgres — see the runner script).
|
||||||
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.**
|
**Current total: 68 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
| File | Tests | What it covers |
|
| 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_auth_age_gate.py` | 17 | `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). |
|
| `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). |
|
||||||
|
| `test_auth_endpoints.py` | 11 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), underage rejection (400), invalid date format (422), exact-18 registration, successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||||
|
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||||
|
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||||
|
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||||
|
|
||||||
**What these are, honestly:** pure unit tests of standalone functions
|
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||||
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
|
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||||
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
|
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||||
actually need Postgres reachable at all (the import chain just needs a
|
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||||
syntactically valid `DATABASE_URL`, nothing connects during collection).
|
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||||
|
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||||
|
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||||
|
truncates all app tables once before the suite runs.
|
||||||
|
|
||||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||||
these yet):
|
these yet):
|
||||||
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
|
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||||
`/worlds`, `/assets/*` have zero tests that actually call them through
|
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||||
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
|
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||||
real (or test) database. Everything currently shipped works because it
|
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||||
was manually verified at merge time (checkpoint review), not because a
|
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||||
test asserts it.
|
auth-header validation is covered via expired-token tests).
|
||||||
- No tests for the RS256 auth upgrade (key generation, refresh-token
|
- No tests for session management (create/validate/revoke sessions).
|
||||||
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`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
|
**6 smoke tests merged into `main`.** Each needs a live backend —
|
||||||
is the only Godot script on `main` right now, and it's **not a real
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
test** (a leftover manual probe, returns a hardcoded string, asserts
|
`application/agp/api_base_url` project setting (default
|
||||||
nothing). Don't count it toward coverage; delete or repurpose it if anyone
|
`http://127.0.0.1:8000`) if unset.
|
||||||
actually needs a sandbox probe again.
|
|
||||||
|
|
||||||
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
|
| File | What it covers |
|
||||||
will be the first real one once merged — `ApiClient` autoload's
|
|---|---|
|
||||||
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
|
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||||
at one), verified by hand end-to-end before opening the PR: exercises the
|
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||||
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
|
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||||
result-dictionary contract), not just that the script compiles. Exits 1 on
|
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||||
failure, so it's CI/script-friendly.
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
|
|
||||||
Run (once merged): `godot --headless --path . --import` once (builds the
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
class cache — skip this and every `class_name`-declared class falsely
|
then for each smoke test: `godot --headless --path . --script
|
||||||
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
|
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||||
`godot --headless --path . --script res://scripts/<name>.gd`
|
their awaited HTTP calls finish.
|
||||||
|
|
||||||
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
|
**Known gaps:**
|
||||||
`login`, `register`, `get_worlds`, `get_me` are implemented (see
|
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||||
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
|
- No smoke tests for dynamic/static object nodes.
|
||||||
real user/world fixture in the target backend to test against meaningfully.
|
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||||
|
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||||
|
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||||
|
the `ApiClient` autoload directly.
|
||||||
|
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||||
|
has no smoke coverage yet.
|
||||||
|
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||||
|
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||||
|
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||||
|
inference, not a direct check.
|
||||||
|
|
||||||
|
## Client export
|
||||||
|
|
||||||
|
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||||
|
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||||
|
exports complete clean from a fresh checkout and produce valid binaries
|
||||||
|
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||||
|
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||||
|
window) — actually launching and clicking through the exported binary is a
|
||||||
|
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||||
|
|
||||||
## What "full coverage" should eventually mean here
|
## What "full coverage" should eventually mean here
|
||||||
|
|
||||||
Not defined precisely yet — the honest target as of 2026-08-19 is just
|
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||||
"more than pure-function unit tests." As real integration/endpoint tests get
|
"integration tests for every endpoint + smoke tests for every major scene."
|
||||||
added, update the tables above rather than letting this doc drift stale;
|
As real integration/endpoint tests get added, update the tables above
|
||||||
a wrong inventory is worse than no inventory.
|
rather than letting this doc drift stale; a wrong inventory is worse than
|
||||||
|
no inventory.
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/bptc=true
|
texture_format/bptc=true
|
||||||
texture_format/s3tc=true
|
texture_format/s3tc=true
|
||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
@@ -52,7 +52,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/bptc=true
|
texture_format/bptc=true
|
||||||
texture_format/s3tc=true
|
texture_format/s3tc=true
|
||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
|
|||||||
+17
-5
@@ -29,6 +29,21 @@ text = "AGP Game"
|
|||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
theme_override_font_sizes/font_size = 72
|
theme_override_font_sizes/font_size = 72
|
||||||
|
|
||||||
|
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Login"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Register"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
visible = false
|
visible = false
|
||||||
@@ -74,10 +89,6 @@ visible = false
|
|||||||
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "Register"
|
|
||||||
|
|
||||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Login"
|
text = "Login"
|
||||||
@@ -106,9 +117,10 @@ layout_mode = 1
|
|||||||
text = "Quit"
|
text = "Quit"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"]
|
||||||
|
|
||||||
|
[node name="WorldList" type="Control"]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_wl")
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "< Back"
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "Browse Worlds"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_font_sizes/font_size = 28
|
||||||
|
|
||||||
|
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "Refresh"
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
|
||||||
|
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_mode = 2
|
||||||
|
vertical_mode = 2
|
||||||
|
|
||||||
|
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "Loading worlds..."
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
|
||||||
|
[node name="ErrorLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = ""
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
|
||||||
|
[node name="EmptyLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = ""
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
|
||||||
|
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
|
||||||
|
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
|
||||||
+32
-32
@@ -1,24 +1,23 @@
|
|||||||
extends Control
|
extends Control
|
||||||
## Main menu with login/register UI wired to the backend auth API.
|
## Main menu with login/register UI wired to the backend auth API.
|
||||||
##
|
##
|
||||||
## The AuthPanel is hidden by default. When the user clicks "Login" or "Register"
|
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
|
||||||
## (the existing Play/Quit buttons), the auth panel slides in. The ModeToggle
|
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
|
||||||
## switches between Login and Register modes, showing/hiding the email and DOB
|
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
||||||
## fields accordingly.
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
##
|
## the panel is open.
|
||||||
## On successful auth the token is stored via ApiClient, the welcome label
|
|
||||||
## shows the username, and the Play button becomes enabled.
|
|
||||||
|
|
||||||
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── UI references ────────────────────────────────────────────────────────────
|
var _auth_buttons_row: HBoxContainer
|
||||||
|
var _login_button: Button
|
||||||
|
var _register_button: Button
|
||||||
var _auth_panel: PanelContainer
|
var _auth_panel: PanelContainer
|
||||||
var _auth_mode_label: Label
|
var _auth_mode_label: Label
|
||||||
var _username_edit: LineEdit
|
var _username_edit: LineEdit
|
||||||
var _password_edit: LineEdit
|
var _password_edit: LineEdit
|
||||||
var _email_edit: LineEdit
|
var _email_edit: LineEdit
|
||||||
var _dob_edit: LineEdit
|
var _dob_edit: LineEdit
|
||||||
var _mode_toggle: CheckButton
|
|
||||||
var _auth_button: Button
|
var _auth_button: Button
|
||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _welcome_label: Label
|
var _welcome_label: Label
|
||||||
@@ -29,17 +28,19 @@ var _is_register_mode: bool = false
|
|||||||
var _is_authenticating: bool = false
|
var _is_authenticating: bool = false
|
||||||
|
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────────────────
|
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Cache node references.
|
# Cache node references.
|
||||||
|
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||||
|
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||||
|
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
|
||||||
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
||||||
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
||||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
||||||
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
|
|
||||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||||
@@ -56,42 +57,38 @@ func _ready() -> void:
|
|||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
|
|
||||||
|
|
||||||
# ── Play / Quit ──────────────────────────────────────────────────────────────
|
# ─── Play / Quit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_play_button_pressed() -> void:
|
func _on_play_button_pressed() -> void:
|
||||||
if _play_button.disabled:
|
if _play_button.disabled:
|
||||||
return
|
return
|
||||||
# TODO: transition to the worlds / gameplay scene once it exists.
|
# Browse worlds is the first real screen past login -- actual gameplay
|
||||||
print("Play pressed — transitioning to game")
|
# (joining a world) doesn't exist yet, browsing does.
|
||||||
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
|
||||||
func _on_quit_button_pressed() -> void:
|
func _on_quit_button_pressed() -> void:
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
# ── Auth panel toggle ────────────────────────────────────────────────────────
|
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_login_button_pressed() -> void:
|
func _on_login_button_pressed() -> void:
|
||||||
"""Show the auth panel (called from a future Login button on the main menu)."""
|
"""Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
|
||||||
_auth_panel.visible = true
|
_auth_panel.visible = true
|
||||||
_auth_panel.grab_focus()
|
_is_register_mode = false
|
||||||
|
_update_auth_mode_ui()
|
||||||
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _on_register_button_pressed() -> void:
|
func _on_register_button_pressed() -> void:
|
||||||
"""Show the auth panel in register mode."""
|
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
|
||||||
_auth_panel.visible = true
|
_auth_panel.visible = true
|
||||||
_is_register_mode = true
|
_is_register_mode = true
|
||||||
_mode_toggle.button_pressed = true
|
|
||||||
_update_auth_mode_ui()
|
|
||||||
_clear_status()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
|
|
||||||
_is_register_mode = button_pressed
|
|
||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
_clear_status()
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _update_auth_mode_ui() -> void:
|
func _update_auth_mode_ui() -> void:
|
||||||
@@ -108,7 +105,7 @@ func _update_auth_mode_ui() -> void:
|
|||||||
_dob_edit.visible = false
|
_dob_edit.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ── Auth submission ──────────────────────────────────────────────────────────
|
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_auth_button_pressed() -> void:
|
func _on_auth_button_pressed() -> void:
|
||||||
"""Handle login or register button press."""
|
"""Handle login or register button press."""
|
||||||
@@ -165,18 +162,21 @@ func _on_auth_complete(result: Dictionary) -> void:
|
|||||||
_is_authenticating = false
|
_is_authenticating = false
|
||||||
|
|
||||||
if result.ok:
|
if result.ok:
|
||||||
# Success — show welcome, enable Play.
|
# Success — show welcome, enable Play, hide the now-redundant
|
||||||
|
# Login/Register entry points (the auth panel itself stays open,
|
||||||
|
# showing the welcome message in place of the form).
|
||||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
print("Auth successful. Token stored.")
|
print("Auth successful. Token stored.")
|
||||||
else:
|
else:
|
||||||
# Failure — show backend error detail.
|
# Failure — show backend error detail.
|
||||||
_show_status(result.detail)
|
_show_status(result.detail)
|
||||||
|
|
||||||
|
|
||||||
# ── Status helpers ───────────────────────────────────────────────────────────
|
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _show_status(message: String) -> void:
|
func _show_status(message: String) -> void:
|
||||||
_status_label.text = message
|
_status_label.text = message
|
||||||
@@ -188,7 +188,7 @@ func _clear_status() -> void:
|
|||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ── Validation ───────────────────────────────────────────────────────────────
|
# ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _is_valid_date(date_str: String) -> bool:
|
func _is_valid_date(date_str: String) -> bool:
|
||||||
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Full click-through simulation of the real user flow: press Register,
|
||||||
|
## fill the form exactly as a human would, submit, confirm Play actually
|
||||||
|
## becomes clickable. This is the check that would have caught the
|
||||||
|
## original bug (no way to even open the form) and would catch a repeat
|
||||||
|
## of it.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
|
||||||
|
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
|
||||||
|
_check("Play starts disabled", play_button.disabled)
|
||||||
|
|
||||||
|
# Click Register -- a real signal emission is fine here since
|
||||||
|
# _on_register_button_pressed() is synchronous (no await), same as a
|
||||||
|
# real click's GUI event dispatch would drive it.
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
|
||||||
|
|
||||||
|
# Fill the form exactly as a user would type into it.
|
||||||
|
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||||
|
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||||
|
password_edit.text = "TestPass123!"
|
||||||
|
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||||
|
dob_edit.text = "2000-01-01"
|
||||||
|
|
||||||
|
# Call the submit handler directly (awaited) rather than via
|
||||||
|
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||||
|
# SceneTree headless mode doesn't drive an await-using handler's
|
||||||
|
# coroutine forward the same way real GUI input event dispatch does
|
||||||
|
# (confirmed via a direct-call vs emit() comparison: the direct call
|
||||||
|
# always resolves, the synthetic emit sometimes doesn't advance past
|
||||||
|
# its own await point even after 50 real process_frame waits). This
|
||||||
|
# is a headless-mode test-harness limitation, not a bug in the actual
|
||||||
|
# click-driven flow.
|
||||||
|
await instance._on_auth_button_pressed()
|
||||||
|
|
||||||
|
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||||
|
_check("Welcome label visible", welcome_label.visible)
|
||||||
|
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> void:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Verifies the main menu's Login/Register buttons correctly open the auth
|
||||||
|
## panel and toggle between Login and Register modes via signal emission.
|
||||||
|
##
|
||||||
|
## Key tests:
|
||||||
|
## - AuthPanel starts hidden.
|
||||||
|
## - LoginButton press opens the panel in Login mode.
|
||||||
|
## - RegisterButton press opens the panel in Register mode.
|
||||||
|
## - Clicking Login while in Register mode switches back to Login mode.
|
||||||
|
## - Clicking Register while in Login mode switches to Register mode.
|
||||||
|
## - The ModeToggle CheckButton no longer exists in the scene.
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
|
||||||
|
|
||||||
|
# --- Test 1: auth panel starts hidden ---
|
||||||
|
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 2: LoginButton press opens panel in Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel visible after LoginButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden in Login mode", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("DOB field hidden in Login mode", not dob_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 3: RegisterButton press switches to Register mode ---
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field visible in Register mode", email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("DOB field visible in Register mode", dob_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 4: LoginButton press switches back to Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden after switching back to Login", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("DOB field hidden after switching back to Login", not dob_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 5: ModeToggle CheckButton no longer exists ---
|
||||||
|
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
|
||||||
|
var has_mode_toggle = instance.has_node(mode_toggle_path)
|
||||||
|
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
return condition
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for the world list screen.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_world_list.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000, override with
|
||||||
|
## AGP_API_BASE_URL).
|
||||||
|
##
|
||||||
|
## Unlike a pure API-level test, this actually instantiates scenes/
|
||||||
|
## world_list.tscn as a real scene and adds it to the tree -- catches
|
||||||
|
## @onready node-path bugs (a node reference pointing at the wrong path
|
||||||
|
## errors right here, not just "looks right" from reading the script) that
|
||||||
|
## an API-only test would miss entirely.
|
||||||
|
##
|
||||||
|
## Tests:
|
||||||
|
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
||||||
|
## 2. POST a real world via the API so the populated-card path actually
|
||||||
|
## gets exercised -- run_checkpoint_tests.py always starts from a
|
||||||
|
## genuinely empty DB, so without this the card-rendering code below
|
||||||
|
## would never run in CI at all, only the empty-list path would.
|
||||||
|
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||||
|
## must not error.
|
||||||
|
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||||
|
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||||
|
## that the rendered card has a real width -- not just non-zero
|
||||||
|
## child_count. 2026-08-20: a real bug shipped where every card
|
||||||
|
## technically existed in the tree (child_count > 0, no error, no
|
||||||
|
## crash) but rendered at 1.0px wide because size_flags_horizontal/
|
||||||
|
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
|
||||||
|
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
|
||||||
|
## child-count check alone would never catch that, only a real size
|
||||||
|
## assertion does.
|
||||||
|
##
|
||||||
|
## Exits 0 on full pass, 1 on any failure.
|
||||||
|
|
||||||
|
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||||
|
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
assert(api != null, "ApiClient autoload not found")
|
||||||
|
|
||||||
|
var passed: int = 0
|
||||||
|
var failed: int = 0
|
||||||
|
|
||||||
|
# --- Setup: register + log in so the world list has a valid token ---
|
||||||
|
print("\n=== Setup: register + login ===")
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := "worldlist_smoke_%d" % unique_id
|
||||||
|
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
||||||
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
|
||||||
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
|
print(" detail: ", reg_result.detail)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Seed a real world so the populated-card path is actually exercised ---
|
||||||
|
print("\n=== Setup: seed a real world ===")
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
|
||||||
|
if not _check("setup: world created", create_result.ok):
|
||||||
|
print(" detail: ", create_result.detail)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Test 1: Instantiate the real scene ---
|
||||||
|
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||||
|
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||||
|
if not _check("scene loads", packed_scene != null):
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var instance: Control = packed_scene.instantiate()
|
||||||
|
if not _check("scene instantiates", instance != null):
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
root.add_child(instance)
|
||||||
|
# One frame for _ready() (and its @onready resolution) to run. A wrong
|
||||||
|
# node path in an @onready var errors during this, not silently.
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("scene added to tree without error", is_instance_valid(instance)):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 2: Wait for the world fetch to resolve, check a real end state ---
|
||||||
|
print("\n=== Test 2: World fetch reaches a real end state ===")
|
||||||
|
# _refresh_worlds() runs from _ready(); give its awaited HTTP call time
|
||||||
|
# to complete rather than a fixed sleep -- poll for either end state.
|
||||||
|
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||||
|
var empty_label: Label = instance.get_node("VBoxContainer/EmptyLabel")
|
||||||
|
var error_label: Label = instance.get_node("VBoxContainer/ErrorLabel")
|
||||||
|
|
||||||
|
var settled := false
|
||||||
|
for i in range(50): # up to ~5s at 100ms/frame-ish via process_frame waits
|
||||||
|
await process_frame
|
||||||
|
if world_list_node.visible or empty_label.visible or error_label.visible:
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
|
||||||
|
if _check("reached a real end state (cards, empty, or error) not stuck loading", settled):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(" world_list.visible=%s empty_label.visible=%s error_label.visible=%s" % [
|
||||||
|
world_list_node.visible, empty_label.visible, error_label.visible
|
||||||
|
])
|
||||||
|
|
||||||
|
if error_label.visible:
|
||||||
|
printerr(" Note: ended in error state: ", error_label.text)
|
||||||
|
elif empty_label.visible:
|
||||||
|
# We just seeded a world -- ending up empty means GET /api/worlds
|
||||||
|
# isn't actually seeing what POST /api/worlds/ just created.
|
||||||
|
_check("ended in populated state, not empty (a world was just seeded)", false)
|
||||||
|
failed += 1
|
||||||
|
elif world_list_node.visible:
|
||||||
|
var card_count := world_list_node.get_child_count()
|
||||||
|
print(" Note: rendered %d world card(s)" % card_count)
|
||||||
|
|
||||||
|
if _check("at least one card rendered", card_count > 0):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# The actual bug this test exists to catch: a card can be present
|
||||||
|
# in the tree, not crash, and still be visually nothing because
|
||||||
|
# it rendered at ~1px wide. Check every card's real computed size,
|
||||||
|
# not just that it exists.
|
||||||
|
for i in range(card_count):
|
||||||
|
var card: Control = world_list_node.get_child(i)
|
||||||
|
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
|
||||||
|
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- 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
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
extends Control
|
||||||
|
## World list screen — browses public worlds via GET /api/worlds.
|
||||||
|
##
|
||||||
|
## Displays a scrollable list of world cards (name + description).
|
||||||
|
## Handles loading, empty list, errors, and refresh.
|
||||||
|
|
||||||
|
|
||||||
|
# UI references
|
||||||
|
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
|
||||||
|
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
|
||||||
|
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
|
||||||
|
@onready var error_label: Label = $VBoxContainer/ErrorLabel
|
||||||
|
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||||
|
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||||
|
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
refresh_button.disabled = false
|
||||||
|
back_button.disabled = false
|
||||||
|
loading_label.visible = false
|
||||||
|
error_label.visible = false
|
||||||
|
empty_label.visible = false
|
||||||
|
world_list.visible = false
|
||||||
|
_refresh_worlds()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_refresh_button_pressed() -> void:
|
||||||
|
_refresh_worlds()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_back_button_pressed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
## Fetch the world list from the backend and update the UI.
|
||||||
|
func _refresh_worlds() -> void:
|
||||||
|
# Reset UI state
|
||||||
|
loading_label.visible = true
|
||||||
|
error_label.visible = false
|
||||||
|
empty_label.visible = false
|
||||||
|
world_list.visible = false
|
||||||
|
refresh_button.disabled = true
|
||||||
|
|
||||||
|
# Clear previous world cards
|
||||||
|
for child in world_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
var result := await ApiClient.get_worlds()
|
||||||
|
|
||||||
|
loading_label.visible = false
|
||||||
|
refresh_button.disabled = false
|
||||||
|
|
||||||
|
if not result.ok:
|
||||||
|
# Show error message (401, network failure, etc.)
|
||||||
|
error_label.text = "Error: %s" % result.detail
|
||||||
|
error_label.visible = true
|
||||||
|
empty_label.visible = false
|
||||||
|
world_list.visible = false
|
||||||
|
return
|
||||||
|
|
||||||
|
var data = result.data
|
||||||
|
if not data is Array:
|
||||||
|
error_label.text = "Unexpected response format."
|
||||||
|
error_label.visible = true
|
||||||
|
empty_label.visible = false
|
||||||
|
world_list.visible = false
|
||||||
|
return
|
||||||
|
|
||||||
|
if data.is_empty():
|
||||||
|
empty_label.text = "No worlds yet. Check back later!"
|
||||||
|
empty_label.visible = true
|
||||||
|
error_label.visible = false
|
||||||
|
world_list.visible = false
|
||||||
|
return
|
||||||
|
|
||||||
|
# Render world cards
|
||||||
|
world_list.visible = true
|
||||||
|
empty_label.visible = false
|
||||||
|
error_label.visible = false
|
||||||
|
|
||||||
|
for world_dict in data:
|
||||||
|
if not world_dict is Dictionary:
|
||||||
|
continue
|
||||||
|
var card := _create_world_card(world_dict)
|
||||||
|
world_list.add_child(card)
|
||||||
|
|
||||||
|
|
||||||
|
## Create a visual card for a single world entry.
|
||||||
|
func _create_world_card(world_dict: Dictionary) -> Control:
|
||||||
|
# PanelContainer for the background, not a ColorRect moved to child index
|
||||||
|
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
||||||
|
# vertical sequence, it has no concept of "float this one child behind
|
||||||
|
# the others as an overlay". That version rendered as an empty dark bar
|
||||||
|
# sitting above the text, not a background behind it.
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
panel.custom_minimum_size = Vector2(0, 80)
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
||||||
|
style.content_margin_left = 12
|
||||||
|
style.content_margin_right = 12
|
||||||
|
style.content_margin_top = 8
|
||||||
|
style.content_margin_bottom = 8
|
||||||
|
panel.add_theme_stylebox_override("panel", style)
|
||||||
|
|
||||||
|
var card := VBoxContainer.new()
|
||||||
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
card.add_theme_constant_override("separation", 4)
|
||||||
|
panel.add_child(card)
|
||||||
|
|
||||||
|
var name_label := Label.new()
|
||||||
|
name_label.text = world_dict.get("name", "Unnamed World")
|
||||||
|
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
name_label.size_flags_horizontal = Control.SIZE_FILL
|
||||||
|
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
name_label.add_theme_font_size_override("font_size", 18)
|
||||||
|
|
||||||
|
var desc_label := Label.new()
|
||||||
|
var description: String = str(world_dict.get("description", ""))
|
||||||
|
desc_label.text = description if description != "" else "No description."
|
||||||
|
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
desc_label.size_flags_horizontal = Control.SIZE_FILL
|
||||||
|
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
desc_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
||||||
|
|
||||||
|
var meta_label := Label.new()
|
||||||
|
var visibility: String = str(world_dict.get("visibility", "public"))
|
||||||
|
var created: String = str(world_dict.get("created_at", ""))
|
||||||
|
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
|
||||||
|
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
meta_label.size_flags_horizontal = Control.SIZE_FILL
|
||||||
|
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
meta_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||||
|
|
||||||
|
card.add_child(name_label)
|
||||||
|
card.add_child(desc_label)
|
||||||
|
card.add_child(meta_label)
|
||||||
|
|
||||||
|
return panel
|
||||||
Reference in New Issue
Block a user