Remote domain configuration via admin domains API
ApiDomain carries the remote block (sync token write-only, never echoed); create/patch accept it, validated with the combined config (auth host mandatory for remote domains). db.update_domain replaces remote wholesale like the other domain fields.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""Tests for remote (satellite) domains: config, replica application, feed."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import domains, satellite, syncfeed
|
||||
from paskia.db.structs import (
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
OriginEntry,
|
||||
RemoteConfig,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
from paskia.db.structs import DB
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
from .conftest import TEST_RP_ID
|
||||
|
||||
REMOTE_URL = "http://remote.test"
|
||||
|
||||
|
||||
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(url=REMOTE_URL, token="t", **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_remote_domain_requires_http_url():
|
||||
config = _remote_domain_config()
|
||||
config.domains["example.com"].remote.url = "ftp://x"
|
||||
with pytest.raises(ValueError, match="http"):
|
||||
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.url == REMOTE_URL
|
||||
|
||||
|
||||
def test_apply_upsert_and_delete():
|
||||
replica = DB()
|
||||
user = User.create(display_name="U", role=UUID(int=1))
|
||||
user.uuid = UUID(int=2)
|
||||
satellite._apply(
|
||||
replica, "users", str(user.uuid), "upsert", _builtins(user)
|
||||
)
|
||||
assert replica.users[user.uuid].display_name == "U"
|
||||
satellite._apply(replica, "users", str(user.uuid), "delete", None)
|
||||
assert not replica.users
|
||||
|
||||
|
||||
def _builtins(obj):
|
||||
import msgspec
|
||||
|
||||
return msgspec.to_builtins(obj)
|
||||
|
||||
|
||||
def test_apply_session_roundtrip():
|
||||
"""Sessions keep their string key and datetime/UUID fields."""
|
||||
replica = DB()
|
||||
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",
|
||||
)
|
||||
satellite._apply(replica, "sessions", session.key, "upsert", _builtins(session))
|
||||
stored = replica.sessions[session.key]
|
||||
assert stored.host == "app2.example.com"
|
||||
assert stored.validated == session.validated
|
||||
assert stored.user_uuid == UUID(int=1)
|
||||
|
||||
|
||||
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),
|
||||
user=UUID(int=1),
|
||||
aaguid=UUID(int=0),
|
||||
public_key=os.urandom(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]
|
||||
assert stored.credential_id == cred.credential_id
|
||||
assert stored.public_key == cred.public_key
|
||||
assert stored.sign_count == 3
|
||||
|
||||
|
||||
def test_feed_emit_and_replay():
|
||||
feed = syncfeed.SyncFeed()
|
||||
user = User.create(display_name="A", role=UUID(int=1))
|
||||
feed.emit("users", "k1", user)
|
||||
feed.emit("users", "k1", None)
|
||||
assert feed.seq == 2
|
||||
assert feed.replay_since(0)[0]["op"] == "upsert"
|
||||
assert feed.replay_since(1)[0]["op"] == "delete"
|
||||
assert feed.replay_since(2) == []
|
||||
assert feed.replay_since(99) is None
|
||||
|
||||
|
||||
def test_feed_ring_overflow_replay_none():
|
||||
feed = syncfeed.SyncFeed()
|
||||
feed.events = __import__("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
|
||||
assert [e["seq"] for e in feed.replay_since(4)] == [5]
|
||||
assert feed.replay_since(5) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operations_emit_events(test_db):
|
||||
"""Writes through db.operations land on the sync feed."""
|
||||
syncfeed.feed.events.clear()
|
||||
syncfeed.feed.seq = 0
|
||||
user = next(iter(test_db.users.values()))
|
||||
ops_db.update_user_display_name(user.uuid, "Renamed")
|
||||
tables = {e["table"] for e in syncfeed.feed.events}
|
||||
assert "users" in tables
|
||||
key = syncfeed.feed.events[-1]["key"]
|
||||
assert syncfeed.feed.events[-1]["fields"]["display_name"] == "Renamed"
|
||||
assert key == str(user.uuid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replica_refresh_and_evict():
|
||||
replica = satellite.RemoteReplica(RemoteConfig(url=REMOTE_URL, token="t"))
|
||||
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"
|
||||
|
||||
replica.evict_session(token)
|
||||
assert not replica.db.sessions
|
||||
|
||||
|
||||
def test_availability_gate():
|
||||
replica = satellite.RemoteReplica(
|
||||
RemoteConfig(url=REMOTE_URL, token="t", cache_ttl=60)
|
||||
)
|
||||
assert not replica.available() # never synced
|
||||
replica.last_contact = __import__("time").monotonic()
|
||||
assert replica.available()
|
||||
Reference in New Issue
Block a user