Compare commits

..

2 commits

Author SHA1 Message Date
유창욱
e625d482ba docs(index): 재감사 결과 기록 (7daee0b, 판정 적합)
Some checks failed
verify / edge (push) Failing after 5s
verify / server (push) Failing after 3s
2026-08-18 10:33:23 +09:00
유창욱
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
26 changed files with 636 additions and 39 deletions

View file

@ -7,7 +7,7 @@ Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보
- 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신. - 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신.
- 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가. - 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가.
- 감사 상태: `docs/delivery-audit.md` (43/43 — 실장비 OFF-HW 검증만 잔여). - 감사 상태: `docs/delivery-audit.md` (1차 43/43 + 재감사 12/12 — **적합**, 실장비 OFF-HW 검증만 잔여).
## 검증 (verify) ## 검증 (verify)

View file

@ -1,5 +1,16 @@
# INDEX — 작업 기록 (완료 결과만) # INDEX — 작업 기록 (완료 결과만)
- **2026-08-18** 납품 재감사 — `7daee0b`. 확장된 표면(라우트 17·표 6종·CLI 도구 5·엣지 모듈 4·
통보 발송·감사 해시체인)을 8개 관점으로 재감사, 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 ·
MEDIUM 4 · LOW 2). 최대 발견은 **신규 감지 유형이 ingest 검증기에만 빠져 카메라 무결성
감지가 end-to-end 무력화**되어 있던 것 — DB CHECK·프런트·엣지는 다 갱신됐는데 가운데
계층만 빠졌고, 파이썬·PHP 테스트가 각자 절반만 보기 때문에 양쪽 다 통과했다.
재발 방지로 언어 경계를 넘는 파리티 테스트 신설(`tests/test_event_type_parity.py`).
그 외 운영 DB 파괴 가능한 `--purge` 범위, 백업이 docroot 자체에 떨어지던 경로 가드,
통보 메일 시각이 콘솔과 9시간 어긋나던 타임존, MariaDB 에서 실패하는 `DROP CHECK`.
판정: **적합** (`docs/delivery-audit.md` Phase 6). 잔여는 실장비 OFF-HW 5건.
검증: PHPUnit 224 (skip 0) · pytest 통과 · 양 산출물 재생성 + 패리티 통과.
- **2026-08-09** 안전 플랫폼 확장 — `e00ad69`. 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영. - **2026-08-09** 안전 플랫폼 확장 — `e00ad69`. 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영.
마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장). 마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장).
**통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다. **통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다.

View file

@ -1,5 +1,9 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리) # Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-08-18): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 감사 43/43 + 2차 재감사(안전 플랫폼 확장 표면) 12/12 해소.
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is verification of every blocker against real code. Ordered for sequential fixing. Each item is
checked off only after a test locks it and verification passes. checked off only after a test locks it and verification passes.
@ -258,6 +262,109 @@ misconfig/degradation fail LOUD.
--- ---
## Phase 6 — 재감사 2026-08-18 (안전 플랫폼 확장 표면)
커밋 `e00ad69` 로 감사 표면이 크게 늘었다: 신규 라우트 17개, DB 표 6종 + 컬럼 4종, CLI 도구
5개, 엣지 모듈 4개(GPIO 출력 포함), 통보 발송 경로(메일·웹훅) 신설, 감사로그 해시체인.
8개 관점(보안·데이터 정합·렌더·패키징·문서 정합·성능·UX·회귀)으로 재감사, 확정 12건 전량 해소.
### CRITICAL
- [x] **A1 [B]** `server/src/Ingest/EventValidator.php` — 신규 감지 유형(`camera_moved`,
`camera_obstructed`, `low_light`)이 ingest 허용 목록에 없어 **서버가 전부 400 으로 거부**했다.
`edge/queue.py` 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
전부 끝난 상태로 end-to-end 무력화되어 있었다 — 로그를 뒤지지 않는 한 아무도 모른다.
DB CHECK 와 프런트 라벨은 갱신했는데 **가운데 검증 계층만** 빠진, 계층 간 목록 중복의 전형적 결함.
DONE — 허용 목록 갱신 + **언어 경계를 넘는 회귀 테스트** 신설
(`tests/test_event_type_parity.py`: `edge/events.py` ↔ EventValidator ↔ schema.sql ↔
프런트 라벨 5종 ↔ 유형 필터). 파이썬·PHP 어느 쪽 테스트도 잡을 수 없던 자리다.
- [x] **A2 [B]** `server/tools/seed_demo.php` — `--purge` 가 위험성평가를
`reviewed_by='안전관리자' AND site='factory-a'` 로 지웠다. '안전관리자'는 이 제품의 **기본
관리자 계정명**이고 'factory-a'는 흔한 현장명이라, 운영 DB 에서 한 번 실행하면 실제
위험성평가가 복구 불가능하게 사라진다.
DONE — 데모 행은 `DEMO_PREFIX`/`DEMO_ACTOR`/`DEMO_LABEL` 표식으로만 식별. 표식 없는 행은
어떤 경우에도 건드리지 않는다.
- [x] **A3 [B]** `server/tools/backup.php` — 웹 공개 경로 차단이 `str_contains($real, '/www/')`
라 **docroot 자체**(`/home/acct/www`)는 뒤에 슬래시가 없어 통과했다. 가장 흔한 오사용이
정확히 그 경우다. 전 현장의 사고 기록 + 작업자 이미지가 웹에서 내려받힌다 (파일명은
`move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만 알면 추측 가능).
DONE — 경로를 세그먼트로 쪼개 `www/public_html/htdocs/html/web/public` 중 하나라도 있으면 거부.
회귀 검증: docroot 자체·하위 경로 모두 차단, 비공개 경로는 통과.
### HIGH
- [x] **A4 [H]** 신규 유형이 `app.js`/`detail.js`/`report.js` 의 `TYPE_LABELS` 와 이벤트 유형
필터에 없어 화면에 `camera_moved` 같은 원시 코드가 그대로 표시됐다. 조사자가 그 유형만
걸러 볼 방법도 없었다. DONE — 3개 맵 + 필터 갱신, A1 의 파리티 테스트가 재발을 막는다.
- [x] **A5 [H]** 서버가 `date_default_timezone_set` 를 하지 않아 통보 메일의 시각이 PHP 기본
(공유호스팅은 흔히 UTC)으로 찍혔다. 대시보드는 브라우저 시각을 쓰므로, 콘솔이 13:01 로
보여준 넘어짐을 메일이 04:01 로 적는다 — 응급 대응에서 읽는 유일한 문장이 9시간 어긋난다.
DONE — `Config::timezone()` (기본 `Asia/Seoul`, 잘못된 값이면 폴백하되 발송은 막지 않음),
`Channels` 가 프로세스 전역 기본값을 건드리지 않고 해당 시간대로 렌더.
- [x] **A6 [H]** 마이그레이션의 `ALTER TABLE … DROP CHECK` 는 MySQL 8.0.16+ 전용 문법이다.
카페24 공유호스팅에 흔한 **MariaDB 는 `DROP CONSTRAINT` 를 쓰므로 마이그레이션이 통째로 실패**한다.
DONE — 해당 블록에 엔진별 분기(A: MySQL 8.0.16+ / B: MariaDB / C: 제약 없음) 를 명시하고,
이 CHECK 가 2차 방어선이며 1차는 `EventValidator` 임을 적어 실패해도 판단할 수 있게 함.
적용 확인용 probe INSERT/DELETE 도 함께 기재.
### MEDIUM
- [x] **A7 [M]** `AlertDispatcher::run` 이 매분 창 전체를 다시 훑으며 이미 보낸
(event, channel) 쌍마다 실패할 INSERT 를 한 번씩 재시도했다. 비용이 '새 이벤트'가 아니라
'창 길이'에 비례해, 크론이 불안정해 창을 넓힌 현장일수록 부하가 커진다.
DONE — `claimedSince()` 로 창의 원장을 한 번에 읽어 사전 필터. `claim()` 은 여전히 최종
권위(경합 시)다. 회귀 테스트가 2·3회차 실행에서 claim 시도가 0 임을 고정.
- [x] **A8 [M]** `move_alert_log` 에 보존 정책이 없어 유일하게 무한 증가하는 표였다
(이벤트 보존은 기본 비활성이라 CASCADE 도 일어나지 않는다).
DONE — `maintain.php` 가 해소된 전달(sent/suppressed)만 정리. **failed 는 남긴다** —
"아무에게도 닿지 않았다"는 사실이 조사에서 필요한 발견이라 청소가 지우면 안 된다.
- [x] **A9 [M]** 전역 관리자가 현장 채널을 수정하면 `site` 가 NULL 로 덮여 그 채널이 **모든
현장에 발화**하게 됐다. 폼 어디에도 그런 안내가 없다.
DONE — 수정 시 기존 site 보존, 신규 생성만 호출자 스코프. 양쪽 회귀 테스트 추가.
- [x] **A10 [M]** 일괄 확인이 대상마다 fetch 를 하나씩 띄웠다. 7건에서는 무해하지만 실제
백로그는 수백 건이고, HTTP/2 에서는 브라우저가 직렬화하지 않아 공유호스팅에 한꺼번에 떨어진다.
DONE — 동시 4개로 제한하고 진행 중에도 목록을 갱신(멈춘 것처럼 보이지 않게), 완료 토스트에
`성공/전체` 를 함께 표기.
### LOW
- [x] **A11 [L]** 패키지 테스트가 `tools/` 배치를 검사하지 않았다. 이 도구들은 메일을 보내고
DB 전체를 읽으므로 www/ 아래로 새면 인증 없는 표면이 된다.
DONE — ① 모든 CLI 도구가 `bootstrap.php`(SAPI 가드)를 require 하는지 ② 패키지에서 tools/ 가
private 폴더에만 있는지 ③ 웹 루트의 PHP 가 `index.php`/`_preflight.php` 뿐인지 검사.
- [x] **A12 [L]** 감사 리포트 판정 배너와 `CLAUDE.md` 의 "43/43" 표기가 stale.
DONE — 이 문서 상단 배너 + CLAUDE.md 갱신.
### 재감사에서 확인만 하고 수정하지 않은 항목 (판단 기록)
- **웹훅 SSRF (내부망 주소 지정 가능)** — 채널 등록은 admin 전용이고, 이 기능의 정당한
용도가 바로 **사내망** 그룹웨어·문자게이트웨이다. 사설 IP 대역을 막으면 기능 자체가
성립하지 않는다. http(s) 한정 + 리다이렉트 금지 + 토큰 헤더까지가 방어선. 수용.
- **서버가 엣지 GPIO 핀 번호를 지정** — 손상된 서버가 임의 핀을 구동할 수 있다. 다만 서버는
이미 존(감지 판정 자체)을 내려보내는 신뢰 주체이고, 핀 범위는 서버측에서 0~40 으로 검증된다.
이 신뢰 경계를 좁히려면 장치측 화이트리스트가 필요 — 실장비 커미셔닝 항목으로 이관.
- **`/setup` 4단계가 첫 카메라의 존만 확인** — 다른 카메라에 존이 있어도 "없음"으로 보일 수
있다. 상태 문구에 "첫 카메라에" 를 명시해 오해를 줄였고, 전 카메라 조회는 온보딩 화면이
N+1 요청을 내는 대가가 이득보다 크다고 판단. 수용.
---
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
- [ ] **GPIO 경광등 실동작** — `edge/beacon.py` 의 핀 구동은 단위 테스트로 증명할 수 없다.
Pi + 릴레이 모듈로 latch/pulse, `active_high` 반전, hold 만료 해제를 육안 확인.
- [ ] **카메라 무결성 임계값** — `IntegrityConfig.moved_mad` / `obstructed_var` /
`low_light_mean` 은 실제 현장 조명·진동에서 튜닝해야 한다. 현재 값은 합성 프레임 기준.
- [ ] **P2-6/7/8** — 낙상 타이밍·트래킹 매칭 (1차 감사에서 이월). 블라인드 수정은 감지 **누락**을
만들 수 있어 실영상 확보 후 조정.
- [ ] **CSI 카메라 / Hailo HEF 추론** — `tests/hardware/` 스킵 항목.
- [ ] **통보 실발송** — 사내 SMTP 릴레이·웹훅 엔드포인트는 현장에만 있다.
`dispatch_alerts.php --dry-run` 으로 대상만 먼저 확인한 뒤 1회 실발송 검증.
---
## Progress log ## Progress log
(updated as each item lands — newest first) (updated as each item lands — newest first)

View file

@ -21,6 +21,10 @@ return [
'public_base_url' => '', // 예: 'https://example.com/move_detect' 'public_base_url' => '', // 예: 'https://example.com/move_detect'
// 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다. // 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
'escalate_after_seconds' => 300, 'escalate_after_seconds' => 300,
// 통보 메시지의 시각 표기 기준. 대시보드는 브라우저 시각을 쓰지만 알림 메일은 PHP 가
// 만들고, 공유호스팅은 date.timezone 이 UTC 인 경우가 흔하다 — 콘솔이 13:01 로 보여준
// 넘어짐을 메일이 04:01 로 적으면 응급 대응에서 그대로 사고가 된다.
'timezone' => 'Asia/Seoul',
// --- 보존 / 용량 ------------------------------------------------------------------ // --- 보존 / 용량 ------------------------------------------------------------------
// 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리). // 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).

View file

@ -41,6 +41,10 @@ return [
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를 // 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분. // 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
'escalate_after_seconds' => 300, 'escalate_after_seconds' => 300,
// 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
'timezone' => 'Asia/Seoul',
// ---- 5. 용량 관리 (그대로 두세요) ----------------------------------- // ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다. // 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.

View file

@ -170,12 +170,35 @@ ALTER TABLE move_actions
ADD KEY ix_actions_event (event_id); ADD KEY ix_actions_event (event_id);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 9. 신규 감지 유형 -- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★
--
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고, -- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다. -- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음). -- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
--
-- 이 CHECK 는 **2차 방어선**이지 검증 계층이 아닙니다. 1차 검증은 애플리케이션의
-- App\Ingest\EventValidator 이고(그쪽은 이미 신규 유형을 받습니다), tests/test_event_type_parity.py
-- 가 둘의 일치를 강제합니다. 따라서 이 블록이 실패하거나 건너뛰어져도 시스템은 정상 동작합니다.
-- 다만 그 경우 **신규 유형의 INSERT 가 제약 위반으로 거부**되므로, 아래 셋 중 맞는 것을 반드시
-- 하나 실행하십시오.
--
-- (A) MySQL 8.0.16 이상 — `DROP CHECK` 지원:
-- ALTER TABLE move_events DROP CHECK chk_events_type;
--
-- (B) MariaDB (10.2.22+) — `DROP CHECK` 없음, `DROP CONSTRAINT` 사용:
-- ALTER TABLE move_events DROP CONSTRAINT chk_events_type;
--
-- (C) 제약이 애초에 없는 환경 (MySQL 5.7 은 CHECK 를 파싱만 하고 무시했습니다):
-- DROP 을 건너뛰고 아래 ADD 만 실행하십시오. ADD 가 "이미 존재" 오류를 내면
-- 그 환경에는 제약이 살아 있는 것이므로 (A) 또는 (B) 로 돌아가십시오.
--
-- 어느 것을 실행해야 할지 모르겠다면 먼저 확인하십시오:
-- SELECT VERSION();
--
-- 아래 DROP 은 (A) 기준으로 적어 두었습니다. MariaDB 라면 이 한 줄을 (B) 로 바꾸세요.
ALTER TABLE move_events ALTER TABLE move_events
DROP CHECK chk_events_type; DROP CHECK chk_events_type;
ALTER TABLE move_events ALTER TABLE move_events
ADD CONSTRAINT chk_events_type CHECK (event_type IN ( ADD CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning', 'fall','no_motion','immobility','ghost','zone_warning',
@ -187,5 +210,9 @@ ALTER TABLE move_events
-- 검증: -- 검증:
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간 -- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
-- php server/tools/verify_audit_chain.php -- 사슬 무결성 -- php tools/verify_audit_chain.php -- 사슬 무결성
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%'; -- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
-- -- 신규 유형이 실제로 들어가는지 (넣고 바로 지웁니다):
-- INSERT INTO move_events (device_id,camera_id,event_type,severity,occurred_at,received_at)
-- VALUES ('_migration_probe','_probe','camera_moved','high',0,0);
-- DELETE FROM move_events WHERE device_id = '_migration_probe';

View file

@ -29,7 +29,12 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트' near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };
var SEVERITY_LABELS = { var SEVERITY_LABELS = {
@ -1069,17 +1074,36 @@
disarmBulk(); disarmBulk();
bulkBtn.disabled = true; bulkBtn.disabled = true;
var done = 0; var done = 0;
Promise.all(targets.map(function (ev) { var queue = targets.slice();
// Bounded concurrency. Firing one fetch per target looked fine at 7 rows, but a real
// backlog is hundreds, and over HTTP/2 the browser does NOT serialise them — the whole
// sweep lands on shared hosting at once and the later writes come back 503. Four at a
// time keeps it well inside any process limit and still clears 200 rows in seconds.
var CONCURRENCY = 4;
function worker() {
var ev = queue.shift();
if (!ev) return Promise.resolve();
return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', { return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', {
method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' }
}).then(function (res) { })
if (res.ok) { patchEventLocally(ev.event_id, false); done++; } .then(function (res) { if (res.ok) { patchEventLocally(ev.event_id, false); done++; } })
}).catch(function () {}); .catch(function () {})
})).then(function () { .then(function () {
// Repaint as it goes: a sweep that shows nothing until it finishes reads as frozen.
lastListSig = null;
renderList();
return worker();
});
}
var lanes = [];
for (var i = 0; i < Math.min(CONCURRENCY, targets.length); i++) lanes.push(worker());
Promise.all(lanes).then(function () {
bulkBtn.disabled = false; bulkBtn.disabled = false;
lastListSig = null; lastListSig = null;
renderList(); renderList();
showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)'); showToast('일괄 확인 ' + done + '건 / ' + targets.length + '건 (위급은 제외되었습니다)');
loadEvents(); loadEvents();
}); });
}); });

View file

@ -33,7 +33,12 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트' near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo // Metric keys -> Korean, so the detail view can print what the edge measured (the ergo

View file

@ -16,7 +16,12 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트' near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다. // 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.

View file

@ -94,6 +94,12 @@ final class AlertDispatcher
// One channel read per site, not per event: a busy window is hundreds of events over a // One channel read per site, not per event: a busy window is hundreds of events over a
// handful of sites, and this job runs on shared hosting where every query costs. // handful of sites, and this job runs on shared hosting where every query costs.
$channelsBySite = []; $channelsBySite = [];
// Everything already in the ledger for this window, in ONE query. Without it the run
// re-attempts claim() for every pair it delivered on every previous run — an INSERT that
// fails on the unique key, once per (event × channel), once a minute, forever. claim()
// stays the authority (two runs can still overlap between this read and the write); this
// is purely the cheap pre-filter that keeps the cost proportional to NEW events.
$alreadyClaimed = $this->alerts->claimedSince($now - $windowSeconds - 60);
foreach ($events as $event) { foreach ($events as $event) {
$stats['considered']++; $stats['considered']++;
@ -105,8 +111,12 @@ final class AlertDispatcher
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) { foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
$channelId = (int)$delivery['channel']['channel_id']; $channelId = (int)$delivery['channel']['channel_id'];
$eventId = (int)$event['event_id']; $eventId = (int)$event['event_id'];
// claim() is the duplicate guard. Losing it means another (possibly overlapping) // Cheap pre-filter (see $alreadyClaimed) — skips the overwhelming majority of
// dispatcher run owns this delivery — skip, do not send. // pairs on every run after the first.
if (isset($alreadyClaimed[$eventId . ':' . $channelId])) { $stats['skipped']++; continue; }
// claim() remains the authority: the pre-filter is a snapshot, and a concurrent
// run can claim a pair between that read and this write. Losing the race means
// someone else owns this delivery — skip, do not send.
if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; } if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
if ($delivery['suppressed']) { if ($delivery['suppressed']) {
$this->alerts->markSuppressed($eventId, $channelId, $now); $this->alerts->markSuppressed($eventId, $channelId, $now);

View file

@ -39,8 +39,30 @@ final class Channels
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보', 'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
]; ];
/** @param string $baseUrl absolute dashboard URL, for the incident link. */ /**
public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {} * @param string $baseUrl absolute dashboard URL, for the incident link.
* @param string $timezone display timezone for the stamps in the message. The dashboard
* formats time in the BROWSER, so it always reads local; a notification is composed
* by PHP, and shared hosting commonly leaves date.timezone at UTC — which printed
* "04:01" for the fall the console showed at "13:01".
*/
public function __construct(
private string $baseUrl,
private int $timeoutSeconds = 8,
private string $timezone = 'Asia/Seoul',
) {}
/** Wall-clock stamp in the configured zone, without touching the process-wide default. */
private function stamp(int $ts): string
{
try {
return (new \DateTimeImmutable('@' . $ts))
->setTimezone(new \DateTimeZone($this->timezone))
->format('Y-m-d H:i:s');
} catch (\Throwable $e) {
return date('Y-m-d H:i:s', $ts); // never let a bad zone stop an alert
}
}
public function send(array $event, array $channel): void public function send(array $event, array $channel): void
{ {
@ -67,7 +89,7 @@ final class Channels
{ {
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type']; $type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity']; $sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
$when = date('Y-m-d H:i:s', (int)$event['occurred_at']); $when = $this->stamp((int)$event['occurred_at']);
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view'; $link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
return implode("\n", [ return implode("\n", [
"작업장 안전 감지 알림", "작업장 안전 감지 알림",

View file

@ -35,6 +35,24 @@ final class Config
*/ */
public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); } public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); }
/**
* Display timezone for every human-facing timestamp the SERVER renders — which today means
* the notification messages (App\Alert\Channels).
*
* The dashboard formats time in the browser, so it always reads local. A notification does
* not: it is composed by PHP, and shared hosting frequently leaves date.timezone at UTC. The
* result was a mail saying "04:01" for the fall the console showed at "13:01" — a nine-hour
* discrepancy in the one message a responder reads under pressure, with nothing on either
* screen to explain it. Defaults to Asia/Seoul because that is where this product is
* deployed; an invalid value falls back rather than throwing, since a bad timezone must not
* stop an alert from going out.
*/
public function timezone(): string
{
$tz = (string)($this->a['timezone'] ?? 'Asia/Seoul');
return in_array($tz, \DateTimeZone::listIdentifiers(), true) ? $tz : 'Asia/Seoul';
}
/** /**
* How long an unresolved critical/high event may sit before it becomes an * How long an unresolved critical/high event may sit before it becomes an
* `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by * `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by

View file

@ -163,12 +163,18 @@ final class AdminConfigController
if ($config === null) return Response::json(['error' => 'invalid_config'], 400); if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
$id = (int)($b['channel_id'] ?? 0); $id = (int)($b['channel_id'] ?? 0);
$site = $this->site(); // new channels belong to the caller's scope
if ($id > 0) { if ($id > 0) {
$existing = null; $existing = null;
foreach ($this->alerts->allChannels($this->site()) as $c) { foreach ($this->alerts->allChannels($this->site()) as $c) {
if ((int)$c['channel_id'] === $id) { $existing = $c; break; } if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
} }
if ($existing === null) return Response::json(['error' => 'not_found'], 404); if ($existing === null) return Response::json(['error' => 'not_found'], 404);
// Keep the channel's own site. A GLOBAL admin (site_scope null) editing a
// site-scoped channel would otherwise rewrite site to NULL and silently promote it
// to every site — the same channel then fires for factories it was never meant to
// reach, and nothing on the form says so.
$site = $existing['site'];
// A masked token means "leave it alone" — the GET never revealed the real one. // A masked token means "leave it alone" — the GET never revealed the real one.
if (($config['token'] ?? null) === '••••••••') { if (($config['token'] ?? null) === '••••••••') {
$old = json_decode($existing['config_json'], true); $old = json_decode($existing['config_json'], true);
@ -179,7 +185,7 @@ final class AdminConfigController
$newId = $this->alerts->saveChannel([ $newId = $this->alerts->saveChannel([
'channel_id' => $id > 0 ? $id : null, 'channel_id' => $id > 0 ? $id : null,
'site' => $this->site(), 'site' => $site,
'kind' => $kind, 'kind' => $kind,
'name' => $name, 'name' => $name,
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),

View file

@ -4,9 +4,29 @@ namespace App\Ingest;
final class EventValidator final class EventValidator
{ {
/**
* Accepted wire codes. MUST stay in sync with three other places, and the sync is not
* enforced by anything but review — RouteTableTest cross-checks routes, but nothing
* cross-checks this list:
* - edge/events.py EventType (what the edge can emit)
* - server/schema.sql chk_events_type (defense-in-depth CHECK)
* - the front-end TYPE_LABELS maps (app.js / detail.js / report.js / compliance.js /
* package.js) and the 유형 filter in View/events.php
*
* A type missing HERE is the worst of the four: the edge emits it, the server answers 400,
* and edge/queue.py retries the row 20 times before dropping it — so the detector looks
* implemented, is fully tested on the device, and produces nothing. That is exactly what
* happened to camera_moved/camera_obstructed/low_light when they were added.
*/
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning', private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', 'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall','ergo_report']; 'lone_worker','overcapacity','overstay','near_fall','ergo_report',
// 감시 자체의 건전성 (edge/detectors/integrity.py)
'camera_moved','camera_obstructed','low_light',
// 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다.
'unacked_escalation',
// 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다.
'ppe_missing','proximity','fire_smoke'];
private const SEVERITIES = ['critical','high','warning','info']; private const SEVERITIES = ['critical','high','warning','info'];
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];

View file

@ -31,4 +31,15 @@ interface AlertRepository
/** Rows still worth retrying (status=failed, attempts under the cap). */ /** Rows still worth retrying (status=failed, attempts under the cap). */
public function retryable(int $maxAttempts, int $limit): array; public function retryable(int $maxAttempts, int $limit): array;
/**
* (event_id, channel_id) pairs already in the ledger for events at/after $sinceQueuedAt,
* as a set keyed "eventId:channelId".
*
* The dispatcher reconsiders its whole window every minute, so without this it re-attempts
* claim() for every already-delivered pair on every run — one failing INSERT each. The cost
* scales with the WINDOW rather than with new events, so widening the window (which a site
* does exactly when cron has been unreliable) multiplies the load instead of the coverage.
*/
public function claimedSince(int $sinceQueuedAt): array;
} }

View file

@ -160,6 +160,30 @@ final class PdoAlertRepository implements AlertRepository
], $st->fetchAll()); ], $st->fetchAll());
} }
public function claimedSince(int $sinceQueuedAt): array
{
$st = $this->pdo->prepare(
'SELECT event_id, channel_id FROM move_alert_log WHERE queued_at >= ?'
);
$st->execute([$sinceQueuedAt]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[$r['event_id'] . ':' . $r['channel_id']] = true;
}
return $out;
}
/** Retention for the delivery ledger. Resolved rows only — a failed delivery is kept until
* it is retried or ages past the cap, because "we never reached anyone" is the finding. */
public function pruneResolved(int $before): int
{
$st = $this->pdo->prepare(
"DELETE FROM move_alert_log WHERE queued_at < ? AND status IN ('sent','suppressed')"
);
$st->execute([$before]);
return $st->rowCount();
}
private static function isDuplicate(PDOException $e): bool private static function isDuplicate(PDOException $e): bool
{ {
// MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class // MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class

View file

@ -43,6 +43,16 @@ render_header('이벤트');
<option value="ergo_report">작업자세 부담 리포트</option> <option value="ergo_report">작업자세 부담 리포트</option>
<option value="monitoring_degraded">감시 성능 저하</option> <option value="monitoring_degraded">감시 성능 저하</option>
<option value="device_fault">장치 이상</option> <option value="device_fault">장치 이상</option>
<!-- 감시 자체의 건전성. 조사자가 "언제부터 저 카메라가 이상했나"만 따로 걸러
볼 수 있어야 한다. -->
<option value="camera_moved">카메라 화각 변경</option>
<option value="camera_obstructed">카메라 가림</option>
<option value="low_light">조도 저하</option>
<option value="unacked_escalation">미확인 경보 에스컬레이션</option>
<!-- 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). -->
<option value="ppe_missing">보호구 미착용</option>
<option value="proximity">중장비 근접</option>
<option value="fire_smoke">화재·연기</option>
</select> </select>
</label> </label>
</div> </div>

View file

@ -233,6 +233,30 @@ final class AlertDispatchTest extends TestCase
$this->assertStringContainsString('이미지가 포함되지 않습니다', $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 public function testWebhookRefusesNonHttpSchemes(): void
{ {
$c = new Channels('https://example.com'); $c = new Channels('https://example.com');

View file

@ -181,6 +181,34 @@ final class SafetyConfigTest extends TestCase
$this->assertSame(400, $res->status()); $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 public function testEmailChannelNeedsAValidRecipient(): void
{ {
$c = $this->admin(); $c = $this->admin();

View file

@ -362,6 +362,10 @@ final class InMemorySuppressionRepository implements SuppressionRepository {
} }
final class InMemoryAlertRepository implements AlertRepository { final class InMemoryAlertRepository implements AlertRepository {
public array $chan = []; public array $log = []; private int $seq = 0; public array $chan = []; public array $log = []; private int $seq = 0;
/** Observation hook: how many times claim() was actually attempted. The dispatcher is
* supposed to pre-filter already-delivered pairs, and the only way to see that from the
* outside is to count the attempts. */
public int $claimCalls = 0;
public function __construct(array $channels = []) { foreach ($channels as $c) $this->saveChannel($c); } public function __construct(array $channels = []) { foreach ($channels as $c) $this->saveChannel($c); }
public function channels(?string $s): array { public function channels(?string $s): array {
$r = array_values(array_filter($this->chan, fn($c) => $r = array_values(array_filter($this->chan, fn($c) =>
@ -385,6 +389,7 @@ final class InMemoryAlertRepository implements AlertRepository {
} }
private function key(int $e, int $c): string { return $e . ':' . $c; } private function key(int $e, int $c): string { return $e . ':' . $c; }
public function claim(int $e, int $c, int $now): bool { public function claim(int $e, int $c, int $now): bool {
$this->claimCalls++;
if (isset($this->log[$this->key($e, $c)])) return false; if (isset($this->log[$this->key($e, $c)])) return false;
$this->log[$this->key($e, $c)] = ['event_id' => $e, 'channel_id' => $c, 'status' => 'pending', $this->log[$this->key($e, $c)] = ['event_id' => $e, 'channel_id' => $c, 'status' => 'pending',
'attempts' => 0, 'last_error' => null, 'queued_at' => $now, 'sent_at' => null]; 'attempts' => 0, 'last_error' => null, 'queued_at' => $now, 'sent_at' => null];
@ -409,6 +414,19 @@ final class InMemoryAlertRepository implements AlertRepository {
return array_slice(array_values(array_filter($this->log, return array_slice(array_values(array_filter($this->log,
fn($r) => $r['status'] === 'failed' && $r['attempts'] < $max)), 0, $limit); fn($r) => $r['status'] === 'failed' && $r['attempts'] < $max)), 0, $limit);
} }
public function claimedSince(int $since): array {
$out = [];
foreach ($this->log as $r) {
if ($r['queued_at'] >= $since) $out[$r['event_id'] . ':' . $r['channel_id']] = true;
}
return $out;
}
public function pruneResolved(int $before): int {
$n = count($this->log);
$this->log = array_filter($this->log, fn($r) =>
!($r['queued_at'] < $before && in_array($r['status'], ['sent', 'suppressed'], true)));
return $n - count($this->log);
}
} }
final class InMemoryHandoverRepository implements HandoverRepository { final class InMemoryHandoverRepository implements HandoverRepository {
public array $rows = []; private int $seq = 0; public array $rows = []; private int $seq = 0;

View file

@ -34,12 +34,28 @@ if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) {
fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n"); fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n");
exit(2); exit(2);
} }
// 백업 파일에는 전 현장의 사고 기록과 개인정보가 들어 있다. 웹에서 읽히는 위치에 두면 // 백업 파일에는 전 현장의 사고 기록과 작업자 이미지(개인정보)가 들어 있다. 웹에서 읽히는
// 그 자체가 사고다. // 위치에 두면 그 자체가 사고다 — 파일명은 `move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만
// 알면 추측 가능하다.
//
// 감사 지적(A3): 예전 검사는 `str_contains($real, '/www/')` 였다. 하위 경로
// (/home/acct/www/backups)는 막았지만 **docroot 자체**(/home/acct/www)는 슬래시가 뒤에
// 없어 그대로 통과했다. 가장 흔한 오사용이 정확히 그 케이스다.
// 이제 경로를 세그먼트로 쪼개 웹 루트 이름이 하나라도 있으면 거부한다.
const WEB_DIR_NAMES = ['www', 'public_html', 'htdocs', 'html', 'web', 'public'];
$real = realpath($outDir); $real = realpath($outDir);
if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) { if ($real !== false) {
fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n"); $segments = array_filter(explode('/', str_replace('\\', '/', $real)), 'strlen');
exit(2); foreach ($segments as $seg) {
if (in_array(strtolower($seg), WEB_DIR_NAMES, true)) {
fwrite(STDERR,
"거부: 백업 대상 경로에 웹 공개 디렉터리('{$seg}')가 포함되어 있습니다.\n"
. " 경로: {$real}\n"
. " 이 파일은 전 현장의 사고 기록과 작업자 이미지를 담습니다. www/ 밖(FTP 최상위\n"
. " 또는 그 상위)의 비공개 디렉터리를 지정하세요.\n");
exit(2);
}
}
} }
$stamp = date('Ymd-His'); $stamp = date('Ymd-His');

View file

@ -49,7 +49,7 @@ if ($baseUrl === '') {
fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n"); fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n");
exit(2); exit(2);
} }
$channels = new Channels($baseUrl); $channels = new Channels($baseUrl, 8, $cfg->timezone());
// --- 1. 에스컬레이션 발행 ------------------------------------------------------------- // --- 1. 에스컬레이션 발행 -------------------------------------------------------------
$watch = new EscalationWatch($events); $watch = new EscalationWatch($events);

View file

@ -19,7 +19,8 @@ declare(strict_types=1);
require __DIR__ . '/bootstrap.php'; require __DIR__ . '/bootstrap.php';
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository}; use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository,
PdoAlertRepository};
[$cfg, $pdo] = tool_boot(); [$cfg, $pdo] = tool_boot();
$args = tool_args($argv); $args = tool_args($argv);
@ -62,6 +63,21 @@ if ($metricDays > 0) {
} }
} }
// ---- 통보 원장 -------------------------------------------------------------------------
// 해소된 전달만 지운다. status='failed' 는 남긴다 — "아무에게도 닿지 않았다"는 사실 자체가
// 조사에서 필요한 발견이고, 그것을 청소 작업이 조용히 지워서는 안 된다.
// 이벤트 보존이 꺼져 있으면(기본값) 이 표는 유일하게 무한 증가하는 표가 된다.
$alertDays = max(30, $metricDays); // 성능 시계열보다 짧게 잡을 이유가 없다
$cutoff = $now - $alertDays * 86400;
if ($dry) {
$st = $pdo->prepare("SELECT COUNT(*) FROM move_alert_log
WHERE queued_at < ? AND status IN ('sent','suppressed')");
$st->execute([$cutoff]);
$done[] = 'alert_log(dry) ' . (int)$st->fetchColumn();
} else {
$done[] = 'alert_log ' . (new PdoAlertRepository($pdo))->pruneResolved($cutoff);
}
// ---- nonce --------------------------------------------------------------------------- // ---- nonce ---------------------------------------------------------------------------
if ($dry) { if ($dry) {
$st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?'); $st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?');

View file

@ -25,9 +25,13 @@ require __DIR__ . '/bootstrap.php';
[$cfg, $pdo] = tool_boot(); [$cfg, $pdo] = tool_boot();
$args = tool_args($argv); $args = tool_args($argv);
// Demo rows are marked in the one field no ingest path writes: a device_id prefix. That makes // Demo rows carry a marker in a field no real path ever writes, so --purge can target exactly
// --purge exact instead of "delete everything and hope". // those rows and nothing else. This is not cosmetic: a purge that matches on a plausible real
const DEMO_PREFIX = 'demo-'; // value (an operator name, a site name) will one day be run against a production database and
// delete records nobody can reconstruct.
const DEMO_PREFIX = 'demo-'; // device_id prefix
const DEMO_ACTOR = 'demo-seed'; // created_by / reviewed_by
const DEMO_LABEL = '[데모] '; // name prefix on channels
$days = max(1, min(365, (int)($args['days'] ?? 45))); $days = max(1, min(365, (int)($args['days'] ?? 45)));
$force = isset($args['force']); $force = isset($args['force']);
@ -62,11 +66,15 @@ function purge(PDO $pdo, string $snapshotDir): void
$pdo->prepare('DELETE FROM move_devices WHERE device_id LIKE ?')->execute([$like]); $pdo->prepare('DELETE FROM move_devices WHERE device_id LIKE ?')->execute([$like]);
$pdo->prepare('DELETE FROM move_cameras WHERE device_id LIKE ?')->execute([$like]); $pdo->prepare('DELETE FROM move_cameras WHERE device_id LIKE ?')->execute([$like]);
$pdo->prepare('DELETE FROM move_device_metrics WHERE device_id LIKE ?')->execute([$like]); $pdo->prepare('DELETE FROM move_device_metrics WHERE device_id LIKE ?')->execute([$like]);
$pdo->prepare("DELETE FROM move_actions WHERE created_by = 'demo-seed'")->execute(); $pdo->prepare("DELETE FROM move_actions WHERE created_by = ?")->execute([DEMO_ACTOR]);
// 데모 채널/위험요인은 이름·검토자에 표식이 있다. 실제 운영 행을 지우지 않도록 // 데모 행은 반드시 DEMO_ACTOR / DEMO_PREFIX 표식으로만 식별한다.
// LIKE 로 좁힌다 — 시연용 DB 와 운영 DB 가 같은 호스트에 있는 경우가 실제로 있다. //
$pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE '%(데모%'")->execute(); // 감사 지적(A2): 예전에는 위험요인을 `reviewed_by='안전관리자' AND site='factory-a'` 로
$pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = '안전관리자' AND site = 'factory-a'")->execute(); // 지웠다. '안전관리자'는 이 제품의 기본 관리자 계정명이고 'factory-a'는 흔한 현장명이라,
// 운영 DB 에서 --purge 를 한 번 돌리면 **실제 위험성평가가 통째로 사라진다.** 표식 없는
// 행은 이제 어떤 경우에도 건드리지 않는다.
$pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE ?")->execute([DEMO_LABEL . '%']);
$pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = ?")->execute([DEMO_ACTOR]);
say("purged demo rows (snapshot files removed: $removed)"); say("purged demo rows (snapshot files removed: $removed)");
} }
@ -259,7 +267,7 @@ foreach ([
['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12], ['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12],
['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5], ['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5],
] as [$c, $note, $ago]) { ] as [$c, $note, $ago]) {
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, 'demo-seed', $now - $ago * 86400]); $act->execute([$c, 'factory-a', $note, $now - $ago * 86400, DEMO_ACTOR, $now - $ago * 86400]);
} }
// ---------------------------------------------------------------- 통보 · 규정 · 성능 // ---------------------------------------------------------------- 통보 · 규정 · 성능
@ -267,13 +275,13 @@ foreach ([
// 다만 어떤 것도 실제로 발송되지 않도록, 채널은 전부 비활성으로 넣는다. // 다만 어떤 것도 실제로 발송되지 않도록, 채널은 전부 비활성으로 넣는다.
$ch = $pdo->prepare('INSERT INTO move_alert_channels (site,kind,name,config_json,min_severity,enabled,step,escalate_after_s,created_at) $ch = $pdo->prepare('INSERT INTO move_alert_channels (site,kind,name,config_json,min_severity,enabled,step,escalate_after_s,created_at)
VALUES (?,?,?,?,?,?,?,?,?)'); VALUES (?,?,?,?,?,?,?,?,?)');
$ch->execute(['factory-a', 'local', '1공장 경광등 (데모)', $ch->execute(['factory-a', 'local', DEMO_LABEL . '1공장 경광등',
json_encode(['gpio_pin' => 17, 'mode' => 'latch', 'hold_s' => 45, 'active_high' => false]), json_encode(['gpio_pin' => 17, 'mode' => 'latch', 'hold_s' => 45, 'active_high' => false]),
'high', 0, 0, 0, $now]); 'high', 0, 0, 0, $now]);
// 메일/웹훅은 enabled=0. 데모 DB 로 실수로 크론을 돌려도 아무 데도 나가지 않는다. // 메일/웹훅은 enabled=0. 데모 DB 로 실수로 크론을 돌려도 아무 데도 나가지 않는다.
$ch->execute(['factory-a', 'email', '안전관리자 메일 (데모 · 사용 안 함)', $ch->execute(['factory-a', 'email', DEMO_LABEL . '안전관리자 메일 (사용 안 함)',
json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]); json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]);
$ch->execute(['factory-a', 'email', '공장장 에스컬레이션 (데모 · 사용 안 함)', $ch->execute(['factory-a', 'email', DEMO_LABEL . '공장장 에스컬레이션 (사용 안 함)',
json_encode(['to' => ['plant-manager@example.invalid']]), 'critical', 0, 1, 600, $now]); json_encode(['to' => ['plant-manager@example.invalid']]), 'critical', 0, 1, 600, $now]);
$risk = $pdo->prepare('INSERT INTO move_risk_controls (site,event_type,hazard,control,severity_rating,likelihood_rating,reviewed_by,reviewed_at,updated_at) $risk = $pdo->prepare('INSERT INTO move_risk_controls (site,event_type,hazard,control,severity_rating,likelihood_rating,reviewed_by,reviewed_at,updated_at)
@ -285,7 +293,7 @@ foreach ([
['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2], ['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2],
['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3], ['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3],
] as [$t, $hazard, $control, $sev, $like]) { ] as [$t, $hazard, $control, $sev, $like]) {
$risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, '안전관리자', $now - 30 * 86400, $now - 30 * 86400]); $risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, DEMO_ACTOR, $now - 30 * 86400, $now - 30 * 86400]);
} }
// 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 — // 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 —

View file

@ -178,3 +178,57 @@ def test_build_script_passes_shellcheck_if_available():
def test_build_script_is_syntactically_valid_bash(): def test_build_script_is_syntactically_valid_bash():
r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True) r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True)
assert r.returncode == 0, f"bash -n failed:\n{r.stderr}" assert r.returncode == 0, f"bash -n failed:\n{r.stderr}"
# ---------------------------------------------------------------------------
# 2026-08-18 재감사(A11): CLI 도구가 웹에서 닿으면 안 된다.
# tools/dispatch_alerts.php 는 메일을 보내고, backup.php 는 DB 전체를 읽는다.
# 이들이 www/ 아래에 실리면 인증 없이 호출 가능한 표면이 된다.
# ---------------------------------------------------------------------------
TOOLS_DIR = os.path.join(ROOT, "server", "tools")
DIST_PRIV = os.path.join(ROOT, "dist", "1_cafe24_upload", "move_detect_app")
DIST_WEB = os.path.join(ROOT, "dist", "1_cafe24_upload", "www", "move_detect")
needs_package = pytest.mark.skipif(
not os.path.isdir(DIST_PRIV), reason="cafe24 package not built in this checkout"
)
def test_every_cli_tool_refuses_to_run_over_http():
# bootstrap.php 의 SAPI 검사가 유일한 코드 레벨 방어선이다. 도구가 그것을 require
# 하지 않으면 그 방어선이 없는 것과 같다.
for name in sorted(os.listdir(TOOLS_DIR)):
if not name.endswith(".php") or name == "bootstrap.php":
continue
src = _read(os.path.join(TOOLS_DIR, name))
assert "require __DIR__ . '/bootstrap.php'" in src, (
f"tools/{name} 가 bootstrap.php 를 require 하지 않습니다 — CLI 전용 보장이 없습니다."
)
guard = _read(os.path.join(TOOLS_DIR, "bootstrap.php"))
assert "PHP_SAPI !== 'cli'" in guard, "bootstrap.php 의 CLI 전용 가드가 사라졌습니다"
@needs_package
def test_tools_ship_outside_the_web_root():
assert os.path.isdir(os.path.join(DIST_PRIV, "tools")), (
"패키지에 tools/ 가 없습니다 — 통보 디스패처와 보존기간 정리가 동봉되지 않았습니다."
)
assert not os.path.exists(os.path.join(DIST_WEB, "tools")), (
"tools/ 가 웹 공개 경로에 실렸습니다. dispatch_alerts.php 는 메일을 보내고 "
"backup.php 는 DB 전체를 읽습니다."
)
@needs_package
def test_packaged_web_root_holds_no_php_beyond_the_front_controller():
# 웹 루트의 PHP 는 index.php 와 (배포 후 삭제하는) _preflight.php 뿐이어야 한다.
allowed = {"index.php", "_preflight.php"}
found = {
os.path.relpath(os.path.join(dirpath, f), DIST_WEB)
for dirpath, _, files in os.walk(DIST_WEB)
for f in files
if f.endswith(".php")
}
unexpected = found - allowed
assert not unexpected, f"웹 루트에 예상치 못한 PHP 파일: {sorted(unexpected)}"

View file

@ -0,0 +1,125 @@
"""엣지가 발화할 수 있는 이벤트 유형과 서버가 받아주는 유형이 일치하는지.
WHY THIS TEST EXISTS
`camera_moved` / `camera_obstructed` / `low_light` 를 추가할 때 엣지 감지기, DB CHECK
제약, 프런트 라벨은 갱신했는데 **가운데의 ingest 검증기(EventValidator::TYPES)를 빠뜨렸다.**
결과는 최악의 형태였다:
- 엣지는 이벤트를 만들고, 큐에 넣고, 전송한다
- 서버는 400 `bad:event_type` 으로 거부한다
- edge/queue.py 는 20회 재시도 후 그 행을 버린다
- 로그를 뒤지지 않는 한 아무도 모른다 — 감지기는 구현돼 있고, 단위 테스트도 통과하고,
장비에서도 돌지만, 대시보드에는 영원히 아무것도 안 뜬다
파이썬 테스트도 PHP 테스트도 이걸 잡을 수 없다. 둘 다 자기 쪽 절반만 보기 때문이다.
그래서 언어 경계를 넘는 이 대조가 필요하다.
프런트 라벨 누락은 덜 치명적이지만(원시 코드가 그대로 보인다) 같은 원인이므로 함께 잡는다.
"""
import re
from pathlib import Path
import pytest
from edge.events import EventType
ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = ROOT / "server" / "src" / "Ingest" / "EventValidator.php"
SCHEMA = ROOT / "server" / "schema.sql"
EVENTS_VIEW = ROOT / "server" / "src" / "View" / "events.php"
# 라벨 맵을 들고 있는 프런트 파일. 새 파일이 생기면 여기에 추가한다.
LABEL_FILES = [
ROOT / "server" / "public" / "assets" / "app.js",
ROOT / "server" / "public" / "assets" / "detail.js",
ROOT / "server" / "public" / "assets" / "report.js",
ROOT / "server" / "public" / "assets" / "compliance.js",
ROOT / "server" / "public" / "assets" / "package.js",
]
# 서버가 스스로 발행하는 유형 — 엣지는 이것을 만들지 않는다 (EscalationWatch).
SERVER_ONLY = {"unacked_escalation"}
def _edge_types() -> set[str]:
return {e.value for e in EventType}
def _quoted(text: str) -> set[str]:
"""'foo','bar' 형태의 작은따옴표 리터럴을 전부 뽑는다."""
return set(re.findall(r"'([a-z_]+)'", text))
def _validator_types() -> set[str]:
src = VALIDATOR.read_text(encoding="utf-8")
m = re.search(r"private const TYPES = \[(.*?)\];", src, re.S)
assert m, "EventValidator::TYPES 배열을 찾지 못했습니다 (리팩터되었나요?)"
return _quoted(m.group(1))
def _schema_types() -> set[str]:
src = SCHEMA.read_text(encoding="utf-8")
m = re.search(r"chk_events_type CHECK \(event_type IN \((.*?)\)\)", src, re.S)
assert m, "schema.sql 의 chk_events_type 를 찾지 못했습니다"
return _quoted(m.group(1))
def test_every_edge_event_type_is_accepted_by_ingest():
missing = _edge_types() - _validator_types()
assert not missing, (
f"엣지가 발화하지만 서버가 400 으로 거부하는 유형: {sorted(missing)}. "
"EventValidator::TYPES 에 추가하세요 — 이 상태에서는 감지기가 조용히 무력화됩니다."
)
def test_every_edge_event_type_passes_the_db_check():
missing = _edge_types() - _schema_types()
assert not missing, (
f"schema.sql 의 chk_events_type 에 없는 유형: {sorted(missing)}. "
"검증기를 통과해도 INSERT 가 제약 위반으로 실패합니다."
)
def test_validator_and_schema_agree():
# 한쪽에만 있는 유형은 둘 중 하나가 갱신되지 않았다는 뜻이다.
assert _validator_types() == _schema_types(), (
"EventValidator 와 schema.sql 의 유형 집합이 다릅니다: "
f"검증기만={sorted(_validator_types() - _schema_types())}, "
f"스키마만={sorted(_schema_types() - _validator_types())}"
)
def test_server_only_types_are_declared_everywhere_too():
# 서버가 만드는 유형은 검증기를 거치지 않지만, 목록을 한 곳에 모아두지 않으면
# 다음 사람이 "이건 왜 스키마에만 있지?" 로 시작해 지우게 된다.
for t in SERVER_ONLY:
assert t in _schema_types(), f"{t} 가 schema.sql CHECK 에 없습니다"
assert t in _validator_types(), f"{t} 가 EventValidator::TYPES 에 없습니다"
@pytest.mark.parametrize("path", LABEL_FILES, ids=lambda p: p.name)
def test_frontend_label_maps_cover_every_type(path: Path):
if not path.is_file():
pytest.skip(f"{path.name} 없음")
src = path.read_text(encoding="utf-8")
# 라벨 맵을 들고 있는 파일만 검사한다 (없으면 그 파일은 유형을 표시하지 않는다).
if "TYPE_LABELS" not in src:
pytest.skip(f"{path.name} 에는 TYPE_LABELS 가 없습니다")
m = re.search(r"TYPE_LABELS = \{(.*?)\n \};", src, re.S)
assert m, f"{path.name} 의 TYPE_LABELS 를 파싱하지 못했습니다"
labelled = set(re.findall(r"([a-z_]+)\s*:", m.group(1)))
missing = (_edge_types() | SERVER_ONLY) - labelled
assert not missing, (
f"{path.name} 의 TYPE_LABELS 에 없는 유형: {sorted(missing)} — "
"화면에 원시 코드가 그대로 표시됩니다."
)
def test_event_type_filter_offers_every_type():
src = EVENTS_VIEW.read_text(encoding="utf-8")
offered = set(re.findall(r'<option value="([a-z_]+)"', src))
missing = (_edge_types() | SERVER_ONLY) - offered
assert not missing, (
f"이벤트 유형 필터에서 고를 수 없는 유형: {sorted(missing)} — "
"그 유형만 보려는 조사자가 걸러낼 방법이 없습니다."
)