"""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'/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}" # --------------------------------------------------------------------------- # 2026-08-18 재감사(A11): CLI 도구가 웹에서 닿으면 안 된다. # tools/dispatch_alerts.php 는 메일을 보내고, backup.php 는 DB 전체를 읽는다. # 이들이 www/ 아래에 실리면 인증 없이 호출 가능한 표면이 된다. # --------------------------------------------------------------------------- TOOLS_DIR = os.path.join(ROOT, "server", "tools") DIST_PRIV = os.path.join(ROOT, "dist", "1_cafe24_upload", "move_detect_app") DIST_WEB = os.path.join(ROOT, "dist", "1_cafe24_upload", "www", "move_detect") needs_package = pytest.mark.skipif( not os.path.isdir(DIST_PRIV), reason="cafe24 package not built in this checkout" ) def test_every_cli_tool_refuses_to_run_over_http(): # bootstrap.php 의 SAPI 검사가 유일한 코드 레벨 방어선이다. 도구가 그것을 require # 하지 않으면 그 방어선이 없는 것과 같다. for name in sorted(os.listdir(TOOLS_DIR)): if not name.endswith(".php") or name == "bootstrap.php": continue src = _read(os.path.join(TOOLS_DIR, name)) assert "require __DIR__ . '/bootstrap.php'" in src, ( f"tools/{name} 가 bootstrap.php 를 require 하지 않습니다 — CLI 전용 보장이 없습니다." ) guard = _read(os.path.join(TOOLS_DIR, "bootstrap.php")) assert "PHP_SAPI !== 'cli'" in guard, "bootstrap.php 의 CLI 전용 가드가 사라졌습니다" @needs_package def test_tools_ship_outside_the_web_root(): assert os.path.isdir(os.path.join(DIST_PRIV, "tools")), ( "패키지에 tools/ 가 없습니다 — 통보 디스패처와 보존기간 정리가 동봉되지 않았습니다." ) assert not os.path.exists(os.path.join(DIST_WEB, "tools")), ( "tools/ 가 웹 공개 경로에 실렸습니다. dispatch_alerts.php 는 메일을 보내고 " "backup.php 는 DB 전체를 읽습니다." ) @needs_package def test_packaged_web_root_holds_no_php_beyond_the_front_controller(): # 웹 루트의 PHP 는 index.php 와 (배포 후 삭제하는) _preflight.php 뿐이어야 한다. allowed = {"index.php", "_preflight.php"} found = { os.path.relpath(os.path.join(dirpath, f), DIST_WEB) for dirpath, _, files in os.walk(DIST_WEB) for f in files if f.endswith(".php") } unexpected = found - allowed assert not unexpected, f"웹 루트에 예상치 못한 PHP 파일: {sorted(unexpected)}"