From 65344f6ea612be9b42f808a3631110986b3354cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 11 Jul 2026 23:12:04 +0900 Subject: [PATCH] docs: P1-B cloud ingest + dashboard implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1 --- .../2026-07-11-p1b-cloud-ingest-dashboard.md | 2127 +++++++++++++++++ 1 file changed, 2127 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md diff --git a/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md b/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md new file mode 100644 index 0000000..18444a0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md @@ -0,0 +1,2127 @@ +# P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cafe24 웹호스팅(PHP+MySQL)에서 도는 관제 백엔드/대시보드 — 엣지(P1-A)가 보내는 서명된 이벤트+스냅샷을 안전하게 수신·저장하고, 인증된 안전관리자가 실시간 사고 목록·상세를 보고, 위험구역 폴리곤을 그려 Pi로 동기한다. + +**Architecture:** 바닐라 PHP(프레임워크 없음) + PDO + PHPUnit. 프론트컨트롤러 라우터 → 컨트롤러 → **Repository 인터페이스**(PDO/MySQL 구현) 뒤에 DB. 보안 핵심 로직(HMAC+nonce 검증·입력검증·이미지 재인코딩·RBAC·세션)은 순수·mock repo로 PHPUnit 로컬 완결. DB 통합은 게이트(로컬 pdo_sqlite or Cafe24 MySQL). 스냅샷은 **웹루트 밖** 저장, 인증 PHP 프록시로만 서빙. + +**Tech Stack:** PHP 8.5, Composer(dev: phpunit만), PDO(MySQL 타깃), gd(이미지 재인코딩), openssl/hash(HMAC), session/argon2id(인증). 런타임 외부 의존성 0(vendor는 phpunit 개발 전용). + +## Global Constraints + +- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`). +- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출. +- **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`. +- **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초). +- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지. +- **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증. +- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer. +- **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트. +- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지. +- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24). +- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로. + +## File Structure + +``` +cloud/ + composer.json # PSR-4 autoload + phpunit(dev) + phpunit.xml + config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore) + schema.sql # MySQL DDL (+ sqlite 호환 노트) + public/ # === Cafe24 웹루트 (이것만 노출) === + index.php # 프론트컨트롤러 + .htaccess # 라우팅 rewrite + storage/src 접근 차단 + assets/{app.css, app.js, zone-draw.js} + src/ + Config.php + Http/{Request.php, Response.php, Router.php} + Db/Database.php + Security/{Hmac.php, Csrf.php, Password.php} + Ingest/{EventValidator.php, ImageProcessor.php, IngestController.php} + Api/{HeartbeatController.php, ConfigController.php} + Auth/{Session.php, Rbac.php, AuthController.php} + Dashboard/{EventListController.php, EventDetailController.php, SnapshotProxy.php, ZoneConfigController.php} + Geometry/Polygon.php + Repository/ # 인터페이스 + EventRepository.php, UserRepository.php, DeviceRepository.php, + ZoneRepository.php, ThresholdRepository.php, NonceRepository.php, + RateLimiterRepository.php, AuditRepository.php, SnapshotRepository.php + Repository/Pdo/ # PDO 구현 (DB 게이트 태스크) + Pdo*.php + View/ # 서버렌더 템플릿(php) + layout.php, login.php, events.php, event_detail.php, zones.php + storage/ # === 웹루트 밖 === + snapshots/ # 재인코딩된 JPEG (UUID) + tests/ # PHPUnit + InMemory 테스트더블 + Support/InMemoryRepositories.php + *Test.php +``` + +--- + +## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit) + +**Files:** +- Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore` + +**Interfaces:** +- Consumes: (없음) +- Produces: `App\Config` with `Config::fromArray(array $a): Config` and typed getters `dbDsn(), dbUser(), dbPass(), snapshotDir(), nonceTtl(): int, timestampWindow(): int, rateLimitPerMin(): int`. + +- [ ] **Step 1: composer.json + autoload** + +```json +{ + "name": "move-detect/cloud", + "type": "project", + "require": { "php": ">=8.2" }, + "require-dev": { "phpunit/phpunit": "^11" }, + "autoload": { "psr-4": { "App\\": "src/" } }, + "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } } +} +``` + +- [ ] **Step 2: phpunit.xml** + +```xml + + + + tests + + +``` + +- [ ] **Step 3: `.gitignore`** (cloud/) + +``` +/vendor/ +/config.php +/storage/snapshots/* +!/storage/snapshots/.gitkeep +``` + +- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php` + +```php + 'sqlite::memory:']); + $this->assertSame('sqlite::memory:', $c->dbDsn()); + $this->assertSame(300, $c->timestampWindow()); + $this->assertSame(60, $c->rateLimitPerMin()); + $this->assertSame(86400, $c->nonceTtl()); + } + + public function testOverrides(): void + { + $c = Config::fromArray(['db_dsn' => 'x', 'timestamp_window' => 120, 'rate_limit_per_min' => 30]); + $this->assertSame(120, $c->timestampWindow()); + $this->assertSame(30, $c->rateLimitPerMin()); + } +} +``` + +- [ ] **Step 5: 실패 확인** + +Run: `cd cloud && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php` +Expected: FAIL — `Class "App\Config" not found` + +- [ ] **Step 6: 구현** — `cloud/src/Config.php` + +```php +a['db_dsn'] ?? ''); } + public function dbUser(): string { return (string)($this->a['db_user'] ?? ''); } + public function dbPass(): string { return (string)($this->a['db_pass'] ?? ''); } + public function snapshotDir(): string { return (string)($this->a['snapshot_dir'] ?? __DIR__ . '/../storage/snapshots'); } + public function nonceTtl(): int { return (int)($this->a['nonce_ttl'] ?? 86400); } + public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); } + public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); } +} +``` + +- [ ] **Step 7: config.example.php** (배포 참조) + +```php + 'mysql:host=localhost;dbname=move_detect;charset=utf8mb4', + 'db_user' => 'CHANGEME', + 'db_pass' => 'CHANGEME', + 'snapshot_dir' => __DIR__ . '/storage/snapshots', + 'timestamp_window' => 300, + 'rate_limit_per_min' => 60, +]; +``` + +- [ ] **Step 8: 통과 + 커밋** + +Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS + +```bash +git add cloud/composer.json cloud/phpunit.xml cloud/.gitignore cloud/config.example.php cloud/src/Config.php cloud/tests/ConfigTest.php +git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config" +``` + +--- + +## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치 + +**Files:** +- Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php` + +**Interfaces:** +- Consumes: (없음) +- Produces: `App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string` (hex), `Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool` (hash_equals, 상수시간). + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/HmacTest.php` + +```php +assertSame($expected, $sig); + $this->assertSame(64, strlen($sig)); + } + + public function testVerifyTrueOnMatchFalseOnTamper(): void + { + $sig = Hmac::sign('secret', 'n1', '1000.0', 'body'); + $this->assertTrue(Hmac::verify('secret', 'n1', '1000.0', 'body', $sig)); + $this->assertFalse(Hmac::verify('secret', 'n1', '1000.0', 'body-TAMPERED', $sig)); + $this->assertFalse(Hmac::verify('wrong', 'n1', '1000.0', 'body', $sig)); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found) + +- [ ] **Step 3: 구현** — `cloud/src/Security/Hmac.php` + +```php +secrets[$d] ?? null; } + public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; } + public function configVersion(string $d): int { return $this->versions[$d] ?? 1; } +} +final class InMemoryNonceRepository implements NonceRepository { + public array $store = []; + public function seen(string $n): bool { return isset($this->store[$n]); } + public function remember(string $n, int $exp): void { $this->store[$n] = $exp; } +} +final class InMemoryRateLimiter implements RateLimiterRepository { + public array $counts = []; + public function hit(string $k, int $w): int { $key = "$k:$w"; return $this->counts[$key] = ($this->counts[$key] ?? 0) + 1; } +} +final class InMemoryEventRepository implements EventRepository { + public array $rows = []; private int $seq = 0; + public function insert(array $e): int { $id = ++$this->seq; $this->rows[$id] = $e + ['event_id' => $id, 'acknowledged_by' => null]; return $id; } + public function recent(int $limit, ?string $sev): array { + $r = array_reverse($this->rows, true); + if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); + return array_slice(array_values($r), 0, $limit); + } + public function find(int $id): ?array { return $this->rows[$id] ?? null; } + public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; } +} +final class InMemorySnapshotRepository implements SnapshotRepository { + public array $rows = []; public array $paths = []; + public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; } + public function forEvent(int $e): array { return $this->rows[$e] ?? []; } + public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } +} +final class InMemoryUserRepository implements UserRepository { + public function __construct(public array $users = []) {} + public function byUsername(string $u): ?array { return $this->users[$u] ?? null; } + public function recordFailure(int $id, int $lock): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = ($row['failed_attempts'] ?? 0) + 1; $row['locked_until'] = $lock; } } + public function clearFailures(int $id): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = 0; $row['locked_until'] = 0; } } +} +final class InMemoryZoneRepository implements ZoneRepository { + public array $zones = []; private int $seq = 0; + public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); } + public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); } + public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; } + public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; } +} +final class InMemoryThresholdRepository implements ThresholdRepository { + public function __construct(private array $data = []) {} + public function forDevice(string $d): array { return $this->data[$d] ?? []; } +} +final class InMemoryAuditRepository implements AuditRepository { + public array $log = []; + public function append(string $a, string $act, string $t, array $b, array $af): void { $this->log[] = compact('a', 'act', 't', 'b', 'af'); } +} +``` + +그리고 `cloud/tests/RepositoryContractTest.php`: + +```php +assertFalse($r->seen('n1')); + $r->remember('n1', 999); + $this->assertTrue($r->seen('n1')); + } + public function testEventInsertAndRecent(): void + { + $r = new InMemoryEventRepository(); + $id = $r->insert(['severity' => 'high', 'event_type' => 'fall']); + $this->assertSame(1, $id); + $this->assertCount(1, $r->recent(10, null)); + $this->assertCount(0, $r->recent(10, 'critical')); + } + public function testRateLimiterIncrements(): void + { + $r = new InMemoryRateLimiter(); + $this->assertSame(1, $r->hit('dev', 100)); + $this->assertSame(2, $r->hit('dev', 100)); + $this->assertSame(1, $r->hit('dev', 160)); + } +} +``` + +- [ ] **Step 3: 실패 → 통과** + +Run: `./vendor/bin/phpunit tests/RepositoryContractTest.php` +Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS. + +- [ ] **Step 4: 커밋** + +```bash +git add cloud/src/Repository/*.php cloud/tests/Support/InMemoryRepositories.php cloud/tests/RepositoryContractTest.php +git commit -m "feat(cloud): repository interfaces + in-memory test doubles" +``` + +--- + +## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증) + +**Files:** +- Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php` + +**Interfaces:** +- Consumes: (없음) +- Produces: `App\Ingest\EventValidator::validate(array $payload): array` — `['ok' => bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/EventValidatorTest.php` + +```php + 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'high', 'occurred_at' => 1000.0, 'snapshots' => ['aGk=']]; + } + public function testAcceptsValid(): void { + $r = EventValidator::validate($this->valid()); + $this->assertTrue($r['ok'], implode(',', $r['errors'])); + $this->assertSame('fall', $r['clean']['event_type']); + } + public function testRejectsUnknownEventType(): void { + $p = $this->valid(); $p['event_type'] = 'explode'; + $this->assertFalse(EventValidator::validate($p)['ok']); + } + public function testRejectsUnknownSeverity(): void { + $p = $this->valid(); $p['severity'] = 'ULTRA'; + $this->assertFalse(EventValidator::validate($p)['ok']); + } + public function testRejectsMissingRequired(): void { + $p = $this->valid(); unset($p['device_id']); + $this->assertFalse(EventValidator::validate($p)['ok']); + } + public function testRejectsTooManySnapshots(): void { + $p = $this->valid(); $p['snapshots'] = ['a', 'b', 'c', 'd']; + $this->assertFalse(EventValidator::validate($p)['ok']); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php` + +```php + 3) $errors[] = 'too_many:snapshots'; + else foreach ($snaps as $s) if (!is_string($s)) { $errors[] = 'bad:snapshot_item'; break; } + + if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []]; + + $clean = [ + 'device_id' => (string)$p['device_id'], + 'camera_id' => (string)$p['camera_id'], + 'event_type' => (string)$p['event_type'], + 'severity' => (string)$p['severity'], + 'track_id' => isset($p['track_id']) ? (int)$p['track_id'] : null, + 'zone_id' => isset($p['zone_id']) ? (string)$p['zone_id'] : null, + 'occurred_at' => (float)$p['occurred_at'], + 'duration_s' => (float)($p['duration_s'] ?? 0.0), + 'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [], + ]; + return ['ok' => true, 'errors' => [], 'clean' => $clean]; + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php +git commit -m "feat(cloud): event payload validator with enum whitelist" +``` + +--- + +## Task 5: ImageProcessor (gd 재인코딩 + UUID) + +**Files:** +- Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php` + +**Interfaces:** +- Consumes: (없음) +- Produces: `App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array` — `['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트). + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/ImageProcessorTest.php` + +```php +tinyJpegBase64()); + $this->assertTrue($r['ok']); + $this->assertSame("\xFF\xD8", substr($r['bytes'], 0, 2)); // JPEG magic + $this->assertSame(32, strlen($r['uuid'])); + $this->assertSame(64, strlen($r['hash'])); + } + public function testRejectsNonImage(): void { + $r = ImageProcessor::reencode(base64_encode("not an image ")); + $this->assertFalse($r['ok']); + } + public function testRejectsBadBase64(): void { + $this->assertFalse(ImageProcessor::reencode('%%%not-base64%%%')['ok']); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php` + +```php + false]; + $im = @imagecreatefromstring($raw); + if ($im === false) return ['ok' => false]; + ob_start(); + $ok = imagejpeg($im, null, $quality); + $bytes = (string)ob_get_clean(); + imagedestroy($im); + if (!$ok || $bytes === '') return ['ok' => false]; + return [ + 'ok' => true, + 'bytes' => $bytes, + 'uuid' => bin2hex(random_bytes(16)), + 'hash' => hash('sha256', $bytes), + ]; + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php +git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)" +``` + +--- + +## Task 6: Http Request/Response + +**Files:** +- Create: `cloud/src/Http/Request.php`, `cloud/src/Http/Response.php`, `cloud/tests/HttpTest.php` + +**Interfaces:** +- Consumes: (없음) +- Produces: + - `App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post)` with getters `method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string`, and static `fromGlobals(): self`. + - `App\Http\Response`: static `json(array $data, int $status=200): self`, `text(string $s, int $status=200): self`, `bytes(string $b, string $contentType, int $status=200): self`, `redirect(string $to): self`; getters `status(), headers(), body()`; `send(): void`. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/HttpTest.php` + +```php + 'n1'], '{"a":1}', [], []); + $this->assertSame('POST', $r->method()); + $this->assertSame('n1', $r->header('x-nonce')); + $this->assertSame(['a' => 1], $r->json()); + } + public function testResponseJson(): void { + $res = Response::json(['ok' => true], 201); + $this->assertSame(201, $res->status()); + $this->assertStringContainsString('application/json', $res->headers()['Content-Type']); + $this->assertSame('{"ok":true}', $res->body()); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Http/Request.php` + +```php +headers = []; + foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v; + } + public function method(): string { return $this->method; } + public function path(): string { return $this->path; } + public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; } + public function body(): string { return $this->body; } + public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; } + public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; } + public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; } + + public static function fromGlobals(): self + { + $headers = function_exists('getallheaders') ? (getallheaders() ?: []) : []; + return new self( + $_SERVER['REQUEST_METHOD'] ?? 'GET', + parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/', + $headers, file_get_contents('php://input') ?: '', $_GET, $_POST + ); + } +} +``` + +`cloud/src/Http/Response.php`: + +```php + 'application/json'], json_encode($data, JSON_UNESCAPED_UNICODE)); + } + public static function text(string $s, int $status = 200): self { + return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s); + } + public static function bytes(string $b, string $contentType, int $status = 200): self { + return new self($status, ['Content-Type' => $contentType], $b); + } + public static function redirect(string $to): self { + return new self(302, ['Location' => $to], ''); + } + public function status(): int { return $this->status; } + public function headers(): array { return $this->headers; } + public function body(): string { return $this->body; } + public function send(): void { + http_response_code($this->status); + foreach ($this->headers as $k => $v) header("$k: $v"); + echo $this->body; + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Http/Request.php cloud/src/Http/Response.php cloud/tests/HttpTest.php +git commit -m "feat(cloud): Http Request/Response primitives" +``` + +--- + +## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합 + +**Files:** +- Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php` + +**Interfaces:** +- Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1) +- Produces: `App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository)` with `handle(Request $req, int $now): Response`. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 `{event_id}`. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/IngestControllerTest.php` + +```php +dir = sys_get_temp_dir() . '/snaps' . bin2hex(random_bytes(4)); mkdir($this->dir); } + + private function make(array $secrets = ['pi-1' => 'sekret']): array { + $cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'rate_limit_per_min' => 60]); + $dev = new InMemoryDeviceRepository($secrets); + $nonce = new InMemoryNonceRepository(); + $rl = new InMemoryRateLimiter(); + $events = new InMemoryEventRepository(); + $snaps = new InMemorySnapshotRepository(); + $c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps); + return [$c, $events, $snaps, $nonce]; + } + private function jpegB64(): string { $im = imagecreatetruecolor(8, 8); ob_start(); imagejpeg($im); $b = ob_get_clean(); imagedestroy($im); return base64_encode($b); } + + private function signedReq(string $secret, array $payload, string $nonce = 'n1', string $ts = '1000'): Request { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $sig = Hmac::sign($secret, $nonce, $ts, $body); + return new Request('POST', '/api/events', + ['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $body, [], []); + } + private function payload(): array { + return ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'high', 'occurred_at' => 1000.0, 'snapshots' => [$this->jpegB64()]]; + } + + public function testAcceptsValidSignedEvent(): void { + [$c, $events, $snaps] = $this->make(); + $res = $c->handle($this->signedReq('sekret', $this->payload()), now: 1000); + $this->assertSame(201, $res->status()); + $this->assertCount(1, $events->rows); + $id = json_decode($res->body(), true)['event_id']; + $this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored + } + public function testRejectsBadSignature(): void { + [$c] = $this->make(); + $req = $this->signedReq('WRONG', $this->payload()); + $this->assertSame(401, $c->handle($req, now: 1000)->status()); + } + public function testRejectsReplayedNonce(): void { + [$c] = $this->make(); + $req = $this->signedReq('sekret', $this->payload()); + $this->assertSame(201, $c->handle($req, now: 1000)->status()); + $this->assertSame(401, $c->handle($req, now: 1000)->status()); // same nonce again + } + public function testRejectsStaleTimestamp(): void { + [$c] = $this->make(); + $req = $this->signedReq('sekret', $this->payload(), ts: '1000'); + $this->assertSame(401, $c->handle($req, now: 5000)->status()); // 4000s > 300 window + } + public function testRejectsUnknownDevice(): void { + [$c] = $this->make([]); // no secrets + $this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000)->status()); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php` + +```php +json(); + $deviceId = (string)($payload['device_id'] ?? ''); + $window = intdiv($now, 60); + if ($deviceId !== '' && $this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) { + return Response::json(['error' => 'rate_limited'], 429); + } + + $nonce = $req->header('X-Nonce') ?? ''; + $ts = $req->header('X-Timestamp') ?? ''; + $sig = $req->header('X-Signature') ?? ''; + $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; + if ($secret === null || $nonce === '' || $ts === '' || $sig === '') { + return Response::json(['error' => 'unauthorized'], 401); + } + if (!Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { + return Response::json(['error' => 'bad_signature'], 401); + } + if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) { + return Response::json(['error' => 'stale_timestamp'], 401); + } + if ($this->nonces->seen($nonce)) { + return Response::json(['error' => 'replay'], 401); + } + $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl()); + + $v = EventValidator::validate($payload); + if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400); + + $eventId = $this->events->insert($v['clean']); + foreach (($payload['snapshots'] ?? []) as $b64) { + $img = ImageProcessor::reencode((string)$b64); + if (!$img['ok']) continue; + $path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg'; + file_put_contents($path, $img['bytes']); + $this->snapshots->add($eventId, $img['uuid'], $img['hash']); + } + return Response::json(['event_id' => $eventId], 201); + } +} +``` + +> **NOTE (implementer):** `InMemorySnapshotRepository::add` stores a `/tmp/...` path in the double; the controller also writes the real file to `snapshot_dir` (setUp creates the dir). Tests assert on `forEvent()` count only. Rate-limit is checked first so a flood is rejected before crypto work. + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php +git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist)" +``` + +--- + +## Task 8: Password (argon2id) + Csrf + +**Files:** +- Create: `cloud/src/Security/Password.php`, `cloud/src/Security/Csrf.php`, `cloud/tests/PasswordCsrfTest.php` + +**Interfaces:** +- Consumes: (없음) +- Produces: + - `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`. + - `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals). + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/PasswordCsrfTest.php` + +```php +assertNotSame('s3cret!', $h); + $this->assertTrue(Password::verify('s3cret!', $h)); + $this->assertFalse(Password::verify('wrong', $h)); + } + public function testCsrf(): void { + $t = Csrf::issue(fn() => 'abc123'); + $this->assertSame('abc123', $t); + $this->assertTrue(Csrf::check($t, 'abc123')); + $this->assertFalse(Csrf::check($t, 'nope')); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Security/Password.php` + +```php +assertTrue(Rbac::can('viewer', 'view_events')); + $this->assertFalse(Rbac::can('viewer', 'acknowledge')); + $this->assertFalse(Rbac::can('viewer', 'edit_zones')); + } + public function testOperator(): void { + $this->assertTrue(Rbac::can('operator', 'acknowledge')); + $this->assertTrue(Rbac::can('operator', 'edit_zones')); + $this->assertFalse(Rbac::can('operator', 'manage_thresholds')); + } + public function testAdminAll(): void { + foreach (['view_events','acknowledge','edit_zones','manage_thresholds','manage_compliance'] as $p) { + $this->assertTrue(Rbac::can('admin', $p)); + } + } + public function testUnknownDenied(): void { + $this->assertFalse(Rbac::can('ghost', 'view_events')); + $this->assertFalse(Rbac::can('admin', 'unknown_perm')); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php` + +```php + ['view_events','acknowledge','edit_zones','manage_thresholds','manage_compliance'], + 'operator' => ['view_events','acknowledge','edit_zones'], + 'viewer' => ['view_events'], + ]; + public static function can(string $role, string $permission): bool { + return in_array($permission, self::MATRIX[$role] ?? [], true); + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php +git commit -m "feat(cloud): RBAC permission matrix" +``` + +--- + +## Task 10: Session + AuthController (로그인/brute-force 잠금) + +**Files:** +- Create: `cloud/src/Auth/Session.php`, `cloud/src/Auth/AuthController.php`, `cloud/tests/AuthControllerTest.php` + +**Interfaces:** +- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3) +- Produces: + - `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie). + - `App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900)` with `login(Request $req, SessionStore $sess, int $now): Response` and `logout(SessionStore $sess): Response`. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/AuthControllerTest.php` + +```php + [ + 'id' => 1, 'username' => 'alice', 'password_hash' => Password::hash('pw'), + 'role' => 'operator', 'site_scope' => null, 'failed_attempts' => 0, 'locked_until' => 0, + ]]); + } + private function req(string $u, string $p): Request { + return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]); + } + public function testLoginSuccessSetsSession(): void { + $c = new AuthController($this->users(), new InMemoryAuditRepository()); + $sess = new ArraySessionStore(); + $res = $c->login($this->req('alice', 'pw'), $sess, now: 1000); + $this->assertSame(200, $res->status()); + $this->assertSame(1, $sess->get('user_id')); + $this->assertSame('operator', $sess->get('role')); + } + public function testBadPasswordIs401(): void { + $c = new AuthController($this->users(), new InMemoryAuditRepository()); + $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000)->status()); + } + public function testLockoutAfterMaxAttempts(): void { + $repo = $this->users(); + $c = new AuthController($repo, new InMemoryAuditRepository(), maxAttempts: 3, lockSeconds: 900); + for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000); + // now locked, even correct password is 423 + $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000)->status()); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php` + +```php +d[$k] ?? null; } + public function set(string $k, mixed $v): void { $this->d[$k] = $v; } + public function clear(): void { $this->d = []; } +} + +final class PhpSessionStore implements SessionStore { + public function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) session_start([ + 'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax', + ]); } + public function get(string $k): mixed { return $_SESSION[$k] ?? null; } + public function set(string $k, mixed $v): void { $_SESSION[$k] = $v; } + public function clear(): void { $_SESSION = []; session_destroy(); } +} +``` + +`cloud/src/Auth/AuthController.php`: + +```php +post('username'); + $password = (string)$req->post('password'); + $u = $this->users->byUsername($username); + if ($u === null) return Response::json(['error' => 'invalid_credentials'], 401); + + if ((int)($u['locked_until'] ?? 0) > $now) { + $this->audit->append($username, 'login_locked', 'auth', [], []); + return Response::json(['error' => 'locked'], 423); + } + if (!Password::verify($password, (string)$u['password_hash'])) { + $attempts = (int)($u['failed_attempts'] ?? 0) + 1; + $lock = $attempts >= $this->maxAttempts ? $now + $this->lockSeconds : 0; + $this->users->recordFailure((int)$u['id'], $lock); + $this->audit->append($username, 'login_failed', 'auth', [], ['attempts' => $attempts]); + return Response::json(['error' => 'invalid_credentials'], 401); + } + $this->users->clearFailures((int)$u['id']); + $sess->set('user_id', (int)$u['id']); + $sess->set('username', $u['username']); + $sess->set('role', $u['role']); + $sess->set('site_scope', $u['site_scope'] ?? null); + $this->audit->append($username, 'login_ok', 'auth', [], []); + return Response::json(['ok' => true], 200); + } + + public function logout(SessionStore $sess): Response + { + $sess->clear(); + return Response::json(['ok' => true], 200); + } +} +``` + +> **NOTE (implementer):** the lockout test relies on `InMemoryUserRepository::recordFailure` mutating `failed_attempts`/`locked_until` on the stored row so the next `byUsername` reflects it (the double loops by reference — verify this persists across calls). `login` reads `locked_until` fresh on each call. + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Auth/Session.php cloud/src/Auth/AuthController.php cloud/tests/AuthControllerTest.php +git commit -m "feat(cloud): session store + auth controller with lockout" +``` + +--- + +## Task 11: Router + 프론트컨트롤러 준비 + +**Files:** +- Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php` + +**Interfaces:** +- Consumes: `Request`/`Response` (T6) +- Produces: `App\Http\Router` with `add(string $method, string $path, callable $handler): void` and `dispatch(Request $req): Response` (404 if no match; path params `{id}` → passed to handler as second arg assoc array). + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/RouterTest.php` + +```php +add('GET', '/events/{id}', fn(Request $req, array $p) => Response::json(['id' => $p['id']])); + $res = $r->dispatch(new Request('GET', '/events/42', [], '', [], [])); + $this->assertSame(200, $res->status()); + $this->assertSame(['id' => '42'], json_decode($res->body(), true)); + } + public function testUnmatchedIs404(): void { + $r = new Router(); + $this->assertSame(404, $r->dispatch(new Request('GET', '/nope', [], '', [], []))->status()); + } + public function testMethodMismatchIs404(): void { + $r = new Router(); + $r->add('POST', '/x', fn() => Response::text('ok')); + $this->assertSame(404, $r->dispatch(new Request('GET', '/x', [], '', [], []))->status()); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Http/Router.php` + +```php +[^/]+)', $path) . '$#'; + $this->routes[] = [strtoupper($method), $regex, $handler]; + } + + public function dispatch(Request $req): Response { + foreach ($this->routes as [$method, $regex, $handler]) { + if ($req->method() !== $method) continue; + if (preg_match($regex, $req->path(), $m)) { + $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY); + return $handler($req, $params); + } + } + return Response::json(['error' => 'not_found'], 404); + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Http/Router.php cloud/tests/RouterTest.php +git commit -m "feat(cloud): path-param router + dispatch" +``` + +--- + +## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시 + +**Files:** +- Create: `cloud/src/Dashboard/EventListController.php`, `cloud/src/Dashboard/EventDetailController.php`, `cloud/src/Dashboard/SnapshotProxy.php`, `cloud/tests/DashboardTest.php` + +**Interfaces:** +- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3) +- Produces: + - `EventListController(EventRepository, SessionStore)::json(Request): Response` — 인증 필요, `?severity=` 필터, 최근 100개. + - `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`). + - `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/DashboardTest.php` + +```php +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()); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php` + +```php +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); + } +} +``` + +`cloud/src/Dashboard/EventDetailController.php`: + +```php +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); + } +} +``` + +`cloud/src/Dashboard/SnapshotProxy.php`: + +```php +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); + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Dashboard/EventListController.php cloud/src/Dashboard/EventDetailController.php cloud/src/Dashboard/SnapshotProxy.php cloud/tests/DashboardTest.php +git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot proxy" +``` + +--- + +## Task 13: Polygon 검증 + ZoneConfigController + +**Files:** +- Create: `cloud/src/Geometry/Polygon.php`, `cloud/src/Dashboard/ZoneConfigController.php`, `cloud/tests/ZoneConfigTest.php` + +**Interfaces:** +- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3) +- Produces: + - `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자. + - `ZoneConfigController(ZoneRepository, AuditRepository, SessionStore)`: `list(Request, array): Response` (camera별), `save(Request): Response` (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), `delete(Request, array): Response`. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/ZoneConfigTest.php` + +```php +set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); return $s; + } + 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): Request { + return new Request('POST', '/api/zones', [], json_encode($body), [], []); + } + public function testSaveRequiresPermission(): void { + $c = new ZoneConfigController(new InMemoryZoneRepository(), 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, 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 testSaveRejectsBadLevelOrPolygon(): void { + $c = new ZoneConfigController(new InMemoryZoneRepository(), 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()); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php` + +```php +sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), $perm)) return Response::json(['error' => 'forbidden'], 403); + return null; + } + + public function list(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200); + } + + public function save(Request $req): Response + { + if ($g = $this->guard('edit_zones')) return $g; + $b = $req->json(); + $level = (string)($b['level'] ?? ''); + $polygon = $b['polygon'] ?? []; + if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) { + return Response::json(['error' => 'invalid_zone'], 400); + } + $zoneId = $this->zones->save([ + 'zone_id' => $b['zone_id'] ?? null, + 'device_id' => (string)($b['device_id'] ?? ''), + 'camera_id' => (string)($b['camera_id'] ?? ''), + 'level' => $level, + 'polygon_json' => json_encode($polygon), + ]); + $this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]); + return Response::json(['zone_id' => $zoneId], 201); + } + + public function delete(Request $req, array $params): Response + { + if ($g = $this->guard('edit_zones')) return $g; + $id = (string)($params['id'] ?? ''); + if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404); + $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []); + return Response::json(['ok' => true], 200); + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Geometry/Polygon.php cloud/src/Dashboard/ZoneConfigController.php cloud/tests/ZoneConfigTest.php +git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)" +``` + +--- + +## Task 14: ConfigController + HeartbeatController (Pi 동기 API) + +**Files:** +- Create: `cloud/src/Api/ConfigController.php`, `cloud/src/Api/HeartbeatController.php`, `cloud/tests/ApiSyncTest.php` + +**Interfaces:** +- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1) +- Produces: + - `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`. + - `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200. + +- [ ] **Step 1: 실패 테스트** — `cloud/tests/ApiSyncTest.php` + +```php + $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $raw, $q, []); + } + public function testConfigReturnsZonesAndThresholds(): void { + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]); + $zones = new InMemoryZoneRepository(); $zones->save(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); + $thr = new InMemoryThresholdRepository(['pi-1' => ['fall_prone_s' => 3.0]]); + $c = new ConfigController($dev, $zones, $thr); + $res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'])); + $this->assertSame(200, $res->status()); + $data = json_decode($res->body(), true); + $this->assertSame(7, $data['version']); + $this->assertCount(1, $data['zones']); + $this->assertSame(3.0, $data['thresholds']['fall_prone_s']); + } + public function testConfigRejectsBadSignature(): void { + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository()); + $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1'])); + $this->assertSame(401, $res->status()); + } + public function testHeartbeatStored(): void { + $cfg = Config::fromArray([]); + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $c = new HeartbeatController($cfg, $dev); + $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; + $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); + $this->assertSame(200, $res->status()); + $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); + } +} +``` + +- [ ] **Step 2: 실패 확인** → FAIL + +- [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php` + +```php +query('device_id'); + $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; + $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; + if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { + return Response::json(['error' => 'unauthorized'], 401); + } + return Response::json([ + 'version' => $this->devices->configVersion($deviceId), + 'zones' => $this->zones->forDevice($deviceId), + 'thresholds' => $this->thresholds->forDevice($deviceId), + ], 200); + } +} +``` + +`cloud/src/Api/HeartbeatController.php`: + +```php +json(); + $deviceId = (string)($b['device_id'] ?? ''); + $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; + $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; + if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { + return Response::json(['error' => 'unauthorized'], 401); + } + $this->devices->touchHeartbeat($deviceId, [ + 'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0), + 'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0), + 'at' => $now, + ]); + return Response::json(['ok' => true], 200); + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +```bash +git add cloud/src/Api/ConfigController.php cloud/src/Api/HeartbeatController.php cloud/tests/ApiSyncTest.php +git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)" +``` + +--- + +## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합) + +**Files:** +- Create: `cloud/public/index.php`, `cloud/public/.htaccess`, `cloud/public/assets/{app.css,app.js,zone-draw.js}`, `cloud/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `cloud/tests/WiringSmokeTest.php` + +**Interfaces:** +- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만). +- Produces: `cloud/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁). + +라우트 표: +| method | path | handler | +|---|---|---| +| POST | /api/events | IngestController::handle | +| POST | /api/heartbeat | HeartbeatController::post | +| GET | /api/config | ConfigController::get | +| POST | /login | AuthController::login | +| POST | /logout | AuthController::logout | +| GET | /api/events | EventListController::json | +| GET | /events/{id} | EventDetailController::show | +| POST | /events/{id}/ack | EventDetailController::acknowledge | +| GET | /api/zones/{camera} | ZoneConfigController::list | +| POST | /api/zones | ZoneConfigController::save | +| POST | /api/zones/{id}/delete | ZoneConfigController::delete | +| GET | /snap/{uuid} | SnapshotProxy::serve | +| GET | /, /zones, /login | View render | + +- [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php` + +```php +add($m, $p, fn() => Response::json(['route' => $p])); + $this->assertSame(200, $r->dispatch(new Request('GET', '/events/9', [], '', [], []))->status()); + $this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status()); + $this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->status()); + } +} +``` + +- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS. + +- [ ] **Step 3: 프론트컨트롤러** — `cloud/public/index.php` + +```php +snapshotDir()); +$users = new PdoUserRepository($pdo); +$zones = new PdoZoneRepository($pdo); +$thr = new PdoThresholdRepository($pdo); +$audit = new PdoAuditRepository($pdo); + +$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); +$config = new ConfigController($devices, $zones, $thr); +$heartbeat = new HeartbeatController($cfg, $devices); +$auth = new AuthController($users, $audit); +$list = new EventListController($events, $sess); +$detail = new EventDetailController($events, $snaps, $audit, $sess); +$proxy = new SnapshotProxy($snaps, $sess); +$zoneCtl = new ZoneConfigController($zones, $audit, $sess); + +$render = function (string $view): Response { + ob_start(); require __DIR__ . '/../src/View/' . $view . '.php'; + return Response::text((string)ob_get_clean()); +}; + +$r = new Router(); +$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now)); +$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now)); +$r->add('GET', '/api/config', fn(Request $q) => $config->get($q)); +$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now)); +$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); +$r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); +$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); +$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); +$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); +$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); +$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p)); +$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)); +$r->add('GET', '/', fn() => $render('events')); +$r->add('GET', '/login', fn() => $render('login')); +$r->add('GET', '/zones', fn() => $render('zones')); + +$r->dispatch(Request::fromGlobals())->send(); +``` + +- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess` + +```apache +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] +``` + +- [ ] **Step 5: 뷰 + 자산** — 서버렌더 PHP 뷰(로직 없음, 데이터는 JS가 API에서 로드): + - `src/View/layout.php`: 공통 헤더/네비. + - `src/View/login.php`: username/password 폼(POST /login), CSRF hidden. + - `src/View/events.php`: 실시간 목록 컨테이너 + `