Per-zone declarative safety rules, set in the zone editor and enforced on the edge from the tracking the pipeline already does (no new geometry, no new model): - min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH. A solo worker in a buddy-rule zone is a compliance violation and a rescue- delay risk, so it outranks the other two. - max_workers: count > limit sustained 10s -> overcapacity/WARNING. - max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING. A short detection dropout (<=5s grace) does NOT reset the dwell timer - without that, run_once's stale-track filter would quietly neuter every 10-minute rule at real-world detection rates. Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone detector's per-track membership into per-zone headcounts (stale tracks already excluded), so counts always match what was actually detected this tick. Fired states clear on condition recovery - re-alerts need a fresh violation, not a cooldown expiry. Malformed rules_json keeps the zone's intrusion detection alive and logs the rules as inactive (never fail dark). Server: move_zones.rules_json (nullable JSON, migration included) validated strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected - a silently dropped rule is one the operator believes is active). Delivered to the edge unchanged via GET /api/config. EventValidator accepts the 3 new types. UI: zone editor gains the three rule fields (dwell entered in minutes, wired in seconds) and shows each zone's rules in its list row; the events console and detail view now render every event type in Korean (wire codes fall back). Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome (editor save payload, list labels, console type labels).
270 lines
18 KiB
PHP
270 lines
18 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
use App\Http\Request;
|
|
use App\Auth\ArraySessionStore;
|
|
use App\Geometry\Polygon;
|
|
use App\Dashboard\ZoneConfigController;
|
|
use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository, InMemorySnapshotRepository};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ZoneConfigTest extends TestCase
|
|
{
|
|
private function authed(string $role = 'operator', ?string $site = null): ArraySessionStore {
|
|
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok');
|
|
if ($site !== null) $s->set('site_scope', $site);
|
|
return $s;
|
|
}
|
|
/** Device repo mapping the fixture devices to their sites (empty = every device unscoped).
|
|
* Every fixture device is registered (via secrets) so exists() is true — a zone save now
|
|
* requires a real device, so the fixtures must model provisioned devices. */
|
|
private function devices(array $sites = []): InMemoryDeviceRepository {
|
|
$ids = array_unique(array_merge(['pi-1', 'pi-2'], array_keys($sites)));
|
|
return new InMemoryDeviceRepository(array_fill_keys($ids, 's'), [], $sites);
|
|
}
|
|
public function testPolygonValidation(): void {
|
|
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
|
|
$this->assertFalse(Polygon::validate([[0,0],[10,0]])); // <3
|
|
$this->assertFalse(Polygon::validate([[0,0],[10,0],['x',10]])); // non-numeric
|
|
}
|
|
private function saveReq(array $body, array $headers = ['X-Csrf-Token' => 'tok']): Request {
|
|
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
|
|
}
|
|
public function testSaveRequiresPermission(): void {
|
|
$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]]]));
|
|
$this->assertSame(403, $res->status());
|
|
}
|
|
public function testSaveValidZone(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$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]]]));
|
|
$this->assertSame(201, $res->status());
|
|
$this->assertCount(1, $repo->forCamera('cam-1'));
|
|
}
|
|
public function testSaveOwnershipMismatchIs400NotError(): void {
|
|
// The repo throws ZoneOwnershipException when the zone isn't owned by the given
|
|
// device/camera. The controller must turn that client error into 400, not a 500 — and,
|
|
// crucially, must NOT catch a real \PDOException (which also extends \RuntimeException).
|
|
$throwing = new class implements \App\Repository\ZoneRepository {
|
|
public function forDevice(string $d): array { return []; }
|
|
public function forCamera(string $c): array { return []; }
|
|
public function find(string $id): ?array { return ['zone_id' => $id, 'device_id' => 'pi-2', 'camera_id' => 'cam-1']; }
|
|
public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); }
|
|
public function delete(string $id): bool { return true; }
|
|
};
|
|
$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',
|
|
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
|
$this->assertSame(400, $res->status());
|
|
}
|
|
public function testSaveLetsDbFaultBecome500NotInvalidZone(): void {
|
|
// A genuine \PDOException must NOT be masked as 400 invalid_zone — it should propagate so
|
|
// the front controller returns 500 and the fault is not silently swallowed.
|
|
$dbFault = new class implements \App\Repository\ZoneRepository {
|
|
public function forDevice(string $d): array { return []; }
|
|
public function forCamera(string $c): array { return []; }
|
|
public function find(string $id): ?array { return ['zone_id' => $id, 'device_id' => 'pi-1', 'camera_id' => 'cam-1']; }
|
|
public function save(array $z): string { throw new \PDOException('server has gone away'); }
|
|
public function delete(string $id): bool { return true; }
|
|
};
|
|
$c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
|
$this->expectException(\PDOException::class);
|
|
$c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1',
|
|
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
|
}
|
|
public function testSaveRejectsBlankOrUnknownDevice(): void {
|
|
// A zone is delivered to the edge only via /api/config -> forDevice($deviceId); a zone saved
|
|
// with a blank or mistyped device_id is enforced by nobody, so the save must be refused
|
|
// (400) rather than confirmed to the operator and rendered as if it were active.
|
|
$repo = new InMemoryZoneRepository();
|
|
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
|
$poly = [[0,0],[9,0],[9,9]];
|
|
// 'ghost-pi' is not among the provisioned devices (pi-1/pi-2).
|
|
$unknown = $c->save($this->saveReq(['device_id' => 'ghost-pi', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => $poly]));
|
|
$this->assertSame(400, $unknown->status());
|
|
// Blank device_id (the operator left the field empty) is rejected too.
|
|
$blank = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => $poly]));
|
|
$this->assertSame(400, $blank->status());
|
|
$this->assertCount(0, $repo->forCamera('cam-1')); // nothing was persisted
|
|
}
|
|
public function testSaveRejectsBadLevelOrPolygon(): void {
|
|
$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' => 'warning', 'polygon' => [[0,0]]]))->status());
|
|
}
|
|
public function testSaveRejectsMissingOrWrongCsrf(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$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]]];
|
|
// missing header
|
|
$this->assertSame(403, $c->save($this->saveReq($body, []))->status());
|
|
// wrong token
|
|
$this->assertSame(403, $c->save($this->saveReq($body, ['X-Csrf-Token' => 'nope']))->status());
|
|
// save did not proceed
|
|
$this->assertCount(0, $repo->forCamera('cam-1'));
|
|
}
|
|
public function testDeleteRejectsMissingOrWrongCsrf(): void {
|
|
$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]]']);
|
|
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
|
// missing header
|
|
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
|
|
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
|
|
// wrong token
|
|
$wrong = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'nope'], '', [], []);
|
|
$this->assertSame(403, $c->delete($wrong, ['id' => 'z1'])->status());
|
|
// delete did not proceed
|
|
$this->assertCount(1, $repo->forCamera('cam-1'));
|
|
// valid token proceeds
|
|
$ok = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
|
|
$this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status());
|
|
$this->assertCount(0, $repo->forCamera('cam-1'));
|
|
}
|
|
|
|
// ---- P3-2b: site-scoped access control on zone endpoints ---------------------------------
|
|
|
|
private function seededRepo(): InMemoryZoneRepository {
|
|
$repo = new InMemoryZoneRepository();
|
|
// z-a belongs to device pi-a (factory-a); z-b to pi-b (factory-b).
|
|
$repo->save(['zone_id' => 'z-a', 'device_id' => 'pi-a', 'camera_id' => 'cam-a', 'level' => 'restricted', 'polygon_json' => '[[0,0],[1,0],[1,1]]']);
|
|
$repo->save(['zone_id' => 'z-b', 'device_id' => 'pi-b', 'camera_id' => 'cam-b', 'level' => 'restricted', 'polygon_json' => '[[0,0],[1,0],[1,1]]']);
|
|
return $repo;
|
|
}
|
|
private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController {
|
|
return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
|
|
new InMemoryAuditRepository(), $this->authed('operator', $site), new InMemorySnapshotRepository());
|
|
}
|
|
private function zonesOf($res): array {
|
|
return json_decode($res->body(), true)['zones'];
|
|
}
|
|
|
|
public function testScopedOperatorCannotDeleteCrossSiteZone(): void {
|
|
$repo = $this->seededRepo();
|
|
$c = $this->scopedController($repo, 'factory-b'); // scoped to factory-b
|
|
$ok = new Request('POST', '/api/zones/z-a/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
|
|
$this->assertSame(404, $c->delete($ok, ['id' => 'z-a'])->status()); // z-a is factory-a
|
|
$this->assertCount(1, $repo->forCamera('cam-a')); // still present
|
|
}
|
|
public function testScopedOperatorCannotReadCrossSiteZones(): void {
|
|
$repo = $this->seededRepo();
|
|
$c = $this->scopedController($repo, 'factory-b');
|
|
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertSame([], $this->zonesOf($res)); // factory-a hidden
|
|
}
|
|
public function testScopedOperatorCannotCreateOnCrossSiteDevice(): void {
|
|
$repo = $this->seededRepo();
|
|
$c = $this->scopedController($repo, 'factory-b');
|
|
$res = $c->save($this->saveReq(['device_id' => 'pi-a', 'camera_id' => 'cam-a', 'level' => 'restricted', 'polygon' => [[0,0],[1,0],[1,1]]]));
|
|
$this->assertSame(404, $res->status());
|
|
}
|
|
public function testScopedOperatorSeesAndManagesOwnSite(): void {
|
|
$repo = $this->seededRepo();
|
|
$c = $this->scopedController($repo, 'factory-a');
|
|
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
|
|
$this->assertCount(1, $this->zonesOf($res)); // own site visible
|
|
$ok = new Request('POST', '/api/zones/z-a/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
|
|
$this->assertSame(200, $c->delete($ok, ['id' => 'z-a'])->status()); // own site deletable
|
|
}
|
|
public function testGlobalOperatorSeesEverySite(): void {
|
|
$repo = $this->seededRepo();
|
|
$c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
|
|
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']);
|
|
$this->assertCount(1, $this->zonesOf($res));
|
|
}
|
|
public function testListRequiresAuthenticatedRole(): void {
|
|
$s = new ArraySessionStore(); // no user_id
|
|
$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());
|
|
}
|
|
|
|
// --- 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());
|
|
}
|
|
|
|
public function testSavePersistsValidOccupancyRules(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$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]],
|
|
'rules' => ['min_workers' => 2, 'max_workers' => 4, 'max_dwell_s' => 600]]));
|
|
$this->assertSame(201, $res->status());
|
|
$row = $repo->forCamera('cam-1')[0];
|
|
$this->assertSame(['min_workers' => 2, 'max_workers' => 4, 'max_dwell_s' => 600],
|
|
json_decode($row['rules_json'], true));
|
|
}
|
|
|
|
public function testSaveWithoutRulesStoresNull(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$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]], 'rules' => []]));
|
|
$this->assertSame(201, $res->status());
|
|
$this->assertNull($repo->forCamera('cam-1')[0]['rules_json']);
|
|
}
|
|
|
|
public function testSaveRejectsInvalidRules(): void {
|
|
// A rule silently dropped would be one the operator believes protects the zone while
|
|
// the edge enforces nothing — every malformed variant must be a hard 400.
|
|
$bad = [
|
|
['min_workers' => 1], // 1인 최소는 무의미 (하한 2)
|
|
['min_workers' => 0],
|
|
['max_workers' => 99], // 상한 50 초과
|
|
['max_dwell_s' => 5], // 초/분 착오 방지 하한 10s
|
|
['max_dwell_s' => 999999],
|
|
['min_workers' => 5, 'max_workers' => 2], // min > max 모순
|
|
['unknown_rule' => 3],
|
|
['min_workers' => 'abc'],
|
|
'not-an-object',
|
|
];
|
|
foreach ($bad as $rules) {
|
|
$repo = new InMemoryZoneRepository();
|
|
$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]], 'rules' => $rules]));
|
|
$this->assertSame(400, $res->status(), 'rules should be rejected: ' . json_encode($rules));
|
|
$this->assertCount(0, $repo->forCamera('cam-1'));
|
|
}
|
|
}
|
|
}
|