move_detect/cloud/tests/ZoneConfigTest.php
유창욱 2ea0420de7 fix(delivery): resolve codex + workflow review findings across edge/cloud/deploy
Two independent adversarial reviews (codex CLI + a 6-dimension verification
workflow) of the delivery-hardening branch surfaced ~40 findings; this closes
the confirmed correctness/security/deploy blockers plus a codex re-review pass.

edge:
- geometry: track_displacement per-second threshold recalibrated (0.02->0.25,
  env-tunable via MOTION_THR, validated finite/positive); confidence-filter
  keypoints with a bbox-center fallback so hallucinated points don't fake motion
- fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident
  (freeze + reset recovery timer so recovery needs a fresh confident streak);
  baseline height only seeded from trusted frames
- queue: PostClient.send() returns a tri-state SendResult; only definite 4xx
  count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx)
  never deletes queued safety events; legacy rows persist their minted uuid
- config: reject placeholder / http / short DEVICE_SECRET at startup
- zones: warn+skip legacy pixel-space polygons that would silently match nothing

cloud:
- zone endpoints wired into P3-2b site-scope (cross-site read/write/delete
  closed) + role gate on list; catch narrowed so DB faults surface as 500
- snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan)
- ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE
  (event_id, content_hash) makes recovery idempotent under retry/race
- event list cursor carries full-precision occurred_at (no float rounding at the
  page boundary); string keyset end-to-end
- SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap
- runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit
  reap fails open

deploy:
- offline bundle hashes EVERY file (fatal on missing/failed/injected; models in
  SBOM; arch in tarball name; REQUIRE_SIGNATURE option)
- installer verifies before any mutation, Hailo arch preflight before install,
  atomic src swap, backend reconcile preserves a working operator choice
- systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert)

Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4
skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings +
fixes) with the second-pass findings folded in.
2026-07-17 19:24:42 +09:00

164 lines
10 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};
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). */
private function devices(array $sites = []): InMemoryDeviceRepository {
return new InMemoryDeviceRepository([], [], $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'));
$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'));
$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'));
$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'));
$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 testSaveRejectsBadLevelOrPolygon(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$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'));
$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'));
// 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));
}
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)); // 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);
$this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status());
}
}