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