move_detect/server/tests/SafetyConfigTest.php
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
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 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

356 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Api\ThresholdCatalog;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{AdminConfigController, SuppressionController, ComplianceController};
use App\Tests\Support\{InMemoryThresholdRepository, InMemoryAlertRepository, InMemoryDeviceRepository,
InMemoryAuditRepository, InMemorySuppressionRepository, InMemoryEventRepository,
InMemorySnapshotRepository, InMemoryActionRepository, InMemoryRiskControlRepository,
InMemoryHandoverRepository};
use PHPUnit\Framework\TestCase;
/**
* Threshold editing, alarm suppression and the compliance surfaces.
*
* These are the screens that let a delivered site tune and operate itself. Before them the
* customer's only route to a threshold was phpMyAdmin, and their only route to silencing a
* maintenance window was to ignore the alarms — which is how a safety console ends up switched
* off.
*/
final class SafetyConfigTest extends TestCase
{
private function sess(string $role = 'admin', ?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->set('user_id', 1); $s->set('username', '관리자'); $s->set('role', $role); $s->set('csrf', 'tok');
if ($site !== null) $s->set('site_scope', $site);
return $s;
}
private function post(array $body): Request
{
return new Request('POST', '/x', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []);
}
private function get(array $query = []): Request
{
return new Request('GET', '/x', [], '', $query, []);
}
// ================================================================ thresholds
private function admin(string $role = 'admin', ?string $site = null,
?InMemoryThresholdRepository $thr = null,
?InMemoryDeviceRepository $dev = null,
?InMemoryAuditRepository $audit = null,
?InMemoryAlertRepository $alerts = null): AdminConfigController
{
return new AdminConfigController(
$thr ?? new InMemoryThresholdRepository(),
$alerts ?? new InMemoryAlertRepository(),
$dev ?? new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1]),
$audit ?? new InMemoryAuditRepository(),
$this->sess($role, $site)
);
}
public function testThresholdEditingIsAdminOnly(): void
{
$this->assertSame(403, $this->admin('operator')->thresholds($this->get())->status());
$this->assertSame(403, $this->admin('viewer')->thresholds($this->get())->status());
$this->assertSame(200, $this->admin('admin')->thresholds($this->get())->status());
}
public function testCatalogTravelsWithTheValuesSoTheEditorCanBoundItsInputs(): void
{
$res = $this->admin()->thresholds($this->get());
$data = json_decode($res->body(), true);
$this->assertArrayHasKey('fall_prone_s', $data['catalog']);
// assertEquals, not assertSame: a whole float survives the JSON round trip as an int.
$this->assertEquals(30.0, $data['catalog']['fall_prone_s']['max']);
$this->assertEquals(1.0, $data['catalog']['fall_prone_s']['min']);
}
public function testOutOfRangeValueIsRejected(): void
{
$c = $this->admin();
$res = $c->saveThreshold($this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 900]));
$this->assertSame(400, $res->status());
$this->assertSame('out_of_range', json_decode($res->body(), true)['error']);
}
public function testNonFiniteValueIsRejected(): void
{
// edge/config.py 가 기동 시 거부하는 바로 그 값들 — 그때는 이미 공장 안 장비가
// 재시작 루프에 빠진 뒤다. 키보드 앞에서 막는다.
foreach (['nan', 'inf', '-inf'] as $bad) {
$res = $this->admin()->saveThreshold(
$this->post(['device_id' => 'pi-1', 'key' => 'cooldown_s', 'value' => $bad]));
$this->assertSame(400, $res->status(), "rejects $bad");
}
}
public function testUnknownKeyIsRejectedRatherThanDeliveredAndIgnored(): void
{
$res = $this->admin()->saveThreshold(
$this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_seconds', 'value' => 3]));
$this->assertSame(400, $res->status());
$this->assertSame('unknown_key', json_decode($res->body(), true)['error']);
}
public function testSaveBumpsConfigVersionSoTheChangeReachesTheDevice(): void
{
$dev = new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 4]);
$c = $this->admin('admin', null, null, $dev);
$this->assertSame(200, $c->saveThreshold(
$this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]))->status());
$this->assertSame(5, $dev->configVersion('pi-1'),
'버전이 오르지 않으면 값은 DB 에만 남고 장비는 옛 설정으로 계속 돈다');
}
public function testSaveIsAudited(): void
{
$audit = new InMemoryAuditRepository();
$c = $this->admin('admin', null, null, null, $audit);
$c->saveThreshold($this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]));
$this->assertCount(1, $audit->log);
$this->assertSame('threshold_set', $audit->log[0]['act']);
}
public function testScopedAdminCannotRetuneAnotherSitesDevice(): void
{
$dev = new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1], [], ['pi-1' => 'factory-a']);
$c = $this->admin('admin', 'factory-b', null, $dev);
$this->assertSame(404, $c->saveThreshold(
$this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]))->status());
}
public function testCsrfIsRequired(): void
{
$c = $this->admin();
$noToken = new Request('POST', '/x', [], json_encode(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]), [], []);
$this->assertSame(403, $c->saveThreshold($noToken)->status());
}
public function testIntegerKnobRejectsAFraction(): void
{
[$ok, $err] = ThresholdCatalog::normalize('infer_every', 1.5);
$this->assertFalse($ok);
$this->assertSame('not_integer', $err);
}
public function testNormalizationIsStableSoOneValueHasOneStoredForm(): void
{
[, $a] = ThresholdCatalog::normalize('fall_prone_s', '3');
[, $b] = ThresholdCatalog::normalize('fall_prone_s', 3.0);
[, $c] = ThresholdCatalog::normalize('fall_prone_s', '3.000');
$this->assertSame($a, $b);
$this->assertSame($b, $c);
}
// ================================================================ alert channels
public function testWebhookTokenIsNeverEchoedBack(): void
{
$alerts = new InMemoryAlertRepository();
$c = $this->admin('admin', null, null, null, null, $alerts);
$c->saveChannel($this->post(['kind' => 'webhook', 'name' => 'groupware', 'enabled' => 1,
'min_severity' => 'high', 'config' => ['url' => 'https://in.house/hook', 'token' => 'SECRET']]));
$body = json_decode($c->channels($this->get())->body(), true);
$this->assertSame('••••••••', $body['channels'][0]['config']['token'],
'설정 화면을 화면공유해도 연동 키가 새지 않아야 한다');
}
public function testEscalationStepRequiresADelay(): void
{
$c = $this->admin();
$res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'manager', 'enabled' => 1,
'min_severity' => 'critical', 'step' => 1, 'escalate_after_s' => 0,
'config' => ['to' => 'a@b.c']]));
$this->assertSame(400, $res->status(), '지연 0 짜리 에스컬레이션은 즉시 채널과 같이 울린다');
}
public function testWebhookRejectsNonHttpUrl(): void
{
$c = $this->admin();
$res = $c->saveChannel($this->post(['kind' => 'webhook', 'name' => 'x', 'enabled' => 1,
'min_severity' => 'high', 'config' => ['url' => 'file:///etc/passwd']]));
$this->assertSame(400, $res->status());
}
public function testEditingAChannelKeepsItsSiteInsteadOfPromotingItGlobal(): void
{
// 재감사(A9): 전역 관리자(site_scope null)가 현장 채널을 수정하면 site 가 NULL 로
// 덮여 그 채널이 모든 현장에 발화하게 됐다. 폼 어디에도 그런 말은 없다.
$alerts = new InMemoryAlertRepository([[
'channel_id' => 9, 'site' => 'factory-a', 'kind' => 'email', 'name' => 'A동 담당',
'config_json' => '{"to":["a@b.c"]}', 'min_severity' => 'high',
'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0,
]]);
$c = $this->admin('admin', null, null, null, null, $alerts); // 전역 관리자
$res = $c->saveChannel($this->post(['channel_id' => 9, 'kind' => 'email',
'name' => 'A동 담당 (수정)', 'enabled' => 1, 'min_severity' => 'critical',
'config' => ['to' => 'a@b.c']]));
$this->assertSame(200, $res->status());
$this->assertSame('factory-a', $alerts->chan[9]['site'],
'수정해도 채널은 자기 현장에 남아야 한다');
}
public function testANewChannelBelongsToTheCallersScope(): void
{
$alerts = new InMemoryAlertRepository();
$c = $this->admin('admin', 'factory-b', null, null, null, $alerts);
$res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'B동', 'enabled' => 1,
'min_severity' => 'high', 'config' => ['to' => 'b@b.c']]));
$id = json_decode($res->body(), true)['channel_id'];
$this->assertSame('factory-b', $alerts->chan[$id]['site']);
}
public function testEmailChannelNeedsAValidRecipient(): void
{
$c = $this->admin();
$res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'x', 'enabled' => 1,
'min_severity' => 'high', 'config' => ['to' => 'not-an-email']]));
$this->assertSame(400, $res->status());
}
// ================================================================ suppression
private function suppression(string $role = 'operator', ?string $site = null,
?InMemorySuppressionRepository $repo = null,
?InMemoryEventRepository $events = null): SuppressionController
{
$ev = $events ?? new InMemoryEventRepository();
return new SuppressionController(
$repo ?? new InMemorySuppressionRepository(), $ev,
new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1]),
new InMemoryAuditRepository(), $this->sess($role, $site)
);
}
public function testSuppressionRequiresAReason(): void
{
$res = $this->suppression()->create($this->post(
['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 30, 'reason' => '']));
$this->assertSame(400, $res->status());
$this->assertSame('reason_required', json_decode($res->body(), true)['error']);
}
public function testSuppressionCannotBeOpenEnded(): void
{
$c = $this->suppression();
// 8시간(한 교대) 상한. 그 이상은 의도적인 재발급을 강제한다.
$this->assertSame(400, $c->create($this->post(['scope_type' => 'device', 'scope_id' => 'pi-1',
'minutes' => 60 * 24, 'reason' => '정비']))->status());
$this->assertSame(201, $c->create($this->post(['scope_type' => 'device', 'scope_id' => 'pi-1',
'minutes' => 60 * 4, 'reason' => '프레스 금형 교체']))->status());
}
public function testViewerCannotSuppress(): void
{
$this->assertSame(403, $this->suppression('viewer')->create($this->post(
['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 30, 'reason' => '정비']))->status());
}
public function testUnknownScopeTargetIsRejected(): void
{
$res = $this->suppression()->create($this->post(
['scope_type' => 'device', 'scope_id' => 'pi-nope', 'minutes' => 30, 'reason' => '정비']));
$this->assertSame(400, $res->status());
}
public function testRevokeIsScoped(): void
{
$repo = new InMemorySuppressionRepository();
$id = $repo->create(['scope_type' => 'device', 'scope_id' => 'pi-1', 'site' => 'factory-a',
'reason' => 'x', 'starts_at' => time() - 10, 'ends_at' => time() + 600,
'created_by' => 'a', 'created_at' => time()]);
$c = $this->suppression('operator', 'factory-b', $repo);
$this->assertSame(404, $c->revoke($this->post([]), ['id' => $id])->status());
}
// ================================================================ compliance
private function compliance(string $role, ?string $site, InMemoryEventRepository $events,
?InMemoryRiskControlRepository $risks = null,
?InMemoryHandoverRepository $handovers = null): ComplianceController
{
return new ComplianceController(
$events, new InMemorySnapshotRepository(), new InMemoryActionRepository(),
new InMemoryAlertRepository(), $risks ?? new InMemoryRiskControlRepository(),
$handovers ?? new InMemoryHandoverRepository(), new InMemoryAuditRepository(),
$this->sess($role, $site)
);
}
public function testIncidentPackageCarriesTheSurroundingTimeline(): void
{
$events = new InMemoryEventRepository();
$at = (float)time();
$before = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'near_fall',
'severity' => 'warning', 'occurred_at' => $at - 300, 'metrics' => []]);
$main = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => $at, 'metrics' => []]);
$after = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'no_motion',
'severity' => 'critical', 'occurred_at' => $at + 30, 'metrics' => []]);
// 다른 카메라의 동시 사건은 들어오면 안 된다.
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-2', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => $at, 'metrics' => []]);
$res = $this->compliance('operator', null, $events)->package($this->get(), ['id' => $main]);
$this->assertSame(200, $res->status());
$data = json_decode($res->body(), true);
$ids = array_column($data['timeline'], 'event_id');
$this->assertSame([$before, $main, $after], $ids, '같은 카메라의 전후 맥락만, 시간순으로');
}
public function testPackageHidesAnotherSitesIncidentAs404(): void
{
$events = new InMemoryEventRepository();
$id = $events->insert(['device_id' => 'pi-1', 'site' => 'factory-a', 'camera_id' => 'cam-1',
'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)time(), 'metrics' => []]);
$res = $this->compliance('operator', 'factory-b', $events)->package($this->get(), ['id' => $id]);
$this->assertSame(404, $res->status());
}
public function testRiskControlNeedsComplianceRole(): void
{
$events = new InMemoryEventRepository();
$body = $this->post(['event_type' => 'fall', 'hazard' => '단차 전도', 'control' => '매트 설치',
'severity_rating' => 5, 'likelihood_rating' => 3]);
$this->assertSame(403, $this->compliance('operator', null, $events)->saveRisk($body)->status());
$this->assertSame(200, $this->compliance('admin', null, $events)->saveRisk($body)->status());
}
public function testRiskRatingIsBounded(): void
{
$events = new InMemoryEventRepository();
$res = $this->compliance('admin', null, $events)->saveRisk($this->post(
['event_type' => 'fall', 'hazard' => 'h', 'control' => 'c',
'severity_rating' => 9, 'likelihood_rating' => 3]));
$this->assertSame(400, $res->status());
}
public function testHandoverCountsAreComputedServerSideNotTakenFromTheRequest(): void
{
$events = new InMemoryEventRepository();
$now = (float)time();
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'critical', 'occurred_at' => $now - 60, 'metrics' => []]);
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_warning',
'severity' => 'warning', 'occurred_at' => $now - 30, 'metrics' => []]);
$handovers = new InMemoryHandoverRepository();
$c = $this->compliance('operator', null, $events, null, $handovers);
// 브라우저가 0 을 주장해도 서버가 다시 센다.
$res = $c->acceptHandover($this->post(['note' => '인수', 'unacked_count' => 0, 'severe_count' => 0]));
$this->assertSame(201, $res->status());
$row = $handovers->recent(null, 1)[0];
$this->assertSame(2, $row['unacked_count']);
$this->assertSame(1, $row['severe_count']);
$this->assertSame('관리자', $row['to_user']);
}
}