Files
MeshCore/tools/hil/test_room_webconfig_acceptance.py
T

404 lines
21 KiB
Python

"""Offline guards and live-wire contract tests; no sockets or hardware."""
import contextlib
import io
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
import room_webconfig_acceptance as hil
import room_browser_acceptance as browser_hil
def manifest():
return {"source_commit": "12345678", "firmware_sha256": "a" * 64,
"status": {"node_id": "0123456789abcdef", "board": "test-board",
"role": "Room Server", "fw": "test-fw", "mode": "lan"},
"config_expected": {"radio": {"name": "private-node-name"}}}
class FakeResponse:
def __init__(self, status=200, body=None, headers=None):
self.status = status
self.body = json.dumps(body or {}).encode()
self.headers = headers or [("Content-Type", "application/json")]
def getheader(self, name, default=None):
return next((value for key, value in self.headers if key.lower() == name.lower()), default)
def getheaders(self):
return self.headers
def read(self, maximum):
return self.body[:maximum]
class FakeConnection:
def __init__(self, response, source="192.0.2.20", error=None):
self.response, self.source, self.error = response, source, error
self.sock, self.closed, self.sent = self, False, None
def getsockname(self):
return self.source, 12345
def connect(self):
if self.error:
raise self.error
def request(self, method, path, **values):
self.sent = method, path, values
def getresponse(self):
return self.response
def close(self):
self.closed = True
class ContractTests(unittest.TestCase):
def client(self, connection):
factory = mock.Mock(return_value=connection)
client = hil.HttpClient({"address": "192.0.2.10", "port": 80,
"host_header": "board.example"},
"192.0.2.20", 2, connection_factory=factory)
return client, factory
def test_explicit_authorization_guard_precedes_files_and_network(self):
output = io.StringIO()
with contextlib.redirect_stdout(output), mock.patch.object(hil, "load_json") as load, \
mock.patch.object(hil.socket, "getaddrinfo") as resolve:
result = hil.main(["--endpoint", "http://192.0.2.10", "--manifest", "/missing",
"--run-id", "guard-test"])
self.assertEqual(result, 1)
load.assert_not_called()
resolve.assert_not_called()
self.assertEqual(json.loads(output.getvalue())["failure"],
"explicit_allow_auth_attempts_required")
def test_private_permissions_and_identity_pins_are_required(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "private.json"
path.write_text('{"admin_password":"never-output-this"}')
path.chmod(0o600)
self.assertEqual(hil.load_json(path, private=True)["admin_password"],
"never-output-this")
path.chmod(0o644)
with self.assertRaisesRegex(hil.CheckFailure, "private_file_requires_mode"):
hil.load_json(path, private=True)
self.assertEqual(hil.validate_manifest(manifest()), manifest())
incomplete = manifest()
del incomplete["status"]["node_id"]
with self.assertRaises(hil.CheckFailure):
hil.validate_manifest(incomplete)
incomplete = manifest()
incomplete["config_expected"] = {}
with self.assertRaises(hil.CheckFailure):
hil.validate_manifest(incomplete)
incomplete["config_expected"] = {"radio": {}}
with self.assertRaises(hil.CheckFailure):
hil.validate_manifest(incomplete)
def test_source_addresses_must_be_distinct_and_room_policy_explicit(self):
value = {"admin_password": "secret", "source_ips": ["192.0.2.20", "192.0.2.21"]}
hil.validate_private(value, "webconfig")
with self.assertRaisesRegex(hil.CheckFailure, "read_only_expectation"):
hil.validate_private(value, "room")
value["source_ips"] = ["192.0.2.20", "192.0.2.20"]
with self.assertRaisesRegex(hil.CheckFailure, "must_differ"):
hil.validate_private(value, "webconfig")
value["source_ips"] = [True, "192.0.2.20"]
with self.assertRaises(hil.CheckFailure):
hil.validate_private(value, "webconfig")
def test_http_binds_and_observes_source_and_preserves_origin_host(self):
connection = FakeConnection(FakeResponse(body={"auth": False}))
client, factory = self.client(connection)
reply = client.request("GET", "/api/status", use_cookie=False)
factory.assert_called_once_with("192.0.2.10", 80, timeout=2,
source_address=("192.0.2.20", 0))
self.assertEqual(connection.sent[2]["headers"]["Host"], "board.example")
self.assertNotIn("Cookie", connection.sent[2]["headers"])
self.assertEqual(reply.body, {"auth": False})
self.assertTrue(connection.closed)
connection.source = "192.0.2.99"
with self.assertRaisesRegex(hil.CheckFailure, "source_binding"):
client.request("GET", "/api/status")
def test_mutating_room_or_config_operations_never_connect(self):
connection = FakeConnection(FakeResponse())
client, factory = self.client(connection)
with self.assertRaises(hil.CheckFailure):
client.request("POST", "/api/config", {"set": {"name": "change"}})
with self.assertRaises(hil.CheckFailure):
client.request("POST", "/api/room", {"op": "board.save", "password": "secret"})
factory.assert_not_called()
def test_json_failures_and_transport_exceptions_do_not_leak_private_text(self):
connection = FakeConnection(FakeResponse(), error=OSError("password=private-value"))
client, _ = self.client(connection)
with self.assertRaisesRegex(hil.CheckFailure, "http_transport_or_json_failure") as error:
client.request("GET", "/api/status")
self.assertNotIn("private-value", str(error.exception))
self.assertTrue(connection.closed)
connection.error = None
connection.response.headers = [("Content-Type", "text/html")]
with self.assertRaisesRegex(hil.CheckFailure, "not_json_content_type"):
client.request("GET", "/api/status")
def test_success_cookie_is_private_and_error_response_keeps_existing_session(self):
connection = FakeConnection(FakeResponse(body={"ok": True}, headers=[
("Content-Type", "application/json"),
("Set-Cookie", "wcs=" + "a" * 32 + "; HttpOnly; SameSite=Lax; Path=/")]))
client, _ = self.client(connection)
client.login("never-print-password")
self.assertEqual(client.cookie, "wcs=" + "a" * 32)
connection.response = FakeResponse(429, {"error": hil.SHARED_ERROR})
self.assertEqual(client.login("never-print-password").status, 429)
self.assertEqual(client.cookie, "wcs=" + "a" * 32)
def test_room_retries_busy_and_polls_owned_200_json_error_without_cookie(self):
client = mock.Mock()
client.request.side_effect = [
hil.Reply(429, {"error": "room service busy"}, [], 0, 0),
hil.Reply(202, {"accepted": True}, [], 0, 0),
hil.Reply(202, {"pending": True}, [], 0, 0),
hil.Reply(200, {"error": hil.ROOM_ERROR}, [], 0, 0)]
room = hil.RoomClient(client, 12, clock=lambda: 0, sleep=lambda _: None)
reply = room.status("private-room-password")
self.assertEqual(reply.status, 200)
self.assertEqual(reply.body["error"], hil.ROOM_ERROR)
calls = client.request.call_args_list
self.assertEqual([call.args[1] for call in calls],
["/api/room", "/api/room", "/api/room/result", "/api/room/result"])
self.assertEqual(calls[0].args[2], calls[1].args[2])
self.assertEqual({call.kwargs["headers"]["X-Room-Token"] for call in calls}, {room.token})
self.assertEqual({call.kwargs["headers"]["X-Room-Seq"] for call in calls}, {"1"})
self.assertTrue(all(call.kwargs["use_cookie"] is False for call in calls))
def test_room_reboot_is_failure_and_200_transport_is_not_enough(self):
client = mock.Mock()
room = hil.RoomClient(client, 12, clock=lambda: 0, sleep=lambda _: None)
client.request.side_effect = [
hil.Reply(202, {"accepted": True}, [], 0, 0),
hil.Reply(200, {"role": 3, "boot": 1}, [], 0, 0),
hil.Reply(202, {"accepted": True}, [], 0, 0),
hil.Reply(200, {"role": 3, "boot": 2}, [], 0, 0)]
room.status("secret")
with self.assertRaisesRegex(hil.CheckFailure, "reboot"):
room.status("secret")
client.request.side_effect = [hil.Reply(200, {"ok": True}, [], 0, 0)]
with self.assertRaisesRegex(hil.CheckFailure, "submission_rejected"):
room.status("secret")
def test_public_read_only_browser_access_uses_guest_wire_role_zero(self):
# Public readers are GUEST (0), distinct from an explicitly assigned
# READ_ONLY (1) ACL role. The live board exposed this distinction.
acceptance = hil.Acceptance.__new__(hil.Acceptance)
acceptance.private = {"room_public_read_only": True}
actor = mock.Mock()
actor.status.return_value = hil.Reply(200, {"role": 0}, [], 0, 0)
acceptance.room_empty(actor)
acceptance.room_wrong(actor, "wrong-password")
actor.status.return_value = hil.Reply(200, {"role": 1}, [], 0, 0)
with self.assertRaisesRegex(hil.CheckFailure, "public_read_only_access"):
acceptance.room_empty(actor)
def test_summary_and_report_exclude_private_inputs_and_mark_hash_as_external(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
identity, private, report = [directory / name for name in
("identity.json", "private.json", "report.json")]
identity.write_text(json.dumps(manifest()))
private.write_text(json.dumps({"admin_password": "never-output-password",
"source_ips": ["192.0.2.20", "192.0.2.21"]}))
private.chmod(0o600)
output = io.StringIO()
with contextlib.redirect_stdout(output), \
mock.patch.object(hil, "resolve_endpoint", return_value={}), \
mock.patch.object(hil, "Acceptance"):
result = hil.main(["--endpoint", "http://192.0.2.10", "--manifest", str(identity),
"--secrets-file", str(private), "--run-id", "private-output-test",
"--scenario", "webconfig", "--allow-auth-attempts",
"--report", str(report)])
self.assertEqual(result, 0)
for text in (output.getvalue(), report.read_text()):
for sensitive in ("never-output-password", "192.0.2.10", "192.0.2.20",
"192.0.2.21", "private-node-name", "0123456789abcdef"):
self.assertNotIn(sensitive, text)
self.assertFalse(json.loads(report.read_text())["live_flash_hash_verified"])
# A report path is an exclusive run artifact, never silently replaced.
with contextlib.redirect_stdout(io.StringIO()), \
mock.patch.object(hil, "resolve_endpoint") as resolve:
result = hil.main(["--endpoint", "http://192.0.2.10", "--manifest", str(identity),
"--secrets-file", str(private), "--run-id", "private-output-test",
"--scenario", "webconfig", "--allow-auth-attempts",
"--report", str(report)])
self.assertEqual(result, 1)
resolve.assert_not_called()
class BrowserFaviconRedirectTests(unittest.TestCase):
origin = "http://127.0.0.1:19081"
def page(self, *, setup=True, approve=True):
with mock.patch.object(browser_hil.CDP, "__init__", return_value=None), \
mock.patch.object(browser_hil.PassiveCDP, "call"):
page = browser_hil.PassiveCDP(None, 1, Path("/inert"), self.origin, setup_mode=setup)
if approve:
page.network("Network.requestWillBeSent", {"requestId": "favicon-1", "request": {
"url": self.origin + "/favicon.ico", "method": "GET", "headers": {}}})
return page
def redirect(self):
return {"requestId": "favicon-1", "request": {
"url": "http://192.168.4.1/", "method": "GET", "headers": {}},
"redirectResponse": {"url": self.origin + "/favicon.ico", "status": 302,
"headers": {"Location": "http://192.168.4.1/"}}}
def test_setup_favicon_exact_chain_is_passive_and_pending_is_not_finished(self):
page = self.page()
page.network("Network.requestWillBeSent", self.redirect())
source, target = page.requests
self.assertEqual(source["status"], 302)
self.assertTrue(source["redirected"])
self.assertFalse(source["finished"])
self.assertEqual(target["path"], "captive_favicon_root")
self.assertEqual(target["redirect_from_ordinal"], source["ordinal"])
self.assertEqual(page.unexpected_requests, 0)
self.assertEqual(page.write_requests, 0)
self.assertEqual(page.favicon_redirect_evidence(), [{
"from_ordinal": 1, "to_ordinal": 2, "redirect_status": 302,
"followup_state": "pending", "followup_finished": False, "followup_failed": False}])
for private in (self.origin, "192.168.4.1", "Location"):
self.assertNotIn(private, json.dumps(page.requests + page.favicon_redirect_evidence()))
def test_unused_favicon_failure_is_recorded_as_failed_not_drained(self):
page = self.page()
page.network("Network.requestWillBeSent", self.redirect())
page.network("Network.loadingFailed", {"requestId": "favicon-1", "errorText": "net::ERR_ADDRESS_UNREACHABLE"})
item = page.favicon_redirect_evidence()[0]
self.assertEqual(item["followup_state"], "failed")
self.assertTrue(item["followup_failed"])
self.assertFalse(item["followup_finished"])
self.assertEqual(page.requests[1]["failure_class"], "net::ERR_ADDRESS_UNREACHABLE")
def test_completed_favicon_response_preserves_both_statuses(self):
page = self.page()
page.network("Network.requestWillBeSent", self.redirect())
page.network("Network.responseReceived", {"requestId": "favicon-1", "response": {"status": 200}})
page.network("Network.loadingFinished", {"requestId": "favicon-1", "encodedDataLength": 100})
self.assertEqual([row["status"] for row in page.requests], [302, 200])
self.assertEqual(page.favicon_redirect_evidence()[0]["followup_state"], "finished")
def test_redirect_requires_setup_mode_and_same_preapproved_request(self):
for setup, approve, identifier in ((False, True, "favicon-1"),
(True, False, "favicon-1"),
(True, True, "another-request")):
with self.subTest(setup=setup, approve=approve, identifier=identifier):
page = self.page(setup=setup, approve=approve)
event = self.redirect()
event["requestId"] = identifier
with self.assertRaisesRegex(hil.CheckFailure, "unexpected_browser_request"):
page.network("Network.requestWillBeSent", event)
self.assertEqual(page.favicon_redirect_evidence(), [])
def test_wrong_location_source_status_destination_or_write_is_rejected(self):
variations = [
("response", "url", self.origin + "/api/status"),
("response", "status", 200),
("response", "headers", {}),
("response", "headers", {"Location": "http://192.168.4.2/"}),
("response", "headers", {"Location": "http://192.168.4.1/?other"}),
("response", "headers", {"Location": "http://192.168.4.1/", "location": "http://192.168.4.1/"}),
("request", "url", "http://192.168.4.1/?other"),
("request", "url", "http://192.168.4.1/#other"),
("request", "url", "http://192.168.4.1/api/status"),
("request", "url", self.origin + "/room"),
("request", "url", self.origin + "/api/status"),
("request", "method", "POST"),
("request", "postData", "unexpected-body"),
]
for target, key, value in variations:
with self.subTest(target=target, key=key, value=value):
page = self.page()
event = self.redirect()
event["redirectResponse" if target == "response" else "request"][key] = value
with self.assertRaisesRegex(hil.CheckFailure, "unexpected_browser_request"):
page.network("Network.requestWillBeSent", event)
self.assertEqual(page.favicon_redirect_evidence(), [])
def test_missing_redirect_metadata_terminal_prior_or_second_hop_is_rejected(self):
for case in ("missing-response", "prior-failed", "prior-finished", "second-hop"):
with self.subTest(case=case):
page = self.page()
event = self.redirect()
if case == "missing-response":
del event["redirectResponse"]
elif case == "prior-failed":
page.network("Network.loadingFailed", {"requestId": "favicon-1"})
elif case == "prior-finished":
page.network("Network.loadingFinished", {"requestId": "favicon-1"})
else:
page.network("Network.requestWillBeSent", event)
with self.assertRaisesRegex(hil.CheckFailure, "unexpected_browser_request"):
page.network("Network.requestWillBeSent", event)
class BrowserStartupTests(unittest.TestCase):
def websocket(self):
return SimpleNamespace(
WebSocketTimeoutException=type("SocketTimeout", (Exception,), {}),
WebSocketConnectionClosedException=type("SocketClosed", (Exception,), {}))
def test_partial_observer_socket_is_closed_after_enable_failure(self):
websocket = self.websocket()
for failed_method in ("Log.enable", "Network.enable"):
with self.subTest(failed_method=failed_method):
socket = mock.Mock()
def initialize(page, *args):
page.socket = socket
def enable(_page, method, *args):
if method == failed_method:
raise websocket.WebSocketConnectionClosedException()
with mock.patch.object(browser_hil.CDP, "__init__", initialize), \
mock.patch.object(browser_hil.PassiveCDP, "call", enable), \
self.assertRaises(websocket.WebSocketConnectionClosedException):
browser_hil.PassiveCDP(websocket, 12345, Path("/owned"), "http://127.0.0.1:1234")
socket.close.assert_called_once_with()
def test_owned_page_startup_retries_both_transient_websocket_errors(self):
websocket = self.websocket()
page = object()
process = SimpleNamespace(poll=lambda: None)
with mock.patch.object(browser_hil, "PassiveCDP", side_effect=[
websocket.WebSocketTimeoutException(),
websocket.WebSocketConnectionClosedException(), page]) as attach, \
mock.patch.object(browser_hil.time, "sleep") as pause:
actual = browser_hil.wait_for_room_browser(
websocket, 12345, Path("/owned"), "http://127.0.0.1:1234", process, setup_mode=True)
self.assertIs(actual, page)
self.assertEqual(attach.call_count, 3)
self.assertTrue(all(call.kwargs == {"setup_mode": True} for call in attach.call_args_list))
self.assertEqual(pause.call_count, 2)
def test_ownership_refusal_is_not_retried(self):
with mock.patch.object(browser_hil, "PassiveCDP", side_effect=hil.CheckFailure(
"CDP_browser_does_not_match_owned_profile")) as attach, \
mock.patch.object(browser_hil.time, "sleep") as pause, \
self.assertRaisesRegex(hil.CheckFailure, "does_not_match_owned_profile"):
browser_hil.wait_for_room_browser(self.websocket(), 12345, Path("/owned"),
"http://127.0.0.1:1234",
SimpleNamespace(poll=lambda: None))
attach.assert_called_once()
pause.assert_not_called()
if __name__ == "__main__":
unittest.main()