mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
The end-to-end tests asserted reply[7] == PERM_ACL_ADMIN, which follows
the constant wherever it goes — the one thing these tests exist to stop.
They now assert the literal bytes from openhop_core's acl_conformance
table, so both repos are pinned to the same numbers rather than to each
other's vocabulary. Checked out against 2a55dd6 with these tests copied
in, all seven fail.
Also cover the fail-closed import guard, which was the only untested
path protecting against a mixed-version install, plus its happy path so
a guard that always fires cannot pass unnoticed.
281 lines
9.6 KiB
Python
281 lines
9.6 KiB
Python
"""ACL role numbering must match upstream MeshCore firmware.
|
|
|
|
Firmware ``src/helpers/ClientACL.h`` puts the role in the LOW TWO BITS of the
|
|
permissions byte (GUEST=0, READ_ONLY=1, READ_WRITE=2, ADMIN=3) and derives
|
|
admin with ``(permissions & 3) == 3``. Every stock MeshCore client decodes the
|
|
login reply's ACL byte that way, so any divergence here shows up as an OpenHop
|
|
admin being rendered read-write/guest in third-party apps (openhop_repeater
|
|
issue #388).
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from openhop_core.protocol.acl_conformance import ROLE_MASK, ROLE_VALUES
|
|
|
|
from repeater.handler_helpers.acl import (
|
|
ACL,
|
|
PERM_ACL_ADMIN,
|
|
PERM_ACL_GUEST,
|
|
PERM_ACL_READ_ONLY,
|
|
PERM_ACL_READ_WRITE,
|
|
PERM_ACL_ROLE_MASK,
|
|
ClientInfo,
|
|
is_admin_permissions,
|
|
role_name,
|
|
role_of,
|
|
)
|
|
|
|
ROOM_CFG = {
|
|
"type": "room_server",
|
|
"settings": {"admin_password": "room-admin", "guest_password": "room-guest"},
|
|
}
|
|
|
|
|
|
class _Id:
|
|
def __init__(self, pubkey: bytes):
|
|
self._pubkey = pubkey
|
|
|
|
def get_public_key(self):
|
|
return self._pubkey
|
|
|
|
|
|
def test_role_constants_match_firmware_clientacl_h():
|
|
"""Literal pin against the firmware header, via the shared vector table."""
|
|
assert PERM_ACL_ROLE_MASK == ROLE_MASK == 0x03
|
|
assert PERM_ACL_GUEST == ROLE_VALUES["guest"] == 0x00
|
|
assert PERM_ACL_READ_ONLY == ROLE_VALUES["read_only"] == 0x01
|
|
assert PERM_ACL_READ_WRITE == ROLE_VALUES["read_write"] == 0x02
|
|
assert PERM_ACL_ADMIN == ROLE_VALUES["admin"] == 0x03
|
|
# Every role must be distinct; GUEST and READ_WRITE used to collide on 0x01.
|
|
roles = {PERM_ACL_GUEST, PERM_ACL_READ_ONLY, PERM_ACL_READ_WRITE, PERM_ACL_ADMIN}
|
|
assert len(roles) == 4
|
|
|
|
|
|
def test_roles_come_from_openhop_core_and_never_diverge():
|
|
"""We re-export core's values rather than keeping a second copy.
|
|
|
|
This must not skip on an old core. A core without these symbols encodes
|
|
the login reply's is_admin byte as ``permissions & 0x02``, which also
|
|
matches READ_WRITE — pairing it with our numbering would announce a room
|
|
server's read-write clients as admins. acl.py fails closed at import for
|
|
exactly that reason, and this asserts the contract it depends on.
|
|
"""
|
|
from openhop_core.protocol import constants
|
|
|
|
assert constants.PERM_ACL_ROLE_MASK is PERM_ACL_ROLE_MASK
|
|
assert constants.PERM_ACL_GUEST is PERM_ACL_GUEST
|
|
assert constants.PERM_ACL_READ_ONLY is PERM_ACL_READ_ONLY
|
|
assert constants.PERM_ACL_READ_WRITE is PERM_ACL_READ_WRITE
|
|
assert constants.PERM_ACL_ADMIN is PERM_ACL_ADMIN
|
|
assert is_admin_permissions is constants.acl_is_admin
|
|
assert role_of is constants.acl_role
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"permissions, expect_admin",
|
|
[
|
|
(PERM_ACL_GUEST, False),
|
|
(PERM_ACL_READ_ONLY, False),
|
|
(PERM_ACL_READ_WRITE, False), # regression: 0x02 bit test said True here
|
|
(PERM_ACL_ADMIN, True),
|
|
],
|
|
)
|
|
def test_is_admin_is_an_equality_test_not_a_bit_test(permissions, expect_admin):
|
|
assert is_admin_permissions(permissions) is expect_admin
|
|
assert ClientInfo(_Id(b"P" * 32), permissions).is_admin() is expect_admin
|
|
|
|
|
|
def test_reserved_upper_bits_do_not_change_the_role():
|
|
"""Bits above the role mask are reserved flags, not part of the role."""
|
|
assert is_admin_permissions(PERM_ACL_ADMIN | 0xFC) is True
|
|
assert is_admin_permissions(PERM_ACL_READ_WRITE | 0xFC) is False
|
|
assert role_of(PERM_ACL_READ_ONLY | 0xF0) == PERM_ACL_READ_ONLY
|
|
assert role_name(PERM_ACL_ADMIN | 0x80) == "admin"
|
|
|
|
|
|
def test_role_names():
|
|
assert role_name(PERM_ACL_GUEST) == "guest"
|
|
assert role_name(PERM_ACL_READ_ONLY) == "read_only"
|
|
assert role_name(PERM_ACL_READ_WRITE) == "read_write"
|
|
assert role_name(PERM_ACL_ADMIN) == "admin"
|
|
|
|
|
|
def test_admin_password_grants_admin_on_repeater_and_room_server():
|
|
repeater_acl = ACL(admin_password="rpt-admin", guest_password="rpt-guest")
|
|
ok, perms = repeater_acl.authenticate_client(
|
|
client_identity=_Id(b"A" * 32),
|
|
shared_secret=b"s" * 32,
|
|
password="rpt-admin",
|
|
timestamp=100,
|
|
)
|
|
assert (ok, perms) == (True, PERM_ACL_ADMIN)
|
|
|
|
room_acl = ACL()
|
|
ok, perms = room_acl.authenticate_client(
|
|
client_identity=_Id(b"B" * 32),
|
|
shared_secret=b"s" * 32,
|
|
password="room-admin",
|
|
timestamp=100,
|
|
target_identity_name="room-a",
|
|
target_identity_config=ROOM_CFG,
|
|
)
|
|
assert (ok, perms) == (True, PERM_ACL_ADMIN)
|
|
|
|
|
|
def test_guest_password_is_guest_on_a_repeater():
|
|
"""Repeater guests may fetch base telemetry but must not change settings.
|
|
|
|
Matches firmware simple_repeater handleLoginReq, which assigns
|
|
PERM_ACL_GUEST for the guest password.
|
|
"""
|
|
acl = ACL(admin_password="rpt-admin", guest_password="rpt-guest")
|
|
ok, perms = acl.authenticate_client(
|
|
client_identity=_Id(b"C" * 32),
|
|
shared_secret=b"s" * 32,
|
|
password="rpt-guest",
|
|
timestamp=100,
|
|
)
|
|
assert ok is True
|
|
assert perms == PERM_ACL_GUEST
|
|
client = acl.get_client(b"C" * 32)
|
|
assert client.is_admin() is False
|
|
assert client.is_guest() is True
|
|
|
|
|
|
def test_guest_password_is_read_write_on_a_room_server():
|
|
"""Room-server guests may post and read messages.
|
|
|
|
Matches firmware simple_room_server, which assigns PERM_ACL_READ_WRITE for
|
|
the room password and reserves PERM_ACL_GUEST for allow_read_only.
|
|
"""
|
|
acl = ACL()
|
|
ok, perms = acl.authenticate_client(
|
|
client_identity=_Id(b"D" * 32),
|
|
shared_secret=b"s" * 32,
|
|
password="room-guest",
|
|
timestamp=100,
|
|
target_identity_name="room-a",
|
|
target_identity_config=ROOM_CFG,
|
|
)
|
|
assert ok is True
|
|
assert perms == PERM_ACL_READ_WRITE
|
|
client = acl.get_client(b"D" * 32)
|
|
assert client.is_admin() is False
|
|
assert client.is_guest() is False # read-write, so posting is allowed
|
|
|
|
|
|
def test_blank_password_read_only_access_is_guest():
|
|
"""allow_read_only mirrors firmware's allow_read_only branch → GUEST."""
|
|
acl = ACL(allow_read_only=True)
|
|
ok, perms = acl.authenticate_client(
|
|
client_identity=_Id(b"E" * 32),
|
|
shared_secret=b"s" * 32,
|
|
password="",
|
|
timestamp=100,
|
|
)
|
|
assert (ok, perms) == (True, PERM_ACL_GUEST)
|
|
assert acl.get_client(b"E" * 32).is_guest() is True
|
|
|
|
|
|
def test_role_change_replaces_the_role_without_touching_reserved_bits():
|
|
acl = ACL(admin_password="rpt-admin", guest_password="rpt-guest")
|
|
identity = _Id(b"F" * 32)
|
|
|
|
ok, perms = acl.authenticate_client(
|
|
client_identity=identity,
|
|
shared_secret=b"s" * 32,
|
|
password="rpt-admin",
|
|
timestamp=100,
|
|
)
|
|
assert (ok, perms) == (True, PERM_ACL_ADMIN)
|
|
|
|
client = acl.get_client(b"F" * 32)
|
|
client.permissions |= 0x40 # a reserved flag set elsewhere
|
|
|
|
ok, perms = acl.authenticate_client(
|
|
client_identity=identity,
|
|
shared_secret=b"s" * 32,
|
|
password="rpt-guest",
|
|
timestamp=101,
|
|
)
|
|
assert ok is True
|
|
assert role_of(perms) == PERM_ACL_GUEST
|
|
assert perms & 0x40 # reserved flag preserved
|
|
assert client.is_admin() is False
|
|
|
|
|
|
def test_every_role_name_is_allowed_by_the_openapi_schema():
|
|
"""The ACL client list advertises an enum; every role we emit must be in it.
|
|
|
|
The role split added "read_write", which the schema did not previously
|
|
allow — generated clients and validators reject undeclared values.
|
|
"""
|
|
spec = yaml.safe_load(
|
|
(Path(__file__).resolve().parents[1] / "repeater/web/openapi.yaml").read_text()
|
|
)
|
|
enum = spec["components"]["schemas"]["ACLClient"]["properties"]["permissions"]["enum"]
|
|
|
|
emitted = {
|
|
role_name(PERM_ACL_GUEST),
|
|
role_name(PERM_ACL_READ_ONLY),
|
|
role_name(PERM_ACL_READ_WRITE),
|
|
role_name(PERM_ACL_ADMIN),
|
|
}
|
|
assert emitted <= set(enum), f"roles missing from the OpenAPI enum: {emitted - set(enum)}"
|
|
|
|
|
|
def test_acl_refuses_to_import_against_a_core_without_the_role_constants(monkeypatch):
|
|
"""The fail-closed guard must actually fire, with a message that helps.
|
|
|
|
A core lacking these symbols still builds the login reply's admin byte
|
|
from ``permissions & 0x02``, which would announce a room server's
|
|
READ_WRITE clients as admins. Refusing to import is the safe failure, and
|
|
it is worth a test because nothing else exercises that path.
|
|
"""
|
|
import importlib
|
|
import sys
|
|
import types
|
|
|
|
import openhop_core.protocol.constants as real_constants
|
|
|
|
stub = types.ModuleType("openhop_core.protocol.constants")
|
|
for name in dir(real_constants):
|
|
if not name.startswith("__"):
|
|
setattr(stub, name, getattr(real_constants, name))
|
|
for missing in (
|
|
"PERM_ACL_ROLE_MASK",
|
|
"PERM_ACL_GUEST",
|
|
"PERM_ACL_READ_ONLY",
|
|
"PERM_ACL_READ_WRITE",
|
|
"PERM_ACL_ADMIN",
|
|
"acl_is_admin",
|
|
"acl_role",
|
|
):
|
|
delattr(stub, missing)
|
|
|
|
monkeypatch.setitem(sys.modules, "openhop_core.protocol.constants", stub)
|
|
monkeypatch.delitem(sys.modules, "repeater.handler_helpers.acl")
|
|
|
|
with pytest.raises(ImportError) as exc:
|
|
importlib.import_module("repeater.handler_helpers.acl")
|
|
|
|
message = str(exc.value)
|
|
assert "openhop_core is too old" in message
|
|
assert "fix/login-perms" in message
|
|
|
|
|
|
def test_acl_imports_normally_against_the_installed_core():
|
|
"""Companion to the guard test: the happy path must still work.
|
|
|
|
Guards that always fire are worse than no guard, and the test above
|
|
manipulates sys.modules.
|
|
"""
|
|
import importlib
|
|
|
|
module = importlib.import_module("repeater.handler_helpers.acl")
|
|
assert module.PERM_ACL_ADMIN == 0x03
|
|
assert module.is_admin_permissions(0x03) is True
|