169 lines
5 KiB
Python
169 lines
5 KiB
Python
|
|
"""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
|