move_detect/server/tests/AlertDispatchTest.php
유창욱 e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +09:00

242 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Alert\{AlertDispatcher, EscalationWatch, Channels};
use App\Tests\Support\{InMemoryEventRepository, InMemoryAlertRepository, InMemorySuppressionRepository};
use PHPUnit\Framework\TestCase;
/**
* The notification path — the piece whose absence meant a fall at 03:00 produced a database row
* and nothing else. These tests pin the rules that decide whether a human is actually reached.
*/
final class AlertDispatchTest extends TestCase
{
private const NOW = 1_800_000_000;
private function channel(array $over = []): array
{
return $over + [
'channel_id' => 1, 'site' => null, 'kind' => 'email', 'name' => 'shift lead',
'config_json' => '{"to":["a@b.c"]}', 'min_severity' => 'high',
'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0,
];
}
private function event(array $over = []): array
{
return $over + [
'event_id' => 1, 'device_id' => 'pi-1', 'site' => null, 'camera_id' => 'cam-1',
'event_type' => 'fall', 'severity' => 'critical', 'zone_id' => null,
'occurred_at' => (float)(self::NOW - 10), 'duration_s' => 1.0,
'acknowledged_by' => null, 'false_positive_by' => null,
];
}
private function dispatcher(
?InMemoryEventRepository $ev = null, ?InMemoryAlertRepository $al = null,
?InMemorySuppressionRepository $su = null
): AlertDispatcher {
return new AlertDispatcher($ev ?? new InMemoryEventRepository(),
$al ?? new InMemoryAlertRepository(),
$su ?? new InMemorySuppressionRepository());
}
// ---- 심각도 문턱 ------------------------------------------------------------
public function testSeverityFloorIsAFloorNotAnEquality(): void
{
$d = $this->dispatcher();
// critical >= high 이므로 발송 대상이다. 예전 구현이 동등 비교였다면 넘어짐이 조용히 빠진다.
$this->assertCount(1, $d->planFor($this->event(['severity' => 'critical']), [$this->channel()], self::NOW));
$this->assertCount(0, $d->planFor($this->event(['severity' => 'warning']), [$this->channel()], self::NOW));
}
public function testDisabledChannelNeverFires(): void
{
$d = $this->dispatcher();
$this->assertCount(0, $d->planFor($this->event(), [$this->channel(['enabled' => 0])], self::NOW));
}
public function testLocalChannelsAreNotServerDelivered(): void
{
// 엣지가 자체 GPIO 로 울린다. 서버가 원장에 잡으면 영구 실패로 남는다.
$d = $this->dispatcher();
$this->assertCount(0, $d->planFor($this->event(), [$this->channel(['kind' => 'local'])], self::NOW));
}
// ---- 해소된 이벤트 ----------------------------------------------------------
public function testAcknowledgedEventStopsTheChain(): void
{
$d = $this->dispatcher();
$this->assertCount(0, $d->planFor($this->event(['acknowledged_by' => 'op']), [$this->channel()], self::NOW));
}
public function testFalsePositiveStopsTheChain(): void
{
$d = $this->dispatcher();
$this->assertCount(0, $d->planFor($this->event(['false_positive_by' => 'op']), [$this->channel()], self::NOW));
}
// ---- 에스컬레이션 타이밍 ----------------------------------------------------
public function testEscalationChannelWaitsForItsDelay(): void
{
$d = $this->dispatcher();
$ch = $this->channel(['channel_id' => 2, 'step' => 1, 'escalate_after_s' => 300]);
$young = $this->event(['occurred_at' => (float)(self::NOW - 60)]);
$old = $this->event(['occurred_at' => (float)(self::NOW - 600)]);
$this->assertCount(0, $d->planFor($young, [$ch], self::NOW), '아직 이르다');
$this->assertCount(1, $d->planFor($old, [$ch], self::NOW), '지연 경과 후 발화');
}
// ---- 사이트 스코프 ----------------------------------------------------------
public function testSiteScopedChannelOnlyServesItsOwnSite(): void
{
$d = $this->dispatcher();
$ch = $this->channel(['site' => 'factory-a']);
$this->assertCount(1, $d->planFor($this->event(['site' => 'factory-a']), [$ch], self::NOW));
$this->assertCount(0, $d->planFor($this->event(['site' => 'factory-b']), [$ch], self::NOW));
// site 가 NULL 인 채널은 전역 (단일 현장 기본 설치).
$this->assertCount(1, $d->planFor($this->event(['site' => 'factory-b']), [$this->channel()], self::NOW));
}
// ---- 억제 ------------------------------------------------------------------
public function testSuppressionWithholdsSendButStillRecordsIt(): void
{
$events = new InMemoryEventRepository();
$id = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_restricted',
'severity' => 'high', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]);
$alerts = new InMemoryAlertRepository([$this->channel()]);
$sup = new InMemorySuppressionRepository();
$sup->create(['scope_type' => 'camera', 'scope_id' => 'cam-1', 'site' => null,
'reason' => '정비', 'starts_at' => self::NOW - 60, 'ends_at' => self::NOW + 600,
'created_by' => 'op', 'created_at' => self::NOW - 60]);
$sent = 0;
$stats = $this->dispatcher($events, $alerts, $sup)
->run(self::NOW, 3600, 100, function () use (&$sent) { $sent++; });
$this->assertSame(0, $sent, '억제 중에는 실제 발송이 없다');
$this->assertSame(1, $stats['suppressed']);
// 그러나 원장에는 남는다 — 조사에서 "경보가 났지만 의도적으로 보류됐다"를 볼 수 있어야 한다.
$log = $alerts->forEvent($id);
$this->assertCount(1, $log);
$this->assertSame('suppressed', $log[0]['status']);
}
// ---- 중복 발송 방지 ---------------------------------------------------------
public function testSecondRunDoesNotResendTheSameEvent(): void
{
$events = new InMemoryEventRepository();
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]);
$alerts = new InMemoryAlertRepository([$this->channel()]);
$d = $this->dispatcher($events, $alerts);
$sent = 0;
$send = function () use (&$sent) { $sent++; };
$d->run(self::NOW, 3600, 100, $send);
$d->run(self::NOW, 3600, 100, $send); // 크론 중첩 실행을 흉내낸다
$this->assertSame(1, $sent, '같은 사건을 두 번 전화하지 않는다');
}
public function testOneFailingChannelDoesNotStopTheOthers(): void
{
$events = new InMemoryEventRepository();
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]);
$alerts = new InMemoryAlertRepository([
$this->channel(['channel_id' => 1, 'name' => 'broken']),
$this->channel(['channel_id' => 2, 'name' => 'works']),
]);
$ok = 0;
$stats = $this->dispatcher($events, $alerts)->run(self::NOW, 3600, 100,
function (array $e, array $ch) use (&$ok) {
if ($ch['name'] === 'broken') throw new \RuntimeException('relay down');
$ok++;
});
$this->assertSame(1, $ok, '불통 채널 하나가 나머지를 막지 않는다');
$this->assertSame(1, $stats['failed']);
$this->assertSame(1, $stats['sent']);
}
// ---- 미확인 에스컬레이션 ----------------------------------------------------
public function testUnackedCriticalBecomesItsOwnEvent(): void
{
$events = new InMemoryEventRepository();
$origin = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]);
$watch = new EscalationWatch($events);
$this->assertSame([$origin], $watch->run(self::NOW, 300, 3600, 100));
$escalations = array_values(array_filter($events->rows,
fn ($r) => ($r['event_type'] ?? '') === 'unacked_escalation'));
$this->assertCount(1, $escalations);
$this->assertSame('critical', $escalations[0]['severity'], '심각도를 낮추면 상위 채널 문턱을 못 넘는다');
$this->assertSame($origin, $escalations[0]['metrics']['origin_event_id']);
}
public function testEscalationIsIdempotent(): void
{
$events = new InMemoryEventRepository();
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]);
$watch = new EscalationWatch($events);
$watch->run(self::NOW, 300, 3600, 100);
$this->assertSame([], $watch->run(self::NOW, 300, 3600, 100), '두 번째 실행은 아무것도 만들지 않는다');
}
public function testAcknowledgedEventNeverEscalates(): void
{
$events = new InMemoryEventRepository();
$id = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]);
$events->acknowledge($id, 'op');
$this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 3600, 100));
}
public function testEscalationDoesNotEscalateItself(): void
{
$events = new InMemoryEventRepository();
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'unacked_escalation',
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]);
$this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 3600, 100),
'무한 사다리 방지');
}
public function testWarningNeverEscalates(): void
{
$events = new InMemoryEventRepository();
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_warning',
'severity' => 'warning', 'occurred_at' => (float)(self::NOW - 6000), 'metrics' => []]);
$this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 86400, 100),
'미처리 경고는 백로그이지 응급이 아니다');
}
// ---- 메시지 내용 ------------------------------------------------------------
public function testMessageCarriesNoImageAndLinksToTheIncident(): void
{
$c = new Channels('https://example.com/move_detect');
$body = $c->body($this->event(['event_id' => 42]));
$this->assertStringContainsString('https://example.com/move_detect/events/42/view', $body);
$this->assertStringContainsString('넘어짐', $body);
// 스냅샷은 PIPA 대상 개인정보다. 메일로 나가면 인증 프록시가 지키던 통제를 벗어난다.
$this->assertStringNotContainsString('/snap/', $body);
$this->assertStringContainsString('이미지가 포함되지 않습니다', $body);
}
public function testWebhookRefusesNonHttpSchemes(): void
{
$c = new Channels('https://example.com');
$this->expectException(\RuntimeException::class);
$c->send($this->event(), $this->channel(['kind' => 'webhook', 'config_json' => '{"url":"file:///etc/passwd"}']));
}
}