test_remote.py: link URL parsing, RAM-only offer mint/spend/expiry, pushed-domain application (accept/idempotence/removal/local conflict/ auth-host collision), admin link lifecycle (offer, scope, revoke) and uplink list/unlink, remote-write rejection on the domains API, live feed scope mutation. test_satellite_e2e.py: real registration handshake over TCP (key material identical on both sides, duplicate and bad-URL rejections), established link reconnect, revocation unlinking the satellite, live scope shrink stopping the feed, domain conflicts reported upstream. Also: ruff format drift fixes in startupbox/test_cli.
865 lines
29 KiB
Python
865 lines
29 KiB
Python
"""Tests for remote (satellite) domains: config, links, replica, feed."""
|
|
|
|
import asyncio
|
|
import secrets
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
import msgspec
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import Response
|
|
|
|
import paskia.db.operations as ops_db
|
|
from paskia import domains, satellite, syncfeed
|
|
from paskia.db.structs import (
|
|
Config,
|
|
Credential,
|
|
DomainConfig,
|
|
OriginEntry,
|
|
RemoteConfig,
|
|
Session,
|
|
Uplink,
|
|
User,
|
|
)
|
|
from paskia.fastapi import sync
|
|
from paskia.fastapi.mainapp import app
|
|
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
|
from paskia.util.apistructs import (
|
|
ApiOrgContext,
|
|
ApiRoleContext,
|
|
ApiSessionContext,
|
|
ApiUserContext,
|
|
)
|
|
from paskia.util.crypto import hash_secret
|
|
|
|
from .conftest import TEST_RP_ID, create_test_session
|
|
|
|
REMOTE_URL = "http://remote.test"
|
|
UPLINK_ID = "up1"
|
|
|
|
|
|
def _api_ctx(display_name="Remote Admin", permissions=("auth:admin",)):
|
|
"""An upstream-pushed per-session context, as embedded in session events."""
|
|
return ApiSessionContext(
|
|
user=ApiUserContext(uuid=UUID(int=104), display_name=display_name),
|
|
org=ApiOrgContext(uuid=UUID(int=101), display_name="Org"),
|
|
role=ApiRoleContext(uuid=UUID(int=103), display_name="Admins"),
|
|
permissions=list(permissions),
|
|
)
|
|
|
|
|
|
def _uplink(**kw) -> Uplink:
|
|
uplink = Uplink(url=REMOTE_URL, created=datetime.now(UTC), key=b"k", **kw)
|
|
uplink.uplink_id = UPLINK_ID
|
|
return uplink
|
|
|
|
|
|
def _remote_domain_config(**kw) -> Config:
|
|
return Config(
|
|
domains={
|
|
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}),
|
|
"example.com": DomainConfig(
|
|
origins={
|
|
"**.example.com": True,
|
|
"auth.example.com": OriginEntry(auth_host=True),
|
|
},
|
|
remote=RemoteConfig(link=UPLINK_ID, **kw),
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def test_remote_domain_valid():
|
|
domains.validate_config(_remote_domain_config())
|
|
|
|
|
|
def test_remote_domain_requires_auth_host():
|
|
config = _remote_domain_config()
|
|
config.domains["example.com"].origins = {"**.example.com": True}
|
|
with pytest.raises(ValueError, match="auth host"):
|
|
domains.validate_config(config)
|
|
|
|
|
|
def test_sanitize_preserves_remote():
|
|
config, warnings = domains.sanitize_config(_remote_domain_config())
|
|
assert not warnings
|
|
assert config.domains["example.com"].remote.link == UPLINK_ID
|
|
|
|
|
|
def test_sanitize_drops_remote_without_link():
|
|
"""A remote config pointing nowhere (e.g. a v1 leftover) degrades to a
|
|
plain local domain instead of failing to serve."""
|
|
config = _remote_domain_config()
|
|
config.domains["example.com"].remote = RemoteConfig()
|
|
sanitized, warnings = domains.sanitize_config(config)
|
|
assert any("remote dropped" in w for w in warnings)
|
|
assert sanitized.domains["example.com"].remote is None
|
|
domains.validate_config(sanitized)
|
|
|
|
|
|
def test_apply_upsert_and_delete():
|
|
replica = satellite.ReplicaDB()
|
|
cred = Credential.create(
|
|
credential_id=b"cid",
|
|
user=UUID(int=1),
|
|
aaguid=UUID(int=0),
|
|
public_key=b"pk",
|
|
sign_count=0,
|
|
rp_id="example.com",
|
|
)
|
|
cred.uuid = UUID(int=2)
|
|
satellite._apply(replica, "credentials", str(cred.uuid), _builtins(cred))
|
|
assert replica.credentials[cred.uuid].public_key == b"pk"
|
|
satellite._apply(replica, "credentials", str(cred.uuid), None)
|
|
assert not replica.credentials
|
|
|
|
|
|
def _builtins(obj):
|
|
return msgspec.to_builtins(obj)
|
|
|
|
|
|
def test_apply_session_roundtrip():
|
|
"""Sessions keep their string key and datetime/UUID fields; the
|
|
embedded ctx lands in the replica's context map (and goes with it)."""
|
|
replica = satellite.ReplicaDB()
|
|
session = Session.create(
|
|
user=UUID(int=1),
|
|
credential=UUID(int=2),
|
|
key=hash_secret("cookie", "sekret"),
|
|
host="app2.example.com",
|
|
ip="127.0.0.1",
|
|
user_agent="ua",
|
|
validated=datetime.now(UTC),
|
|
rp_id="example.com",
|
|
)
|
|
fields = _builtins(session)
|
|
fields["ctx"] = _builtins(_api_ctx())
|
|
satellite._apply(replica, "sessions", session.key, fields)
|
|
stored = replica.sessions[session.key]
|
|
assert stored.host == "app2.example.com"
|
|
assert stored.validated == session.validated
|
|
assert stored.user_uuid == UUID(int=1)
|
|
assert replica.session_contexts[session.key].user.display_name == "Remote Admin"
|
|
satellite._apply(replica, "sessions", session.key, None)
|
|
assert not replica.sessions
|
|
assert not replica.session_contexts
|
|
|
|
|
|
def test_apply_credential_bytes_roundtrip():
|
|
"""credential_id/public_key are bytes over the wire (base64 in JSON)."""
|
|
replica = satellite.ReplicaDB()
|
|
cred = Credential.create(
|
|
credential_id=secrets.token_bytes(32),
|
|
user=UUID(int=1),
|
|
aaguid=UUID(int=0),
|
|
public_key=secrets.token_bytes(64),
|
|
sign_count=3,
|
|
rp_id="example.com",
|
|
)
|
|
cred.uuid = UUID(int=9)
|
|
# Simulate the full wire path: builtins -> JSON -> builtins
|
|
wire = msgspec.json.decode(msgspec.json.encode(_builtins(cred)))
|
|
satellite._apply(replica, "credentials", str(cred.uuid), wire)
|
|
stored = replica.credentials[cred.uuid]
|
|
assert stored.credential_id == cred.credential_id
|
|
assert stored.public_key == cred.public_key
|
|
assert stored.sign_count == 3
|
|
|
|
|
|
def test_feed_emit_to_subscribers():
|
|
queue = syncfeed.subscribe()
|
|
try:
|
|
cred = Credential.create(
|
|
credential_id=b"c",
|
|
user=UUID(int=1),
|
|
aaguid=UUID(int=0),
|
|
public_key=b"p",
|
|
sign_count=0,
|
|
rp_id="example.com",
|
|
)
|
|
syncfeed.emit("credentials", "k1", cred)
|
|
syncfeed.emit("credentials", "k1", None)
|
|
assert queue.get_nowait()["fields"]["sign_count"] == 0
|
|
assert queue.get_nowait()["fields"] is None
|
|
finally:
|
|
syncfeed.unsubscribe(queue)
|
|
|
|
|
|
def test_feed_drops_full_queue():
|
|
queue = syncfeed.subscribe()
|
|
try:
|
|
for i in range(1001):
|
|
syncfeed.emit("credentials", f"k{i}", None)
|
|
assert queue.qsize() == 1000
|
|
syncfeed.emit("credentials", "k1001", None) # subscriber already dropped
|
|
assert queue.qsize() == 1000
|
|
finally:
|
|
syncfeed.unsubscribe(queue)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_operations_repush_session_contexts(test_db, test_user, test_credential):
|
|
"""User-graph changes are never published; instead the affected
|
|
sessions are re-pushed with a freshly computed ctx."""
|
|
queue = syncfeed.subscribe()
|
|
try:
|
|
key, _ = create_test_session(
|
|
user_uuid=test_user.uuid, credential_uuid=test_credential.uuid
|
|
)
|
|
_drain(queue) # the session store itself emits (with ctx)
|
|
ops_db.update_user_display_name(test_user.uuid, "Renamed")
|
|
events = _drain(queue)
|
|
assert all(e["table"] != "users" for e in events)
|
|
repushed = [e for e in events if e["table"] == "sessions" and e["key"] == key]
|
|
assert repushed
|
|
assert repushed[-1]["fields"]["ctx"]["user"]["display_name"] == "Renamed"
|
|
finally:
|
|
syncfeed.unsubscribe(queue)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replica_refresh_and_evict():
|
|
replica = satellite.UplinkReplica(_uplink())
|
|
token = secrets.token_urlsafe(12)
|
|
session = Session.create(
|
|
user=UUID(int=1),
|
|
credential=UUID(int=2),
|
|
key=hash_secret("cookie", token),
|
|
host="app2.example.com",
|
|
ip="1.1.1.1",
|
|
user_agent="ua",
|
|
validated=datetime(2020, 1, 1, tzinfo=UTC),
|
|
)
|
|
replica.db.sessions[session.key] = session
|
|
|
|
now = datetime.now(UTC)
|
|
replica.refresh_session(session.key, now, "2.2.2.2", "new-ua")
|
|
assert replica.db.sessions[session.key].validated == now
|
|
queued = replica._pending_refresh[session.key]
|
|
assert queued["type"] == "session_refresh"
|
|
assert queued["ip"] == "2.2.2.2"
|
|
|
|
# Host-keyed dispatch eviction (the replica's uplink is resolved by host)
|
|
domains.configure(listen=["localhost:4401"])
|
|
domains.init_registry(_remote_domain_config())
|
|
satellite.manager.replicas[UPLINK_ID] = replica
|
|
satellite.evict_session(token, "app2.example.com")
|
|
assert not replica.db.sessions
|
|
satellite.manager.replicas.pop(UPLINK_ID)
|
|
|
|
|
|
def test_availability_gate():
|
|
replica = satellite.UplinkReplica(_uplink(cache_ttl=60))
|
|
assert not replica.available() # never synced
|
|
replica.last_contact = time.monotonic()
|
|
assert replica.available()
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Link URLs and registration offers
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_link_url():
|
|
base, key = satellite.parse_link_url(
|
|
"wss://alpha.bravo.charlie.delta.echo@auth.example.com"
|
|
)
|
|
assert base == "https://auth.example.com"
|
|
assert key == "alpha.bravo.charlie.delta.echo"
|
|
base, key = satellite.parse_link_url("ws://k3y@127.0.0.1:4401")
|
|
assert base == "http://127.0.0.1:4401"
|
|
assert key == "k3y"
|
|
with pytest.raises(ValueError, match="wss"):
|
|
satellite.parse_link_url("https://alpha.bravo@auth.example.com")
|
|
with pytest.raises(ValueError, match="key"):
|
|
satellite.parse_link_url("wss://auth.example.com")
|
|
|
|
|
|
def test_offer_mint_and_spend():
|
|
"""Offers are wss URLs with a RAM-only, single-use, expiring key."""
|
|
url = sync.create_offer(["example.com"], "https://auth.example.com")
|
|
assert url.startswith("wss://")
|
|
assert url.endswith("@auth.example.com")
|
|
key = url.split("://", 1)[1].split("@", 1)[0]
|
|
offer = sync._pop_offer(key)
|
|
assert offer is not None and offer.rp_ids == ["example.com"]
|
|
assert sync._pop_offer(key) is None # single-use
|
|
assert sync._pop_offer("unknown.key.here") is None
|
|
|
|
expired = sync.create_offer([], "http://127.0.0.1:4401")
|
|
assert expired.startswith("ws://")
|
|
key = expired.split("://", 1)[1].split("@", 1)[0]
|
|
sync._offers[key].created -= sync.OFFER_TTL + 1
|
|
assert sync._pop_offer(key) is None # expired
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Pushed-domain application (satellite side)
|
|
# -------------------------------------------------------------------------
|
|
|
|
_PUSHED = {
|
|
"rp_id": "example.com",
|
|
"rp_name": "Example",
|
|
"origins": {"**.example.com": True, "auth.example.com": {"auth_host": True}},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_domains_push_accept_and_remove(test_db, domain_registry):
|
|
"""A push persists the accepted set (marked remote via the link); a
|
|
later push without the domain removes it."""
|
|
rejected = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
|
assert not rejected
|
|
domain = test_db.config.domains["example.com"]
|
|
assert domain.remote.link == UPLINK_ID
|
|
assert domain.rp_name == "Example"
|
|
assert domains.registry().get("example.com").remote is not None
|
|
|
|
# Idempotent: the same push again changes nothing
|
|
assert not satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
|
|
|
# The uplink's scope shrank: the domain leaves the local config
|
|
rejected = satellite.apply_domains_push(UPLINK_ID, [])
|
|
assert not rejected
|
|
assert "example.com" not in test_db.config.domains
|
|
assert domains.registry().get("example.com") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_domains_push_rejects_local_conflict(test_db, domain_registry):
|
|
"""A pushed rp-id that this instance serves locally is rejected."""
|
|
rejected = satellite.apply_domains_push(
|
|
UPLINK_ID, [{**_PUSHED, "rp_id": TEST_RP_ID}]
|
|
)
|
|
assert TEST_RP_ID in rejected
|
|
assert test_db.config.domains[TEST_RP_ID].remote is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_domains_push_rejects_auth_host_collision(test_db, domain_registry):
|
|
"""A pushed auth host that collides with a local rp-id is rejected
|
|
(the merged config must stay servable)."""
|
|
rejected = satellite.apply_domains_push(
|
|
UPLINK_ID,
|
|
[
|
|
{
|
|
"rp_id": "example.com",
|
|
"rp_name": "Example",
|
|
"origins": {"**.example.com": True, TEST_RP_ID: {"auth_host": True}},
|
|
}
|
|
],
|
|
)
|
|
assert "example.com" in rejected
|
|
assert "example.com" not in test_db.config.domains
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# API-level: endpoints served from an injected replica
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
def _replica_db() -> tuple[satellite.ReplicaDB, str]:
|
|
"""A replica holding one credential/session and its pushed context."""
|
|
replica = satellite.ReplicaDB()
|
|
cred = Credential.create(
|
|
credential_id=b"cid",
|
|
user=UUID(int=104),
|
|
aaguid=UUID(int=0),
|
|
public_key=b"pk",
|
|
sign_count=0,
|
|
rp_id="example.com",
|
|
)
|
|
cred.uuid = UUID(int=105)
|
|
replica.credentials[cred.uuid] = cred
|
|
secret = secrets.token_urlsafe(12)
|
|
session = Session.create(
|
|
user=UUID(int=104),
|
|
credential=cred.uuid,
|
|
key=hash_secret("cookie", secret),
|
|
host="app2.example.com",
|
|
ip="127.0.0.1",
|
|
user_agent="pytest",
|
|
validated=datetime.now(UTC),
|
|
rp_id="example.com",
|
|
)
|
|
replica.sessions[session.key] = session
|
|
replica.session_contexts[session.key] = _api_ctx()
|
|
return replica, secret
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def remote_client(test_db):
|
|
"""ASGI client with example.com as a remote domain on a warm replica."""
|
|
config = _remote_domain_config()
|
|
domains.configure(listen=["localhost:4401"])
|
|
domains.init_registry(config)
|
|
ops_db.create_uplink(UPLINK_ID, _uplink(), {})
|
|
replica_db, secret = _replica_db()
|
|
replica = satellite.UplinkReplica(_uplink())
|
|
replica.db = replica_db
|
|
replica.last_contact = time.monotonic()
|
|
replica.connected = True
|
|
satellite.manager.replicas[UPLINK_ID] = replica
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(
|
|
transport=transport, base_url="http://localhost:4401"
|
|
) as client:
|
|
yield client, secret, replica
|
|
satellite.manager.replicas.pop(UPLINK_ID, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_served_from_replica(remote_client):
|
|
client, secret, _ = remote_client
|
|
r = await client.get(
|
|
"/auth/api/forward?perm=auth:admin",
|
|
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
|
)
|
|
assert r.status_code == 204
|
|
assert r.headers["remote-name"] == "Remote Admin"
|
|
assert r.headers["remote-groups"] == "auth:admin"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_replica_denies_missing_perm(remote_client):
|
|
client, secret, _ = remote_client
|
|
r = await client.get(
|
|
"/auth/api/forward?perm=other:scope",
|
|
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_renews_locally_and_queues_writebehind(remote_client):
|
|
client, secret, replica = remote_client
|
|
session = next(iter(replica.db.sessions.values()))
|
|
session.validated = datetime(2020, 1, 1, tzinfo=UTC) # force refresh threshold
|
|
r = await client.post(
|
|
"/auth/api/validate",
|
|
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["renewed"] is True
|
|
assert session.validated.year > 2020 # applied to the replica
|
|
queued = replica._pending_refresh[session.key]
|
|
assert queued["type"] == "session_refresh"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remote_domain_503_when_replica_stale(remote_client):
|
|
client, secret, replica = remote_client
|
|
replica.connected = False
|
|
replica.last_contact = 0
|
|
r = await client.get(
|
|
"/auth/api/forward",
|
|
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
|
)
|
|
assert r.status_code == 503
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_proxied_and_evicted(remote_client, monkeypatch):
|
|
client, secret, replica = remote_client
|
|
|
|
async def fake_forward(request):
|
|
return Response(status_code=200, content=b'{"message": "Logged out"}')
|
|
|
|
monkeypatch.setattr(satellite, "forward_request", fake_forward)
|
|
r = await client.post(
|
|
"/auth/api/logout",
|
|
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert not replica.db.sessions # evicted optimistically
|
|
assert not replica.db.session_contexts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_rejects_remote_writes(client, session_token, test_db):
|
|
"""Remote domains are uplink-managed: the domains API refuses to
|
|
create, edit or delete them."""
|
|
headers = {
|
|
"Host": "localhost:4401",
|
|
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
|
}
|
|
r = await client.post(
|
|
"/auth/api/admin/domains/",
|
|
json={
|
|
"rp_id": "example.com",
|
|
"origins": {
|
|
"**.example.com": True,
|
|
"auth.example.com": {"auth_host": True},
|
|
},
|
|
"remote": {"link": "x"},
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == 400
|
|
assert "uplink" in r.json()["detail"]
|
|
|
|
# An existing remote domain (as an uplink push would have stored it)
|
|
ops_db.apply_uplink_domains(
|
|
UPLINK_ID,
|
|
{
|
|
"example.com": DomainConfig(
|
|
rp_name="Example",
|
|
origins={
|
|
"**.example.com": True,
|
|
"auth.example.com": OriginEntry(auth_host=True),
|
|
},
|
|
remote=RemoteConfig(link=UPLINK_ID),
|
|
)
|
|
},
|
|
)
|
|
r = await client.patch(
|
|
"/auth/api/admin/domains/example.com",
|
|
json={"rp_name": "Ex", "origins": {"**.example.com": True}},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == 400
|
|
assert "uplink" in r.json()["detail"]
|
|
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
|
assert r.status_code == 400
|
|
assert "unlink" in r.json()["detail"]
|
|
|
|
|
|
def test_feed_scoped_subscription_filters():
|
|
"""A scoped subscriber gets only its rp-ids' sessions/credentials
|
|
(upserts filtered, deletes passed); the user graph is never published."""
|
|
scoped = syncfeed.subscribe({"example.com"})
|
|
try:
|
|
in_scope = Session.create(
|
|
user=UUID(int=1),
|
|
credential=UUID(int=2),
|
|
key="k1",
|
|
host="app2.example.com",
|
|
ip="1.1.1.1",
|
|
user_agent="ua",
|
|
validated=datetime.now(UTC),
|
|
rp_id="example.com",
|
|
)
|
|
out_scope = Session.create(
|
|
user=UUID(int=1),
|
|
credential=UUID(int=3),
|
|
key="k2",
|
|
host="localhost",
|
|
ip="1.1.1.1",
|
|
user_agent="ua",
|
|
validated=datetime.now(UTC),
|
|
rp_id="localhost",
|
|
)
|
|
syncfeed.emit("sessions", "k1", in_scope)
|
|
syncfeed.emit("sessions", "k2", out_scope)
|
|
syncfeed.emit("users", "u1", User.create(display_name="A", role=UUID(int=1)))
|
|
syncfeed.emit("sessions", "k2", None) # deletes pass regardless
|
|
assert [e["key"] for e in _drain(scoped)] == ["k1", "k2"]
|
|
finally:
|
|
syncfeed.unsubscribe(scoped)
|
|
|
|
|
|
def test_feed_scope_updates_live():
|
|
"""The subscriber scope is mutable in place: a link's scope adjustment
|
|
takes effect without a reconnect."""
|
|
scope = {"example.com"}
|
|
queue = syncfeed.subscribe(scope)
|
|
try:
|
|
session = Session.create(
|
|
user=UUID(int=1),
|
|
credential=UUID(int=2),
|
|
key="k1",
|
|
host="localhost",
|
|
ip="1.1.1.1",
|
|
user_agent="ua",
|
|
validated=datetime.now(UTC),
|
|
rp_id="localhost",
|
|
)
|
|
syncfeed.emit("sessions", "k1", session)
|
|
assert not _drain(queue)
|
|
scope.add("localhost") # scope widened live
|
|
syncfeed.emit("sessions", "k1", session)
|
|
assert [e["key"] for e in _drain(queue)] == ["k1"]
|
|
finally:
|
|
syncfeed.unsubscribe(queue)
|
|
|
|
|
|
def _drain(queue):
|
|
items = []
|
|
while not queue.empty():
|
|
items.append(queue.get_nowait())
|
|
return items
|
|
|
|
|
|
def test_rebuild_listener_skipped_without_running_loop():
|
|
"""A registry rebuild outside an event loop warns instead of crashing."""
|
|
|
|
async def listener(_registry):
|
|
raise AssertionError("must not run") # pragma: no cover
|
|
|
|
domains.add_rebuild_listener(listener)
|
|
try:
|
|
domains.init_registry(_remote_domain_config())
|
|
finally:
|
|
domains.remove_rebuild_listener(listener)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_listener_runs_with_loop():
|
|
ran = asyncio.Event()
|
|
|
|
async def listener(_registry):
|
|
ran.set()
|
|
|
|
domains.add_rebuild_listener(listener)
|
|
try:
|
|
domains.init_registry(_remote_domain_config())
|
|
await asyncio.wait_for(ran.wait(), 2)
|
|
finally:
|
|
domains.remove_rebuild_listener(listener)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_reports_remote_flag(remote_client):
|
|
client, _, _ = remote_client
|
|
r = await client.get("/auth/api/settings", headers={"Host": "app2.example.com"})
|
|
assert r.status_code == 200
|
|
assert r.json()["remote"] is True
|
|
r = await client.get("/auth/api/settings", headers={"Host": "localhost:4401"})
|
|
assert r.json()["remote"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_remote_down_returns_503(remote_client, monkeypatch):
|
|
"""A transport failure on a proxied call is a deliberate 503, not a 500."""
|
|
client, secret, replica = remote_client
|
|
|
|
class Down:
|
|
async def request(self, *args, **kwargs):
|
|
raise httpx.ConnectError("down")
|
|
|
|
monkeypatch.setitem(satellite._clients, REMOTE_URL, Down())
|
|
r = await client.post(
|
|
"/auth/api/logout",
|
|
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
|
)
|
|
assert r.status_code == 503
|
|
# Not evicted: the remote never saw the logout, the session is still valid
|
|
assert replica.db.sessions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_api_proxied_on_remote_auth_host(remote_client, monkeypatch):
|
|
"""Profile traffic for a remote domain's auth host is forwarded, never
|
|
written to the satellite's local database."""
|
|
client, _, _ = remote_client
|
|
|
|
async def fake_forward(request):
|
|
return Response(status_code=200, content=b"proxied")
|
|
|
|
monkeypatch.setattr(satellite, "forward_request", fake_forward)
|
|
r = await client.get(
|
|
f"/auth/api/user/{UUID(int=104)}/profile.webp",
|
|
headers={"Host": "auth.example.com"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.content == b"proxied"
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Satellite links and uplinks (admin API)
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_satellite_link_lifecycle(client, session_token, test_db):
|
|
"""Offer minting (auth host required), scope adjustment, revocation."""
|
|
headers = {
|
|
"Host": "localhost:4401",
|
|
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
|
}
|
|
r = await client.post(
|
|
"/auth/api/admin/domains/",
|
|
json={
|
|
"rp_id": "example.com",
|
|
"rp_name": "Example",
|
|
"origins": {"**.example.com": True},
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# No auth host yet — the domain cannot be scoped into a link
|
|
r = await client.post(
|
|
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
|
|
)
|
|
assert r.status_code == 400
|
|
assert "auth host" in r.json()["detail"]
|
|
|
|
r = await client.patch(
|
|
"/auth/api/admin/domains/example.com",
|
|
json={
|
|
"rp_name": "Example",
|
|
"origins": {
|
|
"**.example.com": True,
|
|
"auth.example.com": {"auth_host": True},
|
|
},
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Mint an offer: a wss URL carrying the RAM-only initial key
|
|
r = await client.post(
|
|
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["url"].startswith("wss://")
|
|
assert "@auth.example.com" in body["url"]
|
|
key = body["url"].split("://", 1)[1].split("@", 1)[0]
|
|
assert key.count(".") == 4 # five-word passphrase
|
|
assert body["expires_in"] > 0
|
|
|
|
# Unknown domains and nested scopes are refused
|
|
r = await client.post(
|
|
"/auth/api/admin/satellites/",
|
|
json={"rp_ids": ["nope.example"]},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
# Register a link directly (the handshake itself is covered live),
|
|
# then list, adjust scope, revoke
|
|
ops_db.create_satellite_link("ln1", ["example.com"], b"key", label="sat-1")
|
|
r = await client.get("/auth/api/admin/satellites/", headers=headers)
|
|
assert r.status_code == 200
|
|
(entry,) = r.json()
|
|
assert entry["id"] == "ln1"
|
|
assert entry["label"] == "sat-1"
|
|
assert entry["rp_ids"] == ["example.com"]
|
|
assert entry["connected"] is False
|
|
|
|
r = await client.patch(
|
|
"/auth/api/admin/satellites/ln1", json={"rp_ids": []}, headers=headers
|
|
)
|
|
assert r.status_code == 200
|
|
assert test_db.satellite_links["ln1"].rp_ids == []
|
|
|
|
r = await client.delete("/auth/api/admin/satellites/ln1", headers=headers)
|
|
assert r.status_code == 200
|
|
assert not test_db.satellite_links
|
|
r = await client.delete("/auth/api/admin/satellites/ln1", headers=headers)
|
|
assert r.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_offer_scope_rejects_remote_domain(client, session_token, test_db):
|
|
"""A satellite-served domain cannot be scoped into a link: no nesting."""
|
|
headers = {
|
|
"Host": "localhost:4401",
|
|
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
|
}
|
|
ops_db.apply_uplink_domains(
|
|
UPLINK_ID,
|
|
{
|
|
"example.com": DomainConfig(
|
|
origins={
|
|
"**.example.com": True,
|
|
"auth.example.com": OriginEntry(auth_host=True),
|
|
},
|
|
remote=RemoteConfig(link=UPLINK_ID),
|
|
)
|
|
},
|
|
)
|
|
r = await client.post(
|
|
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
|
|
)
|
|
assert r.status_code == 400
|
|
assert "nested" in r.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_uplinks_list_and_unlink(client, session_token, test_db):
|
|
"""Uplinks list shows served domains and replica state; unlink removes
|
|
the uplink and its domains."""
|
|
headers = {
|
|
"Host": "localhost:4401",
|
|
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
|
}
|
|
ops_db.create_uplink(UPLINK_ID, _uplink(), {})
|
|
ops_db.apply_uplink_domains(
|
|
UPLINK_ID,
|
|
{
|
|
"example.com": DomainConfig(
|
|
origins={
|
|
"**.example.com": True,
|
|
"auth.example.com": OriginEntry(auth_host=True),
|
|
},
|
|
remote=RemoteConfig(link=UPLINK_ID),
|
|
)
|
|
},
|
|
)
|
|
r = await client.get("/auth/api/admin/uplinks/", headers=headers)
|
|
assert r.status_code == 200
|
|
(entry,) = r.json()
|
|
assert entry["id"] == UPLINK_ID
|
|
assert entry["url"] == REMOTE_URL
|
|
assert entry["domains"] == ["example.com"]
|
|
assert entry["connected"] is False
|
|
|
|
r = await client.delete(f"/auth/api/admin/uplinks/{UPLINK_ID}", headers=headers)
|
|
assert r.status_code == 200
|
|
assert not test_db.uplinks
|
|
assert "example.com" not in test_db.config.domains
|
|
r = await client.get("/auth/api/admin/uplinks/", headers=headers)
|
|
assert r.json() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_register_uplink_endpoint(client, session_token, monkeypatch):
|
|
"""The registration endpoint runs the handshake synchronously (the
|
|
handshake itself is covered by the live tests) and reports failures."""
|
|
headers = {
|
|
"Host": "localhost:4401",
|
|
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
|
}
|
|
|
|
async def fake_register(url):
|
|
return {
|
|
"link": "ln1",
|
|
"url": "https://remote.test",
|
|
"domains": ["example.com"],
|
|
"rejected": {},
|
|
}
|
|
|
|
monkeypatch.setattr(satellite, "register_uplink", fake_register)
|
|
r = await client.post(
|
|
"/auth/api/admin/uplinks/",
|
|
json={"url": "wss://alpha.bravo.charlie.delta.echo@remote.test"},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["domains"] == ["example.com"]
|
|
|
|
async def failing_register(url):
|
|
raise RuntimeError("the upstream rejected the link URL")
|
|
|
|
monkeypatch.setattr(satellite, "register_uplink", failing_register)
|
|
r = await client.post(
|
|
"/auth/api/admin/uplinks/", json={"url": "wss://x@remote.test"}, headers=headers
|
|
)
|
|
assert r.status_code == 400
|
|
assert "rejected" in r.json()["detail"]
|
|
|
|
async def malformed(url):
|
|
raise ValueError("A link URL is a wss:// URL")
|
|
|
|
monkeypatch.setattr(satellite, "register_uplink", malformed)
|
|
r = await client.post(
|
|
"/auth/api/admin/uplinks/", json={"url": "nope"}, headers=headers
|
|
)
|
|
assert r.status_code == 400
|