Files
paskia/tests/test_satellite_e2e.py
T
LeoVasanko 28fa0ff61f Tests for the link model + lint/format pass
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.
2026-10-01 06:30:32 +00:00

542 lines
19 KiB
Python

"""Live end-to-end tests for satellite domains over a real sync channel.
A real uvicorn server plays the upstream (its sync WebSocket fed from the
process-global database), while the satellite side runs through the ASGI
app with a real UplinkReplica started by the real SatelliteManager. Unlike
test_remote.py (which injects a warm replica by hand), nothing here is
stubbed: the 3-way handshake, pushed domains, snapshot, live events,
write-behind, dead-peer TTL handling, reconnect reconciliation, live scope
changes and revocation all cross a TCP socket.
One in-process distortion remains: both sides share the module-level
database and registry, so the satellite's pushed-domain application is
arranged to be a no-op (the link's domains are absent from the shared
config — the satellite registry is synthetic instead), and endpoints the
satellite *forwards* to the upstream (logout, set-session, OIDC) would
re-forward and loop here — those paths are covered in test_remote.py with
a mocked forward_request.
"""
import asyncio
import os
import socket
import time
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from uuid import UUID
import httpx
import msgspec.structs
import pytest
import pytest_asyncio
import uvicorn
import paskia.db.operations as ops_db
from paskia import domains, satellite
from paskia.db.structs import (
Config,
Credential,
DomainConfig,
OriginEntry,
RemoteConfig,
Uplink,
)
from paskia.fastapi import sync
from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME
from .conftest import TEST_RP_ID, create_test_session
REMOTE_DOMAIN = "example.com"
APP_HOST = "app2.example.com"
LINK_ID = "live-link"
LINK_KEY = b"e2e-link-key"
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
async def wait_for(desc: str, condition, timeout: float = 10) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return
await asyncio.sleep(0.02)
raise AssertionError(f"timed out waiting for {desc}")
@pytest_asyncio.fixture
async def live_server(test_db):
"""A uvicorn-served upstream over a real TCP socket.
Yields a namespace: db, url, port, start_server (for restarts),
servers.
"""
servers: list[tuple[uvicorn.Server, asyncio.Task]] = []
async def start_server(port: int) -> uvicorn.Server:
config = uvicorn.Config(
app, host="127.0.0.1", port=port, log_level="error", lifespan="off"
)
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
servers.append((server, task))
while not server.started:
await asyncio.sleep(0.02)
return server
port = _free_port()
url = f"http://127.0.0.1:{port}"
domains.configure(listen=["localhost:4401"])
domains.init_registry(test_db.config)
await start_server(port)
try:
yield SimpleNamespace(
db=test_db, url=url, port=port, start_server=start_server, servers=servers
)
finally:
for server, task in servers:
server.should_exit = True
await task
@pytest_asyncio.fixture
async def live_remote(live_server):
"""An established link against the live upstream, and the satellite
manager started for real: the replica connects over TCP and runs the
established-link handshake (hello {link, key} → welcome → ready).
Both sides' link rows are stored directly (registration itself is
covered by the dedicated tests); the satellite's remote domain lives
only in the registry, not the shared config, so pushed-domain
application stays a no-op.
"""
live = live_server
ops_db.create_satellite_link(LINK_ID, [REMOTE_DOMAIN], LINK_KEY, label="e2e")
uplink = Uplink(url=live.url, created=datetime.now(UTC), key=LINK_KEY)
ops_db.create_uplink(LINK_ID, uplink, {})
domains.configure(listen=["localhost:4401"])
domains.init_registry(
Config(
domains={
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}),
REMOTE_DOMAIN: DomainConfig(
origins={
f"**.{REMOTE_DOMAIN}": True,
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
},
remote=RemoteConfig(link=LINK_ID),
),
}
)
)
await satellite.manager.start()
replica = satellite.manager.replicas[LINK_ID]
await wait_for("initial snapshot", lambda: replica.available())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://localhost:4401"
) as client:
try:
live.client = client
live.replica = replica
yield live
finally:
await satellite.manager.stop()
def _app_headers(secret: str) -> dict[str, str]:
return {"Host": APP_HOST, "Cookie": f"{AUTH_COOKIE_NAME}={secret}"}
@pytest_asyncio.fixture
async def remote_credential(test_db, test_user):
"""A credential registered on the remote domain. The feed is rp-id
scoped: only the link's own domains' credentials replicate."""
credential = Credential.create(
credential_id=os.urandom(32),
user=test_user.uuid,
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64),
sign_count=0,
rp_id=REMOTE_DOMAIN,
)
ops_db.create_credential(credential)
return credential
# -------------------------------------------------------------------------
# Registration (the real 3-way handshake, crossing TCP)
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_live_registration(live_server):
"""A RAM-only offer is spent by the handshake; both sides store the
link with identical key material, and the established-link handshake
works right after."""
live = live_server
offer_url = sync.create_offer([], live.url)
assert offer_url.startswith("ws://")
result = await satellite.register_uplink(offer_url)
link_id = result["link"]
assert result["domains"] == [] # empty scope: register first, scope later
uplink = live.db.uplinks[link_id]
assert uplink.url == live.url
link = live.db.satellite_links[link_id]
assert uplink.key == link.key and len(link.key) == 64 # both parts
assert link.label # the satellite's self-reported name
# Re-registering to the same upstream is refused (the offer itself is
# single-use — covered in test_remote.py's offer test)
with pytest.raises(ValueError, match="Already linked"):
await satellite.register_uplink(offer_url)
# The established-link handshake: the replica connects and syncs
await satellite.manager.start()
try:
replica = satellite.manager.replicas[link_id]
await wait_for("established link sync", lambda: replica.available())
finally:
await satellite.manager.stop()
@pytest.mark.asyncio
async def test_live_registration_rejections(live_server):
"""Malformed URLs, unknown keys and unreachable hosts all fail the
registration without storing anything."""
with pytest.raises(ValueError, match="wss"):
await satellite.register_uplink("https://key@auth.example.com")
with pytest.raises(ValueError, match="key"):
await satellite.register_uplink("wss://auth.example.com")
with pytest.raises(RuntimeError, match="rejected"):
await satellite.register_uplink(
f"ws://unknown.key@127.0.0.1:{live_server.port}"
)
with pytest.raises(RuntimeError, match="unreachable"):
await satellite.register_uplink("ws://some.key@127.0.0.1:1")
assert not live_server.db.uplinks
assert not live_server.db.satellite_links
# -------------------------------------------------------------------------
# Established link: sync behavior
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_live_snapshot_and_context_repush(
live_remote, test_user, remote_credential
):
"""Sessions arrive with their upstream-computed context; user-graph
changes eagerly re-push the affected sessions' contexts."""
live = live_remote
key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.session_contexts)
assert live.replica.db.session_contexts[key].user.display_name == "Test Admin"
ops_db.update_user_display_name(test_user.uuid, "Live Rename")
await wait_for(
"rename to propagate",
lambda: (
live.replica.db.session_contexts[key].user.display_name == "Live Rename"
),
)
@pytest.mark.asyncio
async def test_live_permission_revocation_propagates(
live_remote, test_user, remote_credential
):
"""Removing a permission from the user's role re-pushes the session
context: the satellite denies within one RTT."""
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.session_contexts)
r = await live.client.get(
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
)
assert r.status_code == 204
perm_uuid = next(
p.uuid for p in live.db.permissions.values() if p.scope == "auth:admin"
)
ops_db.remove_permission_from_role(test_user.role_uuid, perm_uuid)
await wait_for(
"revocation to propagate",
lambda: "auth:admin" not in live.replica.db.session_contexts[key].permissions,
)
r = await live.client.get(
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
)
assert r.status_code == 403
@pytest.mark.asyncio
async def test_live_forward_and_writebehind(live_remote, test_user, remote_credential):
"""A session created on the upstream authorizes locally; the /validate
refresh travels back over the wire into the upstream's database."""
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.sessions)
r = await live.client.get(
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
)
assert r.status_code == 204
assert r.headers["remote-name"] == "Test Admin"
# Age the session on the upstream (propagates), then validate on the satellite
stale = datetime.now(UTC) - timedelta(minutes=10)
ops_db.update_session(key, validated=stale)
await wait_for(
"staleness to propagate",
lambda: live.replica.db.sessions[key].validated == stale,
)
r = await live.client.post("/auth/api/validate", headers=_app_headers(secret))
assert r.status_code == 200
assert r.json()["renewed"] is True
await wait_for(
"write-behind to land on the upstream",
lambda: live.db.sessions[key].validated > stale,
)
@pytest.mark.asyncio
async def test_live_session_delete_propagates(
live_remote, test_user, remote_credential
):
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.sessions)
ops_db.delete_session(key)
await wait_for(
"session delete to propagate", lambda: key not in live.replica.db.sessions
)
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 401
@pytest.mark.asyncio
async def test_live_wrong_key_rejected(live_remote):
"""An uplink whose key does not match the stored link's never syncs."""
uplink = Uplink(url=live_remote.url, created=datetime.now(UTC), key=b"not-the-key")
uplink.uplink_id = "bad-link"
bad = satellite.UplinkReplica(uplink)
await bad.start()
try:
await asyncio.sleep(1.5)
assert not bad.available()
finally:
await bad.stop()
@pytest.mark.asyncio
async def test_live_disconnect_ttl_and_reconnect(
live_remote, test_user, remote_credential
):
"""Channel down: replica keeps serving within cache_ttl, then fails
closed (503); reconnect starts from a snapshot and reconciles drift."""
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.sessions)
server, task = live.servers[0]
server.should_exit = True
await task
await wait_for(
"dead peer detection", lambda: not live.replica.connected, timeout=15
)
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 204 # still authoritative within cache_ttl
# Expire the trust window (a copy: the db-owned struct is not mutated)
uplink_copy = msgspec.structs.replace(live.replica.uplink, cache_ttl=0)
uplink_copy.uplink_id = LINK_ID
live.replica.uplink = uplink_copy
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 503
# Drift while the channel is down, then let the client auto-reconnect
ops_db.update_user_display_name(test_user.uuid, "While Down")
await live.start_server(live.port)
await wait_for("reconnect", lambda: live.replica.available(), timeout=20)
assert (
live.replica.db.session_contexts[key].user.display_name == "While Down"
) # snapshot reconciled the drift
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 204
@pytest.mark.asyncio
async def test_unlink_stops_replica(live_remote):
"""Unlinking drops the uplink, its domains and the replica."""
live = live_remote
await satellite.manager.unlink(LINK_ID)
assert LINK_ID not in satellite.manager.replicas
assert LINK_ID not in live.db.uplinks
@pytest.mark.asyncio
async def test_live_revocation_unlinks_satellite(live_remote):
"""Revoking the link upstream drops the connection; the satellite's
handshake retry gets a policy close and unlinks its side."""
live = live_remote
ops_db.delete_satellite_link(LINK_ID)
await sync.close_link(LINK_ID)
await wait_for(
"satellite to unlink", lambda: LINK_ID not in live.db.uplinks, timeout=25
)
assert LINK_ID not in satellite.manager.replicas
@pytest.mark.asyncio
async def test_live_out_of_scope_session_not_replicated(
live_remote, test_user, test_credential, remote_credential
):
"""The feed is scoped to the link's rp-ids: sessions of other domains
(here the satellite's own) never cross the wire."""
live = live_remote
local_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost:4401",
rp_id=TEST_RP_ID,
)
# Positive control: an in-scope session created afterwards does arrive,
# so the out-of-scope one has already passed through the pipe.
remote_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for(
"in-scope session sync", lambda: remote_key in live.replica.db.sessions
)
assert local_key not in live.replica.db.sessions
@pytest.mark.asyncio
async def test_live_out_of_scope_writebehind_ignored(
live_remote, test_user, test_credential, remote_credential
):
"""A session_refresh for a session outside the link's scope is ignored
by the upstream (fail-closed), while an in-scope one lands."""
live = live_remote
stale = datetime.now(UTC) - timedelta(minutes=10)
local_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost:4401",
rp_id=TEST_RP_ID,
)
remote_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
ops_db.update_session(local_key, validated=stale)
ops_db.update_session(remote_key, validated=stale)
now = datetime.now(UTC)
live.replica.refresh_session(local_key, now, None, None)
live.replica.refresh_session(remote_key, now, None, None)
await wait_for(
"in-scope refresh to land",
lambda: live.db.sessions[remote_key].validated > stale,
)
assert live.db.sessions[local_key].validated == stale
@pytest.mark.asyncio
async def test_live_scope_shrink_stops_feed(live_remote, test_user, remote_credential):
"""Narrowing the link's scope on the upstream takes effect on the live
connection: sessions of removed domains stop flowing."""
live = live_remote
ops_db.set_satellite_link_scope(LINK_ID, [])
await sync.push_link_domains(LINK_ID)
removed_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
# Positive control: widen the scope again; a session created now does
# arrive, so the earlier one demonstrably passed a closed pipe.
ops_db.set_satellite_link_scope(LINK_ID, [REMOTE_DOMAIN])
await sync.push_link_domains(LINK_ID)
remote_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for(
"re-scoped session sync", lambda: remote_key in live.replica.db.sessions
)
assert removed_key not in live.replica.db.sessions
@pytest.mark.asyncio
async def test_live_domain_conflict_reported_upstream(live_remote, test_db):
"""A pushed domain the satellite serves locally is rejected, and the
rejection is reported into the upstream's link state."""
# The shared config now holds the domain authoritatively — from the
# satellite's view this is the local-conflict case.
ops_db.create_domain(
REMOTE_DOMAIN,
DomainConfig(
rp_name="Example",
origins={
f"**.{REMOTE_DOMAIN}": True,
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
},
),
)
await sync.push_link_domains(LINK_ID)
await wait_for(
"rejection to arrive",
lambda: (
REMOTE_DOMAIN in sync.links_status().get(LINK_ID, {}).get("rejected", {})
),
)
# The satellite kept its (synthetic) registry — no local overwrite
assert test_db.config.domains[REMOTE_DOMAIN].remote is None