set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s; } public function testListRequiresAuth(): void { $repo = new InMemoryEventRepository(); $c = new EventListController($repo, new ArraySessionStore()); $this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status()); } public function testListReturnsRecent(): void { $repo = new InMemoryEventRepository(); $repo->insert(['severity' => 'high', 'event_type' => 'fall']); $c = new EventListController($repo, $this->authed()); $res = $c->json(new Request('GET', '/api/events', [], '', [], [])); $this->assertSame(200, $res->status()); $this->assertCount(1, json_decode($res->body(), true)['events']); } public function testListPaginatesWithCursor(): void { $repo = new InMemoryEventRepository(); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3 $c = new EventListController($repo, $this->authed()); // Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row. $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true); $this->assertCount(2, $p1['events']); $this->assertSame([3, 2], [$p1['events'][0]['event_id'], $p1['events'][1]['event_id']]); $this->assertSame('200:2', $p1['next_cursor']); // Page 2: following the cursor yields only the older remaining event; short page => no cursor. $p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2', 'cursor' => $p1['next_cursor']], []))->body(), true); $this->assertCount(1, $p2['events']); $this->assertSame(1, $p2['events'][0]['event_id']); $this->assertNull($p2['next_cursor']); } public function testCursorPreservesMicrosecondPrecision(): void { // Two events whose occurred_at differ only at the microsecond. The cursor must round-trip // that value exactly — the old float stringification (precision=14) rounded it and made // the page-edge row reappear on the next page (duplicate) or vanish (skip). $repo = new InMemoryEventRepository(); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2 $c = new EventListController($repo, $this->authed()); $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true); $this->assertSame(2, $p1['events'][0]['event_id']); // newest first $p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true); $this->assertCount(1, $p2['events']); $this->assertSame(1, $p2['events'][0]['event_id']); // id 2 not duplicated, id 1 not skipped } public function testAcknowledgeRequiresPermissionAndSetsUser(): void { $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); $req = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'tok'], '', [], []); // viewer denied (RBAC check fires before CSRF) $cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer')); $this->assertSame(403, $cv->acknowledge($req, ['id' => (string)$id])->status()); // operator allowed $co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator')); $this->assertSame(200, $co->acknowledge($req, ['id' => (string)$id])->status()); $this->assertSame('u', $repo->find($id)['acknowledged_by']); } public function testAcknowledgeRejectsMissingOrWrongCsrfToken(): void { $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); $c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator')); // missing header $missing = new Request('POST', "/events/$id/ack", [], '', [], []); $this->assertSame(403, $c->acknowledge($missing, ['id' => (string)$id])->status()); // wrong token $wrong = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'nope'], '', [], []); $this->assertSame(403, $c->acknowledge($wrong, ['id' => (string)$id])->status()); // acknowledge did not proceed $this->assertNull($repo->find($id)['acknowledged_by']); } public function testSnapshotProxyAuthAnd404(): void { $snaps = new InMemorySnapshotRepository(); $events = new InMemoryEventRepository(); $proxy = new SnapshotProxy($snaps, $events, new ArraySessionStore()); // unauth $this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status()); $proxy2 = new SnapshotProxy($snaps, $events, $this->authed()); $this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status()); } private function authedSite(string $site): ArraySessionStore { $s = $this->authed('operator'); $s->set('site_scope', $site); return $s; } public function testListIsScopedToTheCallersSite(): void { $repo = new InMemoryEventRepository(); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1.0, 'site' => 'factory-a']); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']); // A factory-a operator sees only the factory-a event. $scoped = new EventListController($repo, $this->authedSite('factory-a')); $rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; $this->assertCount(1, $rows); $this->assertSame('factory-a', $rows[0]['site']); // A global operator (no site_scope) sees both. $global = new EventListController($repo, $this->authed('operator')); $all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; $this->assertCount(2, $all); } public function testDetailHidesOtherSitesEvent(): void { $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'site' => 'factory-b']); $c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authedSite('factory-a')); // 404 (not 403) so a cross-site id is indistinguishable from a non-existent one. $this->assertSame(404, $c->show(new Request('GET', "/events/$id", [], '', [], []), ['id' => (string)$id])->status()); // The owning site sees it. $own = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authedSite('factory-b')); $this->assertSame(200, $own->show(new Request('GET', "/events/$id", [], '', [], []), ['id' => (string)$id])->status()); } public function testSnapshotServeHidesOtherSitesImage(): void { // A real on-disk file so the 404 can only come from the site guard, not a missing file. $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'site' => 'factory-b']); $file = sys_get_temp_dir() . '/snaptest' . bin2hex(random_bytes(4)) . '.jpg'; file_put_contents($file, 'jpegbytes'); $uuid = str_repeat('a', 32); $snaps = new class($file, $id, $uuid) implements \App\Repository\SnapshotRepository { public function __construct(private string $file, private int $eid, private string $uuid) {} public function add(int $e, string $u, string $h): void {} public function forEvent(int $e): array { return []; } 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 latestForCamera(string $cameraId): ?array { return null; } public function purgeOlderThan(int $c): int { return 0; } }; $cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a')); $this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status()); $same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b')); $this->assertSame(200, $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status()); @unlink($file); } }