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:
+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
|
||||
|
||||
Reference in New Issue
Block a user