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')); } } }