move_detect/cloud/tests/DashboardTest.php
유창욱 930490d1b4 feat(cloud,edge): event dedup (event_uuid) + site-scoped access control (P1-9, P3-2b)
Two approved data-model changes.

P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL
UNIQUE; EventValidator accepts/validates it; IngestController returns the
already-stored event (200, dedup:true) on a repeat uuid, race-safe against
the UNIQUE key (catches the insert-race PDOException and returns the
winner).

P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.

Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.

Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 12:58:27 +09:00

127 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy};
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;
final class DashboardTest extends TestCase
{
private function authed(string $role = 'operator'): ArraySessionStore {
$s = new ArraySessionStore(); $s->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 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 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);
}
}