The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud' was misleading. Renames the directory, the edge client module, and every doc/script/identifier that used the term. - cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths) - edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient - cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md - CLOUD_* env/config keys -> SERVER_* - tests renamed to match Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
180 lines
9.2 KiB
Python
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__), ".."))
|
|
# server/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 server/ belongs to
|
|
# server/, which already owns server/docs/server-deploy.md.
|
|
BUILD = os.path.join(ROOT, "server", "deploy", "build_cafe24_package.sh")
|
|
HTACCESS = os.path.join(ROOT, "server", "deploy", "htaccess.www")
|
|
CONFIG_TMPL = os.path.join(ROOT, "server", "deploy", "config.template.php")
|
|
CONFIG_EXAMPLE = os.path.join(ROOT, "server", "config.example.php")
|
|
SERVER_DEPLOY_DOC = os.path.join(ROOT, "server", "docs", "server-deploy.md")
|
|
CONFIG_PHP = os.path.join(ROOT, "server", "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("server", "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), "server/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)
|
|
# server/public/index.php:3 hard-requires ../vendor/autoload.php, and
|
|
# server/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, "server", "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 server/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_server_deploy_doc_does_not_tell_operators_to_drop_vendor():
|
|
doc = _read(SERVER_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}"
|