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:
2026-10-14 09:14:53 +02:00
co-authored by Claude Sonnet 5.5
parent fad41d0fbe
commit 42b81271b8
41 changed files with 2166 additions and 22 deletions
+50
View File
@@ -0,0 +1,50 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
from fastapi.testclient import TestClient
from devplacepy.database import get_setting, get_table, set_consent, set_setting
from devplacepy.database.nodes import save_node_settings
from devplacepy.main import app
from devplacepy.services.manager import service_manager
from devplacepy.utils import register_account
_counter = [0]
def unique(prefix: str) -> str:
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def make_member(*, consent=True, enabled=True, age_band="adult", role="Member") -> dict:
name = unique("nodemb")
uid, _, _ = register_account(
name, f"{name}@t.dev", "secret123", age_band=age_band, accepted_terms=True
)
if role != "Member":
get_table("users").update({"uid": uid, "role": role}, ["uid"])
if consent:
set_consent("user", uid, "bandwidth_sharing", True)
if enabled:
save_node_settings(uid, {"enabled": True})
row = get_table("users").find_one(uid=uid)
return {"uid": uid, "username": name, "headers": {"X-API-KEY": row["api_key"]}}
@pytest.fixture(scope="module")
def client():
previous = get_setting("nodes_enabled", "0")
set_setting("nodes_enabled", "1")
with TestClient(app) as test_client:
service_manager.set_lock_owner(True)
yield test_client
service_manager.set_lock_owner(False)
set_setting("nodes_enabled", previous)
@pytest.fixture
def member(client):
return make_member()
+105
View File
@@ -0,0 +1,105 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_setting, set_setting
from devplacepy.database.nodes import LIMITS, find_device_by_secret
from tests.api.nodes.conftest import make_member
def create(client, who, **body):
payload = {"label": "Home laptop", "kind": "native", **body}
return client.post("/nodes/devices", json=payload, headers=who["headers"])
def test_guests_get_401(client):
assert client.get("/nodes/devices").status_code == 401
assert client.post("/nodes/devices", json={"label": "x"}).status_code == 401
assert client.post("/nodes/devices/x/rotate").status_code == 401
assert client.post("/nodes/devices/x/delete").status_code == 401
def test_registration_returns_the_secret_once_and_never_caches_it(client, member):
response = create(client, member)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
body = response.json()
assert body["secret"].startswith("dpn_")
assert body["device"]["label"] == "Home laptop"
assert "secret_hash" not in body["device"] and "ip_hash" not in body["device"]
assert find_device_by_secret(body["secret"])["uid"] == body["device"]["uid"]
listing = client.get("/nodes/devices", headers=member["headers"]).json()
assert [item["uid"] for item in listing["devices"]] == [body["device"]["uid"]]
assert listing["max_devices"] == LIMITS.max_devices_per_user
assert "secret" not in str(listing)
def test_registration_needs_consent_and_enablement(client):
no_consent = make_member(consent=False)
refused = create(client, no_consent)
assert refused.status_code == 403
assert refused.json()["error"]["reason"] == "no_consent"
not_enabled = make_member(enabled=False)
assert create(client, not_enabled).json()["error"]["reason"] == "not_enabled"
minor = make_member(age_band="16_17")
assert create(client, minor).json()["error"]["reason"] == "not_adult"
def test_kill_switch_blocks_registration(client, member):
previous = get_setting("nodes_enabled", "0")
set_setting("nodes_enabled", "0")
try:
response = create(client, member)
assert response.status_code == 403
assert response.json()["error"]["reason"] == "kill_switch"
finally:
set_setting("nodes_enabled", previous)
def test_device_cap_is_a_409(client, member):
for _ in range(LIMITS.max_devices_per_user):
assert create(client, member).status_code == 201
assert create(client, member).status_code == 409
def test_unknown_kind_is_a_validation_error(client, member):
assert create(client, member, kind="browser").status_code == 422
def test_rotate_replaces_the_secret_and_is_owner_only(client, member):
created = create(client, member).json()
uid, old = created["device"]["uid"], created["secret"]
other = make_member()
assert (
client.post(f"/nodes/devices/{uid}/rotate", headers=other["headers"]).status_code
== 404
)
rotated = client.post(f"/nodes/devices/{uid}/rotate", headers=member["headers"])
assert rotated.status_code == 200
new = rotated.json()["secret"]
assert new != old
assert find_device_by_secret(old) is None
assert find_device_by_secret(new)["uid"] == uid
def test_delete_is_owner_or_admin(client, member):
uid = create(client, member).json()["device"]["uid"]
other = make_member()
assert (
client.post(f"/nodes/devices/{uid}/delete", headers=other["headers"]).status_code
== 403
)
admin = make_member(role="Admin")
assert (
client.post(f"/nodes/devices/{uid}/delete", headers=admin["headers"]).status_code
== 200
)
assert client.get("/nodes/devices", headers=member["headers"]).json()["devices"] == []
assert (
client.post(f"/nodes/devices/{uid}/delete", headers=member["headers"]).status_code
== 404
)
def test_owner_can_delete_their_own_device(client, member):
uid = create(client, member).json()["device"]["uid"]
response = client.post(f"/nodes/devices/{uid}/delete", headers=member["headers"])
assert response.status_code == 200 and response.json() == {"ok": True}
+67
View File
@@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import set_consent
from devplacepy.database.nodes import LIMITS, suspend_device
from devplacepy.services.messaging.tickets import redeem_ticket, redeem_ticket_row
from tests.api.nodes.devices import create
def bearer(secret):
return {"Authorization": f"Bearer {secret}"}
def registered(client, member):
body = create(client, member).json()
return body["device"]["uid"], body["secret"]
def test_no_credentials_or_a_wrong_secret_is_401(client):
assert client.post("/nodes/ticket").status_code == 401
assert client.post("/nodes/ticket", headers=bearer("nope")).status_code == 401
assert client.post("/nodes/ticket", headers=bearer("dpn_unknown")).status_code == 401
def test_a_session_api_key_is_not_a_device_secret(client, member):
assert client.post("/nodes/ticket", headers=member["headers"]).status_code == 401
def test_a_valid_secret_gets_a_short_single_use_ticket(client, member):
device_uid, secret = registered(client, member)
response = client.post("/nodes/ticket", headers=bearer(secret))
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
body = response.json()
assert body["expires_in"] == LIMITS.ticket_ttl_seconds
assert body["ws_path"] == "/nodes/ws"
row = redeem_ticket_row(body["ticket"], "nodes")
assert row["device_uid"] == device_uid and row["user_uid"] == member["uid"]
assert redeem_ticket_row(body["ticket"], "nodes") is None
def test_a_node_ticket_cannot_open_a_chat_socket(client, member):
_, secret = registered(client, member)
ticket = client.post("/nodes/ticket", headers=bearer(secret)).json()["ticket"]
assert redeem_ticket(ticket) is None
assert redeem_ticket_row(ticket, "nodes") is not None
def test_withdrawing_consent_stops_new_tickets(client, member):
_, secret = registered(client, member)
set_consent("user", member["uid"], "bandwidth_sharing", False)
response = client.post("/nodes/ticket", headers=bearer(secret))
assert response.status_code == 403
assert response.json()["error"]["reason"] == "no_consent"
def test_a_suspended_device_gets_no_ticket(client, member):
device_uid, secret = registered(client, member)
suspend_device(device_uid, "test", 30)
response = client.post("/nodes/ticket", headers=bearer(secret))
assert response.status_code == 403
assert response.json()["error"]["reason"] == "suspended"
def test_a_deleted_device_secret_stops_working(client, member):
device_uid, secret = registered(client, member)
client.post(f"/nodes/devices/{device_uid}/delete", headers=member["headers"])
assert client.post("/nodes/ticket", headers=bearer(secret)).status_code == 401
+155
View File
@@ -0,0 +1,155 @@
# retoor <retoor@molodetz.nl>
import json
import pytest
from starlette.websockets import WebSocketDisconnect
from devplacepy.services.manager import service_manager
from devplacepy.services.nodes.spec import (
CLOSE_CAPACITY,
CLOSE_KICKED,
CLOSE_POLICY,
CLOSE_PROTOCOL,
CLOSE_RETRY_OWNER,
SPEC,
)
from tests.api.nodes.devices import create
from tests.api.nodes.ticket import bearer
def hello(**overrides):
frame = {
"type": "hello",
"protocol": SPEC.version,
"client_version": "0.1.0",
"kind": "native",
"platform": "linux/amd64",
"modes": ["tunnel"],
}
frame.update(overrides)
return json.dumps(frame)
def ticket_for(client, member):
body = create(client, member).json()
ticket = client.post("/nodes/ticket", headers=bearer(body["secret"])).json()["ticket"]
return body["device"]["uid"], body["secret"], ticket
def closed_with(connection):
with pytest.raises(WebSocketDisconnect) as info:
connection.receive_text()
return info.value.code
def test_handshake_welcomes_with_the_published_numbers(client, member):
device_uid, _, ticket = ticket_for(client, member)
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
ws.send_text(hello())
welcome = json.loads(ws.receive_text())
assert welcome["type"] == "welcome"
assert welcome["device_uid"] == device_uid
assert welcome["limits"] == SPEC.limits.describe()
assert {item["suffix"] for item in welcome["destinations"]} == {
item.suffix for item in SPEC.destinations
}
assert welcome["settings"]["enabled"] is True
service = service_manager.get_service("nodes")
assert service.registry.get(device_uid) is not None
ws.send_text(json.dumps({"type": "pong"}))
ws.send_text(json.dumps({"type": "usage", "bytes_up": 5, "bytes_down": 7}))
assert service.registry.get(device_uid) is None
def test_a_ticket_works_once(client, member):
_, _, ticket = ticket_for(client, member)
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
ws.send_text(hello())
ws.receive_text()
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as again:
assert closed_with(again) == CLOSE_POLICY
def test_no_ticket_and_a_chat_ticket_are_refused(client, member):
with client.websocket_connect("/nodes/ws") as ws:
assert closed_with(ws) == CLOSE_POLICY
from devplacepy.services.messaging.tickets import issue_ticket
chat_ticket = issue_ticket(member["uid"])
with client.websocket_connect(f"/nodes/ws?ticket={chat_ticket}") as ws:
assert closed_with(ws) == CLOSE_POLICY
def test_a_non_owner_worker_says_retry(client, member):
_, _, ticket = ticket_for(client, member)
service_manager.set_lock_owner(False)
try:
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
assert closed_with(ws) == CLOSE_RETRY_OWNER
finally:
service_manager.set_lock_owner(True)
def test_protocol_violations_close_loudly(client, member):
cases = [
("not json", CLOSE_PROTOCOL),
(json.dumps({"type": "pong"}), CLOSE_PROTOCOL),
(hello(protocol=SPEC.version + 5), CLOSE_POLICY),
(hello(kind="extension"), CLOSE_POLICY),
(hello(modes=["bogus"]), CLOSE_PROTOCOL),
(hello(extra="x"), CLOSE_PROTOCOL),
]
for frame, expected in cases:
_, _, ticket = ticket_for(client, make_fresh(member))
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
ws.send_text(frame)
assert closed_with(ws) == expected, frame
def make_fresh(member):
from tests.api.nodes.conftest import make_member
return make_member()
def test_an_unsolicited_job_result_is_a_protocol_error(client, member):
_, _, ticket = ticket_for(client, member)
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
ws.send_text(hello())
ws.receive_text()
ws.send_text(
json.dumps(
{"type": "job_result", "job_id": "j", "status": 200, "body_b64": ""}
)
)
assert closed_with(ws) == CLOSE_PROTOCOL
def test_a_second_connection_of_the_same_device_replaces_the_first(client, member):
device_uid, secret, first_ticket = ticket_for(client, member)
second_ticket = client.post("/nodes/ticket", headers=bearer(secret)).json()["ticket"]
with client.websocket_connect(f"/nodes/ws?ticket={first_ticket}") as first:
first.send_text(hello())
first.receive_text()
with client.websocket_connect(f"/nodes/ws?ticket={second_ticket}") as second:
second.send_text(hello())
assert json.loads(second.receive_text())["type"] == "welcome"
kicked = json.loads(first.receive_text())
assert kicked == {"type": "kick", "reason": "replaced"}
assert closed_with(first) == CLOSE_KICKED
def test_capacity_is_a_hard_ceiling(client, member):
from devplacepy.database import get_setting, set_setting
service = service_manager.get_service("nodes")
_, _, ticket = ticket_for(client, member)
previous = get_setting("nodes_max_total", "")
set_setting("nodes_max_total", "0")
try:
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
assert closed_with(ws) == CLOSE_CAPACITY
assert service.counters.snapshot()["rejected_capacity"] >= 1
finally:
set_setting("nodes_max_total", previous)
+36
View File
@@ -208,3 +208,39 @@ def test_the_mature_preference_round_trips(app_server):
)
refresh_snapshot()
assert get_table("users").find_one(uid=user["uid"])["mature_opt_in"] in (0, False)
def test_an_agent_cannot_grant_a_member_only_consent_but_can_withdraw_it(app_server):
session, name, user = _member()
agent = {**JSON, "X-Devii-Agent": "1"}
url = f"{BASE_URL}/profile/{name}/consent"
refused = session.post(
url, data={"kind": "bandwidth_sharing", "granted": "1"}, headers=agent
)
assert refused.status_code == 403
refresh_snapshot()
assert consent_granted("user", user["uid"], "bandwidth_sharing") is False
own = session.post(
url, data={"kind": "bandwidth_sharing", "granted": "1"}, headers=JSON
)
assert own.status_code == 200
refresh_snapshot()
assert consent_granted("user", user["uid"], "bandwidth_sharing") is True
withdrawn = session.post(
url, data={"kind": "bandwidth_sharing", "granted": "0"}, headers=agent
)
assert withdrawn.status_code == 200
refresh_snapshot()
assert consent_granted("user", user["uid"], "bandwidth_sharing") is False
def test_an_agent_can_still_grant_an_ordinary_consent(app_server):
session, name, user = _member()
response = session.post(
f"{BASE_URL}/profile/{name}/consent",
data={"kind": "ai_third_party", "granted": "1"},
headers={**JSON, "X-Devii-Agent": "1"},
)
assert response.status_code == 200
+144
View File
@@ -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
+109
View File
@@ -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
+148
View File
@@ -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
+199
View File
@@ -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)