feat(dashboard): load a camera's latest frame as the zone-editor background
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.
- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
the bytes still go through SnapshotProxy's session + site-scope check.
Out-of-scope and missing both 404, so a scoped operator cannot probe for
another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor
Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
This commit is contained in:
parent
5444f19d45
commit
aa4378e906
12 changed files with 146 additions and 17 deletions
|
|
@ -17,3 +17,7 @@ RENAME TABLE
|
||||||
nonces TO move_nonces,
|
nonces TO move_nonces,
|
||||||
rate_limits TO move_rate_limits,
|
rate_limits TO move_rate_limits,
|
||||||
audit_log TO move_audit_log;
|
audit_log TO move_audit_log;
|
||||||
|
|
||||||
|
-- Zone-editor background frame: SELECT ... FROM move_event_snapshots JOIN move_events
|
||||||
|
-- WHERE e.camera_id = ? would otherwise full-scan an unbounded table on an interactive page.
|
||||||
|
ALTER TABLE move_events ADD INDEX ix_events_camera (camera_id, event_id);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@
|
||||||
* - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones.
|
* - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones.
|
||||||
* - "Load" fetches existing zones for the camera via GET /api/zones/{camera}
|
* - "Load" fetches existing zones for the camera via GET /api/zones/{camera}
|
||||||
* and redraws them; each has a Delete button -> POST /api/zones/{id}/delete.
|
* and redraws them; each has a Delete button -> POST /api/zones/{id}/delete.
|
||||||
* - If a `?snapshot={uuid}` query param is present, that image (served via
|
* - "현재 화면 불러오기" fetches the camera's newest snapshot uuid via
|
||||||
* the authenticated /snap/{uuid} proxy) is drawn as the canvas background;
|
* GET /api/cameras/{camera}/latest-snapshot and draws it as the canvas
|
||||||
* otherwise the canvas keeps its plain dark backdrop.
|
* background, so zones are drawn over what the camera actually sees.
|
||||||
|
* - A `?snapshot={uuid}` query param does the same for one specific image
|
||||||
|
* (the event-detail page links here that way); otherwise the canvas keeps
|
||||||
|
* its plain dark backdrop.
|
||||||
*
|
*
|
||||||
* XSS-safe: all dynamic text is set via textContent, never innerHTML.
|
* XSS-safe: all dynamic text is set via textContent, never innerHTML.
|
||||||
*/
|
*/
|
||||||
|
|
@ -25,6 +28,7 @@
|
||||||
var cameraInput = document.getElementById('camera-id');
|
var cameraInput = document.getElementById('camera-id');
|
||||||
var levelSelect = document.getElementById('zone-level');
|
var levelSelect = document.getElementById('zone-level');
|
||||||
var loadBtn = document.getElementById('zone-load-btn');
|
var loadBtn = document.getElementById('zone-load-btn');
|
||||||
|
var frameBtn = document.getElementById('zone-frame-btn');
|
||||||
var clearBtn = document.getElementById('zone-clear-btn');
|
var clearBtn = document.getElementById('zone-clear-btn');
|
||||||
var saveBtn = document.getElementById('zone-save-btn');
|
var saveBtn = document.getElementById('zone-save-btn');
|
||||||
var listEl = document.getElementById('zone-list');
|
var listEl = document.getElementById('zone-list');
|
||||||
|
|
@ -130,6 +134,22 @@
|
||||||
img.src = B + '/snap/' + encodeURIComponent(uuid);
|
img.src = B + '/snap/' + encodeURIComponent(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the camera's most recent frame as the drawing background. The uuid comes from the
|
||||||
|
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
||||||
|
function loadLatestFrame() {
|
||||||
|
var camera = cameraInput.value.trim();
|
||||||
|
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||||
|
setStatus('현재 화면을 불러오는 중...');
|
||||||
|
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.');
|
||||||
|
if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); })
|
||||||
|
.catch(function (e) { setStatus(e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
function loadZones() {
|
function loadZones() {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||||
|
|
@ -212,6 +232,7 @@
|
||||||
|
|
||||||
levelSelect.addEventListener('change', redraw);
|
levelSelect.addEventListener('change', redraw);
|
||||||
loadBtn.addEventListener('click', loadZones);
|
loadBtn.addEventListener('click', loadZones);
|
||||||
|
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
|
||||||
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
|
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
|
||||||
saveBtn.addEventListener('click', saveZone);
|
saveBtn.addEventListener('click', saveZone);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ $csrfCtl = new CsrfController();
|
||||||
$list = new EventListController($events, $sess);
|
$list = new EventListController($events, $sess);
|
||||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||||
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess);
|
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
||||||
|
|
||||||
$render = function (string $view): Response {
|
$render = function (string $view): Response {
|
||||||
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
||||||
|
|
@ -110,6 +110,8 @@ $r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
|
||||||
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
|
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
|
||||||
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
|
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
|
||||||
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
|
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
|
||||||
|
// Background frame for the zone editor: the newest snapshot this camera produced.
|
||||||
|
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
|
||||||
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
|
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
|
||||||
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
|
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
|
||||||
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
|
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ CREATE TABLE move_events (
|
||||||
KEY ix_events_severity_occurred_at (severity, occurred_at),
|
KEY ix_events_severity_occurred_at (severity, occurred_at),
|
||||||
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
|
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
|
||||||
KEY ix_events_device (device_id),
|
KEY ix_events_device (device_id),
|
||||||
|
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
|
||||||
CONSTRAINT chk_events_type CHECK (event_type IN (
|
CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||||
'fall','no_motion','immobility','ghost','zone_warning',
|
'fall','no_motion','immobility','ghost','zone_warning',
|
||||||
'zone_restricted','pinch','monitoring_degraded','device_fault'
|
'zone_restricted','pinch','monitoring_degraded','device_fault'
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use App\Http\{Request, Response};
|
||||||
use App\Auth\{SessionStore, Rbac};
|
use App\Auth\{SessionStore, Rbac};
|
||||||
use App\Security\{Csrf, SiteScope};
|
use App\Security\{Csrf, SiteScope};
|
||||||
use App\Geometry\Polygon;
|
use App\Geometry\Polygon;
|
||||||
use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, ZoneOwnershipException};
|
use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotRepository, ZoneOwnershipException};
|
||||||
|
|
||||||
final class ZoneConfigController
|
final class ZoneConfigController
|
||||||
{
|
{
|
||||||
|
|
@ -14,7 +14,8 @@ final class ZoneConfigController
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ZoneRepository $zones, private DeviceRepository $devices,
|
private ZoneRepository $zones, private DeviceRepository $devices,
|
||||||
private AuditRepository $audit, private SessionStore $sess
|
private AuditRepository $audit, private SessionStore $sess,
|
||||||
|
private SnapshotRepository $snapshots
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private function guard(string $perm): ?Response
|
private function guard(string $perm): ?Response
|
||||||
|
|
@ -45,6 +46,22 @@ final class ZoneConfigController
|
||||||
return Response::json(['zones' => $zones], 200);
|
return Response::json(['zones' => $zones], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /api/cameras/{camera}/latest-snapshot -> {"uuid": "..."} or 404.
|
||||||
|
* The zone editor loads /snap/{uuid} as its canvas background so zones are drawn over the
|
||||||
|
* camera's actual view. Returns only the uuid: the image itself still goes through
|
||||||
|
* SnapshotProxy, which re-checks the session and the site scope before serving bytes. */
|
||||||
|
public function latestSnapshot(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($g = $this->guard('view_events')) return $g;
|
||||||
|
$shot = $this->snapshots->latestForCamera((string)($params['camera'] ?? ''));
|
||||||
|
// 404 for out-of-scope as well as missing -- a scoped operator must not be able to
|
||||||
|
// tell "another site's camera" apart from "no such camera" (mirrors list()).
|
||||||
|
if ($shot === null || !$this->inScope((string)($shot['device_id'] ?? ''))) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
return Response::json(['uuid' => $shot['file_uuid']], 200);
|
||||||
|
}
|
||||||
|
|
||||||
public function save(Request $req): Response
|
public function save(Request $req): Response
|
||||||
{
|
{
|
||||||
if ($g = $this->guard('edit_zones')) return $g;
|
if ($g = $this->guard('edit_zones')) return $g;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,27 @@ final class PdoSnapshotRepository implements SnapshotRepository
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function latestForCamera(string $cameraId): ?array
|
||||||
|
{
|
||||||
|
// Ordered by snapshot_id (insert order), NOT events.occurred_at: occurred_at is the
|
||||||
|
// DEVICE's clock, so a Pi whose RTC is behind would keep serving a stale frame as
|
||||||
|
// "latest" forever. snapshot_id is the server's own monotonic sequence.
|
||||||
|
// Rides ix_events_camera (camera_id, event_id) -- without it this is a full scan of a
|
||||||
|
// table that grows unbounded, on a page an operator opens interactively.
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT s.file_uuid, e.device_id
|
||||||
|
FROM move_event_snapshots s
|
||||||
|
JOIN move_events e ON e.event_id = s.event_id
|
||||||
|
WHERE e.camera_id = ?
|
||||||
|
ORDER BY s.snapshot_id DESC
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$cameraId]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
return $row === false ? null : ['file_uuid' => (string)$row['file_uuid'],
|
||||||
|
'device_id' => (string)$row['device_id']];
|
||||||
|
}
|
||||||
|
|
||||||
public function pathByUuid(string $uuid): ?string
|
public function pathByUuid(string $uuid): ?string
|
||||||
{
|
{
|
||||||
// Validate BEFORE building any filesystem path or touching the DB --
|
// Validate BEFORE building any filesystem path or touching the DB --
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ interface SnapshotRepository
|
||||||
* snapshot's site (via the owning event) for access control. */
|
* snapshot's site (via the owning event) for access control. */
|
||||||
public function eventIdByUuid(string $uuid): ?int;
|
public function eventIdByUuid(string $uuid): ?int;
|
||||||
|
|
||||||
|
/** Most recent snapshot for a camera, as ['file_uuid' => ..., 'device_id' => ...], or null.
|
||||||
|
* Backs the zone editor's "load the latest frame" button: an operator draws hazard zones
|
||||||
|
* over what the camera actually sees instead of guessing coordinates on a blank canvas.
|
||||||
|
* device_id comes back so the caller can site-scope the result. */
|
||||||
|
public function latestForCamera(string $cameraId): ?array;
|
||||||
|
|
||||||
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
|
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
|
||||||
* worker images must not be kept forever. Returns the number purged. */
|
* worker images must not be kept forever. Returns the number purged. */
|
||||||
public function purgeOlderThan(int $cutoff): int;
|
public function purgeOlderThan(int $cutoff): int;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ render_header('구역 설정');
|
||||||
<label for="camera-id">카메라 ID</label>
|
<label for="camera-id">카메라 ID</label>
|
||||||
<input type="text" id="camera-id" placeholder="cam-01">
|
<input type="text" id="camera-id" placeholder="cam-01">
|
||||||
<button type="button" id="zone-load-btn">불러오기</button>
|
<button type="button" id="zone-load-btn">불러오기</button>
|
||||||
|
<button type="button" id="zone-frame-btn">현재 화면 불러오기</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="zone-controls">
|
<div class="zone-controls">
|
||||||
<label for="zone-level">등급</label>
|
<label for="zone-level">등급</label>
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ final class DashboardTest extends TestCase
|
||||||
public function forEvent(int $e): array { return []; }
|
public function forEvent(int $e): array { return []; }
|
||||||
public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; }
|
public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; }
|
||||||
public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; }
|
public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; }
|
||||||
|
public function latestForCamera(string $cameraId): ?array { return null; }
|
||||||
public function purgeOlderThan(int $c): int { return 0; }
|
public function purgeOlderThan(int $c): int { return 0; }
|
||||||
};
|
};
|
||||||
$cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a'));
|
$cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a'));
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ final class IngestControllerTest extends TestCase
|
||||||
public function forEvent(int $e): array { return []; }
|
public function forEvent(int $e): array { return []; }
|
||||||
public function pathByUuid(string $u): ?string { return null; }
|
public function pathByUuid(string $u): ?string { return null; }
|
||||||
public function eventIdByUuid(string $u): ?int { return null; }
|
public function eventIdByUuid(string $u): ?int { return null; }
|
||||||
|
public function latestForCamera(string $cameraId): ?array { return null; }
|
||||||
public function purgeOlderThan(int $c): int { return 0; }
|
public function purgeOlderThan(int $c): int { return 0; }
|
||||||
};
|
};
|
||||||
$events = new InMemoryEventRepository();
|
$events = new InMemoryEventRepository();
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,17 @@ final class InMemorySnapshotRepository implements SnapshotRepository {
|
||||||
foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e;
|
foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/** @var array<string,string> file_uuid => camera_id, recorded by the tests that need it */
|
||||||
|
public array $cameras = [];
|
||||||
|
/** @var array<string,string> file_uuid => device_id */
|
||||||
|
public array $devices = [];
|
||||||
|
public function latestForCamera(string $cameraId): ?array {
|
||||||
|
// Insert order, newest first -- mirrors the Pdo impl's ORDER BY snapshot_id DESC.
|
||||||
|
foreach (array_reverse($this->cameras, true) as $uuid => $cam) {
|
||||||
|
if ($cam === $cameraId) return ['file_uuid' => $uuid, 'device_id' => $this->devices[$uuid] ?? ''];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
public function purgeOlderThan(int $cutoff): int {
|
public function purgeOlderThan(int $cutoff): int {
|
||||||
$n = 0;
|
$n = 0;
|
||||||
foreach ($this->created as $uuid => $ts) {
|
foreach ($this->created as $uuid => $ts) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use App\Http\Request;
|
||||||
use App\Auth\ArraySessionStore;
|
use App\Auth\ArraySessionStore;
|
||||||
use App\Geometry\Polygon;
|
use App\Geometry\Polygon;
|
||||||
use App\Dashboard\ZoneConfigController;
|
use App\Dashboard\ZoneConfigController;
|
||||||
use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository};
|
use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository, InMemorySnapshotRepository};
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class ZoneConfigTest extends TestCase
|
final class ZoneConfigTest extends TestCase
|
||||||
|
|
@ -28,13 +28,13 @@ final class ZoneConfigTest extends TestCase
|
||||||
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
|
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
|
||||||
}
|
}
|
||||||
public function testSaveRequiresPermission(): void {
|
public function testSaveRequiresPermission(): void {
|
||||||
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer'));
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer'), new InMemorySnapshotRepository());
|
||||||
$res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
|
$res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
|
||||||
$this->assertSame(403, $res->status());
|
$this->assertSame(403, $res->status());
|
||||||
}
|
}
|
||||||
public function testSaveValidZone(): void {
|
public function testSaveValidZone(): void {
|
||||||
$repo = new InMemoryZoneRepository();
|
$repo = new InMemoryZoneRepository();
|
||||||
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
|
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
$res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
|
$res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
|
||||||
$this->assertSame(201, $res->status());
|
$this->assertSame(201, $res->status());
|
||||||
$this->assertCount(1, $repo->forCamera('cam-1'));
|
$this->assertCount(1, $repo->forCamera('cam-1'));
|
||||||
|
|
@ -50,7 +50,7 @@ final class ZoneConfigTest extends TestCase
|
||||||
public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); }
|
public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); }
|
||||||
public function delete(string $id): bool { return true; }
|
public function delete(string $id): bool { return true; }
|
||||||
};
|
};
|
||||||
$c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
|
$c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
$res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1',
|
$res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1',
|
||||||
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
||||||
$this->assertSame(400, $res->status());
|
$this->assertSame(400, $res->status());
|
||||||
|
|
@ -65,19 +65,19 @@ final class ZoneConfigTest extends TestCase
|
||||||
public function save(array $z): string { throw new \PDOException('server has gone away'); }
|
public function save(array $z): string { throw new \PDOException('server has gone away'); }
|
||||||
public function delete(string $id): bool { return true; }
|
public function delete(string $id): bool { return true; }
|
||||||
};
|
};
|
||||||
$c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
|
$c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
$this->expectException(\PDOException::class);
|
$this->expectException(\PDOException::class);
|
||||||
$c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1',
|
$c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1',
|
||||||
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
||||||
}
|
}
|
||||||
public function testSaveRejectsBadLevelOrPolygon(): void {
|
public function testSaveRejectsBadLevelOrPolygon(): void {
|
||||||
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
|
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
|
||||||
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status());
|
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status());
|
||||||
}
|
}
|
||||||
public function testSaveRejectsMissingOrWrongCsrf(): void {
|
public function testSaveRejectsMissingOrWrongCsrf(): void {
|
||||||
$repo = new InMemoryZoneRepository();
|
$repo = new InMemoryZoneRepository();
|
||||||
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
|
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
$body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]];
|
$body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]];
|
||||||
// missing header
|
// missing header
|
||||||
$this->assertSame(403, $c->save($this->saveReq($body, []))->status());
|
$this->assertSame(403, $c->save($this->saveReq($body, []))->status());
|
||||||
|
|
@ -89,7 +89,7 @@ final class ZoneConfigTest extends TestCase
|
||||||
public function testDeleteRejectsMissingOrWrongCsrf(): void {
|
public function testDeleteRejectsMissingOrWrongCsrf(): void {
|
||||||
$repo = new InMemoryZoneRepository();
|
$repo = new InMemoryZoneRepository();
|
||||||
$repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
|
$repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
|
||||||
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
|
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
// missing header
|
// missing header
|
||||||
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
|
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
|
||||||
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
|
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
|
||||||
|
|
@ -115,7 +115,7 @@ final class ZoneConfigTest extends TestCase
|
||||||
}
|
}
|
||||||
private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController {
|
private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController {
|
||||||
return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
|
return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
|
||||||
new InMemoryAuditRepository(), $this->authed('operator', $site));
|
new InMemoryAuditRepository(), $this->authed('operator', $site), new InMemorySnapshotRepository());
|
||||||
}
|
}
|
||||||
private function zonesOf($res): array {
|
private function zonesOf($res): array {
|
||||||
return json_decode($res->body(), true)['zones'];
|
return json_decode($res->body(), true)['zones'];
|
||||||
|
|
@ -152,13 +152,56 @@ final class ZoneConfigTest extends TestCase
|
||||||
public function testGlobalOperatorSeesEverySite(): void {
|
public function testGlobalOperatorSeesEverySite(): void {
|
||||||
$repo = $this->seededRepo();
|
$repo = $this->seededRepo();
|
||||||
$c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
|
$c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
|
||||||
new InMemoryAuditRepository(), $this->authed('operator', null)); // no site_scope = global
|
new InMemoryAuditRepository(), $this->authed('operator', null), new InMemorySnapshotRepository()); // no site_scope = global
|
||||||
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
|
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
|
||||||
$this->assertCount(1, $this->zonesOf($res));
|
$this->assertCount(1, $this->zonesOf($res));
|
||||||
}
|
}
|
||||||
public function testListRequiresAuthenticatedRole(): void {
|
public function testListRequiresAuthenticatedRole(): void {
|
||||||
$s = new ArraySessionStore(); // no user_id
|
$s = new ArraySessionStore(); // no user_id
|
||||||
$c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s);
|
$c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s, new InMemorySnapshotRepository());
|
||||||
$this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status());
|
$this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- latest-snapshot background for the zone editor -------------------------------
|
||||||
|
private function snapsWith(array $map, array $devices = []): InMemorySnapshotRepository {
|
||||||
|
$r = new InMemorySnapshotRepository();
|
||||||
|
$r->cameras = $map; $r->devices = $devices;
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
private function snapReq(): Request { return new Request('GET', '/api/cameras/cam-a/latest-snapshot', [], '', [], []); }
|
||||||
|
|
||||||
|
public function testLatestSnapshotReturnsTheNewestUuidForTheCamera(): void {
|
||||||
|
$uuidOld = str_repeat('a', 32); $uuidNew = str_repeat('b', 32);
|
||||||
|
$snaps = $this->snapsWith([$uuidOld => 'cam-a', $uuidNew => 'cam-a'], [$uuidOld => 'pi-1', $uuidNew => 'pi-1']);
|
||||||
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(),
|
||||||
|
$this->authed('operator'), $snaps);
|
||||||
|
$res = $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a']);
|
||||||
|
$this->assertSame(200, $res->status());
|
||||||
|
$this->assertSame($uuidNew, json_decode($res->body(), true)['uuid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLatestSnapshotIs404WhenTheCameraHasNoSnapshotYet(): void {
|
||||||
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(),
|
||||||
|
$this->authed('operator'), new InMemorySnapshotRepository());
|
||||||
|
$this->assertSame(404, $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a'])->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLatestSnapshotRequiresAuthentication(): void {
|
||||||
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(),
|
||||||
|
new ArraySessionStore(), new InMemorySnapshotRepository());
|
||||||
|
$this->assertSame(401, $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a'])->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testScopedOperatorCannotSeeAnotherSitesCameraFrame(): void {
|
||||||
|
// The frame is a worker image: leaking it across sites would defeat the whole scope guard.
|
||||||
|
$uuid = str_repeat('c', 32);
|
||||||
|
$snaps = $this->snapsWith([$uuid => 'cam-b'], [$uuid => 'pi-b']);
|
||||||
|
$devices = $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']);
|
||||||
|
$mine = new ZoneConfigController(new InMemoryZoneRepository(), $devices, new InMemoryAuditRepository(),
|
||||||
|
$this->authed('operator', 'factory-a'), $snaps);
|
||||||
|
$this->assertSame(404, $mine->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status());
|
||||||
|
$theirs = new ZoneConfigController(new InMemoryZoneRepository(), $devices, new InMemoryAuditRepository(),
|
||||||
|
$this->authed('operator', 'factory-b'), $snaps);
|
||||||
|
$this->assertSame(200, $theirs->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue