From 67464971bfd17dd21703007efd5cd61ec3487d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 11 Jul 2026 21:27:38 +0900 Subject: [PATCH] feat(edge): PostClient with HMAC+nonce signed event POST --- edge/alert.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_alert.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 edge/alert.py create mode 100644 tests/test_alert.py diff --git a/edge/alert.py b/edge/alert.py new file mode 100644 index 0000000..b2edb37 --- /dev/null +++ b/edge/alert.py @@ -0,0 +1,44 @@ +import base64 +import hashlib +import hmac +import json + +from edge.events import Event + + +def sign(secret: str, nonce: str, ts: str, body: bytes) -> str: + msg = nonce.encode() + ts.encode() + body + return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest() + + +class PostClient: + def __init__(self, url: str, device_id: str, secret: str, + nonce_fn, now_fn, http_post=None, timeout: float = 5.0): + self.url = url + self.device_id = device_id + self.secret = secret + self.nonce_fn = nonce_fn + self.now_fn = now_fn + self.timeout = timeout + if http_post is None: + import requests + http_post = requests.post + self._post = http_post + + def send(self, event: Event, snapshots: list[bytes]) -> bool: + payload = event.to_payload(self.device_id) + payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots] + body = json.dumps(payload, sort_keys=True) + nonce = self.nonce_fn() + ts = str(self.now_fn()) + headers = { + "Content-Type": "application/json", + "X-Nonce": nonce, + "X-Timestamp": ts, + "X-Signature": sign(self.secret, nonce, ts, body.encode()), + } + try: + resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout) + except Exception: + return False + return 200 <= resp.status_code < 300 diff --git a/tests/test_alert.py b/tests/test_alert.py new file mode 100644 index 0000000..a4241ea --- /dev/null +++ b/tests/test_alert.py @@ -0,0 +1,42 @@ +import json +from edge.events import Event, EventType, Severity +from edge.alert import sign, PostClient + +def _event(): + return Event(EventType.FALL, Severity.HIGH, "cam-01", 7, None, 1000.0, 3.0, + {"torso_angle": 78.0}, None) + +class _FakeResp: + def __init__(self, code): self.status_code = code + +def test_sign_is_deterministic(): + s1 = sign("secret", "n1", "1000", b"body") + s2 = sign("secret", "n1", "1000", b"body") + assert s1 == s2 and len(s1) == 64 + +def test_send_posts_signed_payload(): + captured = {} + def fake_post(url, data=None, headers=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + captured["body"] = data + return _FakeResp(200) + + client = PostClient("https://x/api/events", "pi-01", "secret", + nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, + http_post=fake_post) + ok = client.send(_event(), [b"\xff\xd8jpeg"]) + assert ok is True + assert captured["headers"]["X-Nonce"] == "n1" + assert captured["headers"]["X-Timestamp"] == "1000.0" + payload = json.loads(captured["body"]) + assert payload["device_id"] == "pi-01" + assert payload["event_type"] == "fall" + assert len(payload["snapshots"]) == 1 + expected = sign("secret", "n1", "1000.0", captured["body"].encode()) + assert captured["headers"]["X-Signature"] == expected + +def test_send_returns_false_on_error_code(): + client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, + http_post=lambda *a, **k: _FakeResp(500)) + assert client.send(_event(), []) is False