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
+8 -9
View File
@@ -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:
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)
-1
View File
@@ -94,4 +94,3 @@ async def bootstrap_if_needed() -> bool:
"""
await check_admin_credentials()
return False
+1 -1
View File
@@ -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")
+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.structs import (
OIDC,
DB,
OIDC,
Config,
Credential,
Org,
-1
View File
@@ -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__)
+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]:
"""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]:
+6 -2
View File
@@ -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 = {}
+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)")
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))
+2 -6
View File
@@ -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"
)
+3 -1
View File
@@ -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"}
+3 -3
View File
@@ -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}
)
+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("/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)
+1 -1
View File
@@ -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)
+5 -6
View File
@@ -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(
+1 -4
View File
@@ -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")
+33 -51
View File
@@ -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")
+187 -43
View File
@@ -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
+13 -19
View File
@@ -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(
+158 -92
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
@@ -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:
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()
if db_root is not None:
env["PASKIA_DB"] = db_root
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()
clear_cache()
try:
main()
runtime = runtime_config()
clear_config_cache()
return runtime
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.
def stored_config(tmp_path: Path) -> Config:
"""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
def write_legacy_db(root: Path, config: legacy.LegacyConfig) -> Path:
"""Create a legacy-format database directory <rp-id>.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:write_config"):
with kanta.transaction("test:seed"):
kanta.data.config = config
await kanta.close()
def write_config(db_path: Path, config: Config) -> None:
"""Synchronous wrapper for _write_config."""
asyncio.run(_write_config(db_path, config))
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
-31
View File
@@ -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"""