앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 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
100 lines
4.9 KiB
PHP
100 lines
4.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Guards the REAL route table in public/index.php.
|
|
*
|
|
* WiringSmokeTest exercises the Router's matching algorithm against dummy handlers it registers
|
|
* itself — it never reads index.php, so a route deleted, misspelled, or never added there does
|
|
* not fail any test. index.php cannot simply be require'd (it dispatches on load and touches
|
|
* superglobals), so this parses its registrations instead.
|
|
*
|
|
* That matters most for an air-gapped delivery: a missing route is invisible until an operator
|
|
* on a site with no internet clicks the button.
|
|
*/
|
|
final class RouteTableTest extends TestCase
|
|
{
|
|
/** @return list<string> "METHOD path" pairs registered in index.php */
|
|
private function registeredRoutes(): array
|
|
{
|
|
$src = file_get_contents(dirname(__DIR__) . '/public/index.php');
|
|
$this->assertNotFalse($src, 'index.php unreadable');
|
|
preg_match_all("/\\\$r->add\(\s*'([A-Z]+)'\s*,\s*'([^']+)'/", $src, $m, PREG_SET_ORDER);
|
|
return array_map(static fn ($x) => $x[1] . ' ' . $x[2], $m);
|
|
}
|
|
|
|
public function testEveryExpectedRouteIsRegistered(): void
|
|
{
|
|
// Adding a route must also update this list — that is the point: the list is the
|
|
// reviewed inventory of the app's public surface.
|
|
$expected = [
|
|
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
|
|
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
|
|
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
|
|
'POST /events/{id}/hold',
|
|
'GET /api/zones/{camera}', 'GET /api/zone-presets',
|
|
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
|
|
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
|
|
'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones',
|
|
'POST /api/zones/{id}/delete', 'GET /snap/{uuid}',
|
|
// 성능 추이 · 임계값 · 통보 채널 · 억제 · 위험성평가 · 인수인계 · 사고조사 패키지
|
|
'GET /api/devices/{id}/metrics',
|
|
'GET /api/admin/thresholds', 'POST /api/admin/thresholds',
|
|
'POST /api/admin/thresholds/{id}/delete',
|
|
'GET /api/admin/channels', 'POST /api/admin/channels',
|
|
'POST /api/admin/channels/{id}/delete',
|
|
'GET /api/suppressions', 'POST /api/suppressions', 'POST /api/suppressions/{id}/revoke',
|
|
'GET /api/risks', 'POST /api/risks', 'POST /api/risks/{id}/delete',
|
|
'GET /api/handovers', 'POST /api/handovers',
|
|
'GET /api/events/{id}/package',
|
|
'GET /', 'GET /login', 'GET /zones', 'GET /report', 'GET /events/{id}/view',
|
|
'GET /admin', 'GET /compliance', 'GET /setup', 'GET /events/{id}/package/view',
|
|
];
|
|
$actual = $this->registeredRoutes();
|
|
sort($expected);
|
|
$sorted = $actual;
|
|
sort($sorted);
|
|
$this->assertSame($expected, $sorted);
|
|
}
|
|
|
|
public function testNoRouteIsRegisteredTwice(): void
|
|
{
|
|
// A duplicate silently shadows the first registration (first match wins) — the kind of
|
|
// copy-paste slip that survives review because both lines look correct in isolation.
|
|
$routes = $this->registeredRoutes();
|
|
$this->assertSame(array_values(array_unique($routes)), $routes);
|
|
}
|
|
|
|
public function testEveryEndpointTheFrontendCallsExists(): void
|
|
{
|
|
// The frontend is vanilla JS with no build step, so a typo'd URL is caught by nothing —
|
|
// no bundler, no type checker. Cross-check the two halves against each other.
|
|
$registered = array_map(static fn ($r) => explode(' ', $r, 2)[1], $this->registeredRoutes());
|
|
|
|
$calls = [];
|
|
foreach (glob(dirname(__DIR__) . '/public/assets/*.js') as $js) {
|
|
preg_match_all("/B \+ '([^']+)'/", (string)file_get_contents($js), $m);
|
|
foreach ($m[1] as $raw) {
|
|
// The JS concatenates ids onto a literal prefix ("B + '/events/' + id"), so what
|
|
// is visible here is a PREFIX, not a full path. Strip the query string and the
|
|
// trailing slash to get the literal part.
|
|
$path = rtrim(strtok($raw, '?'), '/');
|
|
$calls[$path === '' ? '/' : $path] = basename($js);
|
|
}
|
|
}
|
|
|
|
foreach ($calls as $path => $file) {
|
|
// Match on a SEGMENT boundary: '/events' is satisfied by '/events/{id}', but a typo
|
|
// like '/api/actio' is NOT satisfied by '/api/actions' (no '/api/actio/' route).
|
|
$hit = in_array($path, $registered, true);
|
|
foreach ($registered as $route) {
|
|
if ($hit) break;
|
|
$hit = str_starts_with($route, rtrim($path, '/') . '/');
|
|
}
|
|
$this->assertTrue($hit, "$file calls $path but index.php registers no such route");
|
|
}
|
|
}
|
|
}
|