move_detect/server/tests/EvidenceHoldTest.php
유창욱 3aca674a78
Some checks failed
verify / edge (push) Has been cancelled
verify / server (push) Has been cancelled
feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.

읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.

크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.

증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.

증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.

마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).

검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-25 18:34:26 +09:00

184 lines
8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoAppStateRepository};
use PDO;
use PHPUnit\Framework\TestCase;
/**
* 증거 보존 보류 — 보존기간 정리가 조사 중인 사건의 사진을 지우지 않는다.
*
* 이 결함은 조용했다. 스냅샷 삭제는 나이 하나만 봤고, 기본 보존은 7일이다. 중대재해 조사는
* 몇 주에서 몇 달이라, 우리가 고객과 노조에 말해 온 목적("사고 후 원인 분석")이 8일째에
* 스스로 증발했다. 아무 오류도 나지 않고, 로그도 남지 않고, 그때가 되어서야 없는 걸 안다.
*
* 납품되는 실제 SQL(LEFT JOIN + hold 조건)이 도는 곳이 여기다 — 다른 테스트의 인메모리
* 대역은 쿼리 의미를 PHP 로 다시 구현하므로 쿼리가 틀려도 통과한다.
*/
final class EvidenceHoldTest extends TestCase
{
private PDO $pdo;
private string $dir;
protected function setUp(): void
{
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
$this->markTestSkipped('pdo_sqlite not available on this host');
}
$this->pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->pdo->exec(<<<'SQL'
CREATE TABLE move_events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
camera_id TEXT NOT NULL,
event_type TEXT NOT NULL,
severity TEXT NOT NULL,
occurred_at REAL NOT NULL,
duration_s REAL NOT NULL DEFAULT 0,
hold_until INTEGER,
received_at INTEGER NOT NULL
);
CREATE TABLE move_event_snapshots (
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
file_uuid TEXT NOT NULL UNIQUE,
content_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE move_app_state (
k TEXT PRIMARY KEY,
v TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
SQL);
$this->dir = sys_get_temp_dir() . '/move-hold-' . bin2hex(random_bytes(6));
mkdir($this->dir);
}
protected function tearDown(): void
{
if (!isset($this->dir) || !is_dir($this->dir)) return;
foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f);
@rmdir($this->dir);
}
/** 한 사건 + 그 사건의 스냅샷 1장. 반환값은 [event_id, file_uuid]. */
private function seed(int $createdAt, ?int $holdUntil = null): array
{
$st = $this->pdo->prepare(
'INSERT INTO move_events (device_id, camera_id, event_type, severity, occurred_at,
hold_until, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?)');
$st->execute(['pi-01', 'cam-01', 'fall', 'high', (float)$createdAt, $holdUntil, $createdAt]);
$eventId = (int)$this->pdo->lastInsertId();
$uuid = bin2hex(random_bytes(16));
file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
$this->pdo->prepare(
'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)')->execute([$eventId, $uuid, str_repeat('a', 64), $createdAt]);
return [$eventId, $uuid];
}
private function snaps(): PdoSnapshotRepository
{
return new PdoSnapshotRepository($this->pdo, $this->dir);
}
public function test_an_old_snapshot_is_purged_when_nothing_holds_it(): void
{
[, $uuid] = $this->seed(1_000);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(1, $purged);
$this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
}
public function test_a_held_snapshot_survives_its_retention_window(): void
{
[, $uuid] = $this->seed(1_000, holdUntil: 9_000);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(0, $purged, '조사 중인 사건의 사진이 보존기간 때문에 지워졌습니다.');
$this->assertFileExists($this->dir . '/' . $uuid . '.jpg');
}
public function test_the_hold_stops_protecting_once_it_expires(): void
{
// 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. 기간이 지나면 다시 대상이 된다.
[, $uuid] = $this->seed(1_000, holdUntil: 2_500);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(1, $purged);
$this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
}
public function test_one_hold_does_not_shield_other_events(): void
{
// JOIN 조건이 틀리면(예: 보류 행이 하나라도 있으면 전부 남김) 프라이버시 기본값이
// 조용히 "영구 보존"으로 뒤집힌다. 한 건만 남고 나머지는 지워져야 한다.
[, $held] = $this->seed(1_000, holdUntil: 9_000);
[, $a] = $this->seed(1_000);
[, $b] = $this->seed(1_000);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(2, $purged);
$this->assertFileExists($this->dir . '/' . $held . '.jpg');
$this->assertFileDoesNotExist($this->dir . '/' . $a . '.jpg');
$this->assertFileDoesNotExist($this->dir . '/' . $b . '.jpg');
}
public function test_an_orphan_snapshot_is_still_purged(): void
{
// 이벤트 행이 먼저 정리된 스냅샷은 보류가 아니라 잔여물이다. LEFT JOIN 이 NULL 을
// 내므로 대상이 되어야 한다 — INNER JOIN 으로 쓰면 이게 영원히 안 지워지고,
// 작업자 사진이 보존기간을 넘겨 디스크에 남는 PIPA 누출이 된다.
$uuid = bin2hex(random_bytes(16));
file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
$this->pdo->prepare(
'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)')->execute([9999, $uuid, str_repeat('b', 64), 1_000]);
$this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
$this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
}
public function test_setting_and_clearing_a_hold_round_trips(): void
{
[$id] = $this->seed(1_000);
$events = new PdoEventRepository($this->pdo);
$this->assertTrue($events->setHold($id, 9_000));
$this->assertSame(0, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
$this->assertTrue($events->setHold($id, null));
$this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
}
public function test_holding_a_missing_event_reports_failure(): void
{
$this->assertFalse((new PdoEventRepository($this->pdo))->setHold(4242, 9_000));
}
public function test_app_state_upsert_round_trips(): void
{
$state = new PdoAppStateRepository($this->pdo);
$this->assertNull($state->get('dispatch_alerts.last_run'));
$state->set('dispatch_alerts.last_run', '1000', 1000);
$this->assertSame(['value' => '1000', 'updated_at' => 1000],
$state->get('dispatch_alerts.last_run'));
// 덮어쓰기여야 한다. 행이 쌓이면 대시보드가 첫 행(옛날 시각)을 읽고 크론이 죽었다고
// 오판하거나, 그 반대로 영원히 살아있다고 본다.
$state->set('dispatch_alerts.last_run', '2000', 2000);
$this->assertSame(['value' => '2000', 'updated_at' => 2000],
$state->get('dispatch_alerts.last_run'));
$this->assertSame(1, (int)$this->pdo->query('SELECT COUNT(*) FROM move_app_state')->fetchColumn());
}
}