Two approved data-model changes. P1-9 — ingest idempotency. A POST can succeed server-side but time out client-side; the edge then re-queues and re-sends the same event, so the cloud stored duplicate alerts. The edge Event now carries a stable event_uuid (uuid4 hex) generated once at creation and preserved through the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL UNIQUE; EventValidator accepts/validates it; IngestController returns the already-stored event (200, dedup:true) on a repeat uuid, race-safe against the UNIQUE key (catches the insert-race PDOException and returns the winner). P3-2b — site-scoped access control. site_scope was stored in the session but never enforced, so any authenticated user could read every site's worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto events.site (server-derived from the authenticated device_id, never client-supplied). App\Security\SiteScope centralizes the rule (NULL scope = global operator). The event list filters in SQL (page(...,$site)); event detail + acknowledge and the snapshot proxy guard per-row and return 404 (not 403) cross-site so existence never leaks. Schema migration ALTERs and per-device/user provisioning UPDATEs are documented at the bottom of schema.sql. Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one row; list scoped to caller site; detail cross-site 404; snapshot cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing (4 skipped: PDO), pytest 158 passing (2 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
93 lines
3.4 KiB
Python
93 lines
3.4 KiB
Python
from edge.events import Event, EventType, Severity
|
|
from edge.queue import OfflineQueue
|
|
|
|
def _event(n=1):
|
|
return Event(EventType.FALL, Severity.HIGH, "cam-01", n, None, float(n), 3.0, {}, None)
|
|
|
|
class _Client:
|
|
def __init__(self, fail=False):
|
|
self.fail = fail
|
|
self.sent = []
|
|
def send(self, event, snapshots):
|
|
if self.fail:
|
|
return False
|
|
self.sent.append(event.track_id)
|
|
return True
|
|
|
|
def test_try_send_success_not_queued(tmp_path):
|
|
q = OfflineQueue(str(tmp_path / "q.db"), _Client())
|
|
assert q.try_send(_event(1), [b"x"]) is True
|
|
assert q.pending() == 0
|
|
|
|
def test_failure_is_queued(tmp_path):
|
|
q = OfflineQueue(str(tmp_path / "q.db"), _Client(fail=True))
|
|
assert q.try_send(_event(1), [b"x"]) is False
|
|
assert q.pending() == 1
|
|
|
|
def test_flush_resends_when_recovered(tmp_path):
|
|
client = _Client(fail=True)
|
|
q = OfflineQueue(str(tmp_path / "q.db"), client)
|
|
q.try_send(_event(1), [b"x"])
|
|
q.try_send(_event(2), [b"y"])
|
|
assert q.pending() == 2
|
|
client.fail = False
|
|
assert q.flush() == 2
|
|
assert q.pending() == 0
|
|
assert client.sent == [1, 2]
|
|
|
|
|
|
class _PoisonClient:
|
|
"""Rejects one specific event forever (e.g. a 400 the cloud will never accept)."""
|
|
def __init__(self, poison_id):
|
|
self.poison = poison_id
|
|
self.sent = []
|
|
def send(self, event, snapshots):
|
|
if event.track_id == self.poison:
|
|
return False
|
|
self.sent.append(event.track_id)
|
|
return True
|
|
|
|
|
|
def test_queue_is_bounded_and_keeps_newest(tmp_path):
|
|
# An extended outage (or a permanent misconfig) must not fill the SD card. The queue caps
|
|
# itself, evicting the OLDEST — a fresh fall alert matters more than a stale one.
|
|
q = OfflineQueue(str(tmp_path / "q.db"), _Client(fail=True), max_rows=5)
|
|
for n in range(20):
|
|
q.try_send(_event(n), [b"x"])
|
|
assert q.pending() == 5
|
|
q.client.fail = False
|
|
q.flush()
|
|
assert q.client.sent == [15, 16, 17, 18, 19] # the newest 5 survived
|
|
|
|
|
|
def test_event_uuid_survives_the_queue_roundtrip(tmp_path):
|
|
# A re-sent event (failed once -> queued -> flushed) must carry the SAME event_uuid so the
|
|
# cloud dedups it instead of storing a duplicate alert. This is the whole point of the key.
|
|
class _CaptureClient:
|
|
def __init__(self):
|
|
self.fail = True
|
|
self.uuids = []
|
|
def send(self, event, snapshots):
|
|
self.uuids.append(event.event_uuid)
|
|
return not self.fail
|
|
client = _CaptureClient()
|
|
q = OfflineQueue(str(tmp_path / "q.db"), client)
|
|
ev = _event(1)
|
|
q.try_send(ev, [b"x"]) # fails -> queued (1st send attempt)
|
|
client.fail = False
|
|
assert q.flush() == 1 # re-sent from the queue (2nd send attempt)
|
|
assert len(client.uuids) == 2
|
|
assert client.uuids[0] == client.uuids[1] == ev.event_uuid
|
|
|
|
|
|
def test_poison_head_is_dropped_and_unblocks_the_queue(tmp_path):
|
|
# A permanently-rejected event at the head must not wedge the whole queue forever; after
|
|
# max_send_attempts it is dropped so the events behind it can drain.
|
|
client = _PoisonClient(99)
|
|
q = OfflineQueue(str(tmp_path / "q.db"), client, max_send_attempts=3)
|
|
q.enqueue(_event(99), [b"x"]) # poison, at the head
|
|
q.enqueue(_event(2), [b"y"]) # good, behind it
|
|
for _ in range(3):
|
|
q.flush()
|
|
assert client.sent == [2] # good event delivered
|
|
assert q.pending() == 0 # poison dropped, queue drained
|