앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 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
184 lines
8 KiB
PHP
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());
|
|
}
|
|
}
|