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.
This commit is contained in:
2026-09-06 04:28:35 +00:00
parent f44bcc9dea
commit 33d3b88941
20 changed files with 436 additions and 284 deletions
+9 -10
View File
@@ -7,10 +7,8 @@ from pathlib import Path
import msgspec import msgspec
from fastapi_vue import server from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints
from kanta import Kanta from kanta import Kanta
from paskia._version import __version__
from paskia.db import legacy from paskia.db import legacy
from paskia.db.bootstrap import bootstrap, log_reset_link from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path 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; # Bootstrap-time naming and hosts apply to the default realm;
# everything is editable via the admin interface afterwards. # everything is editable via the admin interface afterwards.
realm.rp_name = args.rp_name or None realm.rp_name = args.rp_name or None
origins = ( origins = [normalize_origin(o) for o in _split_multi(args.origins)] or None
[normalize_origin(o) for o in _split_multi(args.origins)] or None
)
auth_host = args.auth_host or None auth_host = args.auth_host or None
if auth_host: 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( realm.auth_host, realm.origins = normalize_auth_host_and_origins(
auth_host, origins auth_host, origins
) )
@@ -157,9 +156,7 @@ def cmd_serve(args: argparse.Namespace) -> None:
if adopted: if adopted:
print(f"✅ Converted legacy database to {db_path} (realm: {adopted})") print(f"✅ Converted legacy database to {db_path} (realm: {adopted})")
if not db_path.exists(): if not db_path.exists():
raise SystemExit( raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
f"Database {db_path} not found — run 'paskia init' first."
)
config = _load_stored_config(db_path) config = _load_stored_config(db_path)
try: try:
@@ -172,7 +169,9 @@ def cmd_serve(args: argparse.Namespace) -> None:
registry = build_registry(config) registry = build_registry(config)
# Pass process-global serve parameters to the server process(es) # 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) startupbox.print_startup_config(registry, listen=listen)
-1
View File
@@ -94,4 +94,3 @@ async def bootstrap_if_needed() -> bool:
""" """
await check_admin_credentials() await check_admin_credentials()
return False return False
+1 -1
View File
@@ -9,7 +9,7 @@ from datetime import UTC, datetime
import uuid7 import uuid7
from paskia.authsession import reset_expires 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 from paskia.util.crypto import secret_key
_reset_link_logger = logging.getLogger("paskia.reset_link") _reset_link_logger = logging.getLogger("paskia.reset_link")
+1 -1
View File
@@ -24,8 +24,8 @@ from kanta import Kanta
from paskia.db.paths import db_file_path, users_root_path from paskia.db.paths import db_file_path, users_root_path
from paskia.db.structs import ( from paskia.db.structs import (
OIDC,
DB, DB,
OIDC,
Config, Config,
Credential, Credential,
Org, Org,
-1
View File
@@ -19,7 +19,6 @@ import paskia.db.operations as _ops
from paskia import oidc_notify from paskia import oidc_notify
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db.paths import db_file_path from paskia.db.paths import db_file_path
from paskia.db.structs import DB
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+1 -3
View File
@@ -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]: def credential_ids_for(self, rp_id: str) -> list[bytes]:
"""Get credential IDs registered under a specific realm's rp-id.""" """Get credential IDs registered under a specific realm's rp-id."""
return [ return [c.credential_id for c in self.credentials if c.rp_id == rp_id]
c.credential_id for c in self.credentials if c.rp_id == rp_id
]
@property @property
def sessions(self) -> list[Session]: def sessions(self) -> list[Session]:
+6 -2
View File
@@ -8,10 +8,12 @@ from paskia.fastapi.admin import (
oidc_clients, oidc_clients,
orgs, orgs,
permissions, permissions,
realms as realms_admin,
roles, roles,
users, users,
) )
from paskia.fastapi.admin import (
realms as realms_admin,
)
from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
@@ -99,7 +101,9 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
oidc_clients_dict = {} oidc_clients_dict = {}
if master_admin(ctx): if master_admin(ctx):
provider = db.data().oidc_for(current_realm().rp_id) 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 sessions = db.data().sessions
# Count active sessions per client # Count active sessions per client
client_session_counts = {} client_session_counts = {}
+3 -1
View File
@@ -203,7 +203,9 @@ async def admin_reset_oidc_client_secret(
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)") raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try: 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: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
+2 -6
View File
@@ -28,10 +28,7 @@ def _validate_permission_domain(domain: str | None) -> None:
# Allow OIDC client UUIDs (used for groups claim) # Allow OIDC client UUIDs (used for groups claim)
try: try:
client_uuid = UUID(domain) client_uuid = UUID(domain)
if any( if any(client_uuid in provider.clients for provider in db.data().oidc.values()):
client_uuid in provider.clients
for provider in db.data().oidc.values()
):
return return
except ValueError: except ValueError:
pass pass
@@ -40,8 +37,7 @@ def _validate_permission_domain(domain: str | None) -> None:
if reg.resolve(domain) is not None: if reg.resolve(domain) is not None:
return return
raise ValueError( raise ValueError(
f"Domain '{domain}' must belong to a configured realm " f"Domain '{domain}' must belong to a configured realm or be an OIDC client UUID"
"or be an OIDC client UUID"
) )
+3 -1
View File
@@ -133,7 +133,9 @@ async def admin_update_realm(
) )
realms.validate_config(would_be) 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() _rebuild_registry()
return {"status": "ok"} return {"status": "ok"}
+3 -3
View File
@@ -75,9 +75,9 @@ class DispatchMiddleware:
if origin_realm is not None and origin_realm is not host_realm: if origin_realm is not None and origin_realm is not host_realm:
# Cross-realm connection: only via the origin realm's auth host. # Cross-realm connection: only via the origin realm's auth host.
effective = registry.effective_auth_host(origin_realm) effective = registry.effective_auth_host(origin_realm)
if not effective or hostutil.normalize_host(host) != hostutil.normalize_host( if not effective or hostutil.normalize_host(
effective host
): ) != hostutil.normalize_host(effective):
await send( await send(
{"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION} {"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION}
) )
+3 -1
View File
@@ -165,7 +165,9 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
@app.get("/admin", include_in_schema=False) @app.get("/admin", include_in_schema=False)
@app.get("/auth/admin", include_in_schema=False) @app.get("/auth/admin", include_in_schema=False)
async def admin_root_redirect(): 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) @app.get("/admin/", include_in_schema=False)
+1 -1
View File
@@ -21,7 +21,7 @@ from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm from paskia.realms import current_realm
from paskia.util import avatar, hostutil from paskia.util import avatar
from paskia.util.apistructs import ApiCreateLinkResponse from paskia.util.apistructs import ApiCreateLinkResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
+5 -6
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import contextvars import contextvars
import os import os
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
@@ -31,7 +30,7 @@ class Realm:
def __init__(self, config: RealmConfig, site_url: str, site_path: str): def __init__(self, config: RealmConfig, site_url: str, site_path: str):
# Lazy import: paskia.sansio depends on paskia.db, which (via # Lazy import: paskia.sansio depends on paskia.db, which (via
# paskia.db.operations → paskia.oidc_notify) depends on this module. # 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.config = config
self.site_url = site_url self.site_url = site_url
@@ -149,7 +148,9 @@ class RealmRegistry:
return realm return realm
best = None best = None
for rp_id, realm in self._by_rp_id.items(): 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 best = realm
return best return best
@@ -215,9 +216,7 @@ def validate_config(
for hn, owner in related_hosts.items(): for hn, owner in related_hosts.items():
if hn in rp_ids: if hn in rp_ids:
raise ValueError( raise ValueError(f"Related origin host '{hn}' collides with an rp-id")
f"Related origin host '{hn}' collides with an rp-id"
)
for other in rp_ids: for other in rp_ids:
if other != owner and hostutil.is_subdomain(hn, other): if other != owner and hostutil.is_subdomain(hn, other):
raise ValueError( raise ValueError(
+1 -4
View File
@@ -10,7 +10,7 @@ from uuid import UUID
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from paskia.db.paths import users_root_path 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 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.""" """Return the absolute public avatar URL for a user, or None."""
if not avatar_path(user_uuid).is_file(): if not avatar_path(user_uuid).is_file():
return None 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") return current_realm().api_url(f"user/{user_uuid}/profile.webp")
+33 -51
View File
@@ -12,12 +12,12 @@ in the database to test authenticated endpoints.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os import os
import secrets import secrets
import tempfile import tempfile
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import UUID from uuid import UUID
import httpx import httpx
@@ -25,22 +25,8 @@ import pytest
import pytest_asyncio import pytest_asyncio
from kanta import Kanta 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 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.authsession import reset_expires
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from paskia.db import ( from paskia.db import (
@@ -56,12 +42,15 @@ from paskia.db import (
) )
from paskia.db.bootstrap import bootstrap from paskia.db.bootstrap import bootstrap
from paskia.db.operations import DB 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.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME 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 from paskia.util.crypto import hash_secret
TEST_RP_ID = "localhost"
TEST_LISTEN = ["localhost:4401"]
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def event_loop(): def event_loop():
@@ -71,6 +60,19 @@ def event_loop():
loop.close() 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") @pytest_asyncio.fixture(scope="function")
async def test_db() -> AsyncGenerator[DB]: async def test_db() -> AsyncGenerator[DB]:
"""Create a temporary JSONL database for testing using kanta. """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 - auth:admin and auth:org:admin permissions
- A default organization with Administration role - A default organization with Administration role
- An admin user with the 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: with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB() db = DB()
kanta = Kanta( kanta = Kanta(f.name, db)
f.name,
db,
migrations="paskia.db.migrations",
)
kanta.ctx.rp_id = "test.example.com"
# Register bootstrap callback so kanta seeds the empty DB during open() # Register bootstrap callback so kanta seeds the empty DB during open()
@kanta.bootstrap(action="bootstrap") @kanta.bootstrap(action="bootstrap")
@@ -96,6 +94,7 @@ async def test_db() -> AsyncGenerator[DB]:
data, data,
org_name="Test Organization", org_name="Test Organization",
admin_name="Test Admin", admin_name="Test Admin",
config=Config(realms=[RealmConfig(rp_id=TEST_RP_ID)]),
) )
await kanta.open() await kanta.open()
@@ -107,25 +106,10 @@ async def test_db() -> AsyncGenerator[DB]:
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def passkey_instance() -> Passkey: async def realm_registry(test_db: DB) -> realms.RealmRegistry:
"""Override the module-level passkey instance for testing.""" """Install the realm registry built from the test database config."""
pk = Passkey( realms.configure(listen=TEST_LISTEN)
rp_id="localhost", return realms.init_registry(test_db.config)
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"]
@pytest_asyncio.fixture(scope="function") @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"), aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64), public_key=os.urandom(64),
sign_count=0, sign_count=0,
rp_id=TEST_RP_ID,
) )
create_credential(credential) create_credential(credential)
return 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"), aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64), public_key=os.urandom(64),
sign_count=0, sign_count=0,
rp_id=TEST_RP_ID,
) )
create_credential(credential) create_credential(credential)
return 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") @pytest_asyncio.fixture(scope="function")
async def client( async def client(
test_db: DB, passkey_instance: Passkey test_db: DB, realm_registry: realms.RealmRegistry
) -> AsyncGenerator[httpx.AsyncClient]: ) -> AsyncGenerator[httpx.AsyncClient]:
"""Create an async test client for the FastAPI app. """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
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient( async with httpx.AsyncClient(
transport=transport, transport=transport,
@@ -283,6 +263,7 @@ def create_test_session(
ip: str = "127.0.0.1", ip: str = "127.0.0.1",
user_agent: str = "pytest", user_agent: str = "pytest",
duration: timedelta | None = None, duration: timedelta | None = None,
rp_id: str = TEST_RP_ID,
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Create a test session. Returns (key, token) tuple. """Create a test session. Returns (key, token) tuple.
@@ -309,6 +290,7 @@ def create_test_session(
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
validated=now, validated=now,
rp_id=rp_id,
) )
if session.key in ops_db._db.sessions: if session.key in ops_db._db.sessions:
raise ValueError("Session already exists") raise ValueError("Session already exists")
+187 -43
View File
@@ -22,7 +22,7 @@ import pytest
import pytest_asyncio import pytest_asyncio
import uuid7 import uuid7
from paskia import db from paskia import db, realms
from paskia.db import ( from paskia.db import (
Credential, Credential,
Org, Org,
@@ -37,10 +37,7 @@ from paskia.db import (
create_user, create_user,
) )
from paskia.db.operations import DB from paskia.db.operations import DB
from paskia.util import hostutil
from paskia.util.crypto import hash_secret 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 from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
# -------------------- Additional Fixtures -------------------- # -------------------- 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"), aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64), public_key=os.urandom(64),
sign_count=0, sign_count=0,
rp_id="localhost",
) )
create_credential(credential) create_credential(credential)
return 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"), aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64), public_key=os.urandom(64),
sign_count=0, sign_count=0,
rp_id="localhost",
) )
create_credential(credential) create_credential(credential)
return credential return credential
@@ -253,8 +252,6 @@ class TestAdminOrganizations:
monkeypatch, monkeypatch,
): ):
"""Admin org payload should include canonical avatar URLs for listed users.""" """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( upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -948,8 +945,6 @@ class TestAdminUsersInOrg:
monkeypatch, monkeypatch,
): ):
"""Admin should be able to upload avatar for a managed user.""" """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( response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -1794,21 +1789,13 @@ class TestOrgAdminAuthExceptions:
assert response.status_code == 403 assert response.status_code == 403
class TestServerConfig: class TestRealms:
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates.""" """Tests for the realm management API (/auth/api/admin/realms/)."""
@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()
async def _set_auth_host(self, client, session_token, test_user, test_credential): 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( r = await client.patch(
"/auth/api/admin/server-config/", "/auth/api/admin/realms/localhost",
json={ json={
"rp_name": "", "rp_name": "",
"auth_host": "auth.localhost", "auth_host": "auth.localhost",
@@ -1817,15 +1804,42 @@ class TestServerConfig:
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert db.data().config.auth_host == "https://auth.localhost" realm_cfg = db.data().config.find_realm("localhost")
assert hostutil.dedicated_auth_host() == "auth.localhost" assert realm_cfg.auth_host == "https://auth.localhost"
assert hostutil.auth_site_url() == "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) # Session for requests coming from the auth host (sessions are host-bound)
_, token = create_test_session( _, token = create_test_session(
test_user.uuid, test_credential.uuid, host="auth.localhost" test_user.uuid, test_credential.uuid, host="auth.localhost"
) )
return {**auth_headers(token), "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 @pytest.mark.asyncio
async def test_remove_auth_host_updates_runtime( async def test_remove_auth_host_updates_runtime(
self, self,
@@ -1833,16 +1847,15 @@ class TestServerConfig:
session_token: str, session_token: str,
test_user, test_user,
test_credential, 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( headers = await self._set_auth_host(
client, session_token, test_user, test_credential client, session_token, test_user, test_credential
) )
# The dialog still lists the old auth host among origins, so it is sent back # The dialog still lists the old auth host among origins, so it is sent back
r = await client.patch( r = await client.patch(
"/auth/api/admin/server-config/", "/auth/api/admin/realms/localhost",
json={ json={
"rp_name": "", "rp_name": "",
"auth_host": "", "auth_host": "",
@@ -1851,23 +1864,24 @@ class TestServerConfig:
headers=headers, headers=headers,
) )
assert r.status_code == 200, r.text 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() realm = realms.registry().get("localhost")
assert rt.config.auth_host is None assert realm.own_auth_host is None
assert rt.site_path == "/auth/" assert realm.ui_base_path == "/auth/"
assert "auth.localhost" not in rt.site_url # Site URL derivation is stateless: with the auth host removed, the
assert hostutil.dedicated_auth_host() is None # first remaining origin becomes the site URL.
assert "auth.localhost" not in hostutil.auth_site_url() assert realm.auth_site_url == "https://auth.localhost/auth/"
# GET and settings reflect the cleared state # GET and settings reflect the cleared state
r = await client.get( r = await client.get(
"/auth/api/admin/server-config/", "/auth/api/admin/realms/",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, 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") r = await client.get("/auth/api/settings")
assert r.json()["auth_host"] is None assert r.json()["auth_host"] is None
assert r.json()["own_auth_host"] is None
assert r.json()["ui_base_path"] == "/auth/" assert r.json()["ui_base_path"] == "/auth/"
# Middleware no longer redirects to the removed auth host # Middleware no longer redirects to the removed auth host
@@ -1879,13 +1893,12 @@ class TestServerConfig:
assert "auth.localhost" not in r.headers.get("location", "") assert "auth.localhost" not in r.headers.get("location", "")
@pytest.mark.asyncio @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, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
session_token: str, session_token: str,
test_user, test_user,
test_credential, test_credential,
restore_runtime_config,
): ):
"""With no origins left, site_url must not keep the removed auth host.""" """With no origins left, site_url must not keep the removed auth host."""
headers = await self._set_auth_host( headers = await self._set_auth_host(
@@ -1893,14 +1906,145 @@ class TestServerConfig:
) )
r = await client.patch( r = await client.patch(
"/auth/api/admin/server-config/", "/auth/api/admin/realms/localhost",
json={"rp_name": "", "auth_host": "", "origins": []}, json={"rp_name": "", "auth_host": "", "origins": []},
headers=headers, headers=headers,
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
rt = runtime_config() realm = realms.registry().get("localhost")
assert rt.config.auth_host is None assert realm.own_auth_host is None
assert rt.site_path == "/auth/" assert realm.ui_base_path == "/auth/"
assert "auth.localhost" not in rt.site_url assert "auth.localhost" not in realm.site_url
assert "auth.localhost" not in hostutil.auth_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
+13 -19
View File
@@ -18,12 +18,12 @@ from uuid import UUID
import httpx import httpx
import pytest import pytest
from paskia import authcode, db from paskia import authcode, db, realms
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db import delete_session 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.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.crypto import hash_secret
from paskia.util.passphrase import generate from paskia.util.passphrase import generate
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session 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 "rp_name" in data
assert "session_cookie" in data assert "session_cookie" in data
assert data["rp_id"] == "localhost" assert data["rp_id"] == "localhost"
assert data["rp_name"] == "Test RP" assert data["rp_name"] == "localhost"
assert data["session_cookie"] == "__Host-paskia" assert data["session_cookie"] == "__Host-paskia"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -69,16 +69,14 @@ class TestAvatarUrls:
self, tmp_path, monkeypatch self, tmp_path, monkeypatch
): ):
"""Absolute avatar URLs should preserve /auth/api even with an auth host.""" """Absolute avatar URLs should preserve /auth/api even with an auth host."""
db_root = tmp_path / "test-avatar-db.paskiadb" realms.configure(listen=None)
monkeypatch.setenv("PASKIA_DB", str(db_root)) realms.init_registry(
monkeypatch.setattr( Config(realms=[RealmConfig(rp_id="zi.fi", auth_host="https://auth.zi.fi")])
hostutil,
"api_url",
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
) )
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5") # The autouse avatar fixture redirects storage to tmp_path / "users"
path = db_root / "users" / str(test_uuid) / "profile.webp" 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.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"RIFF1234WEBP") path.write_bytes(b"RIFF1234WEBP")
@@ -646,8 +644,6 @@ class TestUserInfoEndpoint:
monkeypatch, monkeypatch,
): ):
"""User info should include the canonical avatar URL when present.""" """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( upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -676,8 +672,6 @@ class TestUserInfoEndpoint:
monkeypatch, monkeypatch,
): ):
"""Avatar route should honor If-None-Match for unchanged avatars.""" """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( upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -714,8 +708,6 @@ class TestOidcUserInfoEndpoint:
monkeypatch, monkeypatch,
): ):
"""OIDC userinfo should expose picture when profile scope is granted.""" """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( upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -733,9 +725,10 @@ class TestOidcUserInfoEndpoint:
if store is None: if store is None:
raise RuntimeError("Test DB store is not initialized") raise RuntimeError("Test DB store is not initialized")
with store.transaction("create_test_oidc_client"): 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( access_token = oidjwt.create_access_token(
"localhost",
issuer="http://localhost:4401", issuer="http://localhost:4401",
subject=test_user.uuid, subject=test_user.uuid,
audience=str(oidc_client.uuid), audience=str(oidc_client.uuid),
@@ -776,6 +769,7 @@ class TestSetSessionEndpoint:
authcode.CookieCode( authcode.CookieCode(
session_key=session_token, session_key=session_token,
created=datetime.now(UTC), created=datetime.now(UTC),
rp_id="localhost",
) )
) )
response = await client.post( response = await client.post(
+164 -98
View File
@@ -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 ``<rp-id>.paskiadb`` database if present).
"""
from __future__ import annotations from __future__ import annotations
@@ -6,83 +11,88 @@ import asyncio
import os import os
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
import msgspec
import pytest import pytest
from kanta import Kanta from kanta import Kanta
from paskia.__main__ import main from paskia.__main__ import _load_stored_config, main
from paskia.db.structs import DB, Config from paskia.db import legacy
from paskia.util.runtime import clear_config_cache from paskia.db.structs import Config
from paskia.util.runtime import config as runtime_config from paskia.util.runtime import ServeConfig, clear_cache
@pytest.fixture @pytest.fixture
def cli_run(monkeypatch): def run_cli(monkeypatch, tmp_path):
"""Run the CLI main() with the given args and return the RuntimeConfig.""" """Run the CLI main() in a temporary working directory.
def _run(*args: str, db_root: str | None = None) -> Any: Returns a callable; server.run and the startup box are stubbed out.
env = os.environ.copy() The returned dict records the server.run invocation (if any).
if db_root is not None: """
env["PASKIA_DB"] = db_root monkeypatch.chdir(tmp_path)
monkeypatch.setattr(os, "environ", env) 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(sys, "argv", ["paskia", *args])
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None) clear_cache()
monkeypatch.setattr( try:
"paskia.util.startupbox.print_startup_config", lambda _rt: None main()
) finally:
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None) clear_cache()
return calls
clear_config_cache()
main()
runtime = runtime_config()
clear_config_cache()
return runtime
return _run return _run
async def _write_config(db_path: Path, config: Config) -> None: def stored_config(tmp_path: Path) -> Config:
"""Write a Config into a JSONL database file using Kanta. """Read back the stored combined configuration."""
return _load_stored_config(tmp_path / "paskia.kantadb")
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 write_config(db_path: Path, config: Config) -> None: def write_legacy_db(root: Path, config: legacy.LegacyConfig) -> Path:
"""Synchronous wrapper for _write_config.""" """Create a legacy-format database directory <rp-id>.paskiadb/main.db."""
asyncio.run(_write_config(db_path, config)) 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): def test_init_defaults(run_cli, tmp_path):
with tempfile.TemporaryDirectory() as tmp: run_cli("init")
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
assert runtime.config.rp_id == "localhost" config = stored_config(tmp_path)
assert runtime.config.rp_name is None assert [r.rp_id for r in config.realms] == ["localhost"]
assert runtime.config.auth_host is None assert config.realms[0].rp_name is None
assert runtime.config.origins is None assert config.realms[0].auth_host is None
assert runtime.site_url == "http://localhost:4401" assert config.listen is None
assert runtime.site_path == "/auth/"
assert runtime.save is False
def test_cli_explicit_options(cli_run): def test_init_full_options(run_cli, tmp_path):
runtime = cli_run( run_cli(
"init",
"--rp-id", "--rp-id",
"example.com", "example.com",
"--rp-name", "--rp-name",
@@ -91,56 +101,101 @@ def test_cli_explicit_options(cli_run):
"auth.example.com", "auth.example.com",
"--origin", "--origin",
"https://app.example.com", "https://app.example.com",
"--listen",
"4402",
) )
assert runtime.config.rp_id == "example.com" config = stored_config(tmp_path)
assert runtime.config.rp_name == "Example Corp" realm = config.realms[0]
assert runtime.config.auth_host == "https://auth.example.com" assert realm.rp_id == "example.com"
assert runtime.config.origins == [ assert realm.rp_name == "Example Corp"
"https://auth.example.com", assert realm.auth_host == "https://auth.example.com"
"https://app.example.com", assert realm.origins == ["https://auth.example.com", "https://app.example.com"]
] assert config.listen == ["4402"]
assert runtime.site_url == "https://auth.example.com"
assert runtime.site_path == "/"
def test_cli_loads_stored_config(cli_run): def test_init_multiple_rp_ids(run_cli, tmp_path):
with tempfile.TemporaryDirectory() as tmp: run_cli("init", "--rp-id", "company.com,app.com", "--rp-id", "pro.com")
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)
assert runtime.config.rp_name == "Stored Name" config = stored_config(tmp_path)
assert runtime.config.origins == ["https://stored.example.com"] assert [r.rp_id for r in config.realms] == ["company.com", "app.com", "pro.com"]
assert runtime.site_url == "https://stored.example.com" assert config.default_realm.rp_id == "company.com"
def test_cli_overrides_stored_config(cli_run): def test_init_refuses_existing_database(run_cli):
with tempfile.TemporaryDirectory() as tmp: run_cli("init")
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):
with pytest.raises(SystemExit): 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(): def test_cli_help():
@@ -152,3 +207,14 @@ def test_cli_help():
) )
assert result.returncode == 0 assert result.returncode == 0
assert "Paskia authentication server" in result.stdout 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
-31
View File
@@ -15,7 +15,6 @@ from urllib.parse import urlsplit
import httpx import httpx
import pytest import pytest
from paskia.db.paths import db_file_path, users_root_path
from tests.conftest import auth_headers, create_test_image_bytes from tests.conftest import auth_headers, create_test_image_bytes
@@ -93,8 +92,6 @@ class TestUserAvatar:
monkeypatch, monkeypatch,
): ):
"""Uploading a WebP avatar should store and expose the canonical URL.""" """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() upload_bytes = create_test_image_bytes()
response = await client.put( response = await client.put(
@@ -138,8 +135,6 @@ class TestUserAvatar:
monkeypatch, monkeypatch,
): ):
"""Avatar uploads must already be browser-prepared WebP.""" """Avatar uploads must already be browser-prepared WebP."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
response = await client.put( response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={ files={
@@ -165,8 +160,6 @@ class TestUserAvatar:
monkeypatch, monkeypatch,
): ):
"""Deleting avatar should clear the user avatar URL.""" """Deleting avatar should clear the user avatar URL."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
await client.put( await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp", f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -203,30 +196,6 @@ class TestUserAvatar:
assert response.status_code == 403 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: class TestUserLogoutAll:
"""Tests for POST /auth/api/user/logout-all""" """Tests for POST /auth/api/user/logout-all"""