"""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'/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}"