fix(security,edge,test): close the confirmed audit backlog; add the guards that prevent recurrence

감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는
그것이 조용히 지나가게 만든 구조를 고친다.

보안
- CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이
  nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다.
- /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건
  이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다.
- 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로
  쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다.

엣지
- 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다.
- 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다.
- T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 —
  추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다.
  T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게
  추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다.
- systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고
  '살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우).

테스트 — 감사의 최대 갭
- PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다.
  `-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다.
- 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의
  유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체.
- index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트,
  CI 신설(스킵이 하나라도 있으면 실패).
- ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와
  달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다.

프런트
- 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시
  만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이
  device-tree.js에는 이미 있었다.
- 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시.

납품
- VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는
  자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다.
- deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다).
- 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다.

검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인.
브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
This commit is contained in:
유창욱 2026-08-07 23:31:53 +09:00
parent 6b1f4c483f
commit c3227c179c
41 changed files with 1093 additions and 77 deletions

View file

@ -0,0 +1,44 @@
# The repo had no CI at all, which meant PdoIntegrationTest — the only test that runs the
# delivered SQL rather than an in-memory double — had almost certainly never executed anywhere:
# it skips itself unless pdo_sqlite is loaded, and no machine was loading it.
#
# Same two commands as the local `verify` in CLAUDE.md. Keep them identical; a CI that runs
# something else is a CI that proves something else.
name: verify
on:
push:
pull_request:
jobs:
edge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11' # Pi OS Bookworm ships CPython 3.11; match the target
- run: pip install -e ".[dev]" # deps + pytest come from pyproject.toml
- run: python -m pytest tests -q
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2' # Cafe24's floor (see server/docs/server-deploy.md)
# pdo_sqlite is what un-skips the DB integration test; gd/sodium mirror the host's
# image + Argon2id requirements so a missing-extension failure shows up here, not
# on the customer's shared hosting.
extensions: pdo_sqlite, gd, sodium
- run: composer install --no-interaction --prefer-dist
working-directory: server
- run: php vendor/bin/phpunit
working-directory: server
- name: fail if any test skipped
working-directory: server
# A skip is how the DB coverage silently disappeared in the first place. In CI every
# test must actually run, so a re-introduced skip gate fails the build instead of
# quietly shrinking coverage back down.
run: php vendor/bin/phpunit --do-not-cache-result | tee out.txt; ! grep -q "Skipped:" out.txt

View file

@ -12,10 +12,13 @@ Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보
## 검증 (verify)
```
python -m pytest tests -q # edge (203+)
cd server && php vendor/bin/phpunit # server (119+, PDO 4 skip)
python -m pytest tests -q # edge (247+)
cd server && php -d extension=pdo_sqlite vendor/bin/phpunit # server (162+, skip 0)
```
`-d extension=pdo_sqlite` 없이 돌리면 PDO 통합 테스트(납품되는 실제 SQL)가 통째로 스킵된다.
CI(`.forgejo/workflows/verify.yml`)는 같은 명령을 쓰고 스킵이 하나라도 있으면 실패시킨다.
## 제약
- ultralytics(AGPL) 금지 — Hailo HEF/MoveNet만. 라이브뷰·영상 전송 금지(스냅샷만, 얼굴 블러).

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.9.0

View file

@ -6,11 +6,13 @@ performs **zero network fetches** at install or run time. The only runtime
network use is the server POST / config-poll / heartbeat, and even that tolerates
being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
> daemon wires today (`edge/daemon.py:main`) and the one verified end-to-end.
> Hailo-8 acceleration (`edge/adapters/hailo_pose.py`) and the CSI camera
> (`edge/adapters/picamera_source.py`) are separate device-validated paths — see
> `docs/hardware-bringup.md`.
> Scope: the delivered bundle is **Hailo-8 + `yolov8s_pose.hef`**, which
> `build_offline_bundle.sh` fetches (sha256-pinned) and stages into `models/`.
> The CPU MoveNet fallback is **not** bundled: its upstream ONNX URL 404s, so
> `install_offline.sh` configures the unit for whatever backend is actually
> present and refuses to install if none is. Camera side: USB (UVC) and CSI are
> both wired; CSI needs `hailo-all`/`picamera2` from the Pi OS image.
> Device validation for both is `docs/hardware-bringup.md` (still OFF-HW).
## Files
@ -105,17 +107,18 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f
```
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
model (`/opt/move-detect/models/movenet_lightning.onnx`).
`DEVICE_ID`/`DEVICE_SECRET` must match a `move_devices` row in the server (see
`server/docs/server-deploy.md` §6). `HAILO_HEF` points at the bundled model
(`/opt/move-detect/models/yolov8s_pose.hef`); the installer sets it for you and
only falls back to `MOVENET_ONNX` if a MoveNet bundle was built.
## 4. Verify the air gap (do this before trusting a build)
- [ ] On a host with **networking disabled**, `install_offline.sh` completes —
the `--no-index` installs prove nothing is fetched. If pip tries to reach
PyPI, a wheel is missing from the bundle: rebuild (step 1) natively.
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
numpy + requests all import from the venv).
- [ ] Step [4/6] of the installer prints `edge import OK` (cv2 + numpy + requests
import from the venv; onnxruntime only if a MoveNet bundle was built).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the server is reachable) heartbeats.
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
@ -166,6 +169,10 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the server
## Not bundled (by design)
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
the default CPU MoveNet + USB path needs none of them.
- **HailoRT + picamera2** — Hailo's runtime/driver and the CSI camera stack are Pi
system packages, installed out-of-band (above). Note the HEF itself **is** bundled
(`models/yolov8s_pose.hef`); only the driver is not.
- **`python3-venv`** — `install_offline.sh` runs `python3 -m venv`, which Pi OS Lite
does not always include. Install it with the Hailo packages, before going air-gapped.
- **MoveNet ONNX (CPU fallback)** — its upstream URL 404s, so the bundle is Hailo-only.
If you need the CPU path, supply the ONNX to the build machine and rebuild.

View file

@ -155,8 +155,19 @@ sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock"
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
# Provenance. Without this, a bundle is unidentifiable once it leaves the build machine: the
# SHA256SUMS inside a STALE bundle is perfectly self-consistent, so integrity checks pass while
# the contents are months behind the source. version+commit is how a site reports what it runs.
BUNDLE_VERSION="$(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
GIT_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
GIT_DIRTY=""
(cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
echo "bundle: move-detect-edge"
echo "version: $BUNDLE_VERSION"
echo "git_commit: ${GIT_REV}${GIT_DIRTY}"
echo "built_at: $BUILD_DATE"
echo "built_mode: $MODE"
echo "python: $PYVER ($PYABI)"
echo "target_arch: $TARGET_ARCH"

View file

@ -19,12 +19,15 @@ CAFE24_URL=https://your-domain.example/move_detect
CAMERA_ID=cam-01
# --- pose backend / capture ---
# POSE_BACKEND: "movenet" (CPU, needs MOVENET_ONNX) or "hailo" (needs HAILO_HEF).
POSE_BACKEND=movenet
# MOVENET_ONNX: required when POSE_BACKEND=movenet.
MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
# HAILO_HEF: required when POSE_BACKEND=hailo (uncomment and set).
#HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
# POSE_BACKEND: "hailo" (Pi 5 + AI HAT+, needs HAILO_HEF) or "movenet" (CPU, needs MOVENET_ONNX).
# The delivered bundle carries the HEF only — the MoveNet ONNX has no reachable upstream, so a
# movenet unit would point at a file that is not there and fail to start. install_offline.sh
# rewrites these to match whatever the bundle actually contains; edit them only if you know the
# file exists on this device.
POSE_BACKEND=hailo
HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
# MOVENET_ONNX: required only when POSE_BACKEND=movenet (CPU fallback; not bundled by default).
#MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
# CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE).
CAMERA_BACKEND=uvc
# UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam.

View file

@ -43,6 +43,21 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
Restart=always
RestartSec=5
# Catch the failure Restart=always CANNOT: alive but wedged. run_loop pings WATCHDOG=1 at the
# top of every tick, so a blocked camera read / accelerator call / sqlite lock stops the pings
# and systemd restarts the unit instead of leaving an unattended site silently unmonitored.
# 120s, not a tight value: one tick may legitimately include an offline-queue flush slice whose
# POSTs each carry a 5s timeout. Too tight would restart a HEALTHY daemon mid-recovery.
WatchdogSec=120
# Memory ceiling. The evidence ring buffers ~90 frames per camera, so a mis-set capture size or
# a leak grows RSS until the kernel OOM killer fires — and the kernel may kill something else
# entirely, leaving the Pi in an unpredictable state. Let systemd kill THIS unit instead, where
# Restart=always brings it straight back. MemoryHigh throttles first to ride out a brief spike.
MemoryHigh=1200M
MemoryMax=1500M
OOMPolicy=restart
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
# systemd creates/owns /var/lib/move-detect for the service user.
StateDirectory=move-detect

View file

@ -1,5 +1,19 @@
# INDEX — 작업 기록 (완료 결과만)
- **2026-08-08** 감사 잔여 확정 항목 일괄 처리 (VERSION 0.9.0 신설). **보안**: CSP nonce
도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF,
계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지
해소), 타이머 env 전수 NaN/음수 검증, T2 프레임 스킵(`INFER_EVERY`, 스킵 틱은 링버퍼 미투입
=미블러 얼굴 유출 차단), systemd 워치독(sd_notify)+메모리 상한, UVC 해상도·버퍼 고정.
**테스트**: PDO 통합 테스트 스킵 해소(`-d extension=pdo_sqlite`, 리포트·히트맵·익스포트
집계 SQL 최초 실행), 얼굴 블러 테스트를 실제 증거로 교체(무동작 퇴화 감지), index.php 라우트
테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트, CI 신설
(`.forgejo/workflows/verify.yml`, 스킵 발생 시 실패). **프런트**: 이벤트 목록 시그니처 기반
재렌더 스킵+포커스 복원(5초마다 포커스 소실·전체 재낭독 해소), 폴링 중첩 가드, 콜드스타트
실패 표시. **문서**: deploy/README·env.example을 Hailo 단독 번들 실제와 일치, bringup의
미존재 설정 키를 실제 env 노브로 교체. 납품물에 version/git_commit/built_at 스탬프.
pytest 251 / PHPUnit 162 (스킵 0).
- **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프
누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가
(`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장

View file

@ -65,8 +65,15 @@ Measure frame processing under three operational modes:
| **T2: Detection** | Inference every 2–3 frames | Trade-off: skip frames to reduce Hailo load. | ≥ 8 fps | For additional context cameras; acceptable for non-critical zones. |
| **T3: Motion** | Hailo only on motion keyframes | Inference triggered by motion (v4l2 MJPEG keyframe or frame diff). | ≥ 5 fps avg | For background monitoring; many cameras, low overhead. |
1. Configure edge detection core to support tier selection (via thresholds file or env var).
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2/T3 on perimeter).
1. Tier selection is `INFER_EVERY` in `/etc/move-detect/edge.env` (per device):
`1` = T1 (every tick, the default), `2`–`3` = T2. On a skipped tick the frame is still read
and discarded — the camera buffer stays current — but no pose runs and nothing enters the
evidence ring, so no unblurred frame can reach a snapshot.
**T3 (motion-gated inference) is deliberately NOT implemented.** A motion gate that misjudges
would stop inferring on a *motionless fallen worker* — the exact case this product exists to
catch — and its threshold cannot be tuned without real footage. Decide it during bring-up
with measured video, not in advance.
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2 on perimeter).
3. Log FPS, E2E latency (frame capture → alert), CPU, Hailo temp for 30 min per tier.
4. Confirm T1 ≥ 12 fps for the target scene (well-lit, medium activity).
@ -97,14 +104,23 @@ Validate sustained operation and resource cleanup:
After benchmark completion, update configuration:
1. **`edge/config.py` thresholds:**
- Adjust `motion.cooldown_ms`, `motion.min_duration_ms` based on measured T2/T3 FPS.
- Tune `fall.confidence_threshold` if fall recall vs. precision trade-off differs on real video.
Every knob below lives in `/etc/move-detect/edge.env` (read by `edge/config.py::load_config`),
**not** in a code file — no redeploy is needed to retune a site. All are validated at startup:
a non-finite or non-positive value refuses to boot rather than silently disabling a detector.
The full list is in `docs/pilot-runbook.md` §3.
2. **`edge/config.py` camera roles:**
- Assign `role="fall_primary"` to high-fps T1 cam (≥ 12 fps).
- Assign `role="context"` to T2 cameras (8–10 fps).
- Assign `role="perimeter"` to T3 motion-only cameras.
1. **Detector thresholds:**
- `MOTION_THR` (default 0.25) — per-second stillness threshold shared by fall no-motion and
immobility. This is the primary re-tuning knob on real video: it is frame-rate normalised,
so a device running at a different measured fps needs it re-checked.
- `FALL_PRONE_S` (3.0) / `FALL_NO_MOTION_S` (10.0) — how long prone before a fall confirms,
and before it escalates to the 위급 no-motion event. Raise if real footage over-triggers.
- `IMMOBILE_S` (120.0), `GHOST_REAPPEAR_S` (10.0), `ZONE_DEBOUNCE_S` (0.75), `COOLDOWN_S` (30.0).
2. **Per-camera load:**
- `INFER_EVERY` (1) — 1 for the fall-critical camera, 2–3 for context cameras that missed
the T1 target. Set per device; each camera on a device shares it today.
- `TARGET_FPS` (12) — loop pacing. It only slows the loop down; it cannot buy throughput.
3. **Benchmark report:**
- Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version.

View file

@ -4,11 +4,17 @@ import cv2
class UvcSource:
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30,
size: tuple[int, int] = (640, 480), _open=None):
# _open lets tests inject a fake capture; production uses cv2.VideoCapture.
self._dev = dev
self._mjpeg = mjpeg
self._reopen_after = reopen_after
# Pin the capture size to the CSI default. Left unset, a UVC cam opens at ITS default —
# a 1080p webcam yields 6.2MB frames, and the 90-frame evidence ring then costs ~560MB
# per camera (vs ~80MB at 640x480), which is an OOM on a 4-camera Pi. Memory must be a
# property of OUR config, not of whichever webcam the site happened to plug in.
self._size = size
self._open = _open or (lambda d: cv2.VideoCapture(d))
self._fail_streak = 0
self.cap = self._open_cap()
@ -22,6 +28,14 @@ class UvcSource:
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
if self._mjpeg:
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
w, h = self._size
cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
# Ask v4l2 to keep the shallowest queue it will honour. When the loop runs slower than
# the camera's fps, a deep driver queue hands back frames from seconds ago — a fall
# would be detected long after it happened. Not all backends honour this; harmless if
# ignored (the drain-only tick in PipelineRuntime covers the rest).
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
return cap
def read(self):

View file

@ -19,12 +19,32 @@ class Config:
ghost: GhostConfig
zone: ZoneConfig
engine: EngineConfig
# Tier-2 degradation knob: run pose on every Nth tick (1 = every tick, the T1 target).
# Raise this when a device cannot sustain full-rate inference across all its cameras —
# it trades reaction granularity for keeping every camera online, which beats going blind
# on one. See docs/hardware-bringup.md.
infer_every: int = 1
def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default
def _positive(env: dict, key: str, default: float) -> float:
"""A timer/threshold knob that must be a finite positive number.
Every one of these feeds a `now - started >= limit` comparison. NaN makes that comparison
ALWAYS False and 0/negative makes it ALWAYS True, so a typo in edge.env does not crash —
it silently turns a detector off, or turns it into a permanent alarm. COOLDOWN_S=nan is the
worst case: dedup stops suppressing and every tick re-fires the same event. Reject at
startup, where the operator still sees the message, rather than in the field.
"""
val = _f(env, key, default)
if not math.isfinite(val) or val <= 0:
raise ValueError(f"{key} must be a finite positive number, got {val!r}")
return val
def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
@ -98,20 +118,20 @@ def load_config(env: dict) -> Config:
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _f(env, "MOTION_THR", 0.25)
if not math.isfinite(motion_thr) or motion_thr <= 0:
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
motion_thr = _positive(env, "MOTION_THR", 0.25)
fall = FallConfig(
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0),
no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr,
)
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
immobility = ImmobilityConfig(immobile_s=_positive(env, "IMMOBILE_S", 120.0),
motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))
ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(env, "COOLDOWN_S", 30.0))
infer_every = int(_positive(env, "INFER_EVERY", 1.0))
return Config(
infer_every=infer_every,
device_id=env.get("DEVICE_ID", "pi-unknown"),
camera_id=camera_id,
cafe24_url=env.get("CAFE24_URL", ""),

View file

@ -18,7 +18,8 @@ def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None, log=None,
heartbeat_interval_s=None, health_fn=None, configured_cameras=None):
heartbeat_interval_s=None, health_fn=None, configured_cameras=None,
notify=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
@ -33,6 +34,11 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
if health_fn is None:
from edge.health import read_health
health_fn = read_health
if notify is None:
from edge.watchdog import ping as notify
# The ping happens at the TOP of each tick, before any camera work. That is the point: it
# proves the loop is turning. Pinging after the work would still ping while a camera step
# blocks forever, which is the exact failure the watchdog exists to catch.
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
@ -52,6 +58,10 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
i = 0
while iterations is None or i < iterations:
i += 1
try:
notify()
except Exception as e:
note("watchdog", "watchdog ping failed", e)
now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None:
beat_anchor = (now, 0)

View file

@ -8,10 +8,17 @@ from edge.events import Event, EventType, Severity
class OfflineQueue:
def __init__(self, db_path: str, client, max_rows: int = 2000,
max_send_attempts: int = 20):
max_send_attempts: int = 20, max_flush_rows: int = 20):
self.client = client
self.max_rows = max_rows # SD-card guard: evict oldest beyond this
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
# Detection-gap guard. flush() runs INSIDE the single-threaded detection loop, so every
# row it sends is time the camera is not being processed. Draining a full max_rows queue
# in one call (2000 rows x up to 5s POST timeout) would stall fall detection for minutes
# right after an outage recovers — exactly when the backlog proves the site was
# unmonitored. Drain a slice per tick instead: the queue still empties, but the loop keeps
# detecting between slices.
self.max_flush_rows = max_flush_rows
self.db = sqlite3.connect(db_path)
self.db.execute(
"CREATE TABLE IF NOT EXISTS queue ("
@ -60,8 +67,11 @@ class OfflineQueue:
return False
def flush(self) -> int:
# LIMIT bounds BOTH the stall above and peak memory: each row carries up to 3 base64
# JPEGs, so fetching the whole queue would also spike RSS on the Pi.
rows = self.db.execute(
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id LIMIT ?",
(self.max_flush_rows,)).fetchall()
sent = 0
for rid, payload_json, snaps_json, attempts in rows:
try:

View file

@ -16,6 +16,12 @@ class PipelineRuntime:
self.source = source
self.pose = pose
self.sink = sink
# Tier-2 fallback (hardware-bringup.md): when the device cannot sustain full-rate pose
# for every camera, infer on every Nth tick instead of dropping cameras. Detector timers
# are wall/monotonic-based, not frame-count-based, so they stay correct at a lower
# sampling rate — only the reaction granularity coarsens.
self.infer_every = max(1, int(getattr(config, "infer_every", 1)))
self._tick = 0
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
self._wall = wall_clock
@ -33,6 +39,16 @@ class PipelineRuntime:
self.snapshotter = Snapshotter(FaceBlurrer())
def step(self, now: float) -> list:
self._tick += 1
if self.infer_every > 1 and self._tick % self.infer_every != 0:
# Drain-only tick. Pull the frame so the v4l2/CSI buffer stays current — a backed-up
# buffer hands back seconds-old frames and delays every alert — then DISCARD it.
# It must NOT reach the ring buffer: head boxes come from inference, so an
# un-analyzed frame carries no blur regions and would ship an identifiable face in
# the evidence timeline (PIPA). Returning [] (not None) keeps this a healthy tick
# for the daemon's camera-health accounting; a dead camera still reports None below.
ok, _ = self.source.read()
return [] if ok else None
return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink,
now_fn=lambda: now, wall_fn=self._wall)

48
edge/watchdog.py Normal file
View file

@ -0,0 +1,48 @@
"""systemd watchdog ping (sd_notify), stdlib-only.
Restart=always only catches a process that DIES. It cannot catch a process that is alive but
stuck — a v4l2 read blocking on a wedged USB controller, a HailoRT call that never returns, a
sqlite lock. In that state the daemon keeps its PID, stops detecting, and the only signal is the
dashboard turning the device offline 120s later: an unattended site then waits for a human to
notice. WatchdogSec + this ping turns that into an automatic restart.
No pysystemd dependency (the air-gapped bundle carries only wheels we vendor): the protocol is
one datagram to $NOTIFY_SOCKET.
"""
import os
import socket
def sd_notify(state: str) -> bool:
"""Send a status datagram to systemd. Returns False when not running under systemd
(NOTIFY_SOCKET unset) or on any socket error — never raises, because a telemetry failure
must not take down detection."""
addr = os.environ.get("NOTIFY_SOCKET")
if not addr:
return False
# AF_UNIX does not exist on Windows, where developers run the test suite. Guard rather than
# let an AttributeError escape a function whose whole contract is "never raises".
if not hasattr(socket, "AF_UNIX"):
return False
# Abstract namespace sockets start with '@' on Linux; the kernel expects a NUL byte.
if addr.startswith("@"):
addr = "\0" + addr[1:]
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
sock.connect(addr)
sock.sendall(state.encode())
return True
except OSError:
return False
def ping() -> bool:
"""One 'I am still looping' keepalive."""
return sd_notify("WATCHDOG=1")
def ready() -> bool:
"""Announce startup completion. Harmless under Type=exec; required if the unit ever
switches to Type=notify."""
return sd_notify("READY=1")

View file

@ -13,4 +13,5 @@ return [
'ingest_ip_rate_limit_per_min' => 300,
// PIPA retention: worker snapshots are purged after this many days (0 disables).
'snapshot_retention_days' => 7,
'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료)
];

View file

@ -151,6 +151,15 @@ cp -r "$SERVER/migrations" "$PRIV/migrations"
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
# Provenance stamp. A package's own contents are always self-consistent, so nothing inside it
# reveals that it was built before the last four feature commits — which is exactly how a stale
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
{
echo "package: move-detect-cafe24"
echo "version: $(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$PRIV/BUILD-INFO.txt"
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"

View file

@ -28,4 +28,8 @@ return [
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
// 이벤트 '기록'(이미지 아님) 보존일. 0 = 영구 보존이 기본값입니다 — 산업안전 조사나
// 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다.
// DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다.
'event_retention_days' => 0,
];

View file

@ -254,12 +254,17 @@
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ack-btn';
// data-focus keys the row's controls so renderList can hand focus back to the SAME
// button after a poll rebuild (see renderList). Without it a keyboard operator loses
// focus to <body> every 5 seconds, mid-triage.
btn.setAttribute('data-focus', 'ack:' + String(ev.event_id));
btn.textContent = '확인';
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
actions.appendChild(btn);
var fpBtn = document.createElement('button');
fpBtn.type = 'button';
fpBtn.className = 'fp-btn';
fpBtn.setAttribute('data-focus', 'fp:' + String(ev.event_id));
fpBtn.textContent = '오탐';
fpBtn.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
fpBtn.addEventListener('click', function () { markFalsePositive(ev.event_id, fpBtn, btn); });
@ -397,6 +402,9 @@
var pagedByUser = false; // once true, polls stop resetting moreCursor to the live boundary
var newestIdAtPaging = null; // newest live event_id at the moment paging began (gap detection)
var pageGapHint = null; // lazily-built "new events arrived while paging" reload affordance
var lastListSig = null; // last rendered list fingerprint; unchanged -> keep the DOM (and focus)
var eventsInFlight = false; // guards against overlapping polls on a slow shared host
var loadedOnce = false; // distinguishes "cold start failed" from "refresh went stale"
// The camera filter is a (device, camera) PAIR, not a bare camera_id: camera ids are free-form
// strings from the edge, so two devices may both run a "cam-01". Selecting the pair is what the
// rail tree hands us, and it keeps one device's events out of another's filtered view.
@ -437,10 +445,25 @@
return (b.event_id || 0) - (a.event_id || 0);
});
// Skip the rebuild when nothing visible changed. #events-root is an aria-live region and
// this runs every 5s: rebuilding it unconditionally made a screen reader re-announce all
// 200 rows on every poll, and threw keyboard focus to <body> mid-triage. Same signature +
// refocus pattern device-tree.js already uses for the rail.
var anyFilter = filters.severity || filters.type || filters.camera || filters.unackedOnly;
var sig = shown.map(function (ev) {
return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '',
ev.thumb_uuid || ''].join('|');
}).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter);
if (sig === lastListSig) return;
lastListSig = sig;
// Rows are rebuilt, not patched, so remember which control held focus and restore it.
var active = document.activeElement;
var refocus = (active && root.contains(active)) ? active.getAttribute('data-focus') : null;
root.textContent = '';
root.removeAttribute('aria-busy');
if (shown.length === 0) {
var anyFilter = filters.severity || filters.type || filters.camera || filters.unackedOnly;
var empty = document.createElement('div');
empty.className = 'stream-empty';
empty.textContent = anyFilter
@ -452,6 +475,10 @@
shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
root.appendChild(frag);
}
if (refocus) {
var back = root.querySelector('[data-focus="' + refocus + '"]');
if (back) back.focus();
}
if (moreBtn) moreBtn.hidden = (moreCursor === null);
updatePageGapHint();
}
@ -479,6 +506,11 @@
}
function loadEvents() {
// In-flight guard. This runs on a fixed 5s interval against Cafe24 shared hosting; if a
// response takes longer than the interval, unguarded polls stack up and — worse — an older
// response can land after a newer one and overwrite the list with stale rows.
if (eventsInFlight) return Promise.resolve();
eventsInFlight = true;
// Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first"
// pinning only consider this set, so it stays the full cap — during an event burst a smaller
// page could push an unacknowledged critical off it and updateAlarm() would clear the alarm
@ -497,12 +529,27 @@
if (!pagedByUser) moreCursor = data.next_cursor || null;
renderList();
}
loadedOnce = true;
setWatch(true);
})
.catch(function () {
// Leave the last rendered state on transient poll failure, but flag the staleness.
setWatch(false);
});
// Cold start is different from a stale refresh: if the FIRST load never succeeded there
// is no last-good list, and the skeleton "불러오는 중…" would sit there forever looking
// like a slow page rather than a broken one — on a safety console, silence must not be
// mistaken for calm.
if (!loadedOnce && root) {
root.removeAttribute('aria-busy');
root.textContent = '';
var fail = document.createElement('div');
fail.className = 'stream-empty';
fail.textContent = '이벤트를 불러오지 못했습니다. 서버 연결을 확인하세요 — 재시도 중입니다.';
root.appendChild(fail);
lastListSig = null; // let the next successful poll rebuild the list
}
})
.then(function () { eventsInFlight = false; });
}
function loadMore() {

View file

@ -104,7 +104,9 @@ $actionsCtl = new ActionsController(new PdoActionRepository($pdo), $events, $ses
$render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
return Response::text((string)ob_get_clean());
// Security headers travel with the HTML response (not only the packaged .htaccess) so the
// CSP nonce the views just emitted is the one the browser is told to trust.
return Response::text((string)ob_get_clean(), 200, \App\Security\Csp::headers());
};
$r = new Router();
@ -113,7 +115,7 @@ $r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now, (
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess));
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($q, $sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));

View file

@ -45,8 +45,12 @@ final class AuthController
}
if ((int)($u['locked_until'] ?? 0) > $now) {
// Same 401/invalid_credentials as an unknown user. A distinct 423 was a username
// oracle: anyone could lock a guessed name with 5 bad tries, then read "locked" as
// confirmation that the account is real. The lockout is still recorded in the audit
// log, which is where an admin — not an anonymous caller — learns about it.
$this->audit->append($username, 'login_locked', 'auth', [], []);
return Response::json(['error' => 'locked'], 423);
return Response::json(['error' => 'invalid_credentials'], 401);
}
// lock window expired -> reset stale failure counter so the user gets a fresh set of attempts
@ -73,8 +77,17 @@ final class AuthController
return Response::json(['ok' => true], 200);
}
public function logout(SessionStore $sess): Response
public function logout(Request $req, SessionStore $sess): Response
{
// CSRF-checked like every other state-changing route. SameSite=Lax already blocks the
// cross-site POST, but a forced logout is a real availability action on THIS product —
// knocking an operator out of the console mid-incident — so it does not get to be the
// one exception to the rule. Only enforced for an actual session: an already-logged-out
// caller (no csrf seeded) must still get a clean 200 rather than a confusing 403.
$expected = (string)$sess->get('csrf');
if ($expected !== '' && !Csrf::check($expected, (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
// Rotate the id FIRST (invalidates the old cookie) while the session is still active,
// then wipe. The old order called session_regenerate_id() AFTER session_destroy(),
// which warns on an inactive session and leaves the old cookie unreplaced.

View file

@ -14,7 +14,13 @@ final class ArraySessionStore implements SessionStore {
public function get(string $k): mixed { return $this->d[$k] ?? null; }
public function set(string $k, mixed $v): void { $this->d[$k] = $v; }
public function clear(): void { $this->d = []; }
public function regenerate(): void { $this->d = []; }
/**
* Rotates the id only — the DATA survives, matching PhpSessionStore's
* session_regenerate_id(true). This double used to wipe the array, which diverged from the
* real store and hid a live question: whether the csrf token seeded before login is still
* there after it (it is — that is what /logout's CSRF check relies on).
*/
public function regenerate(): void { /* id is not modeled; data is preserved */ }
}
final class PhpSessionStore implements SessionStore {

View file

@ -17,4 +17,13 @@ final class Config
public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); }
public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); }
public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); }
/**
* Retention for the event ROWS (not the images). Default 0 = keep forever, deliberately:
* these are workplace safety incident records, and an occupational-safety investigation or
* a labour dispute may need them years later — silently deleting them by default would be
* destroying evidence on the operator's behalf. The images are a different matter and DO
* expire (snapshotRetentionDays), because those are the personal data PIPA covers.
* A site with a DB quota can opt in by setting this.
*/
public function eventRetentionDays(): int { return (int)($this->a['event_retention_days'] ?? 0); }
}

View file

@ -9,8 +9,9 @@ final class Response
public static function json(array $data, int $status = 200): self {
return new self($status, ['Content-Type' => 'application/json'], json_encode($data, JSON_UNESCAPED_UNICODE));
}
public static function text(string $s, int $status = 200): self {
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
public static function text(string $s, int $status = 200, array $headers = []): self {
// Content-Type stays authoritative; extra headers (e.g. Content-Security-Policy) are added.
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'] + $headers, $s);
}
public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self {
// Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden.

View file

@ -95,8 +95,16 @@ final class IngestController
// PIPA retention: purge worker snapshots older than the configured window. Runs rarely
// (the gate) and off the critical path — the event is already stored.
$days = $this->cfg->snapshotRetentionDays();
if ($days > 0 && ($this->purgeGate)()) {
$this->snapshots->purgeOlderThan($now - $days * 86400);
$eventDays = $this->cfg->eventRetentionDays();
if (($days > 0 || $eventDays > 0) && ($this->purgeGate)()) {
if ($days > 0) {
$this->snapshots->purgeOlderThan($now - $days * 86400);
}
// Event rows: opt-in only (default 0 = keep forever). Runs AFTER the snapshot purge
// and on a much longer window, so images are always gone before their event row is.
if ($eventDays > 0) {
$this->events->purgeOlderThan((float)($now - $eventDays * 86400));
}
}
return Response::json(['event_id' => $eventId], 201);
}

View file

@ -66,6 +66,12 @@ interface EventRepository
* so it is empty in every deployment — checking it would reject every real camera.
*/
public function cameraExists(string $cameraId): bool;
/**
* Delete event rows older than $cutoff, at most $limit per call. Opt-in only
* (Config::eventRetentionDays defaults to 0 = never) — see that method for why safety
* incident rows are not expired by default. Returns the number deleted.
*/
public function purgeOlderThan(float $cutoff, int $limit = 500): int;
/**
* The site this camera's events are recorded under (from its most recent event), or null
* when the camera is unknown OR its device has no site assigned. Pair with cameraExists()

View file

@ -336,6 +336,26 @@ final class PdoEventRepository implements EventRepository
return (int)$st->fetchColumn();
}
public function purgeOlderThan(float $cutoff, int $limit = 500): int
{
// Bounded batch, same shape as the snapshot purge: a shared-hosting request must not
// stall deleting a year of rows. ON DELETE CASCADE on move_event_snapshots would drop
// snapshot ROWS with the event, so purge only what the snapshot purge has already
// emptied of files — cutoff is expected to be far older than the image retention window.
$sel = $this->pdo->prepare(
'SELECT event_id FROM move_events WHERE occurred_at < ? ORDER BY event_id LIMIT ?');
$sel->bindValue(1, sprintf('%.6f', $cutoff), PDO::PARAM_STR);
$sel->bindValue(2, $limit, PDO::PARAM_INT);
$sel->execute();
$ids = array_map('intval', $sel->fetchAll(PDO::FETCH_COLUMN));
if (!$ids) {
return 0;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$this->pdo->prepare("DELETE FROM move_events WHERE event_id IN ($in)")->execute($ids);
return count($ids);
}
public function cameraExists(string $cameraId): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM move_events WHERE camera_id = ? LIMIT 1');

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* Content-Security-Policy for the server-rendered pages.
*
* The app's XSS defence is a discipline — every JS file builds DOM with textContent and never
* innerHTML. Discipline holds until the one commit where it doesn't, and on an air-gapped
* delivery nobody is watching a WAF. CSP is the layer that still holds after that commit.
*
* The two inline <script> blocks in layout.php (APP_BASE/APP_USER bootstrap, logout wiring)
* carry a per-request nonce rather than being unsafe-inline'd: "no build step" prevents
* bundling them into a file, but it does not prevent PHP from emitting a nonce.
*
* Deliberately NOT in .htaccess: the nonce changes every request, and Apache cannot know it.
* Keeping the whole header set here also means the dev server and the delivered package get
* identical headers — previously only the packaged .htaccess had them, so a header regression
* was invisible in development.
*/
final class Csp
{
private static ?string $nonce = null;
/** Per-request nonce, generated once and reused for every inline script on the page. */
public static function nonce(): string
{
return self::$nonce ??= base64_encode(random_bytes(16));
}
/** Reset between tests; a fresh request would otherwise reuse the previous nonce. */
public static function reset(): void
{
self::$nonce = null;
}
public static function policy(string $nonce): string
{
return implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-$nonce'",
"style-src 'self'", // one external stylesheet; no <style> blocks, no style=""
"img-src 'self'", // snapshots come from /snap/{uuid}; no data: URIs anywhere
"connect-src 'self'",
"font-src 'self'", // air-gapped: never a webfont CDN
"object-src 'none'",
"base-uri 'none'", // stops an injected <base> from re-pointing every relative URL
"form-action 'self'",
"frame-ancestors 'none'", // clickjacking, for browsers that ignore X-Frame-Options
]);
}
/**
* Full header set for an HTML page. Mirrors the packaged .htaccess (which stays, so the
* headers survive even on a request Apache serves without PHP) and adds the nonce-based CSP.
*/
public static function headers(): array
{
return [
'Content-Security-Policy' => self::policy(self::nonce()),
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'Referrer-Policy' => 'no-referrer',
];
}
}

View file

@ -39,8 +39,8 @@ if (!function_exists('render_header')) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
<script>
window.APP_BASE = <?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
window.APP_BASE =<?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>
</head>
@ -85,7 +85,7 @@ if (!function_exists('render_footer')) {
function render_footer(): void {
?>
</main>
<script>
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
/* Logout is wired here in the shared layout, not in a page script: app.js loads only on the
* events page and bails early on any page without #events-root, so previously the 로그아웃 button
* was inert on /zones and event-detail pages (a stale open session on a shared control-room PC). */
@ -94,7 +94,19 @@ if (!function_exists('render_footer')) {
var btn = document.getElementById('logout-btn');
if (!btn) return;
btn.addEventListener('click', function () {
fetch(b + '/logout', { method: 'POST' }).then(function () { window.location.href = b + '/login'; });
// Fetch the CSRF token first: /logout is now CSRF-checked like every other state-changing
// route. Navigate to /login even if the POST fails, so a logout attempt never strands the
// operator on an authenticated-looking page.
fetch(b + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
return fetch(b + '/logout', {
method: 'POST',
headers: { 'X-Csrf-Token': (d && d.token) || '' }
});
})
.catch(function () {})
.then(function () { window.location.href = b + '/login'; });
});
})();
</script>

View file

@ -38,16 +38,22 @@ final class AuthControllerTest extends TestCase
$repo = $this->users();
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), $this->sess(), now: 1000, clientIp: '1.2.3.4');
// now locked, even correct password is 423
$this->assertSame(423, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4')->status());
// Locked: the correct password no longer works. The response is deliberately the SAME
// generic 401 an unknown username gets — a distinct 423 let anyone lock a guessed name
// and read "locked" as proof the account exists (username enumeration).
$locked = $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4');
$this->assertSame(401, $locked->status());
$unknown = $c->login($this->req('nobody', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4');
$this->assertSame($unknown->status(), $locked->status());
$this->assertSame($unknown->body(), $locked->body());
}
public function testExpiredLockResetsAttempts(): void {
$repo = $this->users();
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
// lock the account with 3 bad tries at now=1000
for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), $this->sess(), now: 1000, clientIp: '1.2.3.4');
// locked_until is now 1000 + 900 = 1900, verify it's locked
$this->assertSame(423, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4')->status());
// locked_until is now 1000 + 900 = 1900, verify it's locked (generic 401, see above)
$this->assertSame(401, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4')->status());
// now at now=2000 (after lock window expires at 1900), login with correct password should succeed
$sess = $this->sess();
$res = $c->login($this->req('alice', 'pw'), $sess, now: 2000, clientIp: '1.2.3.4');
@ -67,10 +73,28 @@ final class AuthControllerTest extends TestCase
$sess = $this->sess();
$c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4');
$this->assertSame(1, $sess->get('user_id'));
$res = $c->logout($sess);
$res = $c->logout($this->logoutReq('tok'), $sess);
$this->assertSame(200, $res->status());
$this->assertNull($sess->get('user_id')); // session identity cleared
}
private function logoutReq(string $token): Request {
return new Request('POST', '/logout', ['X-Csrf-Token' => $token], '', [], []);
}
public function testLogoutRequiresCsrf(): void {
// A forced logout knocks an operator out of the console mid-incident — an availability
// action on a safety product, so it is CSRF-checked like every other state change.
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$sess = $this->sess();
$c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4');
$this->assertSame(403, $c->logout($this->logoutReq('wrong'), $sess)->status());
$this->assertSame(1, $sess->get('user_id')); // still logged in
}
public function testLogoutWithoutASessionStillSucceeds(): void {
// An already-logged-out caller has no csrf seeded; answering 403 would strand the
// browser on an authenticated-looking page instead of sending it to /login.
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$this->assertSame(200, $c->logout($this->logoutReq(''), new ArraySessionStore())->status());
}
public function testLoginIpRateLimited(): void {
// Even with correct credentials, floods from a single IP must be
// throttled before the (expensive) Argon2id verify runs repeatedly.

63
server/tests/CspTest.php Normal file
View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Response;
use App\Security\Csp;
use PHPUnit\Framework\TestCase;
final class CspTest extends TestCase
{
protected function setUp(): void { Csp::reset(); }
protected function tearDown(): void { Csp::reset(); }
public function testNonceIsStableWithinARequestAndFreshBetweenThem(): void
{
// Stable: both inline <script> blocks in layout.php must carry the SAME nonce as the
// header, or the page breaks. Fresh per request: a reused nonce is no better than
// 'unsafe-inline' once an attacker can read one.
$a = Csp::nonce();
$this->assertSame($a, Csp::nonce());
Csp::reset();
$this->assertNotSame($a, Csp::nonce());
}
public function testPolicyForbidsInlineAndObjectsAndFraming(): void
{
$p = Csp::policy('abc123');
$this->assertStringContainsString("script-src 'self' 'nonce-abc123'", $p);
$this->assertStringNotContainsString('unsafe-inline', $p);
$this->assertStringNotContainsString('unsafe-eval', $p);
$this->assertStringContainsString("object-src 'none'", $p);
$this->assertStringContainsString("base-uri 'none'", $p);
$this->assertStringContainsString("frame-ancestors 'none'", $p);
// Air-gapped delivery: nothing may be fetched from a third-party origin.
$this->assertStringNotContainsString('http://', $p);
$this->assertStringNotContainsString('https://', $p);
}
public function testHtmlResponsesCarryTheSecurityHeaders(): void
{
$h = Response::text('<p>hi</p>', 200, Csp::headers())->headers();
$this->assertSame('text/html; charset=utf-8', $h['Content-Type']);
$this->assertArrayHasKey('Content-Security-Policy', $h);
$this->assertSame('nosniff', $h['X-Content-Type-Options']);
$this->assertSame('DENY', $h['X-Frame-Options']);
$this->assertStringContainsString("'nonce-" . Csp::nonce() . "'", $h['Content-Security-Policy']);
}
public function testEveryInlineScriptInTheViewsCarriesANonce(): void
{
// The policy is only as good as the markup: one un-nonced inline block and the page
// silently loses functionality in production while looking fine in a dev server that
// sends no CSP. No build step means no bundler would catch this either.
foreach (glob(dirname(__DIR__) . '/src/View/*.php') as $view) {
$src = (string)file_get_contents($view);
preg_match_all('/<script(?![^>]*\bsrc=)([^>]*)>/i', $src, $m);
foreach ($m[1] as $attrs) {
$this->assertStringContainsString('nonce=', $attrs,
basename($view) . ' has an inline <script> with no nonce');
}
}
}
}

View file

@ -2,22 +2,26 @@
declare(strict_types=1);
namespace App\Tests;
use App\Repository\Pdo\{PdoNonceRepository, PdoEventRepository, PdoZoneRepository};
use App\Repository\Pdo\{PdoNonceRepository, PdoEventRepository, PdoZoneRepository,
PdoActionRepository, PdoSnapshotRepository};
use PDO;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* Gated DB-integration test: exercises the PDO repositories against a real
* sqlite :memory: database instead of the in-memory test doubles used
* elsewhere in the suite. This host has no pdo_sqlite driver loaded
* (PDO::getAvailableDrivers() does not include 'sqlite'), so setUp() skips
* the whole class -- the suite must stay green with this test reported as
* SKIPPED, not failed. On a host with pdo_sqlite available (or in CI), this
* test runs for real and round-trips Nonce, Event, and Zone -- the three
* repositories whose SQL is portable between MySQL and sqlite (no
* MySQL-only `ON DUPLICATE KEY UPDATE`, which PdoRateLimiterRepository uses
* and is therefore intentionally not covered here).
* DB-integration test: exercises the PDO repositories against a real sqlite :memory: database
* instead of the in-memory doubles used elsewhere. This is the ONLY place the delivered SQL
* actually executes -- every other test asserts against a double that reimplements the query
* semantics in PHP, so a broken query cannot fail them.
*
* RUN IT: `php -d extension=pdo_sqlite vendor/bin/phpunit`. Without that flag the driver is
* absent and setUp() skips the whole class. It used to say "or in CI" -- there was no CI, so
* this file had most likely never run anywhere. `.forgejo/workflows/verify.yml` now loads the
* extension and fails the build on ANY skip, so the coverage cannot silently vanish again.
*
* Covers Nonce, Event (including the report/heatmap/export aggregates), Zone, Action and
* Snapshot. PdoRateLimiterRepository stays out: its `ON DUPLICATE KEY UPDATE` is MySQL-only
* and has no portable sqlite equivalent.
*/
final class PdoIntegrationTest extends TestCase
{
@ -145,6 +149,118 @@ final class PdoIntegrationTest extends TestCase
* in ../schema.sql (see that file's "sqlite compatibility notes" header
* for the general MySQL -> sqlite mapping rules applied here).
*/
/** Insert an event with sensible defaults; only the interesting columns need naming. */
private function seedEvent(PdoEventRepository $repo, array $over = []): int
{
static $n = 0;
$n++;
return $repo->insert($over + [
'event_uuid' => str_repeat((string)($n % 10), 32),
'device_id' => 'dev-1', 'site' => 'factory-a', 'camera_id' => 'cam-1',
'event_type' => 'fall', 'severity' => 'critical', 'track_id' => 1, 'zone_id' => null,
'occurred_at' => 1700000000.0, 'duration_s' => 1.0, 'metrics' => [],
]);
}
public function testCameraLookupsBackTheActionOwnershipCheck(): void
{
// These two back ActionsController's unknown_camera 400 and cross-site 404. Cameras have
// no registry table, so events ARE the ownership record — verify against real SQL.
$repo = new PdoEventRepository($this->pdo);
$this->seedEvent($repo, ['camera_id' => 'cam-1', 'site' => 'factory-a']);
$this->assertTrue($repo->cameraExists('cam-1'));
$this->assertFalse($repo->cameraExists('cam-typo'));
$this->assertSame('factory-a', $repo->siteForCamera('cam-1'));
$this->assertNull($repo->siteForCamera('cam-typo'));
// A re-homed camera belongs to the site it reports to NOW (newest event wins).
$this->seedEvent($repo, ['camera_id' => 'cam-1', 'site' => 'factory-b']);
$this->assertSame('factory-b', $repo->siteForCamera('cam-1'));
}
public function testActionRepositoryScopesBySite(): void
{
$repo = new PdoActionRepository($this->pdo);
$repo->insert(['camera_id' => 'cam-a', 'site' => 'factory-a', 'note' => '매트',
'taken_at' => 100, 'created_by' => 'alice']);
$repo->insert(['camera_id' => 'cam-b', 'site' => 'factory-b', 'note' => '난간',
'taken_at' => 200, 'created_by' => 'bob']);
$this->assertCount(2, $repo->recent(10, null)); // global operator
$scoped = $repo->recent(10, 'factory-a');
$this->assertCount(1, $scoped);
$this->assertSame('매트', $scoped[0]['note']);
$this->assertCount(0, $repo->recent(10, 'factory-c'));
// Fail-closed: an empty-string scope is a misconfigured scope, not "everything".
$this->assertCount(0, $repo->recent(10, ''));
}
public function testCountForCameraDrivesTheBeforeAfterEffect(): void
{
$repo = new PdoEventRepository($this->pdo);
$this->seedEvent($repo, ['occurred_at' => 100.0]);
$this->seedEvent($repo, ['occurred_at' => 150.0]);
$this->seedEvent($repo, ['occurred_at' => 300.0]);
$this->seedEvent($repo, ['occurred_at' => 120.0, 'event_type' => 'ergo_report']);
$this->assertSame(2, $repo->countForCamera('cam-1', 0.0, 200.0, null)); // ergo excluded
$this->assertSame(0, $repo->countForCamera('cam-1', 0.0, 200.0, 'factory-b'));
}
public function testAggregatesThatBackTheReportPageRunAgainstRealSql(): void
{
// reportSummary/fpStats/heatmapPoints/occurrenceTimes/exportRows were previously
// exercised ONLY against the in-memory double, which reimplements their semantics in
// PHP — so a broken query could not fail the suite. Round-trip them for real.
$repo = new PdoEventRepository($this->pdo);
$this->seedEvent($repo, ['occurred_at' => 1000.0, 'spatial_x' => 0.4, 'spatial_y' => 0.6]);
$fp = $this->seedEvent($repo, ['occurred_at' => 1100.0, 'severity' => 'warning']);
$repo->markFalsePositive($fp, 'alice');
$this->seedEvent($repo, ['occurred_at' => 1200.0, 'event_type' => 'ergo_report',
'severity' => 'info', 'metrics' => ['bend_s' => 12]]);
$summary = $repo->reportSummary(0.0, null, 5);
$this->assertNotEmpty($summary['by_type']);
$this->assertNotEmpty($summary['by_camera']);
$this->assertArrayHasKey('tta', $summary);
$this->assertNotEmpty($repo->fpStats(0.0, null));
$this->assertCount(1, $repo->heatmapPoints('cam-1', 0.0, null, 100)); // only the spatial row
$this->assertCount(2, $repo->occurrenceTimes(0.0, null, 100)); // ergo excluded
$this->assertCount(3, $repo->exportRows(0.0, null, 100));
// Site scoping must reach every one of them.
$this->assertSame([], $repo->fpStats(0.0, 'factory-b'));
$this->assertSame([], $repo->heatmapPoints('cam-1', 0.0, 'factory-b', 100));
$this->assertSame([], $repo->exportRows(0.0, 'factory-b', 100));
}
public function testEventPurgeIsOptInAndBounded(): void
{
$repo = new PdoEventRepository($this->pdo);
for ($i = 0; $i < 5; $i++) $this->seedEvent($repo, ['occurred_at' => 100.0 + $i]);
$this->seedEvent($repo, ['occurred_at' => 9000.0]);
$this->assertSame(3, $repo->purgeOlderThan(9000.0, 3)); // batch bound respected
$this->assertSame(2, $repo->purgeOlderThan(9000.0, 500));
$this->assertSame(0, $repo->purgeOlderThan(9000.0, 500)); // the newer row survives
$this->assertCount(1, $repo->page(10, null, null, null, null));
}
public function testSnapshotThumbnailLookupAvoidsNPlusOne(): void
{
$events = new PdoEventRepository($this->pdo);
$snaps = new PdoSnapshotRepository($this->pdo, sys_get_temp_dir());
$a = $this->seedEvent($events, ['occurred_at' => 10.0]);
$b = $this->seedEvent($events, ['occurred_at' => 20.0]);
$snaps->add($a, str_repeat('a', 32), 'h1');
$snaps->add($a, str_repeat('b', 32), 'h2'); // newest wins as the thumbnail
$snaps->add($b, str_repeat('c', 32), 'h3');
$thumbs = $snaps->thumbUuidsFor([$a, $b]);
$this->assertSame(str_repeat('b', 32), $thumbs[$a]);
$this->assertSame(str_repeat('c', 32), $thumbs[$b]);
$this->assertCount(2, $snaps->forEvent($a));
}
private static function sqliteSchema(): string
{
return <<<'SQL'
@ -184,6 +300,24 @@ final class PdoIntegrationTest extends TestCase
rules_json TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE move_actions (
action_id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id TEXT NOT NULL,
site TEXT,
note TEXT NOT NULL,
taken_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE move_event_snapshots (
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
file_uuid TEXT NOT NULL,
content_hash TEXT,
created_at INTEGER NOT NULL
);
SQL;
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
/**
* Guards the REAL route table in public/index.php.
*
* WiringSmokeTest exercises the Router's matching algorithm against dummy handlers it registers
* itself — it never reads index.php, so a route deleted, misspelled, or never added there does
* not fail any test. index.php cannot simply be require'd (it dispatches on load and touches
* superglobals), so this parses its registrations instead.
*
* That matters most for an air-gapped delivery: a missing route is invisible until an operator
* on a site with no internet clicks the button.
*/
final class RouteTableTest extends TestCase
{
/** @return list<string> "METHOD path" pairs registered in index.php */
private function registeredRoutes(): array
{
$src = file_get_contents(dirname(__DIR__) . '/public/index.php');
$this->assertNotFalse($src, 'index.php unreadable');
preg_match_all("/\\\$r->add\(\s*'([A-Z]+)'\s*,\s*'([^']+)'/", $src, $m, PREG_SET_ORDER);
return array_map(static fn ($x) => $x[1] . ' ' . $x[2], $m);
}
public function testEveryExpectedRouteIsRegistered(): void
{
// Adding a route must also update this list — that is the point: the list is the
// reviewed inventory of the app's public surface.
$expected = [
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
'GET /api/zones/{camera}', 'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones',
'POST /api/zones/{id}/delete', 'GET /snap/{uuid}',
'GET /', 'GET /login', 'GET /zones', 'GET /report', 'GET /events/{id}/view',
];
$actual = $this->registeredRoutes();
sort($expected);
$sorted = $actual;
sort($sorted);
$this->assertSame($expected, $sorted);
}
public function testNoRouteIsRegisteredTwice(): void
{
// A duplicate silently shadows the first registration (first match wins) — the kind of
// copy-paste slip that survives review because both lines look correct in isolation.
$routes = $this->registeredRoutes();
$this->assertSame(array_values(array_unique($routes)), $routes);
}
public function testEveryEndpointTheFrontendCallsExists(): void
{
// The frontend is vanilla JS with no build step, so a typo'd URL is caught by nothing —
// no bundler, no type checker. Cross-check the two halves against each other.
$registered = array_map(static fn ($r) => explode(' ', $r, 2)[1], $this->registeredRoutes());
$calls = [];
foreach (glob(dirname(__DIR__) . '/public/assets/*.js') as $js) {
preg_match_all("/B \+ '([^']+)'/", (string)file_get_contents($js), $m);
foreach ($m[1] as $raw) {
// The JS concatenates ids onto a literal prefix ("B + '/events/' + id"), so what
// is visible here is a PREFIX, not a full path. Strip the query string and the
// trailing slash to get the literal part.
$path = rtrim(strtok($raw, '?'), '/');
$calls[$path === '' ? '/' : $path] = basename($js);
}
}
foreach ($calls as $path => $file) {
// Match on a SEGMENT boundary: '/events' is satisfied by '/events/{id}', but a typo
// like '/api/actio' is NOT satisfied by '/api/actions' (no '/api/actio/' route).
$hit = in_array($path, $registered, true);
foreach ($registered as $route) {
if ($hit) break;
$hit = str_starts_with($route, rtrim($path, '/') . '/');
}
$this->assertTrue($hit, "$file calls $path but index.php registers no such route");
}
}
}

View file

@ -129,6 +129,15 @@ final class InMemoryEventRepository implements EventRepository {
&& ($x['event_type'] ?? '') !== 'ergo_report'
&& ($site === null || ($x['site'] ?? null) === $site)));
}
public function purgeOlderThan(float $cutoff, int $limit = 500): int {
$old = array_filter($this->rows, fn($r) => ($r['occurred_at'] ?? 0) < $cutoff);
$n = 0;
foreach (array_keys($old) as $id) {
if ($n >= $limit) break;
unset($this->rows[$id]); $n++;
}
return $n;
}
public function cameraExists(string $cam): bool {
foreach ($this->rows as $r) if (($r['camera_id'] ?? '') === $cam) return true;
return false;

View file

@ -99,3 +99,22 @@ def test_queue_db_path_defaults_absolute_under_state_dir():
# WorkingDirectory read-only) -> crash loop. Default to the writable StateDirectory.
assert queue_db_path({}) == "/var/lib/move-detect/queue.db"
assert queue_db_path({"QUEUE_DB": "/custom/q.db"}) == "/custom/q.db"
def test_timer_knobs_reject_nan_and_nonpositive():
# NaN makes every `now - started >= limit` comparison False and 0/negative makes it always
# True — a typo in edge.env silently disables a detector or turns it into a permanent alarm
# instead of crashing. COOLDOWN_S=nan is the worst: dedup stops and every tick re-fires.
for key in ("COOLDOWN_S", "IMMOBILE_S", "FALL_PRONE_S", "FALL_NO_MOTION_S",
"GHOST_REAPPEAR_S", "ZONE_DEBOUNCE_S", "MOTION_THR"):
for bad in ("nan", "0", "-1"):
with pytest.raises(ValueError) as ei:
load_config({key: bad})
assert key in str(ei.value) # 어느 키가 문제인지 운영자에게 말해준다
def test_infer_every_defaults_to_every_tick():
assert load_config({}).infer_every == 1
assert load_config({"INFER_EVERY": "3"}).infer_every == 3
with pytest.raises(ValueError):
load_config({"INFER_EVERY": "0"})

View file

@ -15,9 +15,12 @@ BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle"
PILOT_RUNBOOK = os.path.join(os.path.dirname(__file__), "..", "docs", "pilot-runbook.md")
# Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main).
# HAILO_HEF, not MOVENET_ONNX: the delivered bundle carries only the HEF (the MoveNet ONNX has
# no reachable upstream), so the template must ship a backend whose model actually exists. An
# uncommented MOVENET_ONNX pointing at a file that was never bundled is a unit that cannot start.
REQUIRED_ENV = {
"DEVICE_ID", "DEVICE_SECRET", "CAFE24_URL", "CAMERA_ID",
"MOVENET_ONNX", "UVC_DEVICE", "TARGET_FPS", "QUEUE_DB",
"HAILO_HEF", "UVC_DEVICE", "TARGET_FPS", "QUEUE_DB",
}

78
tests/test_dist_parity.py Normal file
View file

@ -0,0 +1,78 @@
"""납품 산출물(dist/)이 소스와 어긋났는지 검사.
dist/ 는 gitignore 대상 빌드 출력이라 git 이 드리프트를 알려주지 않는다. 실제로 기능 커밋 4개가
들어간 뒤 번들이 재생성되지 않아, Report/Export/Actions 컨트롤러와 마이그레이션 1건이 빠진
패키지가 납품 직전까지 남아 있었다. 스테일 번들은 '깨진' 상태가 아니라 '내부적으로 정합적인
구버전'이라 SHA256SUMS 도 통과한다 — 그래서 소스와 대조하는 검사가 따로 필요하다.
dist/ 가 없으면(클린 체크아웃) 스킵한다. 있으면 소스와 같아야 한다.
"""
import hashlib
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
CAFE24_PRIV = DIST / "1_cafe24_upload" / "move_detect_app"
PI_SRC = DIST / "2_pi_install" / "src" / "edge"
needs_dist = pytest.mark.skipif(not DIST.is_dir(), reason="dist/ not built in this checkout")
def _digest(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest()
def _compare_tree(src: Path, packaged: Path, suffix: str) -> tuple[list[str], list[str]]:
"""(빠진 파일, 내용이 다른 파일) — 소스 기준."""
missing, differing = [], []
for f in sorted(src.rglob(f"*{suffix}")):
if "__pycache__" in f.parts:
continue
rel = f.relative_to(src)
target = packaged / rel
if not target.is_file():
missing.append(str(rel))
elif _digest(f) != _digest(target):
differing.append(str(rel))
return missing, differing
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_cafe24_package_matches_server_source():
missing, differing = _compare_tree(ROOT / "server" / "src", CAFE24_PRIV / "src", ".php")
assert not missing, f"번들에 없는 서버 소스(생성기 재실행 필요): {missing}"
assert not differing, f"번들과 소스 내용 불일치: {differing}"
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_cafe24_package_ships_every_migration_and_current_schema():
# 마이그레이션이 하나라도 빠지면 기존 DB 업그레이드가 전 요청 500 이 된다.
src = sorted(p.name for p in (ROOT / "server" / "migrations").glob("*.sql"))
packaged = sorted(p.name for p in (CAFE24_PRIV / "migrations").glob("*.sql"))
assert src == packaged, "동봉 마이그레이션이 소스와 다름 — 생성기 재실행 필요"
assert _digest(ROOT / "server" / "schema.sql") == _digest(CAFE24_PRIV / "schema.sql")
@needs_dist
@pytest.mark.skipif(not PI_SRC.is_dir(), reason="pi bundle not built")
def test_pi_bundle_matches_edge_source():
missing, differing = _compare_tree(ROOT / "edge", PI_SRC, ".py")
assert not missing, f"번들에 없는 엣지 소스(생성기 재실행 필요): {missing}"
assert not differing, f"번들과 소스 내용 불일치: {differing}"
@needs_dist
@pytest.mark.skipif(not (DIST / "2_pi_install" / "MANIFEST.txt").is_file(),
reason="pi bundle not built")
def test_pi_bundle_records_its_provenance():
# 버전·커밋이 없으면 현장에 올라간 빌드를 나중에 식별할 수 없다 — 스테일 번들이 조용히
# 통과한 구조적 원인.
text = (DIST / "2_pi_install" / "MANIFEST.txt").read_text(encoding="utf-8")
for field in ("version:", "git_commit:", "built_at:"):
assert field in text, f"MANIFEST.txt에 {field} 없음"
assert "version: unknown" not in text

View file

@ -170,3 +170,29 @@ def test_legacy_row_without_uuid_reuses_minted_uuid_across_retries(tmp_path):
q.flush() # delivered
assert len(_Capture.uuids) == 2
assert _Capture.uuids[0] == _Capture.uuids[1] # same key reused across retries
def test_flush_drains_in_bounded_slices(tmp_path):
# flush() runs inside the single-threaded detection loop, so an unbounded drain after a long
# outage would stall fall detection for minutes at the worst possible moment. Each call must
# send at most max_flush_rows and leave the rest for the next tick.
q = OfflineQueue(str(tmp_path / "q.db"), _Client(), max_flush_rows=3)
for n in range(10):
q.enqueue(_event(n), [])
assert q.pending() == 10
assert q.flush() == 3 # one slice, not the whole queue
assert q.pending() == 7
q.flush(); q.flush()
assert q.pending() == 1 # keeps draining across ticks
q.flush()
assert q.pending() == 0
def test_flush_slice_preserves_fifo_order(tmp_path):
# Bounding the slice must not reorder delivery: the oldest queued alert still goes first.
client = _Client()
q = OfflineQueue(str(tmp_path / "q.db"), client, max_flush_rows=2)
for n in range(4):
q.enqueue(_event(n), [])
q.flush(); q.flush()
assert client.sent == [0, 1, 2, 3]

View file

@ -41,3 +41,47 @@ def test_apply_zones_hotswaps():
rt = PipelineRuntime(cfg, _Source(), _Pose(), _Sink())
rt.apply_zones([Zone("z1", "restricted", [(0, 0), (9, 0), (9, 9)])])
assert rt.zone_detector.zones[0].zone_id == "z1"
def test_infer_every_skips_inference_but_still_drains_the_camera():
# T2 저하 티어: 추론은 N틱마다. 스킵 틱에도 프레임은 읽어야 한다(v4l2 버퍼가 밀리면
# 몇 초 전 프레임이 올라와 모든 경보가 늦어진다).
class _CountingSource:
def __init__(self): self.reads = 0
def read(self):
self.reads += 1
return True, np.full((360, 640, 3), 128, np.uint8)
class _CountingPose:
def __init__(self): self.infers = 0
def infer(self, frame):
self.infers += 1
return [_lying_person()]
src, pose = _CountingSource(), _CountingPose()
cfg = load_config({"INFER_EVERY": "3"})
rt = PipelineRuntime(cfg, src, pose, _Sink())
for t in range(6):
rt.step(float(t))
assert src.reads == 6 # 매 틱 프레임을 뽑아 버퍼를 비운다
assert pose.infers == 2 # 추론은 3틱마다
def test_skipped_tick_never_puts_unanalyzed_frames_in_the_evidence_ring():
# 스킵 틱에는 얼굴 박스가 없다. 그 프레임이 링에 들어가면 증거 타임라인에 블러되지 않은
# 얼굴이 실려 나간다(PIPA). 링에는 추론된 프레임만 있어야 한다.
cfg = load_config({"INFER_EVERY": "4"})
rt = PipelineRuntime(cfg, _Source(), _Pose(), _Sink())
for t in range(8):
rt.step(float(t))
assert len(rt.ring.frames) == 2 # 8틱 중 추론된 2틱만
assert all(boxes for (_t, _f, boxes) in rt.ring.frames) # 전부 얼굴 박스를 동반
def test_dead_camera_still_reports_no_frame_on_a_skipped_tick():
# 스킵 틱이 죽은 카메라를 '정상'으로 보고하면 감시 공백이 초록으로 보인다.
class _DeadSource:
def read(self): return False, None
cfg = load_config({"INFER_EVERY": "2"})
rt = PipelineRuntime(cfg, _DeadSource(), _Pose(), _Sink())
assert rt.step(1.0) is None # 스킵 틱(1 % 2 != 0)에도 None

View file

@ -32,13 +32,31 @@ def test_timeline_short_buffer_returns_fewer_without_duplicates():
assert len(boxes) == 1
def test_face_blur_changes_region():
def test_face_blur_actually_destroys_the_region():
# PIPA 대응의 유일한 실행 가능한 증거. 이전 버전은 out is not None / shape만 봤기 때문에
# blur()가 `return frame`(무동작)으로 퇴화해도 통과했다 — 즉 "스냅샷은 익명화되어 전송된다"는
# 제품의 약속을 뒷받침하지 못했다. 픽셀이 실제로 뭉개졌는지 단언한다.
fb = FaceBlurrer()
f = _frame(0)
f[0:10, 0:10] = 255
f[0:10:2, 0:10] = 0 # 줄무늬 = 얼굴을 식별하게 하는 고주파 디테일
before = f[0:10, 0:10].copy()
out = fb.blur(f.copy(), [BBox(0, 0, 10, 10)])
assert out is not None
assert out.shape == f.shape
face = out[0:10, 0:10]
assert not np.array_equal(face, before), "얼굴 영역이 원본 그대로 남았다"
# 단순히 '달라졌다'가 아니라 디테일이 파괴됐는지를 본다: 분산이 한 자릿수 %로 떨어져야
# 원본 패턴을 복원할 수 없다. 블러가 약해지면(sigma 축소 등) 이 단언이 먼저 깨진다.
assert face.var() < before.var() * 0.1
# 얼굴 밖은 건드리지 않는다 — 증거 가치가 남아야 한다(자세·위치·주변 상황).
assert np.array_equal(out[32:64, 32:64], f[32:64, 32:64])
def test_face_blur_without_boxes_leaves_frame_untouched():
# 박스가 비면 무동작이어야 한다. 이 경로가 조용히 전체를 뭉개면 증거가 사라진다.
fb = FaceBlurrer()
f = _frame(90)
assert np.array_equal(fb.blur(f.copy(), []), f)
def test_snapshotter_returns_jpeg_bytes():
snap = Snapshotter(FaceBlurrer())

75
tests/test_watchdog.py Normal file
View file

@ -0,0 +1,75 @@
import socket
import pytest
from edge import watchdog
from edge.daemon import run_loop
# 타깃은 Pi(Linux). AF_UNIX가 없는 개발 머신(Windows)에서는 실소켓 검증을 건너뛴다 —
# sd_notify가 그 환경에서 예외 없이 False를 반환하는지는 아래 테스트가 따로 지킨다.
needs_unix_socket = pytest.mark.skipif(
not hasattr(socket, "AF_UNIX"), reason="AF_UNIX unavailable on this platform")
class _Runtime:
def __init__(self): self.steps = 0
def step(self, now):
self.steps += 1
return []
def apply_zones(self, zones): pass
class _Client:
def fetch_config(self): return None
def send_heartbeat(self, metrics): return True
class _Queue:
def flush(self): return 0
def pending(self): return 0
def test_sd_notify_is_a_noop_without_systemd(monkeypatch):
# 개발 머신·테스트에서는 NOTIFY_SOCKET이 없다. 여기서 예외가 나면 데몬이 못 뜬다.
monkeypatch.delenv("NOTIFY_SOCKET", raising=False)
assert watchdog.ping() is False
assert watchdog.ready() is False
def test_sd_notify_never_raises_on_a_broken_socket(monkeypatch):
# 소켓이 사라져도(재시작 중 등), 또 AF_UNIX가 없는 플랫폼에서도 텔레메트리 실패가
# 감지를 멈춰서는 안 된다. 예외 대신 False.
monkeypatch.setenv("NOTIFY_SOCKET", "/nonexistent/socket/path")
assert watchdog.ping() is False
@needs_unix_socket
def test_sd_notify_sends_the_datagram(tmp_path, monkeypatch):
path = str(tmp_path / "notify.sock")
srv = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
srv.bind(path)
try:
monkeypatch.setenv("NOTIFY_SOCKET", path)
assert watchdog.ping() is True
assert srv.recv(64) == b"WATCHDOG=1"
finally:
srv.close()
def test_run_loop_pings_the_watchdog_every_tick():
# 워치독이 없으면 '살아 있지만 멈춘' 데몬은 자동 복구되지 않는다.
pings = []
run_loop(_Runtime(), _Client(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None,
iterations=5, notify=lambda: pings.append(1))
assert len(pings) == 5
def test_watchdog_failure_does_not_stop_the_loop():
rt = _Runtime()
def boom():
raise OSError("notify socket gone")
run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None,
iterations=3, notify=boom, log=lambda m, e=None: None)
assert rt.steps == 3 # 감지는 계속 돈다