"""Tests for furtka.gateway — the box-side register/rotate/deregister client. Same shape as test_updater.py's network tests: monkeypatch urllib.request.urlopen directly rather than standing up a real server. """ from __future__ import annotations import json import urllib.error import pytest from furtka import gateway class _FakeResp: def __init__(self, body: dict): self._body = json.dumps(body).encode() def __enter__(self): return self def __exit__(self, *a): return False def read(self): return self._body WG = { "private_key": "cHJpdmF0ZS1rZXk=", "address": "10.8.0.2/24", "server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=", "endpoint": "gateway.example.com:51820", "allowed_ips": "10.8.0.0/24", } @pytest.fixture def gw(tmp_path, monkeypatch): monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json")) import importlib from furtka import paths as p importlib.reload(p) importlib.reload(gateway) return gateway def test_load_config_returns_none_when_never_registered(gw): assert gw.load_config() is None def test_register_saves_config(gw, monkeypatch): calls = [] def fake_urlopen(req, timeout): calls.append((req.full_url, json.loads(req.data))) return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG}) monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) config = gw.register("https://gw.example.com/", "reg-token", "my-box") assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped assert config["box_id"] == "abc123" assert config["box_token"] == "abc123.secret" assert config["wg"] == WG assert gw.load_config() == config url, body = calls[0] assert url == "https://gw.example.com/v1/boxes/register" assert body == {"registration_token": "reg-token", "box_name": "my-box"} def test_register_config_file_is_0600(gw, monkeypatch, tmp_path): monkeypatch.setattr( "urllib.request.urlopen", lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), ) gw.register("https://gw.example.com", "tok", "box") from furtka.paths import gateway_config_file assert oct(gateway_config_file().stat().st_mode)[-3:] == "600" def test_register_raises_gateway_error_on_http_error(gw, monkeypatch): def raising(req, timeout): raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None) monkeypatch.setattr("urllib.request.urlopen", raising) with pytest.raises(gateway.GatewayError, match="HTTP 401"): gw.register("https://gw.example.com", "bad-token", "my-box") assert gw.load_config() is None def test_rotate_token_updates_stored_config(gw, monkeypatch): monkeypatch.setattr( "urllib.request.urlopen", lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}), ) gw.register("https://gw.example.com", "tok", "box") def fake_rotate(req, timeout): assert req.headers.get("Authorization") == "Bearer abc.old" assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token" return _FakeResp({"box_token": "abc.new"}) monkeypatch.setattr("urllib.request.urlopen", fake_rotate) new_token = gw.rotate_token() assert new_token == "abc.new" assert gw.load_config()["box_token"] == "abc.new" def test_rotate_token_without_prior_registration_raises(gw): with pytest.raises(gateway.GatewayError, match="not connected"): gw.rotate_token() def test_deregister_calls_gateway_and_clears_config(gw, monkeypatch): monkeypatch.setattr( "urllib.request.urlopen", lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), ) gw.register("https://gw.example.com", "tok", "box") calls = [] def fake_deregister(req, timeout): calls.append(req.full_url) return _FakeResp({"status": "ok"}) monkeypatch.setattr("urllib.request.urlopen", fake_deregister) gw.deregister() assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"] assert gw.load_config() is None def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch): calls = [] monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1)) gw.deregister() assert calls == [] def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch): monkeypatch.setattr( "urllib.request.urlopen", lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), ) gw.register("https://gw.example.com", "tok", "box") def raising(req, timeout): raise urllib.error.URLError("connection refused") monkeypatch.setattr("urllib.request.urlopen", raising) with pytest.raises(gateway.GatewayError): gw.deregister() # Local config must survive so the caller can retry later. assert gw.load_config() is not None