dir = sys_get_temp_dir() . '/move-readaudit-' . bin2hex(random_bytes(6)); mkdir($this->dir); } protected function tearDown(): void { foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f); @rmdir($this->dir); } /** @return array{0:SnapshotProxy,1:InMemoryAuditRepository,2:ArraySessionStore,3:string} */ private function proxy(?string $siteScope = null): array { $events = new InMemoryEventRepository(); $eventId = $events->insert([ 'device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => 'A동', ]); $uuid = bin2hex(random_bytes(16)); file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes'); $snaps = new InMemorySnapshotRepository($this->dir); $snaps->add($eventId, $uuid, str_repeat('a', 64)); $audit = new InMemoryAuditRepository(); $sess = new ArraySessionStore(); $sess->set('user_id', 7); $sess->set('username', '관리자'); $sess->set('role', 'admin'); $sess->set('site_scope', $siteScope); return [new SnapshotProxy($snaps, $events, $sess, $audit), $audit, $sess, $uuid]; } private function req(): Request { return new Request('GET', '/snap', [], '', []); } public function test_viewing_a_worker_image_is_recorded(): void { [$proxy, $audit, , $uuid] = $this->proxy(); $res = $proxy->serve($this->req(), ['uuid' => $uuid]); $this->assertSame(200, $res->status()); $this->assertCount(1, $audit->log, '사진을 열었는데 감사에 아무것도 남지 않았습니다.'); $this->assertSame('snapshot_view', $audit->log[0]['act']); $this->assertSame("snapshot:$uuid", $audit->log[0]['t']); $this->assertSame('관리자', $audit->log[0]['a']); } public function test_the_same_image_in_one_session_is_recorded_once(): void { // 목록 화면은 5초마다 를 다시 그린다. 감사 추가는 해시 체인 꼬리에 행 잠금을 // 잡는 작업이라, 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다. "이 로그인 세션에서 // 이 사진을 봤다"가 노조가 묻는 질문에 답하기 충분하다. [$proxy, $audit, , $uuid] = $this->proxy(); for ($i = 0; $i < 5; $i++) $proxy->serve($this->req(), ['uuid' => $uuid]); $this->assertCount(1, $audit->log); } public function test_a_different_image_is_recorded_separately(): void { [$proxy, $audit, , $uuid] = $this->proxy(); $proxy->serve($this->req(), ['uuid' => $uuid]); // 없는 uuid 는 404 라 기록도 없다 — 기록은 "본 것"이지 "요청한 것"이 아니다. $missing = $proxy->serve($this->req(), ['uuid' => bin2hex(random_bytes(16))]); $this->assertSame(404, $missing->status()); $this->assertCount(1, $audit->log); } public function test_an_unauthenticated_request_records_nothing(): void { [$proxy, $audit, $sess, $uuid] = $this->proxy(); $sess->set('user_id', null); $this->assertSame(401, $proxy->serve($this->req(), ['uuid' => $uuid])->status()); $this->assertSame([], $audit->log); } public function test_a_blocked_cross_site_read_records_nothing(): void { // 거부된 열람은 열람이 아니다. 여기서 기록하면 감사 로그가 "본 사람" 목록이 아니라 // "요청한 사람" 목록이 되어, 정작 누가 봤는지를 묻는 데 쓸 수 없게 된다. [$proxy, $audit, , $uuid] = $this->proxy(siteScope: 'B동'); $this->assertSame(404, $proxy->serve($this->req(), ['uuid' => $uuid])->status()); $this->assertSame([], $audit->log); } public function test_a_failing_audit_does_not_block_the_image(): void { // 감사가 안전 기능을 죽이면 안 된다 — 사고 조사 중에 DB 가 흔들린다고 사진이 안 열리면 // 그건 프라이버시 보호가 아니라 장애다. 대신 세션 메모에 넣지 않아 다음에 재시도한다. $events = new InMemoryEventRepository(); $eventId = $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => null]); $uuid = bin2hex(random_bytes(16)); file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes'); $snaps = new InMemorySnapshotRepository($this->dir); $snaps->add($eventId, $uuid, str_repeat('a', 64)); $audit = new class implements \App\Repository\AuditRepository { public int $calls = 0; public function append(string $a, string $act, string $t, array $b, array $af): void { $this->calls++; throw new \RuntimeException('db down'); } }; $sess = new ArraySessionStore(); $sess->set('user_id', 7); $sess->set('username', '관리자'); $sess->set('site_scope', null); $proxy = new SnapshotProxy($snaps, $events, $sess, $audit); $this->assertSame(200, $proxy->serve($this->req(), ['uuid' => $uuid])->status()); $proxy->serve($this->req(), ['uuid' => $uuid]); $this->assertSame(2, $audit->calls, '기록 실패 후 재시도하지 않았습니다 — 조용히 누락됩니다.'); } public function test_a_bulk_export_is_recorded_with_its_size(): void { $events = new InMemoryEventRepository(); for ($i = 0; $i < 3; $i++) { $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => (float)(time() - 100), 'site' => null]); } $audit = new InMemoryAuditRepository(); $sess = new ArraySessionStore(); $sess->set('user_id', 7); $sess->set('username', '관리자'); $sess->set('role', 'admin'); $sess->set('site_scope', null); $export = new ExportController($events, new InMemorySnapshotRepository($this->dir), $sess, $audit); $res = $export->labels(new Request('GET', '/api/export/labels', [], '', ['days' => '30'])); $this->assertSame(200, $res->status()); $this->assertCount(1, $audit->log, '최대 2만 건이 파일 하나로 나가는데 기록이 없습니다.'); $this->assertSame('label_export', $audit->log[0]['act']); $this->assertSame(30, $audit->log[0]['af']['days']); $this->assertSame(3, $audit->log[0]['af']['rows']); } public function test_a_forbidden_export_is_not_recorded_as_one(): void { $sess = new ArraySessionStore(); $sess->set('user_id', 7); $sess->set('username', '작업자'); $sess->set('role', 'viewer'); $sess->set('site_scope', null); $audit = new InMemoryAuditRepository(); $export = new ExportController(new InMemoryEventRepository(), new InMemorySnapshotRepository($this->dir), $sess, $audit); $res = $export->labels(new Request('GET', '/api/export/labels', [], '', [])); $this->assertSame(403, $res->status()); $this->assertSame([], $audit->log); } }