From 90d3016b172dabafdc4497a91335457de39c4197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 00:56:43 +0900 Subject: [PATCH] feat(cloud): dashboard list/detail/ack + auth-gated snapshot proxy --- cloud/src/Dashboard/EventDetailController.php | 35 ++++++++++++++ cloud/src/Dashboard/EventListController.php | 20 ++++++++ cloud/src/Dashboard/SnapshotProxy.php | 21 +++++++++ cloud/tests/DashboardTest.php | 47 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 cloud/src/Dashboard/EventDetailController.php create mode 100644 cloud/src/Dashboard/EventListController.php create mode 100644 cloud/src/Dashboard/SnapshotProxy.php create mode 100644 cloud/tests/DashboardTest.php diff --git a/cloud/src/Dashboard/EventDetailController.php b/cloud/src/Dashboard/EventDetailController.php new file mode 100644 index 0000000..92c64da --- /dev/null +++ b/cloud/src/Dashboard/EventDetailController.php @@ -0,0 +1,35 @@ +sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $id = (int)($params['id'] ?? 0); + $event = $this->events->find($id); + if ($event === null) return Response::json(['error' => 'not_found'], 404); + return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200); + } + + public function acknowledge(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) return Response::json(['error' => 'forbidden'], 403); + $id = (int)($params['id'] ?? 0); + $user = (string)$this->sess->get('username'); + if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404); + $this->audit->append($user, 'acknowledge', "event:$id", [], []); + return Response::json(['ok' => true], 200); + } +} diff --git a/cloud/src/Dashboard/EventListController.php b/cloud/src/Dashboard/EventListController.php new file mode 100644 index 0000000..fc44ffb --- /dev/null +++ b/cloud/src/Dashboard/EventListController.php @@ -0,0 +1,20 @@ +sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $severity = $req->query('severity'); + $rows = $this->events->recent(100, $severity); + return Response::json(['events' => $rows], 200); + } +} diff --git a/cloud/src/Dashboard/SnapshotProxy.php b/cloud/src/Dashboard/SnapshotProxy.php new file mode 100644 index 0000000..94675b4 --- /dev/null +++ b/cloud/src/Dashboard/SnapshotProxy.php @@ -0,0 +1,21 @@ +sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $uuid = (string)($params['uuid'] ?? ''); + $path = $this->snaps->pathByUuid($uuid); + if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404); + return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200); + } +} diff --git a/cloud/tests/DashboardTest.php b/cloud/tests/DashboardTest.php new file mode 100644 index 0000000..a1c9286 --- /dev/null +++ b/cloud/tests/DashboardTest.php @@ -0,0 +1,47 @@ +set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); 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 testAcknowledgeRequiresPermissionAndSetsUser(): void { + $repo = new InMemoryEventRepository(); + $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); + $req = new Request('POST', "/events/$id/ack", [], '', [], []); + // viewer denied + $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 testSnapshotProxyAuthAnd404(): void { + $snaps = new InMemorySnapshotRepository(); + $proxy = new SnapshotProxy($snaps, new ArraySessionStore()); // unauth + $this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status()); + $proxy2 = new SnapshotProxy($snaps, $this->authed()); + $this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status()); + } +}