move_detect/tests/test_cafe24_package.py
유창욱 9386dfc580 feat(delivery): install-path fixes + cafe24 package + delivery-audit hardening
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).

## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
  StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
  real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
  8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.

## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
  (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
  walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
  scoped to the mount.

## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
  allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
  heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
  offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
  misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
  ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 09:07:33 +09:00

180 lines
9.2 KiB
Python

"""Guard: the Cafe24 upload package must be complete and safe the moment it is built.
The package is what a non-developer drags into Cafe24's FTP root, so a mistake here is
not caught by anything downstream — there is no CI on the hosting account. Every check
below encodes a failure that is silent in production:
* a missing vendor/ autoloader fatals every request (public/index.php's `require` of
../vendor/autoload.php is unconditional);
* a *dev* autoloader eagerly requires tests/Support/InMemoryRepositories.php, which is
not in the package, so it also fatals every request;
* a missing storage/snapshots/ silently drops every snapshot while still returning
201 to the edge device (IngestController's `continue` on a failed write).
"""
import os
import re
import subprocess
import pytest
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
# cloud/deploy/, not deploy/: deploy/ belongs to the Raspberry Pi — its own README opens with
# "Edge deployment (Raspberry Pi 5, air-gapped)" and everything in it is run as root ON the
# Pi. Cafe24 runs nothing; the package is only ever uploaded. Deploying cloud/ belongs to
# cloud/, which already owns cloud/docs/cloud-deploy.md.
BUILD = os.path.join(ROOT, "cloud", "deploy", "build_cafe24_package.sh")
HTACCESS = os.path.join(ROOT, "cloud", "deploy", "htaccess.www")
CONFIG_TMPL = os.path.join(ROOT, "cloud", "deploy", "config.template.php")
CONFIG_EXAMPLE = os.path.join(ROOT, "cloud", "config.example.php")
CLOUD_DEPLOY_DOC = os.path.join(ROOT, "cloud", "docs", "cloud-deploy.md")
CONFIG_PHP = os.path.join(ROOT, "cloud", "src", "Config.php")
def _read(path):
with open(path, encoding="utf-8") as f:
return f.read()
# Paths are passed to bash/shellcheck relative to ROOT: Git Bash's bash is an MSYS program,
# so it resolves neither C:\a\b (backslashes vanish) nor C:/a/b (not a POSIX path) — it
# wants /c/a/b. A relative path plus cwd=ROOT sidesteps the conversion on every platform.
BUILD_REL = os.path.join("cloud", "deploy", "build_cafe24_package.sh").replace("\\", "/")
def _config_keys_read_by_app():
"""Every config.php key App\\Config actually reads (the sole consumer: its ctor is
private and fromArray() is called once, from public/index.php)."""
return set(re.findall(r"\$this->a\['([a-z0-9_]+)'\]", _read(CONFIG_PHP)))
def test_build_script_exists_and_is_executable_bash():
assert os.path.isfile(BUILD), "cloud/deploy/build_cafe24_package.sh is missing"
assert _read(BUILD).startswith("#!"), "build script needs a shebang"
def test_build_script_generates_a_no_dev_autoloader():
sh = _read(BUILD)
# cloud/public/index.php:3 hard-requires ../vendor/autoload.php, and
# cloud/src/Auth/Session.php declares three symbols under one filename (hence
# composer.json's classmap entry) — so a hand-rolled PSR-4 loader cannot resolve
# App\Auth\PhpSessionStore. Composer's classmap dump is the only correct artifact.
assert "composer dump-autoload" in sh
assert "--no-dev" in sh
assert "--classmap-authoritative" in sh
def test_build_script_excludes_dev_only_trees():
sh = _read(BUILD)
# Shipping the working tree's vendor/ would drag in PHPUnit *and* a dev
# autoload_files.php that requires tests/Support/InMemoryRepositories.php on every
# request. Shipping tests/ or phpunit.xml is dead weight on a production host.
for forbidden in ("phpunit", "tests"):
assert forbidden in sh, f"build script must explicitly deal with {forbidden}"
def test_build_script_mounts_the_app_in_a_subdirectory_of_www():
sh = _read(BUILD)
# This account's www/ already serves another site, so the app is mounted at
# www/move_detect/ and everything secret stays at the FTP root — one level ABOVE www/,
# two above the app. Emitting the app straight into www/ would put config.php (DB
# password) one level up, i.e. inside a docroot the other site also serves.
assert 'APP_DIR="${APP_DIR-move_detect}"' in sh
assert 'WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"' in sh
# Everything private goes in ONE container beside www/, not sprayed across the FTP root:
# that root also holds the co-hosted site's files, and `vendor/`/`config.php` are ordinary
# names in any PHP project — a flat layout risks overwriting them.
assert 'PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"' in sh
assert 'PRIV="$STAGE/$PRIVATE_DIR"' in sh
for private in ('"$PRIV/config.php"', '"$PRIV/src/"', '"$PRIV/storage/snapshots"',
'"$PRIV/vendor/autoload.php"', '"$PRIV/schema.sql"'):
assert private in sh, f"{private} must live inside the private container"
# ...and nothing private may be staged under the web directory.
for leaked in ('"$WEB/config.php"', '"$WEB/src/"', '"$WEB/vendor', '"$WEB/storage'):
assert leaked not in sh, f"{leaked} would be web-reachable"
def test_app_finds_its_root_by_walking_up_not_by_a_hardcoded_depth():
index = _read(os.path.join(ROOT, "cloud", "public", "index.php"))
# A hardcoded '/..' resolves to www/ under a subdirectory mount — exactly where
# config.php must never live. The walk-up makes one package work at either depth.
assert "APP_ROOT" in index
assert "require __DIR__ . '/../vendor/autoload.php'" not in index
assert "require APP_ROOT . '/vendor/autoload.php'" in index
assert "require APP_ROOT . '/config.php'" in index
# The private container is probed at every level, and BEFORE the bare directory: the
# co-hosted site may itself have config.php + vendor/ at the FTP root, and binding to
# those would hand this app another site's database credentials.
assert "APP_PRIVATE_DIR = 'move_detect_app'" in index
assert "[$dir . '/' . APP_PRIVATE_DIR, $dir]" in index, \
"the container must be probed before the bare directory"
# All three markers must match. config.php + vendor/ alone are ordinary names in any PHP
# project sharing the account; src/Config.php is specific to this app.
for marker in ("'/config.php'", "'/vendor/autoload.php'", "'/src/Config.php'"):
assert marker in index, f"{marker} must be part of the app-root check"
def test_build_script_creates_the_snapshot_dir():
sh = _read(BUILD)
# Nothing in cloud/src ever creates this directory, and a failed write is swallowed
# by IngestController's `continue` — the API still answers 201 and the evidence is
# gone for good.
assert "storage/snapshots" in sh
def test_htaccess_forces_https_and_keeps_the_front_controller():
ht = _read(HTACCESS)
# src/Auth/Session.php hard-codes cookie_secure => true, so over plain HTTP the
# browser never returns the session cookie and login silently fails. HTTPS is a hard
# requirement, not hardening.
assert "RewriteCond %{HTTPS} !=on" in ht
assert "https://%{HTTP_HOST}%{REQUEST_URI}" in ht
# ...without breaking the existing rewrite-to-index front controller.
assert "RewriteRule ^ index.php [QSA,L]" in ht
def test_htaccess_denies_dotfiles_and_plaintext_servable_files():
ht = _read(HTACCESS)
assert "Options -Indexes" in ht
assert re.search(r'<FilesMatch\s+"\^\\\."', ht), "dotfiles (.git, .env) must be denied"
# schema.sql is the sharp one: Apache has no handler for .sql, so it serves the full
# DDL as plaintext. Unlike .php, PHP will not swallow it.
assert "sql" in ht
def test_config_template_lists_every_key_the_app_reads():
tmpl = _read(CONFIG_TMPL)
missing = {k for k in _config_keys_read_by_app() if f"'{k}'" not in tmpl}
assert not missing, f"config template omits keys App\\Config reads: {sorted(missing)}"
def test_config_example_lists_every_key_the_app_reads():
example = _read(CONFIG_EXAMPLE)
# Silent-default bug: nonce_ttl and ingest_ip_rate_limit_per_min have `?? default`
# fallbacks, so an operator who never sees them cannot know the knobs exist.
missing = {k for k in _config_keys_read_by_app() if f"'{k}'" not in example}
assert not missing, f"config.example.php omits keys App\\Config reads: {sorted(missing)}"
def test_cloud_deploy_doc_does_not_tell_operators_to_drop_vendor():
doc = _read(CLOUD_DEPLOY_DOC)
# public/index.php:3 is an unconditional `require` of ../vendor/autoload.php.
# "Do not upload vendor/" bricks every request, including the doc's own smoke check.
assert "Do not upload `vendor/`" not in doc
def test_build_script_passes_shellcheck_if_available():
# Probe and invoke through the SAME interpreter. Probing with `bash -c command -v` and
# then invoking shellcheck from the Windows PATH asks two different machines, so the
# probe can pass while the call ENOENTs. And skip visibly rather than `return`, which
# reports a green test that never ran.
r = subprocess.run(["bash", "-c", f"command -v shellcheck >/dev/null || exit 99; "
f"shellcheck -S warning {BUILD_REL}"],
cwd=ROOT, capture_output=True, text=True)
if r.returncode == 99:
pytest.skip("shellcheck not installed; bash -n still guards syntax")
assert r.returncode == 0, f"shellcheck:\n{r.stdout}\n{r.stderr}"
def test_build_script_is_syntactically_valid_bash():
r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True)
assert r.returncode == 0, f"bash -n failed:\n{r.stderr}"