2026-10-14 09:14:53 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
2026-10-16 07:20:44 +02:00
|
|
|
import asyncio
|
2026-10-14 09:14:53 +02:00
|
|
|
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
|
2026-10-16 07:20:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_changed_settings_are_pushed_to_the_node_once(service):
|
|
|
|
|
uid = member()
|
|
|
|
|
device, socket = connected(service, uid)
|
|
|
|
|
session = service.registry.get(device["uid"])
|
|
|
|
|
session.settings = {"stale": True}
|
|
|
|
|
assert reconcile(service) == 0
|
|
|
|
|
pushed = [frame for frame in socket.sent if frame["type"] == "settings_update"]
|
|
|
|
|
assert len(pushed) == 1 and pushed[0]["settings"]["enabled"] is True
|
|
|
|
|
assert session.settings == pushed[0]["settings"]
|
|
|
|
|
reconcile(service)
|
|
|
|
|
assert len([frame for frame in socket.sent if frame["type"] == "settings_update"]) == 1
|
|
|
|
|
save_node_settings(uid, {"bandwidth_mb_day": 9})
|
|
|
|
|
reconcile(service)
|
|
|
|
|
assert session.settings["bandwidth_mb_day"] == 9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_dispatch_loop_starts_once_and_stops_with_the_service(service):
|
|
|
|
|
async def scenario():
|
|
|
|
|
service.ensure_dispatching()
|
|
|
|
|
first = service._dispatch_task
|
|
|
|
|
service.ensure_dispatching()
|
|
|
|
|
assert service._dispatch_task is first and not first.done()
|
|
|
|
|
await service.on_disable()
|
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
|
assert service._dispatch_task is None and first.cancelled()
|
|
|
|
|
|
|
|
|
|
run_async(scenario())
|
2026-10-17 02:23:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_timed_suspension_tells_the_node_when_it_ends(service):
|
|
|
|
|
uid = member()
|
|
|
|
|
device, socket = connected(service, uid)
|
|
|
|
|
suspend_device(device["uid"], "test", 30)
|
|
|
|
|
reconcile(service)
|
|
|
|
|
frame = socket.sent[-1]
|
|
|
|
|
assert frame["reason"] == "suspended" and frame["until"].startswith("20")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_other_kicks_carry_no_until(service):
|
|
|
|
|
_, socket = connected(service, member())
|
|
|
|
|
set_setting("nodes_enabled", "0")
|
|
|
|
|
reconcile(service)
|
|
|
|
|
assert "until" not in socket.sent[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_persist_state_touches_flushes_and_saves_circuits(service):
|
|
|
|
|
from devplacepy.database import nodes as store
|
|
|
|
|
|
|
|
|
|
uid = member()
|
|
|
|
|
device, _ = connected(service, uid)
|
|
|
|
|
get_table("node_devices").update({"uid": device["uid"], "last_seen_at": "2000-01-01T00:00:00+00:00"}, ["uid"])
|
|
|
|
|
store.flush_counters({})
|
|
|
|
|
service.counters.inc("kicked")
|
|
|
|
|
service.counters.observe("reddit", "residential", "ok")
|
|
|
|
|
service.breaker.failure(("destination", "reddit.com"))
|
|
|
|
|
service.persist_state(force=True)
|
|
|
|
|
assert store.get_device(device["uid"])["last_seen_at"] > "2020"
|
|
|
|
|
lifetime = store.lifetime_counters()
|
|
|
|
|
assert lifetime["kicked"] >= 1 and lifetime["outcome:reddit:residential:ok"] >= 1
|
|
|
|
|
assert service.counters.take_pending() == {}
|
|
|
|
|
assert not service.breaker.dirty
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failed_flush_keeps_the_deltas(service, monkeypatch):
|
|
|
|
|
from devplacepy.services.nodes import service as module
|
|
|
|
|
|
|
|
|
|
service.counters.inc("kicked")
|
|
|
|
|
|
|
|
|
|
def boom(deltas):
|
|
|
|
|
raise RuntimeError("disk full")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(module, "flush_counters", boom)
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
service.persist_state(force=True)
|
|
|
|
|
assert service.counters.take_pending() == {"kicked": 1}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_persist_state_respects_the_intervals(service, monkeypatch):
|
|
|
|
|
from devplacepy.services.nodes import service as module
|
|
|
|
|
|
|
|
|
|
connected(service, member())
|
|
|
|
|
calls = []
|
|
|
|
|
monkeypatch.setattr(module, "touch_devices", lambda uids: calls.append(list(uids)))
|
|
|
|
|
service.persist_state(force=True)
|
|
|
|
|
service.persist_state()
|
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_circuits_survive_a_restart(service):
|
|
|
|
|
from devplacepy.database.nodes import load_circuits
|
|
|
|
|
|
|
|
|
|
for _ in range(service.breaker.failures):
|
|
|
|
|
service.breaker.failure(("destination", "reddit.com"))
|
|
|
|
|
service.persist_state()
|
|
|
|
|
assert [row["key"] for row in load_circuits()] == ["destination:reddit.com"]
|
|
|
|
|
fresh = NodesService()
|
|
|
|
|
run_async(fresh.on_enable())
|
|
|
|
|
assert fresh.breaker.open_count() == 1
|
|
|
|
|
fresh.breaker.success(("destination", "reddit.com"))
|
|
|
|
|
fresh.persist_state()
|
|
|
|
|
assert load_circuits() == []
|
|
|
|
|
run_async(fresh.on_disable())
|