e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.
CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
(tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
(/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.
HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.
MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.
LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.
수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.
부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.
검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
266 lines
13 KiB
PHP
266 lines
13 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);
|
|
}
|
|
|
|
// ---- 재감사(2026-08-18) 회귀 ------------------------------------------------
|
|
|
|
public function testAlreadyDeliveredPairsAreNotReClaimedEveryRun(): void
|
|
{
|
|
// A7: 디스패처는 매분 창 전체를 다시 훑는다. 사전 필터가 없으면 이미 보낸
|
|
// (event,channel) 마다 실패할 INSERT 를 한 번씩 또 시도한다 — 비용이 '새 이벤트'가
|
|
// 아니라 '창 길이'에 비례하고, 크론이 불안정해 창을 넓힌 현장일수록 더 나빠진다.
|
|
$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);
|
|
$send = function () {};
|
|
|
|
$d->run(self::NOW, 3600, 100, $send);
|
|
$afterFirst = $alerts->claimCalls;
|
|
$d->run(self::NOW, 3600, 100, $send);
|
|
$d->run(self::NOW, 3600, 100, $send);
|
|
|
|
$this->assertSame(1, $afterFirst, '첫 실행은 한 번 claim 한다');
|
|
$this->assertSame(1, $alerts->claimCalls,
|
|
'이후 실행은 원장을 먼저 읽어 이미 처리된 쌍에 claim 을 시도하지 않는다');
|
|
}
|
|
|
|
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"}']));
|
|
}
|
|
}
|