Fix replica-path leaks and availability semantics from live testing

- _remote_headers and /check used struct convenience properties that
  read the global database; they now use the SessionContext / the handed
  store (also fixes Remote-Credential carrying a struct repr instead of
  the UUID).
- Replica availability: TTL clock starts at disconnect, not at last
  message or failed reconnect; tight WS keepalive for prompt dead-peer
  detection.
- Proxy preserves repeated Set-Cookie via raw headers; sync endpoint
  does its own accept (wsutil decorator pre-accepts) and bypasses host
  dispatch (server-to-server; satellite may use an out-of-domain
  address).
- Admin-credential bootstrap warning skips remote domains.

Verified live with two instances (remote :4501, satellite :4402):
replica snapshot + events, 204 forward with Remote-* in <1ms, validate
write-behind landing on the remote, proxied logout with instant local
eviction, 503 after cache_ttl of disconnect, resync after remote
restart.
This commit is contained in:
2026-09-20 23:45:01 +00:00
parent 9394c38179
commit 44364fdffc
7 changed files with 268 additions and 58 deletions
+191 -11
View File
@@ -1,25 +1,34 @@
"""Tests for remote (satellite) domains: config, replica application, feed."""
import asyncio
import os
import collections
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 (
DB,
Config,
Credential,
DomainConfig,
Org,
OriginEntry,
Permission,
RemoteConfig,
Role,
Session,
User,
)
from paskia.db.structs import DB
from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.util.crypto import hash_secret
from .conftest import TEST_RP_ID
@@ -79,8 +88,6 @@ def test_apply_upsert_and_delete():
def _builtins(obj):
import msgspec
return msgspec.to_builtins(obj)
@@ -108,17 +115,15 @@ def test_apply_credential_bytes_roundtrip():
"""credential_id/public_key are bytes over the wire (base64 in JSON)."""
replica = DB()
cred = Credential.create(
credential_id=os.urandom(32),
credential_id=secrets.token_bytes(32),
user=UUID(int=1),
aaguid=UUID(int=0),
public_key=os.urandom(64),
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
import msgspec
wire = msgspec.json.decode(msgspec.json.encode(_builtins(cred)))
satellite._apply(replica, "credentials", str(cred.uuid), "upsert", wire)
stored = replica.credentials[cred.uuid]
@@ -141,7 +146,7 @@ def test_feed_emit_and_replay():
def test_feed_ring_overflow_replay_none():
feed = syncfeed.SyncFeed()
feed.events = __import__("collections").deque(maxlen=3)
feed.events = collections.deque(maxlen=3)
for i in range(5):
feed.emit("users", f"k{i}", None)
assert feed.replay_since(0) is None # fell off the ring
@@ -194,5 +199,180 @@ def test_availability_gate():
RemoteConfig(url=REMOTE_URL, token="t", cache_ttl=60)
)
assert not replica.available() # never synced
replica.last_contact = __import__("time").monotonic()
replica.last_contact = time.monotonic()
assert replica.available()
# -------------------------------------------------------------------------
# API-level: endpoints served from an injected replica
# -------------------------------------------------------------------------
def _replica_db() -> tuple[DB, str]:
"""A replica DB holding one org/role/perm/user/credential/session."""
replica = DB()
org = Org.create(display_name="Org")
org.uuid = UUID(int=101)
replica.orgs[org.uuid] = org
perm = Permission.create(scope="auth:admin", display_name="Admin")
perm.uuid = UUID(int=102)
perm.orgs[org.uuid] = True
replica.permissions[perm.uuid] = perm
role = Role.create(org=org.uuid, display_name="Admins", permissions={perm.uuid})
role.uuid = UUID(int=103)
replica.roles[role.uuid] = role
user = User.create(display_name="Remote Admin", role=role.uuid)
user.uuid = UUID(int=104)
replica.users[user.uuid] = user
cred = Credential.create(
credential_id=b"cid",
user=user.uuid,
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=user.uuid,
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
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)
replica_db, secret = _replica_db()
replica = satellite.RemoteReplica(RemoteConfig(url=REMOTE_URL, token="t"))
replica.db = replica_db
replica.last_contact = time.monotonic()
replica.connected = True
satellite.manager.replicas[REMOTE_URL] = replica
satellite.attach_stores()
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(REMOTE_URL, 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_proxy(request, remote):
return Response(status_code=200, content=b'{"message": "Logged out"}')
monkeypatch.setattr("paskia.fastapi.proxy.proxy_to_remote", fake_proxy)
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
@pytest.mark.asyncio
async def test_admin_configures_remote_domain(client, session_token, test_db):
"""The admin domains API stores remote config and masks the token."""
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": {"**.example.com": True, "auth.example.com": {"auth_host": True}},
"remote": {"url": "http://remote.test", "token": "sekret", "cache_ttl": 30},
},
headers={"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"},
)
assert r.status_code == 200, r.text
stored = test_db.config.domains["example.com"]
assert stored.remote.url == "http://remote.test"
assert stored.remote.token == "sekret"
r = await client.get(
"/auth/api/admin/domains/",
headers={"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"},
)
entry = next(d for d in r.json() if d["rp_id"] == "example.com")
assert entry["remote"]["url"] == "http://remote.test"
assert "token" not in entry["remote"] # write-only
@pytest.mark.asyncio
async def test_admin_remote_domain_requires_auth_host(client, session_token):
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"origins": {"**.example.com": True},
"remote": {"url": "http://remote.test"},
},
headers={"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"},
)
assert r.status_code == 400
assert "auth host" in r.json()["detail"]