Compare commits

..

No commits in common. "e625d482ba7ecc0a40bf183e9ec0ded6e14fc338" and "5c7c65351d4f4da55c6ff26e8d7f47b5f83a028e" have entirely different histories.

26 changed files with 39 additions and 636 deletions

View file

@ -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` (1차 43/43 + 재감사 12/12 — **적합**, 실장비 OFF-HW 검증만 잔여).
- 감사 상태: `docs/delivery-audit.md` (43/43 — 실장비 OFF-HW 검증만 잔여).
## 검증 (verify)

View file

@ -1,16 +1,5 @@
# 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-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장).
**통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다.

View file

@ -1,9 +1,5 @@
# 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.
@ -262,109 +258,6 @@ 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)

View file

@ -21,10 +21,6 @@ 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 가 정리).

View file

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

View file

@ -170,35 +170,12 @@ ALTER TABLE move_actions
ADD KEY ix_actions_event (event_id);
-- ---------------------------------------------------------------------------
-- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★
--
-- 9. 신규 감지 유형
-- 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',
@ -210,9 +187,5 @@ ALTER TABLE move_events
-- 검증:
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
-- php tools/verify_audit_chain.php -- 사슬 무결성
-- php server/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';

View file

@ -29,12 +29,7 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
};
var SEVERITY_LABELS = {
@ -1074,36 +1069,17 @@
disarmBulk();
bulkBtn.disabled = true;
var done = 0;
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();
Promise.all(targets.map(function (ev) {
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 () {
// 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 () {
}).then(function (res) {
if (res.ok) { patchEventLocally(ev.event_id, false); done++; }
}).catch(function () {});
})).then(function () {
bulkBtn.disabled = false;
lastListSig = null;
renderList();
showToast('일괄 확인 ' + done + '건 / ' + targets.length + '건 (위급은 제외되었습니다)');
showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)');
loadEvents();
});
});

View file

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

View file

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

View file

@ -94,12 +94,6 @@ 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']++;
@ -111,12 +105,8 @@ final class AlertDispatcher
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
$channelId = (int)$delivery['channel']['channel_id'];
$eventId = (int)$event['event_id'];
// 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.
// claim() is the duplicate guard. Losing it means another (possibly overlapping)
// dispatcher run 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);

View file

@ -39,30 +39,8 @@ final class Channels
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
];
/**
* @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
}
}
/** @param string $baseUrl absolute dashboard URL, for the incident link. */
public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {}
public function send(array $event, array $channel): void
{
@ -89,7 +67,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 = $this->stamp((int)$event['occurred_at']);
$when = date('Y-m-d H:i:s', (int)$event['occurred_at']);
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
return implode("\n", [
"작업장 안전 감지 알림",

View file

@ -35,24 +35,6 @@ 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

View file

@ -163,18 +163,12 @@ 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);
@ -185,7 +179,7 @@ final class AdminConfigController
$newId = $this->alerts->saveChannel([
'channel_id' => $id > 0 ? $id : null,
'site' => $site,
'site' => $this->site(),
'kind' => $kind,
'name' => $name,
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),

View file

@ -4,29 +4,9 @@ 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',
// 감시 자체의 건전성 (edge/detectors/integrity.py)
'camera_moved','camera_obstructed','low_light',
// 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다.
'unacked_escalation',
// 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다.
'ppe_missing','proximity','fire_smoke'];
'lone_worker','overcapacity','overstay','near_fall','ergo_report'];
private const SEVERITIES = ['critical','high','warning','info'];
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];

View file

@ -31,15 +31,4 @@ 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;
}

View file

@ -160,30 +160,6 @@ 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

View file

@ -43,16 +43,6 @@ render_header('이벤트');
<option value="ergo_report">작업자세 부담 리포트</option>
<option value="monitoring_degraded">감시 성능 저하</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>
</label>
</div>

View file

@ -233,30 +233,6 @@ 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');

View file

@ -181,34 +181,6 @@ 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();

View file

@ -362,10 +362,6 @@ 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) =>
@ -389,7 +385,6 @@ 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];
@ -414,19 +409,6 @@ 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;

View file

@ -34,28 +34,12 @@ 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) {
$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");
if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) {
fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n");
exit(2);
}
}
}
$stamp = date('Ymd-His');

View file

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

View file

@ -19,8 +19,7 @@ declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository,
PdoAlertRepository};
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository};
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
@ -63,21 +62,6 @@ 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 < ?');

View file

@ -25,13 +25,9 @@ require __DIR__ . '/bootstrap.php';
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
// 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
// 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-';
$days = max(1, min(365, (int)($args['days'] ?? 45)));
$force = isset($args['force']);
@ -66,15 +62,11 @@ 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 = ?")->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]);
$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();
say("purged demo rows (snapshot files removed: $removed)");
}
@ -267,7 +259,7 @@ foreach ([
['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12],
['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5],
] as [$c, $note, $ago]) {
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, DEMO_ACTOR, $now - $ago * 86400]);
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, 'demo-seed', $now - $ago * 86400]);
}
// ---------------------------------------------------------------- 통보 · 규정 · 성능
@ -275,13 +267,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', DEMO_LABEL . '1공장 경광등',
$ch->execute(['factory-a', 'local', '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', DEMO_LABEL . '안전관리자 메일 (사용 안 함)',
$ch->execute(['factory-a', 'email', '안전관리자 메일 (데모 · 사용 안 함)',
json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]);
$ch->execute(['factory-a', 'email', DEMO_LABEL . '공장장 에스컬레이션 (사용 안 함)',
$ch->execute(['factory-a', 'email', '공장장 에스컬레이션 (데모 · 사용 안 함)',
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)
@ -293,7 +285,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, DEMO_ACTOR, $now - 30 * 86400, $now - 30 * 86400]);
$risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, '안전관리자', $now - 30 * 86400, $now - 30 * 86400]);
}
// 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 —

View file

@ -178,57 +178,3 @@ 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)}"

View file

@ -1,125 +0,0 @@
"""엣지가 발화할 수 있는 이벤트 유형과 서버가 받아주는 유형이 일치하는지.
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)} — "
"그 유형만 보려는 조사자가 걸러낼 방법이 없습니다."
)