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:
parent
6b1f4c483f
commit
c3227c179c
41 changed files with 1093 additions and 77 deletions
44
.forgejo/workflows/verify.yml
Normal file
44
.forgejo/workflows/verify.yml
Normal 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
|
||||
|
|
@ -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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.9.0
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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·타 현장
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
48
edge/watchdog.py
Normal 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")
|
||||
|
|
@ -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 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료)
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
66
server/src/Security/Csp.php
Normal file
66
server/src/Security/Csp.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
63
server/tests/CspTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
server/tests/RouteTableTest.php
Normal file
87
server/tests/RouteTableTest.php
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
78
tests/test_dist_parity.py
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
75
tests/test_watchdog.py
Normal 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 # 감지는 계속 돈다
|
||||
Loading…
Reference in a new issue