From 33d3b889416a3e1b494f8d055a65b10f1147ab95 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 6 Sep 2026 04:28:35 +0000 Subject: [PATCH] Test suite for the realm architecture - conftest: bootstrap seeds a localhost realm Config; realm_registry fixture builds the runtime registry; avatar storage redirected to a per-test tmp dir; credentials/sessions stamped with the test realm. - test_cli rewritten for the init/serve split, incl. legacy adoption. - TestServerConfig replaced by TestRealms covering the realm CRUD API, cross-realm validation, delete guards and effective-auth-host fallback. - Avatar/OIDC tests updated for per-realm providers and realm-derived URLs; obsolete PASKIA_DB path tests removed. --- paskia/__main__.py | 19 +- paskia/bootstrap.py | 1 - paskia/db/bootstrap.py | 2 +- paskia/db/legacy.py | 2 +- paskia/db/lifecycle.py | 1 - paskia/db/structs.py | 4 +- paskia/fastapi/admin/adminapp.py | 8 +- paskia/fastapi/admin/oidc_clients.py | 4 +- paskia/fastapi/admin/permissions.py | 8 +- paskia/fastapi/admin/realms.py | 4 +- paskia/fastapi/dispatch.py | 6 +- paskia/fastapi/mainapp.py | 4 +- paskia/fastapi/user.py | 2 +- paskia/realms.py | 11 +- paskia/util/avatar.py | 5 +- tests/conftest.py | 84 ++++----- tests/test_admin.py | 230 ++++++++++++++++++----- tests/test_api.py | 32 ++-- tests/test_cli.py | 262 +++++++++++++++++---------- tests/test_user.py | 31 ---- 20 files changed, 436 insertions(+), 284 deletions(-) diff --git a/paskia/__main__.py b/paskia/__main__.py index eadabd0..25ef656 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -7,10 +7,8 @@ from pathlib import Path import msgspec from fastapi_vue import server -from fastapi_vue.hostutil import parse_endpoints from kanta import Kanta -from paskia._version import __version__ from paskia.db import legacy from paskia.db.bootstrap import bootstrap, log_reset_link from paskia.db.paths import db_file_path @@ -102,12 +100,13 @@ def cmd_init(args: argparse.Namespace) -> None: # Bootstrap-time naming and hosts apply to the default realm; # everything is editable via the admin interface afterwards. realm.rp_name = args.rp_name or None - origins = ( - [normalize_origin(o) for o in _split_multi(args.origins)] or None - ) + origins = [normalize_origin(o) for o in _split_multi(args.origins)] or None auth_host = args.auth_host or None if auth_host: - validate_auth_host(auth_host, rp_id) + try: + validate_auth_host(auth_host, rp_id) + except ValueError as e: + raise SystemExit(str(e)) from e realm.auth_host, realm.origins = normalize_auth_host_and_origins( auth_host, origins ) @@ -157,9 +156,7 @@ def cmd_serve(args: argparse.Namespace) -> None: if adopted: print(f"✅ Converted legacy database to {db_path} (realm: {adopted})") if not db_path.exists(): - raise SystemExit( - f"Database {db_path} not found — run 'paskia init' first." - ) + raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.") config = _load_stored_config(db_path) try: @@ -172,7 +169,9 @@ def cmd_serve(args: argparse.Namespace) -> None: registry = build_registry(config) # Pass process-global serve parameters to the server process(es) - os.environ["PASKIA_CONFIG"] = msgspec.json.encode(ServeConfig(listen=listen)).decode() + os.environ["PASKIA_CONFIG"] = msgspec.json.encode( + ServeConfig(listen=listen) + ).decode() startupbox.print_startup_config(registry, listen=listen) diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index 0d93afb..9ff8f19 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -94,4 +94,3 @@ async def bootstrap_if_needed() -> bool: """ await check_admin_credentials() return False - diff --git a/paskia/db/bootstrap.py b/paskia/db/bootstrap.py index 14f9057..fa0f434 100644 --- a/paskia/db/bootstrap.py +++ b/paskia/db/bootstrap.py @@ -9,7 +9,7 @@ from datetime import UTC, datetime import uuid7 from paskia.authsession import reset_expires -from paskia.db.structs import DB, Config, OIDC, Org, Permission, ResetToken, Role, User +from paskia.db.structs import DB, OIDC, Config, Org, Permission, ResetToken, Role, User from paskia.util.crypto import secret_key _reset_link_logger = logging.getLogger("paskia.reset_link") diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index 1050c9f..cb8d5a1 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -24,8 +24,8 @@ from kanta import Kanta from paskia.db.paths import db_file_path, users_root_path from paskia.db.structs import ( - OIDC, DB, + OIDC, Config, Credential, Org, diff --git a/paskia/db/lifecycle.py b/paskia/db/lifecycle.py index b8bb58a..4aedd86 100644 --- a/paskia/db/lifecycle.py +++ b/paskia/db/lifecycle.py @@ -19,7 +19,6 @@ import paskia.db.operations as _ops from paskia import oidc_notify from paskia.authsession import EXPIRES from paskia.db.paths import db_file_path -from paskia.db.structs import DB logger = logging.getLogger(__name__) diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 34e392e..2c3d022 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -239,9 +239,7 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True): def credential_ids_for(self, rp_id: str) -> list[bytes]: """Get credential IDs registered under a specific realm's rp-id.""" - return [ - c.credential_id for c in self.credentials if c.rp_id == rp_id - ] + return [c.credential_id for c in self.credentials if c.rp_id == rp_id] @property def sessions(self) -> list[Session]: diff --git a/paskia/fastapi/admin/adminapp.py b/paskia/fastapi/admin/adminapp.py index eed8fde..09c2201 100644 --- a/paskia/fastapi/admin/adminapp.py +++ b/paskia/fastapi/admin/adminapp.py @@ -8,10 +8,12 @@ from paskia.fastapi.admin import ( oidc_clients, orgs, permissions, - realms as realms_admin, roles, users, ) +from paskia.fastapi.admin import ( + realms as realms_admin, +) from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.front import frontend from paskia.fastapi.response import MsgspecResponse @@ -99,7 +101,9 @@ async def admin_info(request: Request, auth=AUTH_COOKIE): oidc_clients_dict = {} if master_admin(ctx): provider = db.data().oidc_for(current_realm().rp_id) - clients = sorted(provider.clients.values(), key=lambda c: c.uuid) if provider else [] + clients = ( + sorted(provider.clients.values(), key=lambda c: c.uuid) if provider else [] + ) sessions = db.data().sessions # Count active sessions per client client_session_counts = {} diff --git a/paskia/fastapi/admin/oidc_clients.py b/paskia/fastapi/admin/oidc_clients.py index 2430729..6d496be 100644 --- a/paskia/fastapi/admin/oidc_clients.py +++ b/paskia/fastapi/admin/oidc_clients.py @@ -203,7 +203,9 @@ async def admin_reset_oidc_client_secret( raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)") try: - db.reset_oid_client_secret(current_realm().rp_id, client_uuid, secret_hash, ctx=ctx) + db.reset_oid_client_secret( + current_realm().rp_id, client_uuid, secret_hash, ctx=ctx + ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/paskia/fastapi/admin/permissions.py b/paskia/fastapi/admin/permissions.py index 399978d..35e373b 100644 --- a/paskia/fastapi/admin/permissions.py +++ b/paskia/fastapi/admin/permissions.py @@ -28,10 +28,7 @@ def _validate_permission_domain(domain: str | None) -> None: # Allow OIDC client UUIDs (used for groups claim) try: client_uuid = UUID(domain) - if any( - client_uuid in provider.clients - for provider in db.data().oidc.values() - ): + if any(client_uuid in provider.clients for provider in db.data().oidc.values()): return except ValueError: pass @@ -40,8 +37,7 @@ def _validate_permission_domain(domain: str | None) -> None: if reg.resolve(domain) is not None: return raise ValueError( - f"Domain '{domain}' must belong to a configured realm " - "or be an OIDC client UUID" + f"Domain '{domain}' must belong to a configured realm or be an OIDC client UUID" ) diff --git a/paskia/fastapi/admin/realms.py b/paskia/fastapi/admin/realms.py index 78ce21b..be7292f 100644 --- a/paskia/fastapi/admin/realms.py +++ b/paskia/fastapi/admin/realms.py @@ -133,7 +133,9 @@ async def admin_update_realm( ) realms.validate_config(would_be) - db.update_realm(rp_id, rp_name=rp_name, auth_host=auth_host, origins=origins, ctx=ctx) + db.update_realm( + rp_id, rp_name=rp_name, auth_host=auth_host, origins=origins, ctx=ctx + ) _rebuild_registry() return {"status": "ok"} diff --git a/paskia/fastapi/dispatch.py b/paskia/fastapi/dispatch.py index 0a384ee..7081814 100644 --- a/paskia/fastapi/dispatch.py +++ b/paskia/fastapi/dispatch.py @@ -75,9 +75,9 @@ class DispatchMiddleware: if origin_realm is not None and origin_realm is not host_realm: # Cross-realm connection: only via the origin realm's auth host. effective = registry.effective_auth_host(origin_realm) - if not effective or hostutil.normalize_host(host) != hostutil.normalize_host( - effective - ): + if not effective or hostutil.normalize_host( + host + ) != hostutil.normalize_host(effective): await send( {"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION} ) diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 367c3d2..9fff2dd 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -165,7 +165,9 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): @app.get("/admin", include_in_schema=False) @app.get("/auth/admin", include_in_schema=False) async def admin_root_redirect(): - return RedirectResponse(f"{realms.current_realm().ui_base_path}admin/", status_code=307) + return RedirectResponse( + f"{realms.current_realm().ui_base_path}admin/", status_code=307 + ) @app.get("/admin/", include_in_schema=False) diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index f696abc..c87be84 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -21,7 +21,7 @@ from paskia.fastapi import authz, session from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE from paskia.realms import current_realm -from paskia.util import avatar, hostutil +from paskia.util import avatar from paskia.util.apistructs import ApiCreateLinkResponse app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) diff --git a/paskia/realms.py b/paskia/realms.py index 847bb23..3324d1a 100644 --- a/paskia/realms.py +++ b/paskia/realms.py @@ -12,7 +12,6 @@ from __future__ import annotations import contextvars import os -from urllib.parse import urlparse from fastapi_vue.hostutil import parse_endpoints @@ -31,7 +30,7 @@ class Realm: def __init__(self, config: RealmConfig, site_url: str, site_path: str): # Lazy import: paskia.sansio depends on paskia.db, which (via # paskia.db.operations → paskia.oidc_notify) depends on this module. - from paskia.sansio import Passkey + from paskia.sansio import Passkey # noqa: PLC0415 self.config = config self.site_url = site_url @@ -149,7 +148,9 @@ class RealmRegistry: return realm best = None for rp_id, realm in self._by_rp_id.items(): - if h.endswith(f".{rp_id}") and (best is None or len(rp_id) > len(best.rp_id)): + if h.endswith(f".{rp_id}") and ( + best is None or len(rp_id) > len(best.rp_id) + ): best = realm return best @@ -215,9 +216,7 @@ def validate_config( for hn, owner in related_hosts.items(): if hn in rp_ids: - raise ValueError( - f"Related origin host '{hn}' collides with an rp-id" - ) + raise ValueError(f"Related origin host '{hn}' collides with an rp-id") for other in rp_ids: if other != owner and hostutil.is_subdomain(hn, other): raise ValueError( diff --git a/paskia/util/avatar.py b/paskia/util/avatar.py index de80dca..0f36ee8 100644 --- a/paskia/util/avatar.py +++ b/paskia/util/avatar.py @@ -10,7 +10,7 @@ from uuid import UUID from fastapi import HTTPException, UploadFile from paskia.db.paths import users_root_path -from paskia.util import hostutil +from paskia.realms import current_realm MAX_UPLOAD_BYTES = 10 * 1024 * 1024 @@ -46,9 +46,6 @@ def avatar_url(user_uuid: UUID) -> str | None: """Return the absolute public avatar URL for a user, or None.""" if not avatar_path(user_uuid).is_file(): return None - # Lazy import: paskia.realms pulls in paskia.db, which is circular here. - from paskia.realms import current_realm - return current_realm().api_url(f"user/{user_uuid}/profile.webp") diff --git a/tests/conftest.py b/tests/conftest.py index d55e74f..af2ab07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,12 +12,12 @@ in the database to test authenticated endpoints. from __future__ import annotations import asyncio -import json import os import secrets import tempfile from collections.abc import AsyncGenerator from datetime import UTC, datetime, timedelta +from pathlib import Path from uuid import UUID import httpx @@ -25,22 +25,8 @@ import pytest import pytest_asyncio from kanta import Kanta -# Keep runtime initialization invariant aligned with production: -# db.lifecycle requires PASKIA_CONFIG at import time. -os.environ.setdefault( - "PASKIA_CONFIG", - json.dumps( - { - "config": {"rp_id": "localhost", "rp_name": "localhost"}, - "site_url": "http://localhost:4401", - "site_path": "/auth/", - "save": False, - } - ), -) - import paskia.db.operations as ops_db -from paskia import globals as paskia_globals +from paskia import realms from paskia.authsession import reset_expires from paskia.config import SESSION_LIFETIME from paskia.db import ( @@ -56,12 +42,15 @@ from paskia.db import ( ) from paskia.db.bootstrap import bootstrap from paskia.db.operations import DB -from paskia.db.structs import Session +from paskia.db.structs import Config, RealmConfig, Session from paskia.fastapi.mainapp import app from paskia.fastapi.session import AUTH_COOKIE_NAME -from paskia.sansio import Passkey +from paskia.util import avatar from paskia.util.crypto import hash_secret +TEST_RP_ID = "localhost" +TEST_LISTEN = ["localhost:4401"] + @pytest.fixture(scope="session") def event_loop(): @@ -71,6 +60,19 @@ def event_loop(): loop.close() +@pytest.fixture(autouse=True) +def _avatar_tmp_root(tmp_path, monkeypatch): + """Redirect avatar storage to a per-test temporary directory.""" + root = tmp_path / "users" + + def users_root(create_root: bool = False) -> Path: + if create_root: + root.mkdir(parents=True, exist_ok=True) + return root + + monkeypatch.setattr(avatar, "users_root_path", users_root) + + @pytest_asyncio.fixture(scope="function") async def test_db() -> AsyncGenerator[DB]: """Create a temporary JSONL database for testing using kanta. @@ -79,15 +81,11 @@ async def test_db() -> AsyncGenerator[DB]: - auth:admin and auth:org:admin permissions - A default organization with Administration role - An admin user with the Administration role + - The localhost realm configuration (with its OIDC provider) """ with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: db = DB() - kanta = Kanta( - f.name, - db, - migrations="paskia.db.migrations", - ) - kanta.ctx.rp_id = "test.example.com" + kanta = Kanta(f.name, db) # Register bootstrap callback so kanta seeds the empty DB during open() @kanta.bootstrap(action="bootstrap") @@ -96,6 +94,7 @@ async def test_db() -> AsyncGenerator[DB]: data, org_name="Test Organization", admin_name="Test Admin", + config=Config(realms=[RealmConfig(rp_id=TEST_RP_ID)]), ) await kanta.open() @@ -107,25 +106,10 @@ async def test_db() -> AsyncGenerator[DB]: @pytest_asyncio.fixture(scope="function") -async def passkey_instance() -> Passkey: - """Override the module-level passkey instance for testing.""" - pk = Passkey( - rp_id="localhost", - rp_name="Test RP", - origins=["http://localhost:4401"], - ) - original = { - "rp_id": paskia_globals.passkey.rp_id, - "rp_name": paskia_globals.passkey.rp_name, - "allowed_origins": paskia_globals.passkey.allowed_origins, - } - paskia_globals.passkey.rp_id = pk.rp_id - paskia_globals.passkey.rp_name = pk.rp_name - paskia_globals.passkey.allowed_origins = pk.allowed_origins - yield pk - paskia_globals.passkey.rp_id = original["rp_id"] - paskia_globals.passkey.rp_name = original["rp_name"] - paskia_globals.passkey.allowed_origins = original["allowed_origins"] +async def realm_registry(test_db: DB) -> realms.RealmRegistry: + """Install the realm registry built from the test database config.""" + realms.configure(listen=TEST_LISTEN) + return realms.init_registry(test_db.config) @pytest_asyncio.fixture(scope="function") @@ -192,6 +176,7 @@ async def test_credential(test_db: DB, test_user: User) -> Credential: aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, + rp_id=TEST_RP_ID, ) create_credential(credential) return credential @@ -206,6 +191,7 @@ async def regular_credential(test_db: DB, regular_user: User) -> Credential: aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, + rp_id=TEST_RP_ID, ) create_credential(credential) return credential @@ -247,15 +233,9 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential) @pytest_asyncio.fixture(scope="function") async def client( - test_db: DB, passkey_instance: Passkey + test_db: DB, realm_registry: realms.RealmRegistry ) -> AsyncGenerator[httpx.AsyncClient]: - """Create an async test client for the FastAPI app. - - Note: We import the app inside the fixture to ensure globals are - initialized first. - """ - # Import app after globals are set - + """Create an async test client for the FastAPI app.""" transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, @@ -283,6 +263,7 @@ def create_test_session( ip: str = "127.0.0.1", user_agent: str = "pytest", duration: timedelta | None = None, + rp_id: str = TEST_RP_ID, ) -> tuple[str, str]: """Create a test session. Returns (key, token) tuple. @@ -309,6 +290,7 @@ def create_test_session( ip=ip, user_agent=user_agent, validated=now, + rp_id=rp_id, ) if session.key in ops_db._db.sessions: raise ValueError("Session already exists") diff --git a/tests/test_admin.py b/tests/test_admin.py index d169405..b7e85d6 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -22,7 +22,7 @@ import pytest import pytest_asyncio import uuid7 -from paskia import db +from paskia import db, realms from paskia.db import ( Credential, Org, @@ -37,10 +37,7 @@ from paskia.db import ( create_user, ) from paskia.db.operations import DB -from paskia.util import hostutil from paskia.util.crypto import hash_secret -from paskia.util.runtime import clear_config_cache -from paskia.util.runtime import config as runtime_config from tests.conftest import auth_headers, create_test_image_bytes, create_test_session # -------------------- Additional Fixtures -------------------- @@ -91,6 +88,7 @@ async def second_org_credential(test_db: DB, second_org_user: User) -> Credentia aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, + rp_id="localhost", ) create_credential(credential) return credential @@ -145,6 +143,7 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential: aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, + rp_id="localhost", ) create_credential(credential) return credential @@ -253,8 +252,6 @@ class TestAdminOrganizations: monkeypatch, ): """Admin org payload should include canonical avatar URLs for listed users.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, @@ -948,8 +945,6 @@ class TestAdminUsersInOrg: monkeypatch, ): """Admin should be able to upload avatar for a managed user.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb")) - response = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, @@ -1794,21 +1789,13 @@ class TestOrgAdminAuthExceptions: assert response.status_code == 403 -class TestServerConfig: - """Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates.""" - - @pytest.fixture(scope="function") - def restore_runtime_config(self): - """Restore PASKIA_CONFIG env and cache after a test mutates runtime.""" - original = os.environ["PASKIA_CONFIG"] - yield - os.environ["PASKIA_CONFIG"] = original - clear_config_cache() +class TestRealms: + """Tests for the realm management API (/auth/api/admin/realms/).""" async def _set_auth_host(self, client, session_token, test_user, test_credential): - """Configure an auth host via PATCH, as the admin UI would.""" + """Configure an auth host on the localhost realm, as the admin UI would.""" r = await client.patch( - "/auth/api/admin/server-config/", + "/auth/api/admin/realms/localhost", json={ "rp_name": "", "auth_host": "auth.localhost", @@ -1817,15 +1804,42 @@ class TestServerConfig: headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert r.status_code == 200, r.text - assert db.data().config.auth_host == "https://auth.localhost" - assert hostutil.dedicated_auth_host() == "auth.localhost" - assert hostutil.auth_site_url() == "https://auth.localhost/" + realm_cfg = db.data().config.find_realm("localhost") + assert realm_cfg.auth_host == "https://auth.localhost" + realm = realms.registry().get("localhost") + assert realm.own_auth_host == "auth.localhost" + assert realm.auth_site_url == "https://auth.localhost/" # Session for requests coming from the auth host (sessions are host-bound) _, token = create_test_session( test_user.uuid, test_credential.uuid, host="auth.localhost" ) return {**auth_headers(token), "Host": "auth.localhost"} + @pytest.mark.asyncio + async def test_list_realms(self, client: httpx.AsyncClient, session_token: str): + r = await client.get( + "/auth/api/admin/realms/", + headers={**auth_headers(session_token), "Host": "localhost:4401"}, + ) + assert r.status_code == 200, r.text + data = r.json() + assert len(data) == 1 + realm = data[0] + assert realm["rp_id"] == "localhost" + assert realm["is_default"] is True + assert realm["auth_host"] is None + assert realm["site_url"] == "http://localhost:4401" + + @pytest.mark.asyncio + async def test_realms_require_master_admin( + self, client: httpx.AsyncClient, regular_session_token: str + ): + r = await client.get( + "/auth/api/admin/realms/", + headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, + ) + assert r.status_code in (401, 403) + @pytest.mark.asyncio async def test_remove_auth_host_updates_runtime( self, @@ -1833,16 +1847,15 @@ class TestServerConfig: session_token: str, test_user, test_credential, - restore_runtime_config, ): - """Removing auth_host must clear it from runtime config and URLs.""" + """Removing auth_host must clear it from runtime realm config and URLs.""" headers = await self._set_auth_host( client, session_token, test_user, test_credential ) # The dialog still lists the old auth host among origins, so it is sent back r = await client.patch( - "/auth/api/admin/server-config/", + "/auth/api/admin/realms/localhost", json={ "rp_name": "", "auth_host": "", @@ -1851,23 +1864,24 @@ class TestServerConfig: headers=headers, ) assert r.status_code == 200, r.text - assert db.data().config.auth_host is None + assert db.data().config.find_realm("localhost").auth_host is None - rt = runtime_config() - assert rt.config.auth_host is None - assert rt.site_path == "/auth/" - assert "auth.localhost" not in rt.site_url - assert hostutil.dedicated_auth_host() is None - assert "auth.localhost" not in hostutil.auth_site_url() + realm = realms.registry().get("localhost") + assert realm.own_auth_host is None + assert realm.ui_base_path == "/auth/" + # Site URL derivation is stateless: with the auth host removed, the + # first remaining origin becomes the site URL. + assert realm.auth_site_url == "https://auth.localhost/auth/" # GET and settings reflect the cleared state r = await client.get( - "/auth/api/admin/server-config/", + "/auth/api/admin/realms/", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) - assert r.json()["auth_host"] == "" + assert r.json()[0]["auth_host"] is None r = await client.get("/auth/api/settings") assert r.json()["auth_host"] is None + assert r.json()["own_auth_host"] is None assert r.json()["ui_base_path"] == "/auth/" # Middleware no longer redirects to the removed auth host @@ -1879,13 +1893,12 @@ class TestServerConfig: assert "auth.localhost" not in r.headers.get("location", "") @pytest.mark.asyncio - async def test_remove_auth_host_without_origins_falls_back_to_rp_id( + async def test_remove_auth_host_without_origins_falls_back( self, client: httpx.AsyncClient, session_token: str, test_user, test_credential, - restore_runtime_config, ): """With no origins left, site_url must not keep the removed auth host.""" headers = await self._set_auth_host( @@ -1893,14 +1906,145 @@ class TestServerConfig: ) r = await client.patch( - "/auth/api/admin/server-config/", + "/auth/api/admin/realms/localhost", json={"rp_name": "", "auth_host": "", "origins": []}, headers=headers, ) assert r.status_code == 200, r.text - rt = runtime_config() - assert rt.config.auth_host is None - assert rt.site_path == "/auth/" - assert "auth.localhost" not in rt.site_url - assert "auth.localhost" not in hostutil.auth_site_url() + realm = realms.registry().get("localhost") + assert realm.own_auth_host is None + assert realm.ui_base_path == "/auth/" + assert "auth.localhost" not in realm.site_url + assert "auth.localhost" not in realm.auth_site_url + + @pytest.mark.asyncio + async def test_create_and_delete_realm( + self, client: httpx.AsyncClient, session_token: str + ): + headers = {**auth_headers(session_token), "Host": "localhost:4401"} + r = await client.post( + "/auth/api/admin/realms/", + json={ + "rp_id": "example.com", + "rp_name": "Example", + "origins": ["https://app.example.com", "https://unrelated-site.com"], + }, + headers=headers, + ) + assert r.status_code == 200, r.text + + r = await client.get("/auth/api/admin/realms/", headers=headers) + realms_list = {realm["rp_id"]: realm for realm in r.json()} + assert set(realms_list) == {"localhost", "example.com"} + created = realms_list["example.com"] + assert created["rp_name"] == "Example" + assert created["is_default"] is False + assert created["related_origins"] == ["https://unrelated-site.com"] + + # OIDC provider seeded for the new realm + assert db.data().oidc_for("example.com") is not None + + r = await client.delete("/auth/api/admin/realms/example.com", headers=headers) + assert r.status_code == 200, r.text + assert db.data().config.find_realm("example.com") is None + assert realms.registry().get("example.com") is None + + @pytest.mark.asyncio + async def test_create_realm_validation( + self, client: httpx.AsyncClient, session_token: str + ): + headers = {**auth_headers(session_token), "Host": "localhost:4401"} + + # rp_id is required + r = await client.post("/auth/api/admin/realms/", json={}, headers=headers) + assert r.status_code == 400 + + # Duplicate rp-id + r = await client.post( + "/auth/api/admin/realms/", json={"rp_id": "localhost"}, headers=headers + ) + assert r.status_code == 400 + + # Invalid rp-id + r = await client.post( + "/auth/api/admin/realms/", json={"rp_id": "not a domain!"}, headers=headers + ) + assert r.status_code == 400 + + # auth-host must be a subdomain of the rp-id + r = await client.post( + "/auth/api/admin/realms/", + json={"rp_id": "example.com", "auth_host": "auth.other.com"}, + headers=headers, + ) + assert r.status_code == 400 + + # Related origin host may not collide across realms + r = await client.post( + "/auth/api/admin/realms/", + json={"rp_id": "example.com", "origins": ["https://shared-app.com"]}, + headers=headers, + ) + assert r.status_code == 200 + r = await client.post( + "/auth/api/admin/realms/", + json={"rp_id": "other.com", "origins": ["https://shared-app.com"]}, + headers=headers, + ) + assert r.status_code == 400 + + @pytest.mark.asyncio + async def test_delete_realm_guards( + self, client: httpx.AsyncClient, session_token: str, test_credential + ): + headers = {**auth_headers(session_token), "Host": "localhost:4401"} + + # Cannot delete the last realm + r = await client.delete("/auth/api/admin/realms/localhost", headers=headers) + assert r.status_code == 400 + + # Unknown realm + r = await client.delete("/auth/api/admin/realms/nope.com", headers=headers) + assert r.status_code == 400 + + # A realm with credentials still registered under it cannot be deleted + r = await client.post( + "/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers + ) + assert r.status_code == 200 + cred = Credential.create( + credential_id=secrets.token_bytes(32), + user=test_credential.user_uuid, + aaguid=UUID("00000000-0000-0000-0000-000000000000"), + public_key=secrets.token_bytes(64), + sign_count=0, + rp_id="example.com", + ) + create_credential(cred) + r = await client.delete("/auth/api/admin/realms/example.com", headers=headers) + assert r.status_code == 400 + + @pytest.mark.asyncio + async def test_effective_auth_host_fallback( + self, + client: httpx.AsyncClient, + session_token: str, + test_user, + test_credential, + ): + """A realm without its own auth host uses the shared one in settings.""" + headers = await self._set_auth_host( + client, session_token, test_user, test_credential + ) + r = await client.post( + "/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers + ) + assert r.status_code == 200 + + # Settings on the example.com host report the shared effective auth host + r = await client.get("/auth/api/settings", headers={"Host": "example.com"}) + assert r.status_code == 200 + assert r.json()["rp_id"] == "example.com" + assert r.json()["auth_host"] == "auth.localhost" + assert r.json()["own_auth_host"] is None diff --git a/tests/test_api.py b/tests/test_api.py index 70cd10b..51cfd79 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,12 +18,12 @@ from uuid import UUID import httpx import pytest -from paskia import authcode, db +from paskia import authcode, db, realms from paskia.authsession import EXPIRES from paskia.db import delete_session -from paskia.db.structs import Client +from paskia.db.structs import Client, Config, RealmConfig from paskia.fastapi.api import _REFRESH_INTERVAL -from paskia.util import avatar, hostutil, oidjwt, permutil +from paskia.util import avatar, oidjwt, permutil from paskia.util.crypto import hash_secret from paskia.util.passphrase import generate from tests.conftest import auth_headers, create_test_image_bytes, create_test_session @@ -42,7 +42,7 @@ class TestSettingsEndpoint: assert "rp_name" in data assert "session_cookie" in data assert data["rp_id"] == "localhost" - assert data["rp_name"] == "Test RP" + assert data["rp_name"] == "localhost" assert data["session_cookie"] == "__Host-paskia" @pytest.mark.asyncio @@ -69,16 +69,14 @@ class TestAvatarUrls: self, tmp_path, monkeypatch ): """Absolute avatar URLs should preserve /auth/api even with an auth host.""" - db_root = tmp_path / "test-avatar-db.paskiadb" - monkeypatch.setenv("PASKIA_DB", str(db_root)) - monkeypatch.setattr( - hostutil, - "api_url", - lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}", + realms.configure(listen=None) + realms.init_registry( + Config(realms=[RealmConfig(rp_id="zi.fi", auth_host="https://auth.zi.fi")]) ) - user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5") - path = db_root / "users" / str(test_uuid) / "profile.webp" + # The autouse avatar fixture redirects storage to tmp_path / "users" + user_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5") + path = tmp_path / "users" / str(user_uuid) / "profile.webp" path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"RIFF1234WEBP") @@ -646,8 +644,6 @@ class TestUserInfoEndpoint: monkeypatch, ): """User info should include the canonical avatar URL when present.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, @@ -676,8 +672,6 @@ class TestUserInfoEndpoint: monkeypatch, ): """Avatar route should honor If-None-Match for unchanged avatars.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, @@ -714,8 +708,6 @@ class TestOidcUserInfoEndpoint: monkeypatch, ): """OIDC userinfo should expose picture when profile scope is granted.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, @@ -733,9 +725,10 @@ class TestOidcUserInfoEndpoint: if store is None: raise RuntimeError("Test DB store is not initialized") with store.transaction("create_test_oidc_client"): - test_db.oidc.clients[oidc_client.uuid] = oidc_client + test_db.oidc["localhost"].clients[oidc_client.uuid] = oidc_client access_token = oidjwt.create_access_token( + "localhost", issuer="http://localhost:4401", subject=test_user.uuid, audience=str(oidc_client.uuid), @@ -776,6 +769,7 @@ class TestSetSessionEndpoint: authcode.CookieCode( session_key=session_token, created=datetime.now(UTC), + rp_id="localhost", ) ) response = await client.post( diff --git a/tests/test_cli.py b/tests/test_cli.py index ac8ac93..451f861 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,9 @@ -"""Tests for the CLI entry point in paskia/__main__.py.""" +"""Tests for the CLI entry point in paskia/__main__.py. + +The CLI is split into ``paskia init`` (create the combined paskia.kantadb +with the initial realm(s)) and bare ``paskia`` (serve the stored realms, +adopting a lone legacy ``.paskiadb`` database if present). +""" from __future__ import annotations @@ -6,83 +11,88 @@ import asyncio import os import subprocess import sys -import tempfile from pathlib import Path -from typing import Any +import msgspec import pytest from kanta import Kanta -from paskia.__main__ import main -from paskia.db.structs import DB, Config -from paskia.util.runtime import clear_config_cache -from paskia.util.runtime import config as runtime_config +from paskia.__main__ import _load_stored_config, main +from paskia.db import legacy +from paskia.db.structs import Config +from paskia.util.runtime import ServeConfig, clear_cache @pytest.fixture -def cli_run(monkeypatch): - """Run the CLI main() with the given args and return the RuntimeConfig.""" +def run_cli(monkeypatch, tmp_path): + """Run the CLI main() in a temporary working directory. - def _run(*args: str, db_root: str | None = None) -> Any: - env = os.environ.copy() - if db_root is not None: - env["PASKIA_DB"] = db_root - monkeypatch.setattr(os, "environ", env) + Returns a callable; server.run and the startup box are stubbed out. + The returned dict records the server.run invocation (if any). + """ + monkeypatch.chdir(tmp_path) + calls: dict = {} + monkeypatch.setattr( + "fastapi_vue.server.run", + lambda app, **kw: calls.update({"app": app, **kw}), + ) + monkeypatch.setattr( + "paskia.util.startupbox.print_startup_config", lambda *a, **kw: None + ) + monkeypatch.setattr("logging.basicConfig", lambda **kw: None) + # Isolate environment mutations (PASKIA_CONFIG) from other tests + env = os.environ.copy() + env.pop("PASKIA_CONFIG", None) + env.pop("PASKIA_VITE_URL", None) + monkeypatch.setattr(os, "environ", env) + def _run(*args: str) -> dict: monkeypatch.setattr(sys, "argv", ["paskia", *args]) - monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None) - monkeypatch.setattr( - "paskia.util.startupbox.print_startup_config", lambda _rt: None - ) - monkeypatch.setattr("logging.basicConfig", lambda **_kw: None) - - clear_config_cache() - main() - runtime = runtime_config() - clear_config_cache() - return runtime + clear_cache() + try: + main() + finally: + clear_cache() + return calls return _run -async def _write_config(db_path: Path, config: Config) -> None: - """Write a Config into a JSONL database file using Kanta. - - The initial root uses a different rp_id so the stored diff includes the - target rp_id (required because Config omits defaults when diffing). - """ - kanta = Kanta( - str(db_path), - DB(config=Config(rp_id="uninitialized.invalid")), - migrations="paskia.db.migrations", - ) - kanta.ctx.rp_id = config.rp_id - await kanta.open() - with kanta.transaction("test:write_config"): - kanta.data.config = config - await kanta.close() +def stored_config(tmp_path: Path) -> Config: + """Read back the stored combined configuration.""" + return _load_stored_config(tmp_path / "paskia.kantadb") -def write_config(db_path: Path, config: Config) -> None: - """Synchronous wrapper for _write_config.""" - asyncio.run(_write_config(db_path, config)) +def write_legacy_db(root: Path, config: legacy.LegacyConfig) -> Path: + """Create a legacy-format database directory .paskiadb/main.db.""" + src_dir = root / f"{config.rp_id}.paskiadb" + src_dir.mkdir() + db_file = src_dir / "main.db" + + async def _write() -> None: + kanta = Kanta(str(db_file), legacy.LegacyDB()) + await kanta.open() + with kanta.transaction("test:seed"): + kanta.data.config = config + await kanta.close() + + asyncio.run(_write()) + return src_dir -def test_cli_defaults(cli_run): - with tempfile.TemporaryDirectory() as tmp: - runtime = cli_run("--rp-id", "localhost", db_root=tmp) +def test_init_defaults(run_cli, tmp_path): + run_cli("init") - assert runtime.config.rp_id == "localhost" - assert runtime.config.rp_name is None - assert runtime.config.auth_host is None - assert runtime.config.origins is None - assert runtime.site_url == "http://localhost:4401" - assert runtime.site_path == "/auth/" - assert runtime.save is False + config = stored_config(tmp_path) + assert [r.rp_id for r in config.realms] == ["localhost"] + assert config.realms[0].rp_name is None + assert config.realms[0].auth_host is None + assert config.listen is None -def test_cli_explicit_options(cli_run): - runtime = cli_run( +def test_init_full_options(run_cli, tmp_path): + run_cli( + "init", "--rp-id", "example.com", "--rp-name", @@ -91,56 +101,101 @@ def test_cli_explicit_options(cli_run): "auth.example.com", "--origin", "https://app.example.com", + "--listen", + "4402", ) - assert runtime.config.rp_id == "example.com" - assert runtime.config.rp_name == "Example Corp" - assert runtime.config.auth_host == "https://auth.example.com" - assert runtime.config.origins == [ - "https://auth.example.com", - "https://app.example.com", - ] - assert runtime.site_url == "https://auth.example.com" - assert runtime.site_path == "/" + config = stored_config(tmp_path) + realm = config.realms[0] + assert realm.rp_id == "example.com" + assert realm.rp_name == "Example Corp" + assert realm.auth_host == "https://auth.example.com" + assert realm.origins == ["https://auth.example.com", "https://app.example.com"] + assert config.listen == ["4402"] -def test_cli_loads_stored_config(cli_run): - with tempfile.TemporaryDirectory() as tmp: - db_path = Path(tmp) / "main.db" - write_config( - db_path, - Config( - rp_id="example.com", - rp_name="Stored Name", - origins=["https://stored.example.com"], - ), - ) - runtime = cli_run("--rp-id", "example.com", db_root=tmp) +def test_init_multiple_rp_ids(run_cli, tmp_path): + run_cli("init", "--rp-id", "company.com,app.com", "--rp-id", "pro.com") - assert runtime.config.rp_name == "Stored Name" - assert runtime.config.origins == ["https://stored.example.com"] - assert runtime.site_url == "https://stored.example.com" + config = stored_config(tmp_path) + assert [r.rp_id for r in config.realms] == ["company.com", "app.com", "pro.com"] + assert config.default_realm.rp_id == "company.com" -def test_cli_overrides_stored_config(cli_run): - with tempfile.TemporaryDirectory() as tmp: - db_path = Path(tmp) / "main.db" - write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name")) - runtime = cli_run( - "--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp - ) - - assert runtime.config.rp_name == "Overridden" - - -def test_cli_save_flag(cli_run): - runtime = cli_run("--save") - assert runtime.save is True - - -def test_cli_invalid_auth_host(cli_run): +def test_init_refuses_existing_database(run_cli): + run_cli("init") with pytest.raises(SystemExit): - cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org") + run_cli("init") + + +def test_init_refuses_legacy_database(run_cli, tmp_path): + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com")) + with pytest.raises(SystemExit): + run_cli("init") + + +def test_init_invalid_auth_host(run_cli): + with pytest.raises(SystemExit): + run_cli("init", "--rp-id", "example.com", "--auth-host", "notsub.example.org") + + +def test_serve_requires_database(run_cli): + with pytest.raises(SystemExit, match="paskia init"): + run_cli() + + +def test_serve_uses_stored_config(run_cli, tmp_path): + run_cli("init", "--rp-id", "example.com", "--rp-name", "Stored Name") + calls = run_cli() + + assert calls["app"] == "paskia.fastapi.mainapp:app" + assert calls["listen"] is None # stored listen (None) used + serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig) + assert serve.listen is None + + +def test_serve_listen_override_not_persisted(run_cli, tmp_path): + run_cli("init", "--listen", "4402") + calls = run_cli("--listen", "4403") + + assert calls["listen"] == ["4403"] + serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig) + assert serve.listen == ["4403"] + # Stored config keeps the original listen value + assert stored_config(tmp_path).listen == ["4402"] + + +def test_serve_adopts_legacy_database(run_cli, tmp_path): + src_dir = write_legacy_db( + tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name") + ) + # Persisted user files move to the new data root + avatar = src_dir / "users" / "019c6831-84cf-7b88-b66c-c8165890b7c5" + avatar.mkdir(parents=True) + (avatar / "profile.webp").write_bytes(b"RIFF1234WEBP") + + run_cli() + + config = stored_config(tmp_path) + assert [r.rp_id for r in config.realms] == ["example.com"] + assert config.realms[0].rp_name == "Legacy Name" + # Legacy directory renamed aside, user files adopted + assert not src_dir.exists() + assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir() + assert ( + tmp_path + / "paskia.data" + / "users" + / "019c6831-84cf-7b88-b66c-c8165890b7c5" + / "profile.webp" + ).read_bytes() == b"RIFF1234WEBP" + + +def test_serve_multiple_legacy_databases_abort(run_cli, tmp_path): + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com")) + with pytest.raises(SystemExit, match="Multiple legacy"): + run_cli() def test_cli_help(): @@ -152,3 +207,14 @@ def test_cli_help(): ) assert result.returncode == 0 assert "Paskia authentication server" in result.stdout + + +def test_cli_init_help(): + result = subprocess.run( + [sys.executable, "-m", "paskia", "init", "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "Bootstrap" in result.stdout diff --git a/tests/test_user.py b/tests/test_user.py index c5c5bca..44efff2 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -15,7 +15,6 @@ from urllib.parse import urlsplit import httpx import pytest -from paskia.db.paths import db_file_path, users_root_path from tests.conftest import auth_headers, create_test_image_bytes @@ -93,8 +92,6 @@ class TestUserAvatar: monkeypatch, ): """Uploading a WebP avatar should store and expose the canonical URL.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - upload_bytes = create_test_image_bytes() response = await client.put( @@ -138,8 +135,6 @@ class TestUserAvatar: monkeypatch, ): """Avatar uploads must already be browser-prepared WebP.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - response = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={ @@ -165,8 +160,6 @@ class TestUserAvatar: monkeypatch, ): """Deleting avatar should clear the user avatar URL.""" - monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) - await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, @@ -203,30 +196,6 @@ class TestUserAvatar: assert response.status_code == 403 -def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch): - legacy_path = tmp_path / "legacy.paskiadb" - legacy_bytes = b'{"v":0}\n' - legacy_path.write_bytes(legacy_bytes) - - monkeypatch.setenv("PASKIA_DB", str(legacy_path)) - - db_path = db_file_path(create_root=True) - - assert legacy_path.is_dir() - assert db_path == legacy_path / "main.db" - assert db_path.read_bytes() == legacy_bytes - - -def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch): - root_path = tmp_path / "instance-root" - monkeypatch.setenv("PASKIA_DB", str(root_path)) - - users_path = users_root_path(create_root=True) - - assert users_path == root_path / "users" - assert users_path.parent == root_path - - class TestUserLogoutAll: """Tests for POST /auth/api/user/logout-all"""