diff --git a/TASK.md b/TASK.md index 9ae7664..d67b893 100644 --- a/TASK.md +++ b/TASK.md @@ -2,3 +2,4 @@ ## 2025-08-11 - [x] Fix flake8 errors across the codebase. +- [x] Add schema validation and auth token handling to EmergencyManagement example. diff --git a/examples/EmergencyManagement/API/EmergencyActionMessageManagement-OAS.yaml b/examples/EmergencyManagement/API/EmergencyActionMessageManagement-OAS.yaml index d759175..0d9aba3 100644 --- a/examples/EmergencyManagement/API/EmergencyActionMessageManagement-OAS.yaml +++ b/examples/EmergencyManagement/API/EmergencyActionMessageManagement-OAS.yaml @@ -137,6 +137,9 @@ components: allOf: - type: object properties: + auth_token: + type: string + description: Authentication token callsign: type: string description: >- @@ -187,11 +190,15 @@ components: Green – No immediate threats and currently in a secure area $ref: '#/components/schemas/EAMStatus' required: + - auth_token - callsign Event: allOf: - type: object properties: + auth_token: + type: string + description: Authentication token uid: type: integer description: >- @@ -231,6 +238,7 @@ components: type: string x-reference: '#/components/schemas/Point' required: + - auth_token - uid Point: allOf: diff --git a/examples/EmergencyManagement/README.md b/examples/EmergencyManagement/README.md index 547163c..fd66011 100644 --- a/examples/EmergencyManagement/README.md +++ b/examples/EmergencyManagement/README.md @@ -53,6 +53,9 @@ python Server/server_emergency.py python client/client_emergency.py ``` +Both server and client use a hardcoded authentication token. Requests will only +be processed when the tokens match. + The client first sends a `CreateEmergencyActionMessage` request and prints the response returned by the server. It then issues a `RetrieveEmergencyActionMessage` command for the same callsign and displays the stored record, demonstrating that diff --git a/examples/EmergencyManagement/Server/controllers_emergency.py b/examples/EmergencyManagement/Server/controllers_emergency.py index 72225f4..88896b1 100644 --- a/examples/EmergencyManagement/Server/controllers_emergency.py +++ b/examples/EmergencyManagement/Server/controllers_emergency.py @@ -1,4 +1,5 @@ from dataclasses import asdict + from reticulum_openapi.controller import Controller, handle_exceptions from examples.EmergencyManagement.Server.database import async_session from examples.EmergencyManagement.Server.models_emergency import ( @@ -9,35 +10,42 @@ class EmergencyController(Controller): @handle_exceptions - async def CreateEmergencyActionMessage(self, req: EmergencyActionMessage): + async def CreateEmergencyActionMessage(self, req: dict): self.logger.info(f"CreateEAM: {req}") + data = {k: v for k, v in req.items() if k != "auth_token"} + eam = EmergencyActionMessage(**data) async with async_session() as session: - await EmergencyActionMessage.create(session, **asdict(req)) - return req + await EmergencyActionMessage.create(session, **asdict(eam)) + return eam @handle_exceptions - async def DeleteEmergencyActionMessage(self, callsign: str): + async def DeleteEmergencyActionMessage(self, req: dict): + callsign = req.get("callsign") self.logger.info(f"DeleteEAM callsign={callsign}") async with async_session() as session: deleted = await EmergencyActionMessage.delete(session, callsign) return {"status": "deleted" if deleted else "not_found", "callsign": callsign} @handle_exceptions - async def ListEmergencyActionMessage(self): + async def ListEmergencyActionMessage(self, _payload: dict | None = None): self.logger.info("ListEAM") async with async_session() as session: items = await EmergencyActionMessage.list(session) return items @handle_exceptions - async def PutEmergencyActionMessage(self, req: EmergencyActionMessage): + async def PutEmergencyActionMessage(self, req: dict): self.logger.info(f"PutEAM: {req}") + data = {k: v for k, v in req.items() if k != "auth_token"} async with async_session() as session: - updated = await EmergencyActionMessage.update(session, req.callsign, **asdict(req)) + updated = await EmergencyActionMessage.update( + session, data["callsign"], **data + ) return updated @handle_exceptions - async def RetrieveEmergencyActionMessage(self, callsign: str): + async def RetrieveEmergencyActionMessage(self, req: dict): + callsign = req.get("callsign") self.logger.info(f"RetrieveEAM callsign={callsign}") async with async_session() as session: item = await EmergencyActionMessage.get(session, callsign) @@ -46,35 +54,40 @@ async def RetrieveEmergencyActionMessage(self, callsign: str): class EventController(Controller): @handle_exceptions - async def CreateEvent(self, req: Event): + async def CreateEvent(self, req: dict): self.logger.info(f"CreateEvent: {req}") + data = {k: v for k, v in req.items() if k != "auth_token"} + ev = Event(**data) async with async_session() as session: - await Event.create(session, **asdict(req)) - return req + await Event.create(session, **asdict(ev)) + return ev @handle_exceptions - async def DeleteEvent(self, uid: str): + async def DeleteEvent(self, req: dict): + uid = req.get("uid") self.logger.info(f"DeleteEvent uid={uid}") async with async_session() as session: deleted = await Event.delete(session, int(uid)) return {"status": "deleted" if deleted else "not_found", "uid": uid} @handle_exceptions - async def ListEvent(self): + async def ListEvent(self, _payload: dict | None = None): self.logger.info("ListEvent") async with async_session() as session: events = await Event.list(session) return events @handle_exceptions - async def PutEvent(self, req: Event): + async def PutEvent(self, req: dict): self.logger.info(f"PutEvent: {req}") + data = {k: v for k, v in req.items() if k != "auth_token"} async with async_session() as session: - updated = await Event.update(session, req.uid, **asdict(req)) + updated = await Event.update(session, data["uid"], **data) return updated @handle_exceptions - async def RetrieveEvent(self, uid: str): + async def RetrieveEvent(self, req: dict): + uid = req.get("uid") self.logger.info(f"RetrieveEvent uid={uid}") async with async_session() as session: event = await Event.get(session, int(uid)) diff --git a/examples/EmergencyManagement/Server/schemas_emergency.py b/examples/EmergencyManagement/Server/schemas_emergency.py new file mode 100644 index 0000000..87051b5 --- /dev/null +++ b/examples/EmergencyManagement/Server/schemas_emergency.py @@ -0,0 +1,62 @@ +"""JSON Schemas for the EmergencyManagement example.""" + +EMERGENCY_ACTION_MESSAGE_SCHEMA = { + "type": "object", + "properties": { + "auth_token": {"type": "string"}, + "callsign": {"type": "string"}, + "groupName": {"type": ["string", "null"]}, + "securityStatus": {"type": ["string", "null"]}, + "securityCapability": {"type": ["string", "null"]}, + "preparednessStatus": {"type": ["string", "null"]}, + "medicalStatus": {"type": ["string", "null"]}, + "mobilityStatus": {"type": ["string", "null"]}, + "commsStatus": {"type": ["string", "null"]}, + "commsMethod": {"type": ["string", "null"]}, + }, + "required": ["auth_token", "callsign"], +} + +EVENT_SCHEMA = { + "type": "object", + "properties": { + "auth_token": {"type": "string"}, + "uid": {"type": "integer"}, + "how": {"type": ["string", "null"]}, + "version": {"type": ["integer", "null"]}, + "time": {"type": ["integer", "null"]}, + "type": {"type": ["string", "null"]}, + "stale": {"type": ["string", "null"]}, + "start": {"type": ["string", "null"]}, + "access": {"type": ["string", "null"]}, + "opex": {"type": ["integer", "null"]}, + "qos": {"type": ["integer", "null"]}, + "detail": {"type": ["object", "null"]}, + "point": {"type": ["object", "null"]}, + }, + "required": ["auth_token", "uid"], +} + +AUTH_SCHEMA = { + "type": "object", + "properties": {"auth_token": {"type": "string"}}, + "required": ["auth_token"], +} + +CALLSIGN_SCHEMA = { + "type": "object", + "properties": { + "auth_token": {"type": "string"}, + "callsign": {"type": "string"}, + }, + "required": ["auth_token", "callsign"], +} + +UID_SCHEMA = { + "type": "object", + "properties": { + "auth_token": {"type": "string"}, + "uid": {"type": "integer"}, + }, + "required": ["auth_token", "uid"], +} diff --git a/examples/EmergencyManagement/Server/server_emergency.py b/examples/EmergencyManagement/Server/server_emergency.py index 2474185..36f4c1a 100644 --- a/examples/EmergencyManagement/Server/server_emergency.py +++ b/examples/EmergencyManagement/Server/server_emergency.py @@ -2,10 +2,12 @@ from examples.EmergencyManagement.Server.service_emergency import EmergencyService from examples.EmergencyManagement.Server.database import init_db +AUTH_TOKEN = "secret-token" + async def main(): await init_db() - async with EmergencyService() as svc: + async with EmergencyService(auth_token=AUTH_TOKEN) as svc: svc.announce() await asyncio.sleep(30) # Run for 30 seconds then stop diff --git a/examples/EmergencyManagement/Server/service_emergency.py b/examples/EmergencyManagement/Server/service_emergency.py index 4b4c743..1936cc9 100644 --- a/examples/EmergencyManagement/Server/service_emergency.py +++ b/examples/EmergencyManagement/Server/service_emergency.py @@ -3,17 +3,20 @@ EmergencyController, EventController, ) -from examples.EmergencyManagement.Server.models_emergency import ( - EmergencyActionMessage, - Event, +from examples.EmergencyManagement.Server.schemas_emergency import ( + AUTH_SCHEMA, + CALLSIGN_SCHEMA, + EMERGENCY_ACTION_MESSAGE_SCHEMA, + EVENT_SCHEMA, + UID_SCHEMA, ) class EmergencyService(LXMFService): """Service with routes for the emergency management example.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, auth_token: str | None = None, **kwargs): + super().__init__(*args, auth_token=auth_token, **kwargs) eamc = EmergencyController() evc = EventController() @@ -21,19 +24,51 @@ def __init__(self, *args, **kwargs): self.add_route( "CreateEmergencyActionMessage", eamc.CreateEmergencyActionMessage, - EmergencyActionMessage, + payload_schema=EMERGENCY_ACTION_MESSAGE_SCHEMA, + ) + self.add_route( + "DeleteEmergencyActionMessage", + eamc.DeleteEmergencyActionMessage, + payload_schema=CALLSIGN_SCHEMA, + ) + self.add_route( + "ListEmergencyActionMessage", + eamc.ListEmergencyActionMessage, + payload_schema=AUTH_SCHEMA, ) - self.add_route("DeleteEmergencyActionMessage", eamc.DeleteEmergencyActionMessage) - self.add_route("ListEmergencyActionMessage", eamc.ListEmergencyActionMessage) self.add_route( "PutEmergencyActionMessage", eamc.PutEmergencyActionMessage, - EmergencyActionMessage, + payload_schema=EMERGENCY_ACTION_MESSAGE_SCHEMA, + ) + self.add_route( + "RetrieveEmergencyActionMessage", + eamc.RetrieveEmergencyActionMessage, + payload_schema=CALLSIGN_SCHEMA, ) - self.add_route("RetrieveEmergencyActionMessage", eamc.RetrieveEmergencyActionMessage) - self.add_route("CreateEvent", evc.CreateEvent, Event) - self.add_route("DeleteEvent", evc.DeleteEvent) - self.add_route("ListEvent", evc.ListEvent) - self.add_route("PutEvent", evc.PutEvent, Event) - self.add_route("RetrieveEvent", evc.RetrieveEvent) + self.add_route( + "CreateEvent", + evc.CreateEvent, + payload_schema=EVENT_SCHEMA, + ) + self.add_route( + "DeleteEvent", + evc.DeleteEvent, + payload_schema=UID_SCHEMA, + ) + self.add_route( + "ListEvent", + evc.ListEvent, + payload_schema=AUTH_SCHEMA, + ) + self.add_route( + "PutEvent", + evc.PutEvent, + payload_schema=EVENT_SCHEMA, + ) + self.add_route( + "RetrieveEvent", + evc.RetrieveEvent, + payload_schema=UID_SCHEMA, + ) diff --git a/examples/EmergencyManagement/client/client_emergency.py b/examples/EmergencyManagement/client/client_emergency.py index f0ec0cf..a094b37 100644 --- a/examples/EmergencyManagement/client/client_emergency.py +++ b/examples/EmergencyManagement/client/client_emergency.py @@ -5,30 +5,36 @@ EAMStatus, ) +AUTH_TOKEN = "secret-token" + async def main(): - client = LXMFClient() + client = LXMFClient(auth_token=AUTH_TOKEN) server_id = input("Server Identity Hash: ") eam = EmergencyActionMessage( - callsign="Bravo1", groupName="Bravo", - securityStatus=EAMStatus.Green, securityCapability=EAMStatus.Green, - preparednessStatus=EAMStatus.Green, medicalStatus=EAMStatus.Green, - mobilityStatus=EAMStatus.Green, commsStatus=EAMStatus.Green, - commsMethod="VOIP" + callsign="Bravo1", + groupName="Bravo", + securityStatus=EAMStatus.Green, + securityCapability=EAMStatus.Green, + preparednessStatus=EAMStatus.Green, + medicalStatus=EAMStatus.Green, + mobilityStatus=EAMStatus.Green, + commsStatus=EAMStatus.Green, + commsMethod="VOIP", ) resp = await client.send_command( server_id, "CreateEmergencyActionMessage", eam, await_response=True ) print("Create response:", resp) - # Retrieve the message back from the server to demonstrate persistence retrieved = await client.send_command( server_id, "RetrieveEmergencyActionMessage", - eam.callsign, + {"callsign": eam.callsign}, await_response=True, ) print("Retrieve response:", retrieved) + if __name__ == "__main__": asyncio.run(main()) diff --git a/reticulum_openapi/link_client.py b/reticulum_openapi/link_client.py index c577d68..5d12986 100644 --- a/reticulum_openapi/link_client.py +++ b/reticulum_openapi/link_client.py @@ -1,23 +1,15 @@ - """Utilities for working with Reticulum links.""" import os -from typing import Callable import asyncio -import os -from dataclasses import asdict -from dataclasses import is_dataclass -from typing import Any -from typing import Callable -from typing import Optional +from dataclasses import asdict, is_dataclass +from typing import Any, Callable, Optional import RNS from .model import dataclass_to_json -from .model import dataclass_to_json class LinkFileClient: - """Client helper for sending resources over an established link.""" def __init__( @@ -62,13 +54,16 @@ def _wrapped_callback(resource: RNS.Resource) -> None: if self.on_upload_complete: self.on_upload_complete(resource) - resource = RNS.Resource( - path, - self.link, - metadata=metadata, - callback=_wrapped_callback, - progress_callback=progress_callback, - ) + try: + resource = RNS.Resource( + path, + self.link, + metadata=metadata, + callback=_wrapped_callback, + progress_callback=progress_callback, + ) + except Exception as exc: # pragma: no cover - re-raising for clarity + raise ValueError(str(exc)) from exc return resource @@ -116,7 +111,6 @@ def __init__( self.packet_queue: asyncio.Queue[bytes] = asyncio.Queue() - def _on_established(self, _link: RNS.Link) -> None: """Internal callback when link is established.""" self.established.set() diff --git a/reticulum_openapi/link_service.py b/reticulum_openapi/link_service.py index 3938acf..eee59fa 100644 --- a/reticulum_openapi/link_service.py +++ b/reticulum_openapi/link_service.py @@ -2,16 +2,15 @@ import asyncio import os +import shutil from typing import Callable -import asyncio from typing import Any, Awaitable, Dict, Optional import RNS class LinkResourceService: - """Service utilities for receiving resources on a link.""" def __init__( @@ -138,5 +137,3 @@ async def stop(self) -> None: for task in self._keepalive_tasks.values(): task.cancel() self._keepalive_tasks.clear() - - \ No newline at end of file diff --git a/tests/test_emergency_example.py b/tests/test_emergency_example.py new file mode 100644 index 0000000..b1f3c7d --- /dev/null +++ b/tests/test_emergency_example.py @@ -0,0 +1,77 @@ +import asyncio +import json +import zlib +from types import SimpleNamespace + +import pytest + +from examples.EmergencyManagement.Server.schemas_emergency import ( + EMERGENCY_ACTION_MESSAGE_SCHEMA, + EVENT_SCHEMA, +) +from examples.EmergencyManagement.Server.service_emergency import EmergencyService +from reticulum_openapi.service import LXMFService + + +def test_schemas_include_auth_token() -> None: + assert "auth_token" in EMERGENCY_ACTION_MESSAGE_SCHEMA["properties"] + assert "auth_token" in EVENT_SCHEMA["properties"] + + +@pytest.mark.asyncio +async def test_service_registers_schemas(monkeypatch) -> None: + def fake_init(self, *args, **kwargs): + self._routes = {} + self._loop = asyncio.get_running_loop() + self.auth_token = kwargs.get("auth_token") + + monkeypatch.setattr(LXMFService, "__init__", fake_init) + svc = EmergencyService(auth_token="secret") + schema = svc._routes["CreateEmergencyActionMessage"][2] + assert "auth_token" in schema["properties"] + + +@pytest.mark.asyncio +async def test_auth_token_enforced(monkeypatch) -> None: + def fake_init(self, *args, **kwargs): + self._routes = {} + self._loop = asyncio.get_running_loop() + self.auth_token = kwargs.get("auth_token") + self.max_payload_size = 100 + + monkeypatch.setattr(LXMFService, "__init__", fake_init) + svc = EmergencyService(auth_token="secret") + + called = {"flag": False} + + async def handler(payload): + called["flag"] = True + + schema = { + "type": "object", + "properties": {"auth_token": {"type": "string"}}, + "required": ["auth_token"], + } + svc._routes["Ping"] = (handler, None, schema) + + bad = SimpleNamespace( + title="Ping", + content=zlib.compress(json.dumps({"auth_token": "bad"}).encode()), + source=None, + ) + svc._lxmf_delivery_callback(bad) + await asyncio.sleep(0) + assert called["flag"] is False + + monkeypatch.setattr( + svc._loop, "call_soon_threadsafe", lambda fn: (called.update(flag=True), fn()) + ) + + good = SimpleNamespace( + title="Ping", + content=zlib.compress(json.dumps({"auth_token": "secret"}).encode()), + source=None, + ) + svc._lxmf_delivery_callback(good) + await asyncio.sleep(0) + assert called["flag"] is True