diff --git a/CLAUDE.md b/CLAUDE.md index 7560625..87beba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보 - 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신. - 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가. -- 감사 상태: `docs/delivery-audit.md` (43/43 — 실장비 OFF-HW 검증만 잔여). +- 감사 상태: `docs/delivery-audit.md` (1차 43/43 + 재감사 12/12 — **적합**, 실장비 OFF-HW 검증만 잔여). ## 검증 (verify) diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index b6b5cf1..8f0c26b 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -1,5 +1,9 @@ # 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 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. @@ -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 (updated as each item lands — newest first) diff --git a/server/config.example.php b/server/config.example.php index 58840c5..1cedf5b 100644 --- a/server/config.example.php +++ b/server/config.example.php @@ -21,6 +21,10 @@ return [ 'public_base_url' => '', // 예: 'https://example.com/move_detect' // 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다. 'escalate_after_seconds' => 300, + // 통보 메시지의 시각 표기 기준. 대시보드는 브라우저 시각을 쓰지만 알림 메일은 PHP 가 + // 만들고, 공유호스팅은 date.timezone 이 UTC 인 경우가 흔하다 — 콘솔이 13:01 로 보여준 + // 넘어짐을 메일이 04:01 로 적으면 응급 대응에서 그대로 사고가 된다. + 'timezone' => 'Asia/Seoul', // --- 보존 / 용량 ------------------------------------------------------------------ // 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리). diff --git a/server/deploy/config.template.php b/server/deploy/config.template.php index 47890d2..38940e9 100644 --- a/server/deploy/config.template.php +++ b/server/deploy/config.template.php @@ -41,6 +41,10 @@ return [ // 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를 // 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분. 'escalate_after_seconds' => 300, + // 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC + // 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이 + // 서로 다르게 나옵니다. 한국 현장은 그대로 두세요. + 'timezone' => 'Asia/Seoul', // ---- 5. 용량 관리 (그대로 두세요) ----------------------------------- // 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다. diff --git a/server/migrations/2026-08-09-safety-platform.sql b/server/migrations/2026-08-09-safety-platform.sql index 6876839..df5a13e 100644 --- a/server/migrations/2026-08-09-safety-platform.sql +++ b/server/migrations/2026-08-09-safety-platform.sql @@ -170,12 +170,35 @@ ALTER TABLE move_actions ADD KEY ix_actions_event (event_id); -- --------------------------------------------------------------------------- --- 9. 신규 감지 유형 +-- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★ +-- -- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고, -- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다. -- 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 DROP CHECK chk_events_type; + ALTER TABLE move_events ADD CONSTRAINT chk_events_type CHECK (event_type IN ( '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; -- 체인 이전 구간 --- php server/tools/verify_audit_chain.php -- 사슬 무결성 +-- php tools/verify_audit_chain.php -- 사슬 무결성 -- 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'; diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 5e893f4..0d7084c 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -29,7 +29,12 @@ ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', 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 = { @@ -1069,17 +1074,36 @@ disarmBulk(); bulkBtn.disabled = true; 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', { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } - }).then(function (res) { - if (res.ok) { patchEventLocally(ev.event_id, false); done++; } - }).catch(function () {}); - })).then(function () { + }) + .then(function (res) { if (res.ok) { patchEventLocally(ev.event_id, false); done++; } }) + .catch(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; lastListSig = null; renderList(); - showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)'); + showToast('일괄 확인 ' + done + '건 / ' + targets.length + '건 (위급은 제외되었습니다)'); loadEvents(); }); }); diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index f4967a8..31dc7ca 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -33,7 +33,12 @@ ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', 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 diff --git a/server/public/assets/report.js b/server/public/assets/report.js index baecddb..e5484da 100644 --- a/server/public/assets/report.js +++ b/server/public/assets/report.js @@ -16,7 +16,12 @@ ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', 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: '화재·연기' }; // 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다. diff --git a/server/src/Alert/AlertDispatcher.php b/server/src/Alert/AlertDispatcher.php index 42c385d..60df1bd 100644 --- a/server/src/Alert/AlertDispatcher.php +++ b/server/src/Alert/AlertDispatcher.php @@ -94,6 +94,12 @@ final class AlertDispatcher // 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. $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) { $stats['considered']++; @@ -105,8 +111,12 @@ final class AlertDispatcher foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) { $channelId = (int)$delivery['channel']['channel_id']; $eventId = (int)$event['event_id']; - // claim() is the duplicate guard. Losing it means another (possibly overlapping) - // dispatcher run owns this delivery — skip, do not send. + // Cheap pre-filter (see $alreadyClaimed) — skips the overwhelming majority of + // 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 ($delivery['suppressed']) { $this->alerts->markSuppressed($eventId, $channelId, $now); diff --git a/server/src/Alert/Channels.php b/server/src/Alert/Channels.php index 9c4df9d..6e725c5 100644 --- a/server/src/Alert/Channels.php +++ b/server/src/Alert/Channels.php @@ -39,8 +39,30 @@ final class Channels '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 { @@ -67,7 +89,7 @@ final class Channels { $type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type']; $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'; return implode("\n", [ "작업장 안전 감지 알림", diff --git a/server/src/Config.php b/server/src/Config.php index a851400..bc41556 100644 --- a/server/src/Config.php +++ b/server/src/Config.php @@ -35,6 +35,24 @@ final class Config */ 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 * `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by diff --git a/server/src/Dashboard/AdminConfigController.php b/server/src/Dashboard/AdminConfigController.php index 717bc94..d7e688d 100644 --- a/server/src/Dashboard/AdminConfigController.php +++ b/server/src/Dashboard/AdminConfigController.php @@ -163,12 +163,18 @@ final class AdminConfigController if ($config === null) return Response::json(['error' => 'invalid_config'], 400); $id = (int)($b['channel_id'] ?? 0); + $site = $this->site(); // new channels belong to the caller's scope if ($id > 0) { $existing = null; foreach ($this->alerts->allChannels($this->site()) as $c) { if ((int)$c['channel_id'] === $id) { $existing = $c; break; } } 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. if (($config['token'] ?? null) === '••••••••') { $old = json_decode($existing['config_json'], true); @@ -179,7 +185,7 @@ final class AdminConfigController $newId = $this->alerts->saveChannel([ 'channel_id' => $id > 0 ? $id : null, - 'site' => $this->site(), + 'site' => $site, 'kind' => $kind, 'name' => $name, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), diff --git a/server/src/Ingest/EventValidator.php b/server/src/Ingest/EventValidator.php index 2aa3e90..75e31f7 100644 --- a/server/src/Ingest/EventValidator.php +++ b/server/src/Ingest/EventValidator.php @@ -4,9 +4,29 @@ namespace App\Ingest; 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', '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 REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; diff --git a/server/src/Repository/AlertRepository.php b/server/src/Repository/AlertRepository.php index b223eed..328fa3d 100644 --- a/server/src/Repository/AlertRepository.php +++ b/server/src/Repository/AlertRepository.php @@ -31,4 +31,15 @@ interface AlertRepository /** Rows still worth retrying (status=failed, attempts under the cap). */ 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; } diff --git a/server/src/Repository/Pdo/PdoAlertRepository.php b/server/src/Repository/Pdo/PdoAlertRepository.php index 5f58faa..c72b1a1 100644 --- a/server/src/Repository/Pdo/PdoAlertRepository.php +++ b/server/src/Repository/Pdo/PdoAlertRepository.php @@ -160,6 +160,30 @@ final class PdoAlertRepository implements AlertRepository ], $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 { // MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class diff --git a/server/src/View/events.php b/server/src/View/events.php index 1f1ed3b..7284965 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -43,6 +43,16 @@ render_header('이벤트'); + + + + + + + + + diff --git a/server/tests/AlertDispatchTest.php b/server/tests/AlertDispatchTest.php index 866bc1f..11e49c4 100644 --- a/server/tests/AlertDispatchTest.php +++ b/server/tests/AlertDispatchTest.php @@ -233,6 +233,30 @@ final class AlertDispatchTest extends TestCase $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'); diff --git a/server/tests/SafetyConfigTest.php b/server/tests/SafetyConfigTest.php index 2043287..4b8bdcd 100644 --- a/server/tests/SafetyConfigTest.php +++ b/server/tests/SafetyConfigTest.php @@ -181,6 +181,34 @@ final class SafetyConfigTest extends TestCase $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(); diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index 6297fe8..329ee9d 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -362,6 +362,10 @@ final class InMemorySuppressionRepository implements SuppressionRepository { } final class InMemoryAlertRepository implements AlertRepository { 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 channels(?string $s): array { $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; } public function claim(int $e, int $c, int $now): bool { + $this->claimCalls++; if (isset($this->log[$this->key($e, $c)])) return false; $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]; @@ -409,6 +414,19 @@ final class InMemoryAlertRepository implements AlertRepository { return array_slice(array_values(array_filter($this->log, 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 { public array $rows = []; private int $seq = 0; diff --git a/server/tools/backup.php b/server/tools/backup.php index 35f4dc5..6a00411 100644 --- a/server/tools/backup.php +++ b/server/tools/backup.php @@ -34,12 +34,28 @@ if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) { fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n"); 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); -if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) { - fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n"); - exit(2); +if ($real !== false) { + $segments = array_filter(explode('/', str_replace('\\', '/', $real)), 'strlen'); + 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'); diff --git a/server/tools/dispatch_alerts.php b/server/tools/dispatch_alerts.php index 8d8023e..c5e6fb5 100644 --- a/server/tools/dispatch_alerts.php +++ b/server/tools/dispatch_alerts.php @@ -49,7 +49,7 @@ if ($baseUrl === '') { fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n"); exit(2); } -$channels = new Channels($baseUrl); +$channels = new Channels($baseUrl, 8, $cfg->timezone()); // --- 1. 에스컬레이션 발행 ------------------------------------------------------------- $watch = new EscalationWatch($events); diff --git a/server/tools/maintain.php b/server/tools/maintain.php index 230dbcd..58159f8 100644 --- a/server/tools/maintain.php +++ b/server/tools/maintain.php @@ -19,7 +19,8 @@ declare(strict_types=1); require __DIR__ . '/bootstrap.php'; -use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository}; +use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository, + PdoAlertRepository}; [$cfg, $pdo] = tool_boot(); $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 --------------------------------------------------------------------------- if ($dry) { $st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?'); diff --git a/server/tools/seed_demo.php b/server/tools/seed_demo.php index dbea241..76e9cb5 100644 --- a/server/tools/seed_demo.php +++ b/server/tools/seed_demo.php @@ -25,9 +25,13 @@ require __DIR__ . '/bootstrap.php'; [$cfg, $pdo] = tool_boot(); $args = tool_args($argv); -// Demo rows are marked in the one field no ingest path writes: a device_id prefix. That makes -// --purge exact instead of "delete everything and hope". -const DEMO_PREFIX = 'demo-'; +// Demo rows carry a marker in a field no real path ever writes, so --purge can target exactly +// those rows and nothing else. This is not cosmetic: a purge that matches on a plausible real +// 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))); $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_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_actions WHERE created_by = 'demo-seed'")->execute(); - // 데모 채널/위험요인은 이름·검토자에 표식이 있다. 실제 운영 행을 지우지 않도록 - // LIKE 로 좁힌다 — 시연용 DB 와 운영 DB 가 같은 호스트에 있는 경우가 실제로 있다. - $pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE '%(데모%'")->execute(); - $pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = '안전관리자' AND site = 'factory-a'")->execute(); + $pdo->prepare("DELETE FROM move_actions WHERE created_by = ?")->execute([DEMO_ACTOR]); + // 데모 행은 반드시 DEMO_ACTOR / DEMO_PREFIX 표식으로만 식별한다. + // + // 감사 지적(A2): 예전에는 위험요인을 `reviewed_by='안전관리자' AND site='factory-a'` 로 + // 지웠다. '안전관리자'는 이 제품의 기본 관리자 계정명이고 '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)"); } @@ -259,7 +267,7 @@ foreach ([ ['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12], ['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5], ] 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) 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]), 'high', 0, 0, 0, $now]); // 메일/웹훅은 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]); -$ch->execute(['factory-a', 'email', '공장장 에스컬레이션 (데모 · 사용 안 함)', +$ch->execute(['factory-a', 'email', DEMO_LABEL . '공장장 에스컬레이션 (사용 안 함)', 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) @@ -285,7 +293,7 @@ foreach ([ ['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2], ['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3], ] 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 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 — diff --git a/tests/test_cafe24_package.py b/tests/test_cafe24_package.py index d60eac5..14540ee 100644 --- a/tests/test_cafe24_package.py +++ b/tests/test_cafe24_package.py @@ -178,3 +178,57 @@ def test_build_script_passes_shellcheck_if_available(): def test_build_script_is_syntactically_valid_bash(): 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}" + + +# --------------------------------------------------------------------------- +# 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)}" diff --git a/tests/test_event_type_parity.py b/tests/test_event_type_parity.py new file mode 100644 index 0000000..0530f79 --- /dev/null +++ b/tests/test_event_type_parity.py @@ -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'