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:
+8
-9
@@ -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)
|
||||
|
||||
|
||||
@@ -94,4 +94,3 @@ async def bootstrap_if_needed() -> bool:
|
||||
"""
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user