You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ADN-DMR-Peer-Server/tests/infrastructure/test_proxy_self_service.py

254 lines
8.4 KiB

# ADN DMR Peer Server - tests infrastructure proxy self service
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Integrated proxy self-service DB hooks (legacy adn-proxy parity)."""
from __future__ import annotations
from twisted.internet.defer import Deferred
from adn_server.application.proxy import ProxyUseCases
from adn_server.domain import bytes_4
from adn_server.domain.proxy import ClientEndpoint
from adn_server.infrastructure.hbp_constants import RPTC, RPTO
from adn_server.infrastructure.proxy.null_self_service import NullProxySelfServiceStore
from adn_server.infrastructure.proxy.rpto_queue import InMemoryPendingRptoQueue
from adn_server.infrastructure.proxy.self_service_bridge import ProxySelfServiceBridge
from adn_server.infrastructure.proxy.self_service_config import self_service_settings
from adn_server.infrastructure.proxy.slot_store import InMemoryProxySlotStore
class _RecordingSink:
def __init__(self) -> None:
self.injected: list[tuple[bytes, tuple[str, int]]] = []
def inject(self, data: bytes, client_addr: tuple[str, int]) -> None:
self.injected.append((data, client_addr))
class _RecordingSender:
def __init__(self) -> None:
self.sent: list[tuple[bytes, ClientEndpoint]] = []
def send_to_client(self, data: bytes, client: ClientEndpoint) -> None:
self.sent.append((data, client))
class _FakeStore(NullProxySelfServiceStore):
def __init__(self) -> None:
self.actions: list[tuple[str, bytes]] = []
self.options_by_peer: dict[bytes, str] = {}
self.pending_modified: list[tuple[bytes, str]] = []
def ins_conf(
self,
int_id: int,
peer_id_bytes: bytes,
callsign: str,
host: str,
mode: str,
) -> None:
self.actions.append(("ins_conf", peer_id_bytes))
def updt_tbl(
self,
action: str,
peer_id_bytes: bytes,
*,
psswd: str | None = None,
) -> None:
self.actions.append((action, peer_id_bytes))
def slct_opt(self, peer_id_bytes: bytes):
from twisted.internet.defer import succeed
opt = self.options_by_peer.get(peer_id_bytes)
if not opt:
return succeed([])
return succeed([(opt,)])
def slct_db(self):
from twisted.internet.defer import succeed
return succeed([(pid, opt) for pid, opt in self.pending_modified])
def _bridge() -> tuple[ProxySelfServiceBridge, _RecordingSink, _FakeStore, _RecordingSender]:
store = _FakeStore()
sink = _RecordingSink()
sender = _RecordingSender()
use_cases = ProxyUseCases(InMemoryProxySlotStore(), InMemoryPendingRptoQueue(), max_peers=4)
peer = bytes_4(7300444)
use_cases.attach_client(peer, "192.168.1.10", 62031)
bridge = ProxySelfServiceBridge(
store,
use_cases,
sink,
sender,
pbkdf2_salt="ADN",
pbkdf2_iterations=2000,
)
return bridge, sink, store, sender
def _run_deferred(d: Deferred) -> None:
results: list[object] = []
d.addBoth(lambda x: results.append(x) or x)
assert results, "deferred did not fire synchronously"
def test_self_service_settings_reads_database_and_self_service_keys() -> None:
cfg = {
"DATABASE": {
"DB_SERVER": "localhost",
"DB_USERNAME": "hbmon",
"DB_PASSWORD": "secret",
"DB_NAME": "hbmon",
"DB_PORT": 3306,
},
"SELF_SERVICE": {
"USE_SELFSERVICE": True,
"PBKDF2_SALT": "ADN",
"PBKDF2_ITERATIONS": 2000,
},
}
ss = self_service_settings(cfg)
assert ss["enabled"] is True
assert ss["db_name"] == "hbmon"
assert ss["pbkdf2_iterations"] == 2000
def test_rptc_login_opt_skips_without_pass() -> None:
bridge, sink, store, _sender = _bridge()
peer = bytes_4(7300444)
store.options_by_peer[peer] = "TS2=730444;"
packet = RPTC + peer + b"CE1ILI " + b"\x00" * 85 + b"4"
bridge.before_inject(packet, ("192.168.1.10", 62031), peer)
assert ("ins_conf", peer) in store.actions
_run_deferred(bridge._login_opt(peer))
assert sink.injected == []
def test_rptc_after_pass_reinjects_rpto() -> None:
"""RPTC after PASS= re-fetches OPTIONS once ins_conf has run (logged_in in DB)."""
bridge, sink, store, _sender = _bridge()
peer = bytes_4(7300444)
store.options_by_peer[peer] = "TS2=730444;"
bridge.before_inject(
RPTO + peer + b"PASS=secret123",
("192.168.1.10", 62031),
peer,
)
assert len(sink.injected) == 1
packet = RPTC + peer + b"CE1ILI " + b"\x00" * 85 + b"4"
bridge.before_inject(packet, ("192.168.1.10", 62031), peer)
assert ("ins_conf", peer) in store.actions
assert len(sink.injected) == 2
assert sink.injected[-1][0] == RPTO + peer + b"TS2=730444;"
def test_rpto_pass_stores_password_and_skips_inject() -> None:
bridge, sink, store, sender = _bridge()
peer = bytes_4(7300444)
packet = RPTO + peer + b"PASS=secret123"
skip = bridge.before_inject(packet, ("192.168.1.10", 62031), peer)
assert skip is True
assert sink.injected == []
assert ("psswd", peer) in store.actions
assert sender.sent and sender.sent[0][0][:6] == b"RPTACK"
def test_rpto_pass_fetches_options_immediately() -> None:
"""PASS= triggers MySQL slct_opt + RPTO inject without waiting for RPTC/10s timer."""
bridge, sink, store, sender = _bridge()
peer = bytes_4(7300444)
store.options_by_peer[peer] = "TS2=730444;SINGLE=1;"
skip = bridge.before_inject(
RPTO + peer + b"PASS=secret123",
("192.168.1.10", 62031),
peer,
)
assert skip is True
assert ("psswd", peer) in store.actions
assert sender.sent and sender.sent[0][0][:6] == b"RPTACK"
assert sink.injected
assert sink.injected[0][0] == RPTO + peer + b"TS2=730444;SINGLE=1;"
def test_send_opts_skips_without_pass() -> None:
bridge, sink, store, _sender = _bridge()
peer = bytes_4(7300444)
store.pending_modified = [(peer, "TS2=730444;")]
_run_deferred(bridge.send_opts())
assert ("rst_mod", peer) not in store.actions
assert sink.injected == []
def test_send_opts_pushes_modified_rows_after_pass() -> None:
bridge, sink, store, _sender = _bridge()
peer = bytes_4(7300444)
bridge.before_inject(
RPTO + peer + b"PASS=secret123",
("192.168.1.10", 62031),
peer,
)
store.pending_modified = [(peer, "TS2=730444;")]
_run_deferred(bridge.send_opts())
assert ("rst_mod", peer) in store.actions
assert sink.injected[0][0] == RPTO + peer + b"TS2=730444;"
def test_session_expired_logs_out() -> None:
bridge, _sink, store, _sender = _bridge()
peer = bytes_4(7300444)
bridge.on_session_expired(peer)
assert ("log_out", peer) in store.actions
def test_yaml_loader_preserves_self_service_block(tmp_path) -> None:
"""SELF_SERVICE from adn-server.yaml must reach runtime (not stripped at load)."""
from adn_server.infrastructure.config_loader import YamlConfigLoader
cfg_path = tmp_path / "adn-server.yaml"
cfg_path.write_text(
"""
GLOBAL: {}
PROXY:
LISTEN_PORT: 62031
TARGET_SYSTEM: SYSTEM
DATABASE:
DB_SERVER: localhost
DB_USERNAME: hbmon
DB_PASSWORD: secret
DB_NAME: hbmon
DB_PORT: 3306
SELF_SERVICE:
USE_SELFSERVICE: true
SYSTEMS:
SYSTEM:
MODE: MASTER
ENABLED: true
""",
encoding="utf-8",
)
config = YamlConfigLoader(tmp_path).load(str(cfg_path))
assert config["SELF_SERVICE"]["USE_SELFSERVICE"] is True
assert self_service_settings(config)["enabled"] is True

Powered by TurnKey Linux.