Add community node socket: devices, tickets, consent gate, kill switch
Phase 3 of plan.md. Device registration with one-time secrets, scoped single-use socket tickets, the lock-owner WS /nodes/ws with handshake and heartbeat eviction, a single batch ConsentGate, and NodesService which disconnects nodes within one tick when consent, device or the nodes_enabled kill switch changes. A reconcile of 2000 sessions measured about 110 ms after replacing per-session queries with batches. Devii may withdraw but never grant bandwidth_sharing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, set_consent, set_setting
|
||||
from devplacepy.database.nodes import save_node_settings
|
||||
from devplacepy.services.nodes.consent import REASONS, ConsentGate
|
||||
from devplacepy.utils import register_account
|
||||
|
||||
GATE = ConsentGate()
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def fresh(age_band="adult"):
|
||||
_counter[0] += 1
|
||||
name = f"gate{int(time.time() * 1000)}{_counter[0]}"
|
||||
uid, _, _ = register_account(
|
||||
name, f"{name}@t.dev", "secret123", age_band=age_band, accepted_terms=True
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def switched_on(local_db):
|
||||
set_setting("nodes_enabled", "1")
|
||||
yield
|
||||
set_setting("nodes_enabled", "0")
|
||||
|
||||
|
||||
def ready():
|
||||
uid = fresh()
|
||||
set_consent("user", uid, "bandwidth_sharing", True)
|
||||
save_node_settings(uid, {"enabled": True})
|
||||
return uid
|
||||
|
||||
|
||||
def test_a_fully_qualified_member_is_allowed(switched_on):
|
||||
verdict = GATE.allows(ready())
|
||||
assert verdict.allowed and verdict.reason == "" and verdict.message == ""
|
||||
|
||||
|
||||
def test_the_kill_switch_beats_everything(local_db):
|
||||
set_setting("nodes_enabled", "1")
|
||||
uid = ready()
|
||||
set_setting("nodes_enabled", "0")
|
||||
assert GATE.allows(uid).reason == "kill_switch"
|
||||
|
||||
|
||||
def test_default_is_off_without_the_setting(local_db):
|
||||
get_table("site_settings").delete(key="nodes_enabled")
|
||||
assert GATE.allows(ready()).reason == "kill_switch"
|
||||
|
||||
|
||||
def test_unknown_account(switched_on):
|
||||
assert GATE.allows("nobody").reason == "no_account"
|
||||
assert GATE.allows("").reason == "no_account"
|
||||
|
||||
|
||||
def test_each_missing_requirement_has_its_own_reason(switched_on):
|
||||
no_consent = fresh()
|
||||
save_node_settings(no_consent, {"enabled": True})
|
||||
assert GATE.allows(no_consent).reason == "no_consent"
|
||||
not_enabled = fresh()
|
||||
set_consent("user", not_enabled, "bandwidth_sharing", True)
|
||||
assert GATE.allows(not_enabled).reason == "not_enabled"
|
||||
withdrawn = ready()
|
||||
set_consent("user", withdrawn, "bandwidth_sharing", False)
|
||||
assert GATE.allows(withdrawn).reason == "no_consent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("band", ["", "under_min", "13_15", "16_17"])
|
||||
def test_only_adults_pass(switched_on, band):
|
||||
uid = fresh(age_band=band)
|
||||
set_consent("user", uid, "bandwidth_sharing", True)
|
||||
save_node_settings(uid, {"enabled": True})
|
||||
assert GATE.allows(uid).reason == "not_adult"
|
||||
|
||||
|
||||
def test_account_states_block(switched_on):
|
||||
uid = ready()
|
||||
users = get_table("users")
|
||||
users.update({"uid": uid, "suspended_until": "2999-01-01T00:00:00+00:00"}, ["uid"])
|
||||
assert GATE.allows(uid).reason == "suspended"
|
||||
users.update({"uid": uid, "suspended_until": ""}, ["uid"])
|
||||
users.update({"uid": uid, "is_active": False}, ["uid"])
|
||||
assert GATE.allows(uid).reason == "account_inactive"
|
||||
users.update({"uid": uid, "is_active": True}, ["uid"])
|
||||
users.update({"uid": uid, "deletion_requested_at": "2026-01-01T00:00:00+00:00"}, ["uid"])
|
||||
assert GATE.allows(uid).reason == "deletion_pending"
|
||||
|
||||
|
||||
def test_every_reason_is_described():
|
||||
for reason in (
|
||||
"kill_switch",
|
||||
"no_account",
|
||||
"account_inactive",
|
||||
"deletion_pending",
|
||||
"suspended",
|
||||
"not_adult",
|
||||
"no_consent",
|
||||
"not_enabled",
|
||||
):
|
||||
assert REASONS[reason]
|
||||
|
||||
|
||||
def test_batch_decisions_equal_single_decisions(switched_on):
|
||||
qualified = ready()
|
||||
no_consent = fresh()
|
||||
save_node_settings(no_consent, {"enabled": True})
|
||||
minor = fresh(age_band="16_17")
|
||||
set_consent("user", minor, "bandwidth_sharing", True)
|
||||
save_node_settings(minor, {"enabled": True})
|
||||
withdrawn = ready()
|
||||
set_consent("user", withdrawn, "bandwidth_sharing", False)
|
||||
uids = [qualified, no_consent, minor, withdrawn, "nobody"]
|
||||
many = GATE.allows_many(uids)
|
||||
assert list(many) == uids
|
||||
for uid in uids:
|
||||
assert many[uid] == GATE.allows(uid)
|
||||
assert [many[uid].reason for uid in uids] == [
|
||||
"", "no_consent", "not_adult", "no_consent", "no_account",
|
||||
]
|
||||
|
||||
|
||||
def test_batch_with_the_kill_switch_off_refuses_everyone(local_db):
|
||||
set_setting("nodes_enabled", "0")
|
||||
many = GATE.allows_many([ready(), ready()])
|
||||
assert {verdict.reason for verdict in many.values()} == {"kill_switch"}
|
||||
|
||||
|
||||
def test_batches_larger_than_the_chunk_size_are_all_decided(switched_on, monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.nodes.consent.BATCH_SIZE", 2)
|
||||
uids = [ready() for _ in range(5)]
|
||||
many = GATE.allows_many(uids)
|
||||
assert len(many) == 5 and all(item.allowed for item in many.values())
|
||||
|
||||
|
||||
def test_the_latest_consent_row_wins_in_a_batch(switched_on):
|
||||
uid = ready()
|
||||
set_consent("user", uid, "bandwidth_sharing", False)
|
||||
set_consent("user", uid, "bandwidth_sharing", True)
|
||||
assert GATE.allows_many([uid])[uid].allowed
|
||||
@@ -0,0 +1,109 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.nodes import frames
|
||||
from devplacepy.services.nodes.frames import FrameError, decode, encode
|
||||
from devplacepy.services.nodes.spec import (
|
||||
CLIENT_TO_SERVER,
|
||||
CLOSE_FRAME_TOO_LARGE,
|
||||
CLOSE_PROTOCOL,
|
||||
SERVER_TO_CLIENT,
|
||||
SPEC,
|
||||
)
|
||||
|
||||
HELLO = {
|
||||
"type": "hello",
|
||||
"protocol": 1,
|
||||
"client_version": "0.1.0",
|
||||
"kind": "native",
|
||||
"platform": "linux/amd64",
|
||||
"modes": ["tunnel"],
|
||||
}
|
||||
|
||||
|
||||
def reason(raw, direction=CLIENT_TO_SERVER):
|
||||
with pytest.raises(FrameError) as info:
|
||||
decode(raw, direction)
|
||||
return info.value
|
||||
|
||||
|
||||
def test_a_valid_frame_round_trips():
|
||||
assert decode(json.dumps(HELLO)) == HELLO
|
||||
assert decode(json.dumps({"type": "pong"})) == {"type": "pong"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", "nope", "[]", "1", "null", '"x"'])
|
||||
def test_non_object_json_is_rejected(raw):
|
||||
assert reason(raw).code == "bad_json"
|
||||
|
||||
|
||||
def test_unknown_and_wrong_direction_types_are_rejected():
|
||||
assert reason(json.dumps({"type": "teleport"})).code == "unknown_frame"
|
||||
assert reason(json.dumps({"type": "welcome"})).code == "unknown_frame"
|
||||
assert reason(json.dumps({"type": 5})).code == "unknown_frame"
|
||||
assert reason(json.dumps({})).code == "unknown_frame"
|
||||
|
||||
|
||||
def test_missing_unexpected_and_mistyped_fields_are_rejected():
|
||||
assert reason(json.dumps({**HELLO, "protocol": None})).code == "bad_field_type"
|
||||
missing = {key: value for key, value in HELLO.items() if key != "platform"}
|
||||
assert reason(json.dumps(missing)).code == "missing_field"
|
||||
assert reason(json.dumps({**HELLO, "extra": 1})).code == "unexpected_field"
|
||||
assert reason(json.dumps({**HELLO, "protocol": "1"})).code == "bad_field_type"
|
||||
assert reason(json.dumps({**HELLO, "modes": "tunnel"})).code == "bad_field_type"
|
||||
|
||||
|
||||
def test_a_bool_is_not_an_int():
|
||||
assert reason(json.dumps({**HELLO, "protocol": True})).code == "bad_field_type"
|
||||
usage = {"type": "usage", "bytes_up": False, "bytes_down": 1}
|
||||
assert reason(json.dumps(usage)).code == "bad_field_type"
|
||||
|
||||
|
||||
def test_optional_fields_may_be_absent_but_not_wrong():
|
||||
with_limits = {**HELLO, "limits": {"a": 1}}
|
||||
assert decode(json.dumps(with_limits))["limits"] == {"a": 1}
|
||||
assert reason(json.dumps({**HELLO, "limits": []})).code == "bad_field_type"
|
||||
|
||||
|
||||
def test_the_size_cap_is_enforced_before_and_after_parsing():
|
||||
huge = "x" * (frames.ceiling(CLIENT_TO_SERVER) + 1)
|
||||
error = reason(huge)
|
||||
assert error.code == "frame_too_large"
|
||||
assert error.close_code == CLOSE_FRAME_TOO_LARGE
|
||||
padded = {**HELLO, "platform": "x" * SPEC.frame("hello", CLIENT_TO_SERVER).max_bytes}
|
||||
assert reason(json.dumps(padded)).code == "frame_too_large"
|
||||
assert reason(b"{" + b" " * frames.ceiling(CLIENT_TO_SERVER)).close_code == (
|
||||
CLOSE_FRAME_TOO_LARGE
|
||||
)
|
||||
|
||||
|
||||
def test_the_big_frame_may_exceed_the_small_cap():
|
||||
body = "A" * (SPEC.frame("hello", CLIENT_TO_SERVER).max_bytes * 2)
|
||||
result = {"type": "job_result", "job_id": "j", "status": 200, "body_b64": body}
|
||||
assert decode(json.dumps(result))["body_b64"] == body
|
||||
|
||||
|
||||
def test_protocol_errors_close_with_the_protocol_code():
|
||||
assert reason("nope").close_code == CLOSE_PROTOCOL
|
||||
|
||||
|
||||
def test_encode_validates_what_the_server_sends():
|
||||
text = encode("kick", SERVER_TO_CLIENT, reason="bye")
|
||||
assert json.loads(text) == {"type": "kick", "reason": "bye"}
|
||||
with pytest.raises(FrameError):
|
||||
encode("kick", SERVER_TO_CLIENT)
|
||||
with pytest.raises(FrameError):
|
||||
encode("kick", CLIENT_TO_SERVER, reason="x")
|
||||
with pytest.raises(FrameError):
|
||||
encode("kick", SERVER_TO_CLIENT, reason="x", extra=1)
|
||||
|
||||
|
||||
def test_every_frame_in_the_spec_can_be_built_and_decoded():
|
||||
samples = {"str": "x", "int": 1, "bool": True, "object": {}, "list": []}
|
||||
for spec_frame in SPEC.frames:
|
||||
fields = {item.name: samples[item.type] for item in spec_frame.fields}
|
||||
text = encode(spec_frame.type, spec_frame.direction, **fields)
|
||||
assert decode(text, spec_frame.direction)["type"] == spec_frame.type
|
||||
@@ -0,0 +1,148 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, set_consent, set_setting
|
||||
from devplacepy.database.nodes import (
|
||||
create_device,
|
||||
revoke_device,
|
||||
save_node_settings,
|
||||
suspend_device,
|
||||
)
|
||||
from devplacepy.services.nodes.service import NodesService
|
||||
from devplacepy.utils import register_account
|
||||
from tests.conftest import run_async
|
||||
from tests.unit.services.nodes.session import FakeSocket
|
||||
from devplacepy.services.nodes.session import NodeSession
|
||||
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def member():
|
||||
_counter[0] += 1
|
||||
name = f"svc{int(time.time() * 1000)}{_counter[0]}"
|
||||
uid, _, _ = register_account(
|
||||
name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True
|
||||
)
|
||||
set_consent("user", uid, "bandwidth_sharing", True)
|
||||
save_node_settings(uid, {"enabled": True})
|
||||
return uid
|
||||
|
||||
|
||||
def connected(service, uid):
|
||||
device, _ = create_device(uid, "x", "native")
|
||||
socket = FakeSocket()
|
||||
session = NodeSession(socket, device)
|
||||
service.registry.admit(session)
|
||||
return device, socket
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(local_db):
|
||||
set_setting("nodes_enabled", "1")
|
||||
yield NodesService()
|
||||
set_setting("nodes_enabled", "0")
|
||||
|
||||
|
||||
def reconcile(service):
|
||||
return run_async(service.reconcile())
|
||||
|
||||
|
||||
def test_healthy_nodes_stay_connected(service):
|
||||
_, socket = connected(service, member())
|
||||
assert reconcile(service) == 0
|
||||
assert socket.closed is None
|
||||
|
||||
|
||||
def test_the_kill_switch_disconnects_everyone_in_one_tick(service):
|
||||
sockets = [connected(service, member())[1] for _ in range(4)]
|
||||
set_setting("nodes_enabled", "0")
|
||||
assert reconcile(service) == 4
|
||||
assert service.registry.count() == 0
|
||||
assert all(socket.sent[-1] == {"type": "kick", "reason": "kill_switch"} for socket in sockets)
|
||||
|
||||
|
||||
def test_withdrawn_consent_disconnects_that_owner_only(service):
|
||||
leaving, staying = member(), member()
|
||||
_, gone = connected(service, leaving)
|
||||
_, kept = connected(service, staying)
|
||||
set_consent("user", leaving, "bandwidth_sharing", False)
|
||||
assert reconcile(service) == 1
|
||||
assert gone.sent[-1]["reason"] == "no_consent"
|
||||
assert kept.closed is None and service.registry.count() == 1
|
||||
|
||||
|
||||
def test_a_revoked_device_is_kicked(service):
|
||||
uid = member()
|
||||
device, socket = connected(service, uid)
|
||||
revoke_device(device["uid"], uid)
|
||||
assert reconcile(service) == 1
|
||||
assert socket.sent[-1]["reason"] == "revoked"
|
||||
|
||||
|
||||
def test_a_suspended_device_is_kicked(service):
|
||||
uid = member()
|
||||
device, socket = connected(service, uid)
|
||||
suspend_device(device["uid"], "test", 30)
|
||||
assert reconcile(service) == 1
|
||||
assert socket.sent[-1]["reason"] == "suspended"
|
||||
|
||||
|
||||
def test_donation_turned_off_by_the_member_disconnects(service):
|
||||
uid = member()
|
||||
_, socket = connected(service, uid)
|
||||
save_node_settings(uid, {"enabled": False})
|
||||
assert reconcile(service) == 1
|
||||
assert socket.sent[-1]["reason"] == "not_enabled"
|
||||
|
||||
|
||||
def test_a_deleted_account_is_disconnected(service):
|
||||
uid = member()
|
||||
_, socket = connected(service, uid)
|
||||
get_table("users").update(
|
||||
{"uid": uid, "deletion_requested_at": "2026-01-01T00:00:00+00:00"}, ["uid"]
|
||||
)
|
||||
assert reconcile(service) == 1
|
||||
assert socket.sent[-1]["reason"] == "deletion_pending"
|
||||
|
||||
|
||||
def test_the_gate_is_asked_once_for_all_owners_per_tick(service, monkeypatch):
|
||||
uid, other = member(), member()
|
||||
for owner in (uid, uid, uid, other):
|
||||
connected(service, owner)
|
||||
calls = []
|
||||
original = service.gate.allows_many
|
||||
|
||||
def spy(uids):
|
||||
uids = list(uids)
|
||||
calls.append(sorted(set(uids)))
|
||||
return original(uids)
|
||||
|
||||
monkeypatch.setattr(service.gate, "allows_many", spy)
|
||||
reconcile(service)
|
||||
assert calls == [sorted({uid, other})]
|
||||
|
||||
|
||||
def test_an_empty_registry_costs_nothing(service, monkeypatch):
|
||||
monkeypatch.setattr(service.gate, "allows_many", lambda uids: 1 / 0)
|
||||
assert reconcile(service) == 0
|
||||
|
||||
|
||||
def test_stopping_the_service_kicks_all(service):
|
||||
_, socket = connected(service, member())
|
||||
run_async(service.on_disable())
|
||||
assert socket.sent[-1]["reason"] == "service_stopped"
|
||||
assert service.registry.count() == 0
|
||||
|
||||
|
||||
def test_metrics_report_connected_and_capacity(service):
|
||||
connected(service, member())
|
||||
labels = {item["label"]: item["value"] for item in service.collect_metrics()["stats"]}
|
||||
assert labels["connected"] == 1 and labels["capacity"] > 0
|
||||
|
||||
|
||||
def test_the_service_has_the_kill_switch_as_a_config_field(service):
|
||||
keys = [field.key for field in service.all_fields()]
|
||||
assert "nodes_enabled" in keys and "nodes_max_total" in keys
|
||||
@@ -0,0 +1,199 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.nodes.frames import FrameError
|
||||
from devplacepy.services.nodes.registry import NodeCounters, NodeRegistry
|
||||
from devplacepy.services.nodes.session import END_HEARTBEAT, NodeSession
|
||||
from devplacepy.services.nodes.spec import CLOSE_KICKED, CLOSE_POLICY, SPEC
|
||||
from tests.conftest import run_async as run
|
||||
|
||||
LIMITS = SPEC.limits
|
||||
HELLO = {
|
||||
"type": "hello",
|
||||
"protocol": SPEC.version,
|
||||
"client_version": "0.1.0",
|
||||
"kind": "native",
|
||||
"platform": "linux/amd64",
|
||||
"modes": ["tunnel", "fetch"],
|
||||
}
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, incoming=()):
|
||||
self.incoming: asyncio.Queue = asyncio.Queue()
|
||||
for item in incoming:
|
||||
self.incoming.put_nowait(item)
|
||||
self.sent: list[dict] = []
|
||||
self.closed: tuple[int, str] | None = None
|
||||
|
||||
async def receive_text(self):
|
||||
return await self.incoming.get()
|
||||
|
||||
async def send_text(self, text):
|
||||
self.sent.append(json.loads(text))
|
||||
|
||||
async def close(self, code=1000, reason=""):
|
||||
self.closed = (code, reason)
|
||||
|
||||
|
||||
def session_for(socket, user="u1", uid="d1", kind="native"):
|
||||
return NodeSession(socket, {"uid": uid, "user_uid": user, "kind": kind})
|
||||
|
||||
|
||||
|
||||
|
||||
def test_handshake_sends_welcome(local_db):
|
||||
socket = FakeSocket([json.dumps(HELLO)])
|
||||
session = session_for(socket)
|
||||
frame = run(session.handshake())
|
||||
assert frame["platform"] == "linux/amd64"
|
||||
welcome = socket.sent[0]
|
||||
assert welcome["type"] == "welcome" and welcome["device_uid"] == "d1"
|
||||
assert welcome["limits"] == LIMITS.describe()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation,code",
|
||||
[
|
||||
({"protocol": SPEC.version + 1}, "protocol_unsupported"),
|
||||
({"protocol": SPEC.min_supported - 1}, "protocol_unsupported"),
|
||||
({"kind": "extension"}, "kind_mismatch"),
|
||||
({"modes": []}, "bad_modes"),
|
||||
({"modes": ["tunnel", "teleport"]}, "bad_modes"),
|
||||
],
|
||||
)
|
||||
def test_handshake_rejects_bad_hellos(local_db, mutation, code):
|
||||
socket = FakeSocket([json.dumps({**HELLO, **mutation})])
|
||||
with pytest.raises(FrameError) as info:
|
||||
run(session_for(socket).handshake())
|
||||
assert info.value.code == code
|
||||
assert socket.sent == []
|
||||
|
||||
|
||||
def test_the_first_frame_must_be_hello(local_db):
|
||||
socket = FakeSocket([json.dumps({"type": "pong"})])
|
||||
with pytest.raises(FrameError) as info:
|
||||
run(session_for(socket).handshake())
|
||||
assert info.value.code == "expected_hello"
|
||||
|
||||
|
||||
def test_a_silent_client_times_out(local_db, monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.nodes.session.LIMITS", type("L", (), {
|
||||
"hello_timeout_seconds": 0.05, "ping_seconds": 1, "missed_pings_before_evict": 2,
|
||||
"describe": staticmethod(lambda: {}),
|
||||
}))
|
||||
with pytest.raises(FrameError) as info:
|
||||
run(session_for(FakeSocket()).handshake())
|
||||
assert info.value.code == "hello_timeout" and info.value.close_code == CLOSE_POLICY
|
||||
|
||||
|
||||
def test_usage_is_advisory_and_summed():
|
||||
session = session_for(FakeSocket())
|
||||
session._handle({"type": "usage", "bytes_up": 5, "bytes_down": 7})
|
||||
session._handle({"type": "usage", "bytes_up": -9, "bytes_down": 1})
|
||||
assert (session.reported_up, session.reported_down) == (5, 8)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["job_result", "job_error", "tunnel_data", "tunnel_close", "tunnel_opened"])
|
||||
def test_frames_without_an_assigned_job_are_protocol_errors(kind):
|
||||
with pytest.raises(FrameError):
|
||||
session_for(FakeSocket())._handle({"type": kind})
|
||||
|
||||
|
||||
def test_missed_pings_evict_after_the_floor(monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.nodes.session.LIMITS", type("L", (), {
|
||||
"ping_seconds": 0.02, "missed_pings_before_evict": 2,
|
||||
}))
|
||||
socket = FakeSocket()
|
||||
session = session_for(socket)
|
||||
ended = run(session.run())
|
||||
assert ended == END_HEARTBEAT
|
||||
assert [item["type"] for item in socket.sent] == ["ping", "ping"]
|
||||
assert socket.closed[0] == CLOSE_POLICY
|
||||
|
||||
|
||||
def test_any_frame_resets_the_missed_counter(monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.nodes.session.LIMITS", type("L", (), {
|
||||
"ping_seconds": 0.05, "missed_pings_before_evict": 2,
|
||||
}))
|
||||
socket = FakeSocket()
|
||||
session = session_for(socket)
|
||||
|
||||
async def scenario():
|
||||
task = asyncio.create_task(session.run())
|
||||
await asyncio.sleep(0.07)
|
||||
assert session.missed_pings == 1
|
||||
await socket.incoming.put(json.dumps({"type": "pong"}))
|
||||
await asyncio.sleep(0.02)
|
||||
assert session.missed_pings == 0
|
||||
task.cancel()
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_kick_sends_the_reason_then_closes_once():
|
||||
socket = FakeSocket()
|
||||
session = session_for(socket)
|
||||
run(session.kick("revoked"))
|
||||
run(session.kick("again"))
|
||||
assert socket.sent == [{"type": "kick", "reason": "revoked"}]
|
||||
assert socket.closed == (CLOSE_KICKED, "revoked")
|
||||
|
||||
|
||||
def test_registry_replaces_the_same_device_and_enforces_capacity(local_db):
|
||||
registry = NodeRegistry(NodeCounters())
|
||||
first = session_for(FakeSocket(), uid="d1")
|
||||
assert registry.admit(first) == (True, None)
|
||||
second = session_for(FakeSocket(), uid="d1")
|
||||
admitted, previous = registry.admit(second)
|
||||
assert admitted and previous is first
|
||||
registry.release(first)
|
||||
assert registry.get("d1") is second
|
||||
registry.release(second)
|
||||
assert registry.count() == 0
|
||||
counts = registry.counters.snapshot()
|
||||
assert counts["connected_total"] == 2 and counts["replaced"] == 1
|
||||
|
||||
|
||||
def test_registry_capacity_zero_admits_nobody(local_db):
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
|
||||
previous = get_setting("nodes_max_total", "")
|
||||
set_setting("nodes_max_total", "0")
|
||||
try:
|
||||
registry = NodeRegistry(NodeCounters())
|
||||
assert registry.capacity() == 0
|
||||
assert registry.admit(session_for(FakeSocket())) == (False, None)
|
||||
assert registry.counters.snapshot()["rejected_capacity"] == 1
|
||||
finally:
|
||||
set_setting("nodes_max_total", previous)
|
||||
|
||||
|
||||
def test_registry_capacity_never_exceeds_the_spec_ceiling(local_db):
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
|
||||
previous = get_setting("nodes_max_total", "")
|
||||
set_setting("nodes_max_total", str(LIMITS.max_connected_nodes * 10))
|
||||
try:
|
||||
assert NodeRegistry(NodeCounters()).capacity() == LIMITS.max_connected_nodes
|
||||
finally:
|
||||
set_setting("nodes_max_total", previous)
|
||||
|
||||
|
||||
def test_counters_reject_unknown_names():
|
||||
with pytest.raises(KeyError):
|
||||
NodeCounters().inc("made_up")
|
||||
|
||||
|
||||
def test_kick_all_empties_the_registry(local_db):
|
||||
registry = NodeRegistry(NodeCounters())
|
||||
sockets = [FakeSocket() for _ in range(3)]
|
||||
for index, socket in enumerate(sockets):
|
||||
registry.admit(session_for(socket, uid=f"d{index}"))
|
||||
assert run(registry.kick_all("bye")) == 3
|
||||
assert registry.count() == 0
|
||||
assert all(socket.closed[0] == CLOSE_KICKED for socket in sockets)
|
||||
Reference in New Issue
Block a user