diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 00000000..d5d3c091 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,8 @@ +{ + "name": "Codec Carver", + "install": "bash .cursor/install.sh", + "start": "bash .cursor/start.sh", + "ports": [ + { "name": "web", "port": 8000 } + ] +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 00000000..123c8e07 --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Idempotent Cloud Agent setup for codec-carver. +# Runs after each checkout. Always resolve the repository root from this file +# so a cached or non-root working directory cannot pip/cargo the wrong tree. +set -euo pipefail +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +# ffmpeg/ffprobe are required at runtime for probing and conversion. +# The default Cloud Agent base image already ships them; install only if missing. +if ! command -v ffmpeg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends ffmpeg +fi + +# rustfmt backs `cargo fmt` (rust-toolchain.toml pins a minimal profile without it). +# Fail closed: CI and local agents both run `cargo fmt --check`. +if ! command -v rustup >/dev/null 2>&1; then + echo "rustup is required so rustfmt can be added for cargo fmt checks." >&2 + exit 1 +fi +rustup component add rustfmt + +# Match CI exactly: install the authenticated hash lock, then link the checked-out +# editable tree without dependency resolution or index access. requirements-dev.txt +# remains an opt-in local fuzzing surface until it has its own reviewed hash lock; +# unattended Cloud Agent bootstrap must not resolve those unhashed requirements. +python3 -m pip install --user --disable-pip-version-check \ + --require-hashes -r requirements-lock.txt +python3 -m pip install --user --disable-pip-version-check \ + --no-index --no-deps --no-build-isolation -e . + +# Rust core binary (codec-carver-core) used by the audio-library CLI. +# A fresh checkout wipes rust-core/target, so (re)build it here. +cargo build --release --manifest-path rust-core/Cargo.toml + +# Expose pip --user console scripts (codec-carver, codec-carver-library, ...) in +# non-login shells too. Login shells already pick up ~/.local/bin via ~/.profile. +if ! grep -q '.local/bin' "$HOME/.bashrc" 2>/dev/null; then + printf '\nif [ -d "$HOME/.local/bin" ]; then PATH="$HOME/.local/bin:$PATH"; fi\n' >> "$HOME/.bashrc" +fi diff --git a/.cursor/start.sh b/.cursor/start.sh new file mode 100755 index 00000000..fc4908bd --- /dev/null +++ b/.cursor/start.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Per-boot reconciliation. install is not re-run when a pod boots from a +# prebuilt environment, so anything that must exist on every boot lives here. +# `start` must reach a clear ready-or-fail state: a green exit with a dead +# uvicorn leaves the published :8000 port pointing at nothing. +set -euo pipefail +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +WEB_HEALTH_URL="${CODEC_CARVER_WEB_HEALTH_URL:-http://127.0.0.1:8000/health}" +WEB_READY_ATTEMPTS="${CODEC_CARVER_WEB_READY_ATTEMPTS:-40}" +WEB_LOG="${CODEC_CARVER_WEB_LOG:-/tmp/codec-carver-web.log}" + +# 1) Ensure the Rust backend binary exists (a fresh checkout wipes rust-core/target). +if [ ! -x rust-core/target/release/codec-carver-core ]; then + cargo build --release --manifest-path rust-core/Cargo.toml +fi + +_web_is_ready() { + curl -sf -o /dev/null "${WEB_HEALTH_URL}" +} + +# 2) Start the FastAPI SaaS web service (saas_web:app) if it is not already up. +# Backgrounded so `start` returns after readiness; the probe keeps it a single +# instance on every boot mode (just-in-time or prebuilt build/snapshot). +if _web_is_ready; then + exit 0 +fi + +nohup python3 -m uvicorn saas_web:app --host 0.0.0.0 --port 8000 \ + > "${WEB_LOG}" 2>&1 & +web_pid=$! + +attempt=0 +while [ "${attempt}" -lt "${WEB_READY_ATTEMPTS}" ]; do + if _web_is_ready; then + exit 0 + fi + if ! kill -0 "${web_pid}" 2>/dev/null; then + echo "uvicorn exited before becoming ready; see ${WEB_LOG}" >&2 + exit 1 + fi + attempt=$((attempt + 1)) + sleep 0.25 +done + +echo "uvicorn did not become ready after ${WEB_READY_ATTEMPTS} probes; see ${WEB_LOG}" >&2 +exit 1 diff --git a/AGENTS.md b/AGENTS.md index 090d7d2d..44e8abe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,17 @@ repo. or feature-extraction path. +## Cloud Agent environment + +`.cursor/environment.json` is repository-managed and overrides dashboard +environments. `install` (`bash .cursor/install.sh`) must stay idempotent, +hash-lock Python deps, and fail if `rustfmt` cannot be added. `start` +(`bash .cursor/start.sh`) must wait on `GET /health` before exiting 0; do not +`nohup` uvicorn and return. Do not add `$schema` to the JSON. Decision record: +[`docs/doctoring/cloud-agent-environment.md`](docs/doctoring/cloud-agent-environment.md). +The module map, job-store ERD, and connector order live in +[`ARCHITECTURE.md`](ARCHITECTURE.md). + ## Code-owner review gates — disabled (on hold) As of 2026-08-04, code-owner review requirements (`require_code_owner_reviews` in branch diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..d43a291a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,134 @@ +# Architecture + +Codec Carver is the speech/video conversion module for STT and omni-modal LLM +input. It must run **standalone** (CLI, FastAPI upload service, MCP tool) and +as a **git submodule** consumed by naruon and sibling ContextualWisdomLab +services. + +## Runtime surfaces + +```mermaid +flowchart LR + subgraph standalone [Standalone] + CLI["codec-carver CLI"] + WEB["saas_web FastAPI"] + MCP["mcp_driver shrink_media"] + end + subgraph core [Conversion core] + MS["media_shrinker.convert_file"] + RS["rust-core codec-carver-core"] + end + CLI --> MS + WEB --> MS + MCP --> MS + CLI --> RS + WEB --> JOB["job_store SQLite WAL"] + MS --> FF["ffmpeg / ffprobe"] +``` + +| Surface | Module | Buyer action | +| --- | --- | --- | +| CLI | `media_shrinker.py` | Carve a folder of recordings into size-capped FLAC/Opus. | +| Library CLI | `audio_library.py` → Rust | Inventory, hash, TMK, and GPU transcription batches. | +| Web | `saas_web.py` | Upload one file or a batch; poll `/jobs/{id}` for long work. | +| MCP | `mcp_driver.py` | Call `shrink_media` from an agent runtime. | +| Jobs | `job_store.py` | Survive process restart; do not keep results only in RAM. | + +Sources are never overwritten. Generated names keep the original filename plus +a new suffix (`meeting.wav.flac`). + +## Conversion pipeline + +```mermaid +flowchart TD + A[find_candidates] --> B[probe_media / ffprobe] + B --> C{duration over cap?} + C -->|yes| D[silencedetect + build_segments] + C -->|no| E[single segment] + D --> F[build_audio_plan FLAC] + E --> F + F --> G{_execute_plan} + G -->|FLAC over target| H[build_opus_plan] + H --> G + G --> I[preserve_file_attributes] + I --> J[write_report JSON] +``` + +Silence splits prefer long quiet intervals; a hard split just under the +duration cap is the fallback. Parsers of ffmpeg/ffprobe text +(`parse_silencedetect_intervals`, `_parse_probe_payload`) treat that text as +untrusted input and raise `MediaShrinkerError` rather than unexpected types. + +## Cloud Agent environment + +`.cursor/environment.json` is the highest-precedence environment source for +Cloud Agents. Do not add `$schema` (the public schema rejects undeclared +fields). + +| Phase | Script | Must do | +| --- | --- | --- | +| `install` | `.cursor/install.sh` | `cd` to repo root; hash-locked pip; fail-closed rustfmt; release-build Rust. | +| `start` | `.cursor/start.sh` | Rebuild Rust only if the binary is missing; start uvicorn; **wait on `GET /health`**. | +| ports | `8000` / `web` | Bind `0.0.0.0:8000` so the published port reaches the SaaS UI. | + +Decision record: [`docs/doctoring/cloud-agent-environment.md`](docs/doctoring/cloud-agent-environment.md). + +## Job-store data (current) + +`job_store.py` persists async conversions. Table `jobs` is a single-word name +and is **known debt**: the org naming rule requires two-or-more-word +snake_case objects (`conversion_jobs`) plus a 3NF-preserving rename migration. +Do not add more single-word tables. Columns already use multi-word names where +they store paths (`output_path`, `output_name`, `temp_dir`). + +```mermaid +erDiagram + JOBS ||--o| CONVERSION_OUTPUT : produces + JOBS { + text id PK + text status + text created_at + text updated_at + text output_path + text output_name + text error + text temp_dir + } +``` + +Callers pass `now` explicitly. The store never calls `datetime.now()`. + +## Security and operability baseline + +- ffmpeg/ffprobe: `-nostdin` and `-protocol_whitelist file,crypto,data`. +- Uploaded names: sanitize to a safe basename; normalize Windows separators. +- Permissions copied onto outputs drop setuid/setgid/sticky bits. +- Opt-in API keys (`CODEC_CARVER_API_KEYS`) are a **known deviation**. Runtime + reads must move to a SQLite/KV credential registry; env is bootstrap + transport only (issues #329, #373). +- `GET /` and `GET /health` stay reachable without a key so a buyer can open + the form and a probe can confirm liveness. +- PII in recordings is the product. Do not mask audio content; protect it with + access control, retention, and encryption at rest instead. + +## Ecosystem connectors (leverage order) + +1. **naruon** — consume carved FLAC/Opus + JSON reports as speech/DOM input. +2. **contextual-orchestrator** — route transcription/LLM describe calls; do not + read provider keys from raw env at runtime. +3. **wardnet** — place the SaaS UI behind the org WAF/APIM when exposed. +4. **keyverse** — replace shared API keys with passwordless service identity. +5. **clearfolio / newsdom-api** — attach transcripts and reports to documents. + +Each connector must keep this repo independently runnable. + +## Test and research baseline + +- Unit/integration: `python3 -m unittest discover -s tests -v` (100% coverage + over the configured sources; 100% interrogate on production modules). +- Reality checks: WAV → FLAC lossless; tiny `--target-bytes` → Opus; silence + splits on long fixtures; Rust inventory SHA-256. +- Fuzz: Atheris harnesses in `fuzz/` plus Hypothesis mirrors in + `tests/test_fuzz_properties.py`. +- Speech/codec changes attach APA 7th citations under `docs/doctoring/` and + `docs/papers/` (PDF only when redistribution is permitted). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9313538b..97eb53ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] ### Added +- Cloud Agent `install`/`start` 스크립트가 해시 잠금 pip, fail-closed rustfmt, `GET /health` 준비 대기를 강제합니다. 근거는 [`docs/doctoring/cloud-agent-environment.md`](docs/doctoring/cloud-agent-environment.md)에 있습니다. +- `GET /health` 활성 프로브를 추가해 업로드 페이지와 같이 API 키 없이 프로세스 생존을 확인할 수 있습니다. - 다중 파일 업로드 선택 시 즉각적인 파일 개수 피드백 및 제한 초과 경고 메시지 추가 - 일괄 업로드 폼에 대상 바이트 프리셋 버튼과 총 파일 크기 미리보기를 추가하여 사용성을 개선했습니다. - 클라이언트 측 폼 검증 시 하드코딩된 '5 GiB' 텍스트를 동적으로 변환되도록 수정하고 일괄 업로드 폼에 최대 크기(MAX_UPLOAD_BYTES) 검증 피드백을 추가했습니다. diff --git a/CLAUDE.md b/CLAUDE.md index cc7870fb..adc35cc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,10 @@ CI installs pinned dependencies with `pip install --require-hashes -r requiremen Four flat top-level modules (declared as `py-modules` in `pyproject.toml`; there is no package directory): - **`media_shrinker.py`** — the core engine and CLI, deliberately stdlib-only (external work happens in `ffmpeg`/`ffprobe` subprocesses). The console script `codec-carver` maps to `media_shrinker:main`. Pipeline for a batch run: `find_candidates` scans the root (pruned `os.walk`, excludes the output dir and `--exclude-dir-prefix` dirs) → per file, `convert_file` probes with ffprobe (`probe_media` / `_parse_probe_payload`), detects silence and builds a split plan for long sources (`detect_silence_intervals`, `parse_silencedetect_intervals`, `build_segments`) → each segment gets a `ConversionPlan` (`build_audio_plan` prefers FLAC; `build_opus_plan` is the fallback when a FLAC output exceeds the target size) → `_execute_plan` runs ffmpeg and `preserve_file_attributes` restores permissions/timestamps/xattrs best-effort → `write_report` emits a JSON report. `convert_file(source, root=..., output_dir=..., target_bytes=...)` is the programmatic API that the web and MCP layers call. -- **`saas_web.py`** — single-file FastAPI upload UI (the `[web]` extra; what the Docker image serves). Streams one upload into a temp workspace, calls `media_shrinker.convert_file`, and returns the first generated output as a download. Middleware enforces a 5 GiB upload cap and security headers. Processing is synchronous per request. +- **`saas_web.py`** — single-file FastAPI upload UI (the `[web]` extra; what the Docker image serves). Streams one upload into a temp workspace, calls `media_shrinker.convert_file`, and returns the first generated output as a download. `GET /health` is the liveness probe used by Cloud Agent `start` and load balancers. Long work uses `/jobs` backed by `job_store`. Middleware enforces a 5 GiB upload cap, optional API keys, and security headers. - **`mcp_driver.py`** — FastMCP server (the `[mcp]` extra) exposing a single `shrink_media` tool that wraps `convert_file`. -- **`job_store.py`** — stdlib-only SQLite (WAL) durable job store intended for async/worker job tracking. It is tested but not yet wired into `saas_web.py`. Callers pass `now` explicitly; the store never calls `datetime.now()` itself. +- **`job_store.py`** — stdlib-only SQLite (WAL) durable job store used by `saas_web.py` async jobs. Callers pass `now` explicitly; the store never calls `datetime.now()` itself. +- **`.cursor/`** — repository-managed Cloud Agent environment. `install.sh` hash-locks pip and fail-closes rustfmt; `start.sh` waits on `GET /health`. See `ARCHITECTURE.md` and `docs/doctoring/cloud-agent-environment.md`. Supporting directories: `fuzz/` holds Atheris harnesses plus seed corpora for the three untrusted-input parsing surfaces (`parse_silencedetect_intervals`, `_parse_probe_payload`, `build_segments`); the same invariants run as Hypothesis property tests in `tests/test_fuzz_properties.py` so they execute in the normal suite. `docs/papers/` holds the fuzzing survey the harness design references. diff --git a/Dockerfile b/Dockerfile index a7ee8ee8..0f0d70b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,8 @@ USER appuser EXPOSE 8000 -# Liveness: the FastAPI app serves the upload UI at "/". +# Liveness: use the same cheap JSON readiness surface as Cloud Agent startup. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/', timeout=4).status==200 else 1)" + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4).status==200 else 1)" CMD ["uvicorn", "saas_web:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docs/doctoring/cloud-agent-environment.md b/docs/doctoring/cloud-agent-environment.md new file mode 100644 index 00000000..5d1919e3 --- /dev/null +++ b/docs/doctoring/cloud-agent-environment.md @@ -0,0 +1,68 @@ +# Cloud Agent environment bootstrap + +## Decision + +Codec Carver commits a repository-managed Cloud Agent environment at +`.cursor/environment.json`. `install` installs hash-locked Python dependencies, +adds `rustfmt` to the pinned minimal Rust toolchain, and builds +`codec-carver-core`. `start` rebuilds that binary only when a fresh checkout +wiped `rust-core/target`, then brings up `saas_web:app` on `0.0.0.0:8000` and +**waits** until `GET /health` succeeds. + +Agents should treat a green `start` as “the upload UI is listening.” A +`nohup` without a readiness wait is not an environment start. + +## Technical basis + +Cursor resolves environment configuration from the checked-out +`.cursor/environment.json` before any dashboard-managed personal or team +environment (Cursor, n.d.). The public schema rejects undeclared fields, +including `$schema`, so this repository does not add one. + +Python package integrity follows pip hash-checking mode: install the lock +file with `--require-hashes`, then link the editable tree with `--no-index +--no-deps` so the index cannot substitute a different wheel (Python Software +Foundation, n.d.). That pairing is the same contract CI uses and is the +minimum SLSA-aligned control this repository can apply without a hermetic +build service (SLSA, 2023). + +Fail-closed toolchain setup follows NIST SSDF PW.4 / PW.8: produce a +repeatable build environment and do not continue when a required verification +tool (`rustfmt`) is missing (Souppaya et al., 2022). + +`GET /health` is a liveness probe, not a substitute for job-store or ffmpeg +readiness. Load balancers and Cloud Agent `start` share that URL so a crashed +uvicorn cannot look like a successful boot. + +## Verification and rollback + +- `bash -n .cursor/install.sh .cursor/start.sh` must succeed. +- `tests/test_cloud_agent_environment.py` locks the JSON fields, the hash-locked + pip lines, rustfmt fail-closed install, and the `/health` wait. +- `GET /health` stays auth-exempt and returns + `{"status": "ok", "service": "codec-carver"}` whether or not API keys are + configured. +- Roll back by restoring the previous scripts; do not reintroduce `|| true` on + rustfmt or a `nohup` that returns before the probe succeeds. + +## Next action + +After this lands, mark the Cloud Agent environment draft ready for review and +close the earlier environment-only PR that lacked the readiness contract. + +## References + +Cursor. (n.d.). *Cloud Agents environment schema*. Retrieved August 16, 2026, +from https://cursor.com/schemas/environment.schema.json + +Python Software Foundation. (n.d.). *Secure installs: Hash-checking mode* +(pip documentation). Retrieved August 16, 2026, from +https://pip.pypa.io/en/stable/topics/secure-installs/ + +SLSA. (2023). *Supply-chain Levels for Software Artifacts (SLSA) v1.0*. +https://slsa.dev/spec/v1.0/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development +Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of +software vulnerabilities* (NIST Special Publication 800-218). National +Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/saas_web.py b/saas_web.py index 63265e94..9f7b2932 100644 --- a/saas_web.py +++ b/saas_web.py @@ -98,20 +98,31 @@ def get_configured_api_keys(): return [key.strip() for key in raw.split(",") if key.strip()] +# Upload UI and liveness stay reachable without a key so a browser can open the +# form and Cloud Agent / load-balancer probes can confirm the process is up. +_PUBLIC_GET_PATHS = frozenset({"/", "/health"}) + + +def _is_public_get(request: Request) -> bool: + """Return True when ``request`` is an unauthenticated GET probe or UI page.""" + + return request.method == "GET" and request.url.path in _PUBLIC_GET_PATHS + + @app.middleware("http") async def require_api_key(request: Request, call_next): - """Enforce opt-in API-key authentication on all endpoints except GET /. + """Enforce opt-in API-key authentication except on public GET probes. When one or more keys are configured via CODEC_CARVER_API_KEYS, every - request other than GET / (the upload UI page) must carry an X-API-Key - header matching a configured key; comparison uses hmac.compare_digest to - stay constant-time. Requests failing the check receive a 401 JSON error - without echoing any key material. When no keys are configured, all - requests pass through unchanged. + request other than GET / (the upload UI) and GET /health (liveness) must + carry an X-API-Key header matching a configured key; comparison uses + hmac.compare_digest to stay constant-time. Requests failing the check + receive a 401 JSON error without echoing any key material. When no keys + are configured, all requests pass through unchanged. """ configured_keys = get_configured_api_keys() - if configured_keys and not (request.method == "GET" and request.url.path == "/"): + if configured_keys and not _is_public_get(request): provided_key = request.headers.get("x-api-key", "") if not any( hmac.compare_digest(provided_key, key) for key in configured_keys @@ -512,6 +523,18 @@ async def get_ui(): return HTML_TEMPLATE +@app.get("/health") +async def health() -> dict[str, str]: + """Return a liveness payload for Cloud Agent start and load balancers. + + This path stays auth-exempt so a probe can confirm the process is listening + without presenting an API key. It does not report job-store or ffmpeg + readiness; those checks belong to a future dedicated readiness route. + """ + + return {"status": "ok", "service": "codec-carver"} + + @app.post("/shrink") def shrink_media( background_tasks: BackgroundTasks, diff --git a/tests/test_cloud_agent_environment.py b/tests/test_cloud_agent_environment.py new file mode 100644 index 00000000..b3f9e667 --- /dev/null +++ b/tests/test_cloud_agent_environment.py @@ -0,0 +1,76 @@ +"""Contract tests for the repo-managed Cloud Agent environment.""" + +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ENVIRONMENT_JSON = ROOT / ".cursor" / "environment.json" +INSTALL_SH = ROOT / ".cursor" / "install.sh" +START_SH = ROOT / ".cursor" / "start.sh" +DOCKERFILE = ROOT / "Dockerfile" + + +class CloudAgentEnvironmentTests(unittest.TestCase): + """Keep install/start scripts aligned with CI and the public env schema.""" + + def test_environment_json_declares_install_start_and_web_port(self) -> None: + """Require the fields Cloud Agents need to boot the SaaS UI.""" + + payload = json.loads(ENVIRONMENT_JSON.read_text(encoding="utf-8")) + self.assertNotIn("$schema", payload) + self.assertEqual(payload["name"], "Codec Carver") + self.assertEqual(payload["install"], "bash .cursor/install.sh") + self.assertEqual(payload["start"], "bash .cursor/start.sh") + self.assertEqual(payload["ports"], [{"name": "web", "port": 8000}]) + + def test_install_and_start_scripts_are_valid_bash(self) -> None: + """Reject a script that `bash -n` cannot parse before an agent runs it.""" + + for script in (INSTALL_SH, START_SH): + with self.subTest(script=script.name): + completed = subprocess.run( + ["bash", "-n", str(script)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + + def test_install_pins_repo_root_and_hash_locked_python(self) -> None: + """Install must use only the CI hash lock or an offline editable link.""" + + text = INSTALL_SH.read_text(encoding="utf-8") + self.assertIn('cd "$(dirname "$0")/.."', text) + self.assertIn("--require-hashes -r requirements-lock.txt", text) + self.assertIn("--no-index --no-deps --no-build-isolation -e .", text) + self.assertNotIn("-r requirements-dev.txt", text) + self.assertNotIn("|| true", text) + self.assertIn("rustup component add rustfmt", text) + self.assertIn("cargo build --release --manifest-path rust-core/Cargo.toml", text) + + def test_start_waits_for_health_and_fails_if_worker_dies(self) -> None: + """Start must probe /health and exit non-zero when uvicorn dies.""" + + text = START_SH.read_text(encoding="utf-8") + self.assertIn('cd "$(dirname "$0")/.."', text) + self.assertIn("http://127.0.0.1:8000/health", text) + self.assertIn("uvicorn saas_web:app --host 0.0.0.0 --port 8000", text) + self.assertIn("uvicorn exited before becoming ready", text) + self.assertIn("uvicorn did not become ready", text) + self.assertIn("kill -0", text) + + def test_docker_healthcheck_uses_the_same_cheap_health_endpoint(self) -> None: + """Require container liveness to avoid rendering the upload page.""" + + text = DOCKERFILE.read_text(encoding="utf-8") + self.assertIn("http://127.0.0.1:8000/health", text) + self.assertNotIn("urlopen('http://127.0.0.1:8000/'", text) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 3b57e033..774083f3 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -732,6 +732,36 @@ def test_get_ui_always_open_without_key(self): self.assertEqual(response.status_code, 200) self.assertIn(b"Codec Carver SaaS", response.content) + def test_health_always_open_without_key(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = client.get("/health") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {"status": "ok", "service": "codec-carver"}, + ) + + def test_health_open_when_keys_unconfigured(self): + with patch.dict(os.environ): + os.environ.pop("CODEC_CARVER_API_KEYS", None) + response = client.get("/health") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + + def test_is_public_get_covers_ui_and_health_only(self): + ui = SimpleNamespace(method="GET", url=SimpleNamespace(path="/")) + health = SimpleNamespace(method="GET", url=SimpleNamespace(path="/health")) + shrink = SimpleNamespace(method="POST", url=SimpleNamespace(path="/")) + jobs = SimpleNamespace(method="GET", url=SimpleNamespace(path="/jobs/x")) + + self.assertTrue(saas_web._is_public_get(ui)) + self.assertTrue(saas_web._is_public_get(health)) + self.assertFalse(saas_web._is_public_get(shrink)) + self.assertFalse(saas_web._is_public_get(jobs)) + self.assertEqual(saas_web._PUBLIC_GET_PATHS, frozenset({"/", "/health"})) + def test_job_api_requires_key_when_configured(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = client.get("/jobs/missing")