diff --git a/.jules/palette.md b/.jules/palette.md
index 2dcd639e..1efcf686 100644
--- a/.jules/palette.md
+++ b/.jules/palette.md
@@ -1,3 +1,6 @@
+## 2024-05-24 - Inline Validation for Required Inputs
+**Learning:** Emptying a required input without clear validation feedback leaves screen readers and visual users confused, especially when aria-invalid is removed.
+**Action:** Ensure inline validation messages are explicitly set and `aria-invalid` is properly toggled to `true` when a required field is cleared.
## 2024-07-15 - Dynamic Size formatting and Total Size Validation
**Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error.
**Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`.
diff --git a/patch.diff b/patch.diff
new file mode 100644
index 00000000..f516d4ea
--- /dev/null
+++ b/patch.diff
@@ -0,0 +1,53 @@
+--- saas_web.py
++++ saas_web.py
+@@ -226,7 +226,10 @@
+ input.removeAttribute('aria-invalid');
+ preview.style.color = '#0f6674';
+ if (!file) {
+- preview.innerText = '';
++ preview.innerText = 'This field is required.';
++ preview.style.color = '#dc3545';
++ input.setCustomValidity('This field is required.');
++ input.setAttribute('aria-invalid', 'true');
+ return;
+ }
+ const text = formatBinaryBytes(file.size);
+@@ -257,9 +260,10 @@
+ });
+
+ if (this.value === '') {
+- preview.innerText = '';
+- this.setCustomValidity('');
+- this.removeAttribute('aria-invalid');
++ preview.innerText = 'This field is required.';
++ preview.style.color = '#dc3545';
++ this.setCustomValidity('This field is required.');
++ this.setAttribute('aria-invalid', 'true');
+ return;
+ }
+
+@@ -288,9 +292,10 @@
+ });
+
+ if (this.value === '') {
+- preview.innerText = '';
+- this.setCustomValidity('');
+- this.removeAttribute('aria-invalid');
++ preview.innerText = 'This field is required.';
++ preview.style.color = '#dc3545';
++ this.setCustomValidity('This field is required.');
++ this.setAttribute('aria-invalid', 'true');
+ return;
+ }
+
+@@ -321,7 +326,10 @@
+
+ const files = input.files;
+ if (!files || files.length === 0) {
+- preview.innerText = '';
++ preview.innerText = 'This field is required.';
++ preview.style.color = '#dc3545';
++ input.setCustomValidity('This field is required.');
++ input.setAttribute('aria-invalid', 'true');
+ return;
+ }
diff --git a/patch_test.diff b/patch_test.diff
new file mode 100644
index 00000000..e9815bd7
--- /dev/null
+++ b/patch_test.diff
@@ -0,0 +1,19 @@
+--- tests/test_saas_web.py
++++ tests/test_saas_web.py
+@@ -58,6 +58,16 @@
+ self.assertIn('onchange="updateBatchFilePreview(this)"', html)
+ self.assertIn('id="batch_files_preview"', html)
+ self.assertIn("function updateBatchFilePreview(input)", html)
++
++ def test_get_ui_includes_required_validation(self):
++ response = client.get("/")
++ self.assertEqual(response.status_code, 200)
++ html = response.text
++
++ # Test that required field logic is verified via presence of aria-invalid when empty
++ self.assertIn("preview.innerText = 'This field is required.';", html)
++ self.assertIn("input.setCustomValidity('This field is required.');", html)
++ self.assertEqual(html.count("preview.innerText = 'This field is required.';"), 4)
+
+ def test_get_ui_includes_drag_and_drop_zones(self):
+ response = client.get("/")
diff --git a/patch_test2.diff b/patch_test2.diff
new file mode 100644
index 00000000..e9815bd7
--- /dev/null
+++ b/patch_test2.diff
@@ -0,0 +1,19 @@
+--- tests/test_saas_web.py
++++ tests/test_saas_web.py
+@@ -58,6 +58,16 @@
+ self.assertIn('onchange="updateBatchFilePreview(this)"', html)
+ self.assertIn('id="batch_files_preview"', html)
+ self.assertIn("function updateBatchFilePreview(input)", html)
++
++ def test_get_ui_includes_required_validation(self):
++ response = client.get("/")
++ self.assertEqual(response.status_code, 200)
++ html = response.text
++
++ # Test that required field logic is verified via presence of aria-invalid when empty
++ self.assertIn("preview.innerText = 'This field is required.';", html)
++ self.assertIn("input.setCustomValidity('This field is required.');", html)
++ self.assertEqual(html.count("preview.innerText = 'This field is required.';"), 4)
+
+ def test_get_ui_includes_drag_and_drop_zones(self):
+ response = client.get("/")
diff --git a/saas_web.py b/saas_web.py
index 63265e94..48696a77 100644
--- a/saas_web.py
+++ b/saas_web.py
@@ -225,7 +225,10 @@ async def add_security_headers(request: Request, call_next):
input.removeAttribute('aria-invalid');
preview.style.color = '#0f6674';
if (!file) {
- preview.innerText = '';
+ preview.innerText = 'This field is required.';
+ preview.style.color = '#dc3545';
+ input.setCustomValidity('This field is required.');
+ input.setAttribute('aria-invalid', 'true');
return;
}
const text = formatBinaryBytes(file.size);
@@ -257,9 +260,10 @@ async def add_security_headers(request: Request, call_next):
});
if (this.value === '') {
- preview.innerText = '';
- this.setCustomValidity('');
- this.removeAttribute('aria-invalid');
+ preview.innerText = 'This field is required.';
+ preview.style.color = '#dc3545';
+ this.setCustomValidity('This field is required.');
+ this.setAttribute('aria-invalid', 'true');
return;
}
@@ -291,9 +295,10 @@ async def add_security_headers(request: Request, call_next):
});
if (this.value === '') {
- preview.innerText = '';
- this.setCustomValidity('');
- this.removeAttribute('aria-invalid');
+ preview.innerText = 'This field is required.';
+ preview.style.color = '#dc3545';
+ this.setCustomValidity('This field is required.');
+ this.setAttribute('aria-invalid', 'true');
return;
}
@@ -324,7 +329,10 @@ async def add_security_headers(request: Request, call_next):
const files = input.files;
if (!files || files.length === 0) {
- preview.innerText = '';
+ preview.innerText = 'This field is required.';
+ preview.style.color = '#dc3545';
+ input.setCustomValidity('This field is required.');
+ input.setAttribute('aria-invalid', 'true');
return;
}
diff --git a/saas_web.py.orig b/saas_web.py.orig
new file mode 100644
index 00000000..63265e94
--- /dev/null
+++ b/saas_web.py.orig
@@ -0,0 +1,892 @@
+"""FastAPI upload UI for shrinking one media file through Codec Carver."""
+
+import json
+import hmac
+import logging
+import os
+import shutil
+import tempfile
+import uuid
+import zipfile
+from datetime import datetime, timezone
+from pathlib import Path
+from fastapi import FastAPI, UploadFile, File, BackgroundTasks, Form, Request
+from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
+from job_store import JobStore
+import media_shrinker
+
+app = FastAPI(title="Codec Carver SaaS")
+MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024
+MAX_REQUEST_BYTES = MAX_UPLOAD_BYTES + 10 * 1024 * 1024
+MAX_BATCH_FILES = 20
+# A shrink target larger than the biggest accepted upload is meaningless; cap it
+# to keep numeric input bounded.
+MAX_TARGET_BYTES = MAX_UPLOAD_BYTES
+# This service only processes audio/video. Uploaded files are never executed or
+# served as web content — they are handed to ffmpeg, which rejects non-media —
+# but validating the declared content type rejects obviously-wrong uploads early.
+_ALLOWED_CONTENT_PREFIXES = ("audio/", "video/")
+
+
+def _validate_request(file: "UploadFile", target_bytes: int) -> str | None:
+ """Return an error message for an invalid upload request, or None if valid."""
+ if target_bytes <= 0:
+ return "Invalid target_bytes value. Must be greater than 0."
+ if target_bytes > MAX_TARGET_BYTES:
+ return "Invalid target_bytes value. Exceeds the maximum allowed size."
+ if not file.filename:
+ return "No file uploaded or filename missing"
+ content_type = getattr(file, "content_type", None)
+ if content_type and not content_type.startswith(_ALLOWED_CONTENT_PREFIXES):
+ return "Unsupported content type; upload an audio or video file."
+ return None
+
+
+class RequestTooLarge(Exception):
+ """Raised when streamed request bytes exceed the accepted upload envelope."""
+
+ pass
+
+
+@app.middleware("http")
+async def limit_request_size(request: Request, call_next):
+ """Reject declared or streamed request bodies above the service limit."""
+
+ content_length = request.headers.get("content-length")
+ if content_length is not None:
+ try:
+ declared_size = int(content_length)
+ except ValueError:
+ return JSONResponse(status_code=400, content={"error": "Invalid Content-Length"})
+ if declared_size < 0:
+ return JSONResponse(status_code=400, content={"error": "Invalid Content-Length"})
+ if declared_size > MAX_REQUEST_BYTES:
+ return JSONResponse(status_code=413, content={"error": "Payload Too Large"})
+
+ received = 0
+ receive = request._receive
+
+ async def limited_receive():
+ """Count streamed request bytes before handing them to FastAPI."""
+
+ nonlocal received
+ message = await receive()
+ if message.get("type") == "http.request":
+ received += len(message.get("body", b""))
+ if received > MAX_REQUEST_BYTES:
+ raise RequestTooLarge
+ return message
+
+ request._receive = limited_receive
+ try:
+ return await call_next(request)
+ except RequestTooLarge:
+ return JSONResponse(status_code=413, content={"error": "Payload Too Large"})
+
+def get_configured_api_keys():
+ """Return the API keys configured via the CODEC_CARVER_API_KEYS env var.
+
+ The variable holds a comma-separated list of keys. Whitespace around each
+ key is stripped and empty entries are ignored. Keys are read from the
+ environment at request time (not import time) so tests can patch the
+ environment easily and key rotation needs no server restart. Returns an
+ empty list when the variable is unset or contains no usable keys, which
+ leaves the service open (today's default behaviour).
+ """
+
+ raw = os.environ.get("CODEC_CARVER_API_KEYS", "")
+ return [key.strip() for key in raw.split(",") if key.strip()]
+
+
+@app.middleware("http")
+async def require_api_key(request: Request, call_next):
+ """Enforce opt-in API-key authentication on all endpoints except GET /.
+
+ 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.
+ """
+
+ configured_keys = get_configured_api_keys()
+ if configured_keys and not (request.method == "GET" and request.url.path == "/"):
+ provided_key = request.headers.get("x-api-key", "")
+ if not any(
+ hmac.compare_digest(provided_key, key) for key in configured_keys
+ ):
+ return JSONResponse(
+ status_code=401,
+ content={"error": "Invalid or missing API key"},
+ )
+ return await call_next(request)
+
+
+@app.middleware("http")
+async def add_security_headers(request: Request, call_next):
+ """Attach conservative browser security headers to every response."""
+
+ response = await call_next(request)
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["X-XSS-Protection"] = "1; mode=block"
+ response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+ response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
+ if request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https":
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
+ return response
+logger = logging.getLogger(__name__)
+
+HTML_TEMPLATE = """
+
+
+
+
+
+ Codec Carver SaaS
+
+
+
+
+
Shrink Media File
+
+
+
+
+
Shrink Multiple Files
+
+
+
+
+"""
+
+def cleanup_temp_dir(temp_dir_path: Path):
+ """Clean up the temporary directory after the response is sent."""
+ if temp_dir_path.exists():
+ shutil.rmtree(temp_dir_path, ignore_errors=True)
+
+
+def _zip_outputs(outputs: list[Path], dest_dir: Path, archive_name: str) -> Path:
+ """Bundle multiple generated outputs into a single (uncompressed) zip archive."""
+ archive_path = dest_dir / archive_name
+ # ZIP_STORED: the audio is already compressed, so re-compressing wastes CPU.
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_STORED) as archive:
+ for output in outputs:
+ archive.write(output, arcname=output.name)
+ return archive_path
+
+
+def _existing_outputs(results) -> list[Path]:
+ """Return generated output paths that still exist on disk."""
+
+ if not results:
+ return []
+ return [
+ result.output_path
+ for result in results
+ if result.output_path and result.output_path.exists()
+ ]
+
+
+def _download_path_for_outputs(
+ outputs: list[Path], dest_dir: Path, archive_name: str
+) -> Path:
+ """Return the single download path for one output or a zip for many outputs."""
+
+ if len(outputs) == 1:
+ return outputs[0]
+ return _zip_outputs(outputs, dest_dir, archive_name)
+
+
+def _persist_upload(file: UploadFile) -> tuple[Path, Path, Path, Path]:
+ """Save an uploaded file into a fresh temp workspace.
+
+ Returns ``(temp_dir_path, input_dir, output_dir, source_path)``. Any
+ filesystem or size-limit failure raises after cleaning up its own partial
+ workspace, so callers can map it to an error response.
+ """
+ temp_dir_path: Path | None = None
+ try:
+ temp_dir_path = Path(tempfile.mkdtemp(prefix="codec_carver_"))
+ input_dir = temp_dir_path / "input"
+ output_dir = temp_dir_path / "output"
+ input_dir.mkdir()
+ output_dir.mkdir()
+
+ safe_filename = Path((file.filename or "").replace("\\", "/")).name
+ if not safe_filename or safe_filename in (".", ".."):
+ safe_filename = "upload.tmp"
+
+ source_path = input_dir / safe_filename
+ bytes_written = 0
+ with open(source_path, "wb") as f:
+ while chunk := file.file.read(1024 * 1024): # 1 MB chunks
+ bytes_written += len(chunk)
+ if bytes_written > MAX_UPLOAD_BYTES:
+ raise ValueError("File exceeds maximum allowed upload size")
+ f.write(chunk)
+ return temp_dir_path, input_dir, output_dir, source_path
+ except Exception:
+ if temp_dir_path is not None:
+ cleanup_temp_dir(temp_dir_path)
+ raise
+
+
+@app.get("/", response_class=HTMLResponse)
+async def get_ui():
+ """Return the single-page upload form."""
+
+ return HTML_TEMPLATE
+
+
+@app.post("/shrink")
+def shrink_media(
+ background_tasks: BackgroundTasks,
+ file: UploadFile = File(...),
+ target_bytes: int = Form(2_000_000_000)
+):
+ """Persist an uploaded media file, shrink it, and return the generated file.
+
+ Security model for the uploaded bytes (self-contained; no trust in
+ downstream internals): the upload is (1) validated (audio/video content
+ type + bounded target size) by ``_validate_request``, (2) written under a
+ private per-request temp directory with a sanitized filename (never a
+ web-served or executable location), and (3) passed to ``media_shrinker``
+ only as a **file-path argument** to ``ffmpeg``/``ffprobe`` invoked via
+ ``subprocess.run`` with an explicit argument list and ``shell=False`` — the
+ bytes are never executed, ``eval``/``exec``'d, or interpolated into a shell.
+ The generated output is returned as an ``application/octet-stream`` download,
+ or as ``application/zip`` when conversion produces multiple segments. The
+ uploaded file itself is never served back. The temp workspace is removed
+ after the response.
+ """
+
+ error = _validate_request(file, target_bytes)
+ if error is not None:
+ return {"error": error}
+
+ try:
+ temp_dir_path, input_dir, output_dir, source_path = _persist_upload(file)
+ except Exception:
+ logger.exception("Failed to prepare uploaded media")
+ return {"error": "Upload processing failed"}
+
+ # Process the file using media_shrinker
+ try:
+ results = media_shrinker.convert_file(
+ source=source_path,
+ root=input_dir,
+ output_dir=output_dir,
+ target_bytes=target_bytes,
+ )
+
+ # Collect every generated output. Long recordings are split into several
+ # segments; returning only the first would silently drop the rest.
+ outputs = _existing_outputs(results)
+ background_tasks.add_task(cleanup_temp_dir, temp_dir_path)
+
+ if not outputs:
+ logger.error("Processing produced no output: %r", results)
+ return {"error": "Processing failed or no output generated"}
+
+ output_path = _download_path_for_outputs(
+ outputs, temp_dir_path, source_path.stem + "_shrunk.zip"
+ )
+ media_type = (
+ "application/zip"
+ if output_path.suffix == ".zip"
+ else "application/octet-stream"
+ )
+ return FileResponse(
+ path=output_path,
+ filename=output_path.name,
+ media_type=media_type,
+ )
+
+ except Exception:
+ cleanup_temp_dir(temp_dir_path)
+ logger.exception("Media processing failed")
+ return {"error": "Upload processing failed"}
+
+
+@app.post("/shrink-batch")
+def shrink_media_batch(
+ background_tasks: BackgroundTasks,
+ files: list[UploadFile] = File(default=[]),
+ target_bytes: int = Form(2_000_000_000),
+):
+ """Shrink several uploaded media files and return one zip archive."""
+ if target_bytes <= 0:
+ return JSONResponse(
+ status_code=400,
+ content={"error": "Invalid target_bytes value. Must be greater than 0."},
+ )
+ if target_bytes > MAX_TARGET_BYTES:
+ return JSONResponse(
+ status_code=400,
+ content={"error": "Invalid target_bytes value. Exceeds the maximum allowed size."},
+ )
+ if not files:
+ return JSONResponse(status_code=400, content={"error": "No files uploaded"})
+ if len(files) > MAX_BATCH_FILES:
+ return JSONResponse(
+ status_code=400,
+ content={"error": f"Too many files. Maximum is {MAX_BATCH_FILES} files per batch."},
+ )
+
+ try:
+ temp_dir_path = Path(tempfile.mkdtemp(prefix="codec_carver_batch_"))
+ except Exception:
+ logger.exception("Failed to create batch upload workspace")
+ return JSONResponse(status_code=500, content={"error": "Upload processing failed"})
+
+ workspace_root = temp_dir_path.resolve()
+ manifest = []
+ zip_path = temp_dir_path / "codec_carver_batch.zip"
+ try:
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_STORED) as archive:
+ for index, upload in enumerate(files):
+ safe_filename = Path((upload.filename or "").replace("\\", "/")).name
+ if not safe_filename or safe_filename in (".", ".."):
+ safe_filename = "upload.tmp"
+ entry = {
+ "index": index,
+ "filename": safe_filename,
+ "status": "error",
+ "output_name": None,
+ "output_bytes": None,
+ "error": None,
+ }
+ manifest.append(entry)
+
+ error = _validate_request(upload, target_bytes)
+ if error is not None:
+ entry["error"] = error
+ continue
+
+ input_dir = temp_dir_path / f"input_{index}"
+ output_dir = temp_dir_path / f"output_{index}"
+ try:
+ input_dir.mkdir()
+ output_dir.mkdir()
+ source_path = input_dir / safe_filename
+ bytes_written = 0
+ with open(source_path, "wb") as f:
+ while chunk := upload.file.read(1024 * 1024):
+ bytes_written += len(chunk)
+ if bytes_written > MAX_UPLOAD_BYTES:
+ raise ValueError("File exceeds maximum allowed upload size")
+ f.write(chunk)
+ except Exception:
+ logger.exception("Failed to prepare batch upload #%d", index)
+ entry["error"] = "Upload processing failed"
+ continue
+
+ try:
+ results = media_shrinker.convert_file(
+ source=source_path,
+ root=input_dir,
+ output_dir=output_dir,
+ target_bytes=target_bytes,
+ )
+ except Exception:
+ logger.exception("Batch media processing failed for upload #%d", index)
+ entry["error"] = "Upload processing failed"
+ continue
+
+ outputs = _existing_outputs(results)
+ if not outputs:
+ logger.error("Batch processing produced no output for upload #%d: %r", index, results)
+ entry["error"] = "Processing failed or no output generated"
+ continue
+
+ for output_index, output_path in enumerate(outputs, start=1):
+ output_path = output_path.resolve()
+ if not (output_path.is_file() and output_path.is_relative_to(workspace_root)):
+ logger.error("Batch output for upload #%d is missing or outside the workspace", index)
+ entry["error"] = "Processing failed or no output generated"
+ break
+ suffix = "" if len(outputs) == 1 else f".part{output_index:04d}"
+ arcname = f"{index + 1:02d}_{output_path.stem}{suffix}{output_path.suffix}"
+ archive.write(output_path, arcname=arcname)
+ entry["status"] = "ok"
+ entry["output_name"] = arcname
+ entry["output_bytes"] = (entry["output_bytes"] or 0) + output_path.stat().st_size
+
+ archive.writestr(
+ "results.json",
+ json.dumps({"target_bytes": target_bytes, "results": manifest}, indent=2),
+ )
+ except Exception:
+ cleanup_temp_dir(temp_dir_path)
+ logger.exception("Failed to build batch archive")
+ return JSONResponse(status_code=500, content={"error": "Upload processing failed"})
+
+ background_tasks.add_task(cleanup_temp_dir, temp_dir_path)
+ return FileResponse(
+ path=zip_path,
+ filename="codec_carver_batch.zip",
+ media_type="application/zip",
+ )
+
+# --- Async job model --------------------------------------------------------
+# The synchronous /shrink endpoint blocks for the whole conversion, which is
+# impractical for long recordings. These endpoints let a client submit a job,
+# poll its status, and download the result when ready (Upload -> Processing ->
+# Result). SQLite keeps status durable across restarts and visible across
+# worker/web processes.
+
+
+def _default_job_store_path() -> Path:
+ """Return the configured SQLite path for async job state."""
+
+ configured = os.environ.get("CODEC_CARVER_JOB_DB")
+ if configured:
+ return Path(configured)
+ return Path(tempfile.gettempdir()) / "codec_carver_jobs.sqlite3"
+
+
+JOB_STORE = JobStore(str(_default_job_store_path()))
+
+
+def _now() -> datetime:
+ """Return an aware UTC timestamp for job-store writes."""
+
+ return datetime.now(timezone.utc)
+
+
+def _get_job_store() -> JobStore:
+ """Return the active job store; tests replace ``JOB_STORE`` directly."""
+
+ return JOB_STORE
+
+
+def _run_job(
+ job_id: str,
+ source_path: Path,
+ input_dir: Path,
+ output_dir: Path,
+ target_bytes: int,
+ temp_dir_path: Path,
+) -> None:
+ """Background worker: shrink one uploaded file and record the outcome."""
+ store = _get_job_store()
+ try:
+ store.set_status(job_id, "processing", now=_now())
+ except KeyError:
+ logger.error("Job %s disappeared before processing", job_id)
+ cleanup_temp_dir(temp_dir_path)
+ return
+
+ try:
+ results = media_shrinker.convert_file(
+ source=source_path,
+ root=input_dir,
+ output_dir=output_dir,
+ target_bytes=target_bytes,
+ )
+ except Exception:
+ logger.exception("Job processing failed")
+ try:
+ store.set_status(job_id, "failed", now=_now(), error="Processing failed")
+ except KeyError:
+ logger.error("Job %s disappeared while recording failure", job_id)
+ cleanup_temp_dir(temp_dir_path)
+ return
+
+ outputs = _existing_outputs(results)
+ if outputs:
+ output_path = _download_path_for_outputs(
+ outputs, temp_dir_path, source_path.stem + "_shrunk.zip"
+ )
+ try:
+ store.set_status(
+ job_id,
+ "done",
+ now=_now(),
+ output_path=str(output_path),
+ output_name=output_path.name,
+ )
+ except KeyError:
+ logger.error("Job %s disappeared while recording result", job_id)
+ cleanup_temp_dir(temp_dir_path)
+ else:
+ logger.error("Job produced no output: %r", results)
+ try:
+ store.set_status(
+ job_id,
+ "failed",
+ now=_now(),
+ error="Processing failed or no output generated",
+ )
+ except KeyError:
+ logger.error("Job %s disappeared while recording empty output", job_id)
+ cleanup_temp_dir(temp_dir_path)
+
+
+@app.post("/jobs")
+def submit_job(
+ background_tasks: BackgroundTasks,
+ file: UploadFile = File(...),
+ target_bytes: int = Form(2_000_000_000),
+):
+ """Enqueue a shrink job and return its id for asynchronous status polling."""
+ error = _validate_request(file, target_bytes)
+ if error is not None:
+ return JSONResponse(status_code=400, content={"error": error})
+
+ try:
+ temp_dir_path, input_dir, output_dir, source_path = _persist_upload(file)
+ except Exception:
+ logger.exception("Failed to prepare uploaded media")
+ return JSONResponse(
+ status_code=500, content={"error": "Upload processing failed"}
+ )
+
+ job_id = uuid.uuid4().hex
+ try:
+ _get_job_store().create(job_id, temp_dir=str(temp_dir_path), now=_now())
+ except ValueError:
+ cleanup_temp_dir(temp_dir_path)
+ logger.exception("Failed to create async job record")
+ return JSONResponse(
+ status_code=500, content={"error": "Upload processing failed"}
+ )
+
+ background_tasks.add_task(
+ _run_job, job_id, source_path, input_dir, output_dir, target_bytes, temp_dir_path
+ )
+ return {"job_id": job_id, "status": "queued"}
+
+
+@app.get("/jobs/{job_id}")
+def job_status(job_id: str):
+ """Return the current status of a previously submitted job."""
+ job = _get_job_store().get(job_id)
+ if job is None:
+ return JSONResponse(status_code=404, content={"error": "Unknown job"})
+ return {"job_id": job_id, "status": job["status"], "error": job.get("error")}
+
+
+def _cleanup_job(job_id: str) -> None:
+ """Forget a job and remove its temporary workspace."""
+ store = _get_job_store()
+ job = store.get(job_id)
+ store.delete(job_id)
+ if job is not None and job.get("temp_dir"):
+ cleanup_temp_dir(Path(job["temp_dir"]))
+
+
+@app.get("/jobs/{job_id}/result")
+def job_result(job_id: str, background_tasks: BackgroundTasks):
+ """Download a finished job's output, then clean up its workspace."""
+ job = _get_job_store().get(job_id)
+ if job is None:
+ return JSONResponse(status_code=404, content={"error": "Unknown job"})
+ if job["status"] != "done":
+ return JSONResponse(
+ status_code=409, content={"error": f"Job is {job['status']}"}
+ )
+ # Defense in depth: only ever serve a regular file that lives inside this
+ # job's own temp workspace. `job_id` is an opaque store key and is never
+ # used to build a path, but confining the served path makes traversal
+ # impossible even if the store were ever populated from untrusted data.
+ output_path_text = job.get("output_path")
+ temp_dir_text = job.get("temp_dir")
+ if not output_path_text or not temp_dir_text:
+ return JSONResponse(
+ status_code=410, content={"error": "Result no longer available"}
+ )
+ output_path = Path(output_path_text).resolve()
+ workspace = Path(temp_dir_text).resolve()
+ if not output_path.is_relative_to(workspace) or not output_path.is_file():
+ return JSONResponse(
+ status_code=410, content={"error": "Result no longer available"}
+ )
+ background_tasks.add_task(_cleanup_job, job_id)
+ media_type = (
+ "application/zip" if output_path.suffix == ".zip" else "application/octet-stream"
+ )
+ return FileResponse(
+ path=output_path,
+ filename=job["output_name"],
+ media_type=media_type,
+ )
+
+
+if __name__ == "__main__": # pragma: no cover
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/tests/test_empty_target_validation.py b/tests/test_empty_target_validation.py
index 56374432..37afb0cf 100644
--- a/tests/test_empty_target_validation.py
+++ b/tests/test_empty_target_validation.py
@@ -51,9 +51,9 @@ def _assert_empty_branch(self, handler: str) -> None:
empty_marker = "if (this.value === '') {"
invalid_marker = "if (isNaN(val) || val <= 0) {"
self.assertIn(empty_marker, handler)
- self.assertIn("preview.innerText = '';", handler)
- self.assertIn("this.setCustomValidity('');", handler)
- self.assertIn("this.removeAttribute('aria-invalid');", handler)
+ self.assertIn("preview.innerText = 'This field is required.';", handler)
+ self.assertIn("this.setCustomValidity('This field is required.');", handler)
+ self.assertIn("this.setAttribute('aria-invalid', 'true');", handler)
self.assertIn(
"return;",
handler[handler.index(empty_marker) : handler.index(invalid_marker)],
diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py
index 3b57e033..b625184c 100644
--- a/tests/test_saas_web.py
+++ b/tests/test_saas_web.py
@@ -671,6 +671,16 @@ def test_get_ui_includes_batch_upload_form(self):
self.assertIn('id="batch_files_preview"', html)
self.assertIn("function updateBatchFilePreview(input)", html)
+ def test_get_ui_includes_required_validation(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ html = response.text
+
+ # Test that required field logic is verified via presence of aria-invalid when empty
+ self.assertIn("preview.innerText = 'This field is required.';", html)
+ self.assertIn("input.setCustomValidity('This field is required.');", html)
+ self.assertEqual(html.count("preview.innerText = 'This field is required.';"), 4)
+
@unittest.skipUnless(
_HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
diff --git a/tests/test_saas_web.py.orig b/tests/test_saas_web.py.orig
new file mode 100644
index 00000000..3b57e033
--- /dev/null
+++ b/tests/test_saas_web.py.orig
@@ -0,0 +1,1227 @@
+import asyncio
+import io
+import json
+import os
+import tempfile
+import unittest
+import zipfile
+from unittest.mock import patch, MagicMock
+from pathlib import Path
+from types import SimpleNamespace
+
+try:
+ from fastapi import BackgroundTasks
+ from fastapi.testclient import TestClient
+ from fastapi.responses import Response
+
+ import saas_web
+ from saas_web import app
+
+ _HAS_FASTAPI = True
+except ImportError:
+ _HAS_FASTAPI = False
+
+from media_shrinker import ConversionResult
+from job_store import JobStore
+
+if _HAS_FASTAPI:
+ client = TestClient(app)
+
+
+@unittest.skipUnless(
+ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
+)
+class TestSaasWeb(unittest.TestCase):
+ def test_get_ui(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b"Codec Carver SaaS", response.content)
+
+ def test_get_ui_includes_accessible_file_input_helpers(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ html = response.text
+
+ self.assertIn('accept="audio/*,video/*"', html)
+ self.assertIn('aria-describedby="file_help file_size_preview"', html)
+ self.assertIn('id="file_help"', html)
+ self.assertIn('class="required-star" aria-hidden="true"', html)
+
+ def test_get_ui_includes_binary_file_size_validation(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ html = response.text
+
+ self.assertIn("const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024;", html)
+ self.assertIn("['B', 'KiB', 'MiB', 'GiB']", html)
+ self.assertIn("const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES);", html)
+ self.assertIn("File exceeds ' + limitText + ' limit.", html)
+ self.assertIn("Total file size exceeds ' + limitText + ' limit.", html)
+ self.assertIn("preview.style.color = '#0f6674';", html)
+ self.assertIn('onchange="updateFileSizePreview(this)"', html)
+
+ def test_security_headers_present_without_plain_http_hsts(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["X-Content-Type-Options"], "nosniff")
+ self.assertEqual(response.headers["X-Frame-Options"], "DENY")
+ self.assertEqual(response.headers["X-XSS-Protection"], "1; mode=block")
+ self.assertEqual(
+ response.headers["Content-Security-Policy"],
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
+ )
+ self.assertEqual(
+ response.headers["Referrer-Policy"],
+ "strict-origin-when-cross-origin",
+ )
+ self.assertEqual(
+ response.headers["Permissions-Policy"],
+ "geolocation=(), microphone=(), camera=()",
+ )
+ self.assertNotIn("Strict-Transport-Security", response.headers)
+
+ def test_hsts_header_present_for_forwarded_https(self):
+ response = client.get("/", headers={"X-Forwarded-Proto": "https"})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.headers["Strict-Transport-Security"],
+ "max-age=31536000; includeSubDomains",
+ )
+
+ def test_request_size_limit_rejects_oversized_declared_body(self):
+ response = client.post(
+ "/shrink",
+ headers={"Content-Length": str(saas_web.MAX_REQUEST_BYTES + 1)},
+ )
+ self.assertEqual(response.status_code, 413)
+ self.assertEqual(response.json(), {"error": "Payload Too Large"})
+
+ def test_request_size_limit_rejects_invalid_content_length(self):
+ response = client.post(
+ "/shrink",
+ headers={"Content-Length": "not-a-number"},
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {"error": "Invalid Content-Length"})
+
+ def test_request_size_limit_rejects_negative_content_length(self):
+ response = client.post(
+ "/shrink",
+ headers={"Content-Length": "-1"},
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {"error": "Invalid Content-Length"})
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_media_endpoint(self, mock_convert_file):
+ # Create a dummy output file for the FileResponse
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_output = Path(temp_dir) / "output.flac"
+ temp_output.write_bytes(b"dummy audio data")
+
+ # Setup mock return value
+ mock_result = MagicMock(spec=ConversionResult)
+ mock_result.output_path = temp_output
+ mock_convert_file.return_value = [mock_result]
+
+ # Create a dummy upload file
+ dummy_file_path = Path(temp_dir) / "input.wav"
+ dummy_file_path.write_bytes(b"dummy wav data")
+
+ with open(dummy_file_path, "rb") as f:
+ response = client.post(
+ "/shrink",
+ files={"file": ("input.wav", f, "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b"dummy audio data")
+
+ # Verify the mock was called
+ mock_convert_file.assert_called_once()
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_media_failure(self, mock_convert_file):
+ # Setup mock to return empty or error
+ mock_convert_file.return_value = []
+
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ dummy_file_path = Path(temp_dir) / "input.wav"
+ dummy_file_path.write_bytes(b"dummy wav data")
+
+ with open(dummy_file_path, "rb") as f:
+ response = client.post(
+ "/shrink",
+ files={"file": ("input.wav", f, "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(
+ response.status_code, 200
+ ) # Returns 200 with JSON error dict currently
+ self.assertIn(b"error", response.content)
+ self.assertNotIn("details", response.json())
+
+ def test_shrink_media_rejects_nonpositive_target_bytes(self):
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ dummy_file_path = Path(temp_dir) / "input.wav"
+ dummy_file_path.write_bytes(b"dummy wav data")
+
+ with open(dummy_file_path, "rb") as f:
+ response = client.post(
+ "/shrink",
+ files={"file": ("input.wav", f, "audio/wav")},
+ data={"target_bytes": 0},
+ )
+
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Must be greater than 0."},
+ )
+
+ def test_shrink_media_rejects_missing_filename(self):
+ response = saas_web.shrink_media(
+ BackgroundTasks(),
+ file=SimpleNamespace(filename="", file=io.BytesIO(b"dummy wav data")),
+ target_bytes=10000,
+ )
+
+ self.assertEqual(response, {"error": "No file uploaded or filename missing"})
+
+ @patch("saas_web.tempfile.mkdtemp", side_effect=OSError("disk full"))
+ def test_shrink_media_handles_temp_dir_failure(self, _mock_mkdtemp):
+ response = saas_web.shrink_media(
+ BackgroundTasks(),
+ file=SimpleNamespace(
+ filename="input.wav", file=io.BytesIO(b"dummy wav data")
+ ),
+ target_bytes=10000,
+ )
+
+ self.assertEqual(response, {"error": "Upload processing failed"})
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_media_uses_safe_fallback_filename(self, mock_convert_file):
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ output = Path(temp_dir) / "output.flac"
+ output.write_bytes(b"audio")
+ mock_result = MagicMock(spec=ConversionResult)
+ mock_result.output_path = output
+ mock_convert_file.return_value = [mock_result]
+
+ response = saas_web.shrink_media(
+ BackgroundTasks(),
+ file=SimpleNamespace(filename=".", file=io.BytesIO(b"dummy wav data")),
+ target_bytes=10000,
+ )
+
+ self.assertEqual(Path(response.path), output)
+ self.assertEqual(
+ mock_convert_file.call_args.kwargs["source"].name, "upload.tmp"
+ )
+
+ def test_shrink_media_rejects_uploaded_body_over_limit(self):
+ previous_limit = saas_web.MAX_UPLOAD_BYTES
+ saas_web.MAX_UPLOAD_BYTES = 3
+ try:
+ response = saas_web.shrink_media(
+ BackgroundTasks(),
+ file=SimpleNamespace(filename="input.wav", file=io.BytesIO(b"1234")),
+ target_bytes=10000,
+ )
+ finally:
+ saas_web.MAX_UPLOAD_BYTES = previous_limit
+
+ self.assertEqual(response, {"error": "Upload processing failed"})
+
+ @patch("saas_web.Path.mkdir", side_effect=OSError("mkdir failed"))
+ def test_shrink_media_handles_workspace_prepare_failure(self, _mock_mkdir):
+ response = saas_web.shrink_media(
+ BackgroundTasks(),
+ file=SimpleNamespace(
+ filename="input.wav", file=io.BytesIO(b"dummy wav data")
+ ),
+ target_bytes=10000,
+ )
+
+ self.assertEqual(response, {"error": "Upload processing failed"})
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_media_exception_does_not_expose_internal_path(
+ self, mock_convert_file
+ ):
+ mock_convert_file.side_effect = RuntimeError(
+ "/tmp/codec_carver_secret/input.wav"
+ )
+
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ dummy_file_path = Path(temp_dir) / "input.wav"
+ dummy_file_path.write_bytes(b"dummy wav data")
+
+ with open(dummy_file_path, "rb") as f:
+ response = client.post(
+ "/shrink",
+ files={"file": ("input.wav", f, "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()
+ self.assertEqual(payload, {"error": "Upload processing failed"})
+ self.assertNotIn("/tmp/codec_carver_secret", response.text)
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_media_failed_result_does_not_expose_internal_path(
+ self, mock_convert_file
+ ):
+ mock_result = MagicMock(spec=ConversionResult)
+ mock_result.output_path = Path("/tmp/codec_carver_secret/output.flac")
+ mock_convert_file.return_value = [mock_result]
+
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ dummy_file_path = Path(temp_dir) / "input.wav"
+ dummy_file_path.write_bytes(b"dummy wav data")
+
+ with open(dummy_file_path, "rb") as f:
+ response = client.post(
+ "/shrink",
+ files={"file": ("input.wav", f, "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()
+ self.assertEqual(
+ payload, {"error": "Processing failed or no output generated"}
+ )
+ self.assertNotIn("/tmp/codec_carver_secret", response.text)
+
+ def test_get_ui_includes_target_bytes_validation_feedback(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ html = response.text
+ self.assertIn("preview.innerText = 'Must be greater than 0.';", html)
+ self.assertIn("preview.style.color = '#dc3545';", html)
+
+ def test_request_size_limit_rejects_streamed_body_over_limit(self):
+ async def receive():
+ return {"type": "http.request", "body": b"1234"}
+
+ async def call_next(request):
+ await request._receive()
+ return Response()
+
+ request = SimpleNamespace(headers={}, _receive=receive)
+ previous_limit = saas_web.MAX_REQUEST_BYTES
+ saas_web.MAX_REQUEST_BYTES = 3
+ try:
+ response = asyncio.run(saas_web.limit_request_size(request, call_next))
+ finally:
+ saas_web.MAX_REQUEST_BYTES = previous_limit
+
+ self.assertEqual(response.status_code, 413)
+ self.assertEqual(response.body, b'{"error":"Payload Too Large"}')
+
+ def test_request_size_limit_passes_non_request_asgi_messages(self):
+ async def receive():
+ return {"type": "http.disconnect"}
+
+ async def call_next(request):
+ self.assertEqual(await request._receive(), {"type": "http.disconnect"})
+ return Response(status_code=204)
+
+ request = SimpleNamespace(headers={}, _receive=receive)
+ response = asyncio.run(saas_web.limit_request_size(request, call_next))
+
+ self.assertEqual(response.status_code, 204)
+
+ def test_get_ui_includes_preset_buttons(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ html = response.text
+
+ self.assertIn('class="preset-container"', html)
+ self.assertIn('data-bytes="26214400"', html)
+ self.assertIn('data-bytes="104857600"', html)
+ self.assertIn('data-bytes="524288000"', html)
+ self.assertIn('data-bytes="1073741824"', html)
+ self.assertIn(
+ "document.getElementById('preset_buttons_container').addEventListener('click'",
+ html,
+ )
+ self.assertIn('aria-pressed="false"', html)
+ self.assertIn('role="group" aria-label="Preset target sizes"', html)
+ self.assertNotIn('onclick="setTargetBytes(', html)
+ self.assertIn(
+ "const presetValue = Number.parseInt(btn.dataset.bytes, 10);", html
+ )
+ self.assertIn("!e.isTrusted && presetValue === val", html)
+ self.assertNotIn("btn.dataset.bytes === this.value", html)
+
+
+@unittest.skipUnless(
+ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
+)
+class TestShrinkBatch(unittest.TestCase):
+ """Tests for the POST /shrink-batch multi-file endpoint."""
+
+ @staticmethod
+ def _fake_convert(source, root, output_dir, target_bytes):
+ """Fake convert_file that writes a shrunk copy into output_dir."""
+ output_path = Path(output_dir) / (Path(source).stem + ".flac")
+ output_path.write_bytes(b"shrunk:" + Path(source).read_bytes())
+ result = MagicMock(spec=ConversionResult)
+ result.output_path = output_path
+ return [result]
+
+ @staticmethod
+ def _read_zip(response):
+ """Return (namelist, manifest dict, zipfile) for a zip response."""
+ archive = zipfile.ZipFile(io.BytesIO(response.content))
+ manifest = json.loads(archive.read("results.json"))
+ return archive.namelist(), manifest, archive
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_two_files_returns_zip_with_outputs_and_manifest(
+ self, mock_convert_file
+ ):
+ mock_convert_file.side_effect = self._fake_convert
+
+ response = client.post(
+ "/shrink-batch",
+ files=[
+ ("files", ("a.wav", b"audio-a", "audio/wav")),
+ ("files", ("b.mp4", b"video-b", "video/mp4")),
+ ],
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["content-type"], "application/zip")
+ names, manifest, archive = self._read_zip(response)
+ self.assertIn("01_a.flac", names)
+ self.assertIn("02_b.flac", names)
+ self.assertIn("results.json", names)
+ self.assertEqual(archive.read("01_a.flac"), b"shrunk:audio-a")
+ self.assertEqual(archive.read("02_b.flac"), b"shrunk:video-b")
+ self.assertEqual(manifest["target_bytes"], 10000)
+ self.assertEqual(len(manifest["results"]), 2)
+ self.assertEqual(manifest["results"][0]["status"], "ok")
+ self.assertEqual(manifest["results"][0]["filename"], "a.wav")
+ self.assertEqual(manifest["results"][0]["output_name"], "01_a.flac")
+ self.assertEqual(manifest["results"][0]["output_bytes"], len(b"shrunk:audio-a"))
+ self.assertEqual(manifest["results"][1]["status"], "ok")
+ self.assertEqual(mock_convert_file.call_count, 2)
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_one_failure_does_not_abort_batch(self, mock_convert_file):
+ def convert(source, root, output_dir, target_bytes):
+ if Path(source).name == "bad.wav":
+ raise RuntimeError("/tmp/codec_carver_secret/bad.wav")
+ return self._fake_convert(source, root, output_dir, target_bytes)
+
+ mock_convert_file.side_effect = convert
+
+ response = client.post(
+ "/shrink-batch",
+ files=[
+ ("files", ("bad.wav", b"broken", "audio/wav")),
+ ("files", ("good.wav", b"fine", "audio/wav")),
+ ],
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ names, manifest, archive = self._read_zip(response)
+ self.assertNotIn("01_bad.flac", names)
+ self.assertIn("02_good.flac", names)
+ self.assertEqual(manifest["results"][0]["status"], "error")
+ self.assertEqual(manifest["results"][0]["error"], "Upload processing failed")
+ self.assertNotIn("codec_carver_secret", archive.read("results.json").decode())
+ self.assertEqual(manifest["results"][1]["status"], "ok")
+
+ def test_shrink_batch_rejects_zero_files(self):
+ response = client.post("/shrink-batch", data={"target_bytes": 10000})
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {"error": "No files uploaded"})
+
+ def test_shrink_batch_rejects_too_many_files(self):
+ uploads = [
+ ("files", (f"f{i}.wav", b"x", "audio/wav"))
+ for i in range(saas_web.MAX_BATCH_FILES + 1)
+ ]
+ response = client.post(
+ "/shrink-batch", files=uploads, data={"target_bytes": 10000}
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(
+ response.json(),
+ {
+ "error": f"Too many files. Maximum is {saas_web.MAX_BATCH_FILES} files per batch."
+ },
+ )
+
+ def test_shrink_batch_rejects_nonpositive_target_bytes(self):
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("a.wav", b"audio", "audio/wav"))],
+ data={"target_bytes": 0},
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Must be greater than 0."},
+ )
+
+ def test_shrink_batch_rejects_oversized_target_bytes(self):
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("a.wav", b"audio", "audio/wav"))],
+ data={"target_bytes": saas_web.MAX_TARGET_BYTES + 1},
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Exceeds the maximum allowed size."},
+ )
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_rejects_disallowed_content_type_per_file(
+ self, mock_convert_file
+ ):
+ mock_convert_file.side_effect = self._fake_convert
+
+ response = client.post(
+ "/shrink-batch",
+ files=[
+ ("files", ("evil.sh", b"#!/bin/sh", "application/x-sh")),
+ ("files", ("good.wav", b"fine", "audio/wav")),
+ ],
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ names, manifest, _archive = self._read_zip(response)
+ self.assertEqual(names, ["02_good.flac", "results.json"])
+ self.assertEqual(manifest["results"][0]["status"], "error")
+ self.assertIn("Unsupported content type", manifest["results"][0]["error"])
+ self.assertEqual(manifest["results"][1]["status"], "ok")
+ mock_convert_file.assert_called_once()
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_records_no_output_as_error(self, mock_convert_file):
+ mock_convert_file.return_value = []
+
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("a.wav", b"audio", "audio/wav"))],
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ names, manifest, _archive = self._read_zip(response)
+ self.assertEqual(names, ["results.json"])
+ self.assertEqual(manifest["results"][0]["status"], "error")
+ self.assertEqual(
+ manifest["results"][0]["error"],
+ "Processing failed or no output generated",
+ )
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_never_serves_output_outside_workspace(
+ self, mock_convert_file
+ ):
+ with tempfile.TemporaryDirectory() as outside_dir:
+ outside_file = Path(outside_dir) / "secret.flac"
+ outside_file.write_bytes(b"secret contents")
+ mock_result = MagicMock(spec=ConversionResult)
+ mock_result.output_path = outside_file
+ mock_convert_file.return_value = [mock_result]
+
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("a.wav", b"audio", "audio/wav"))],
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ names, manifest, _archive = self._read_zip(response)
+ self.assertEqual(names, ["results.json"])
+ self.assertEqual(manifest["results"][0]["status"], "error")
+ self.assertEqual(
+ manifest["results"][0]["error"],
+ "Processing failed or no output generated",
+ )
+ self.assertNotIn(b"secret contents", response.content)
+
+ def test_shrink_batch_records_oversized_file_as_error(self):
+ previous_limit = saas_web.MAX_UPLOAD_BYTES
+ saas_web.MAX_UPLOAD_BYTES = 3
+ try:
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("big.wav", b"12345", "audio/wav"))],
+ data={"target_bytes": 10000},
+ )
+ finally:
+ saas_web.MAX_UPLOAD_BYTES = previous_limit
+
+ self.assertEqual(response.status_code, 200)
+ names, manifest, _archive = self._read_zip(response)
+ self.assertEqual(names, ["results.json"])
+ self.assertEqual(manifest["results"][0]["status"], "error")
+ self.assertEqual(manifest["results"][0]["error"], "Upload processing failed")
+
+ @patch("saas_web.tempfile.mkdtemp", side_effect=OSError("disk full"))
+ def test_shrink_batch_handles_workspace_creation_failure(self, _mock_mkdtemp):
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("a.wav", b"audio", "audio/wav"))],
+ data={"target_bytes": 10000},
+ )
+ self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.json(), {"error": "Upload processing failed"})
+
+ @patch("saas_web.zipfile.ZipFile", side_effect=OSError("cannot write zip"))
+ def test_shrink_batch_handles_archive_failure(self, _mock_zipfile):
+ response = client.post(
+ "/shrink-batch",
+ files=[("files", ("a.wav", b"audio", "audio/wav"))],
+ data={"target_bytes": 10000},
+ )
+ self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.json(), {"error": "Upload processing failed"})
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_uses_safe_fallback_filename_with_backslashes(self, mock_convert_file):
+ mock_convert_file.return_value = []
+
+ response = saas_web.shrink_media_batch(
+ BackgroundTasks(),
+ files=[
+ SimpleNamespace(
+ filename="..\\..\\windows.ini",
+ content_type="audio/wav",
+ file=io.BytesIO(b"dummy"),
+ )
+ ],
+ target_bytes=10000,
+ )
+
+ try:
+ with zipfile.ZipFile(response.path) as archive:
+ manifest = json.loads(archive.read("results.json"))
+ self.assertEqual(manifest["results"][0]["filename"], "windows.ini")
+ self.assertEqual(
+ mock_convert_file.call_args.kwargs["source"].name, "windows.ini"
+ )
+ finally:
+ saas_web.cleanup_temp_dir(Path(response.path).parent)
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_shrink_batch_uses_safe_fallback_filename(self, mock_convert_file):
+ mock_convert_file.return_value = []
+
+ response = saas_web.shrink_media_batch(
+ BackgroundTasks(),
+ files=[
+ SimpleNamespace(
+ filename="..",
+ content_type="audio/wav",
+ file=io.BytesIO(b"dummy"),
+ )
+ ],
+ target_bytes=10000,
+ )
+
+ try:
+ with zipfile.ZipFile(response.path) as archive:
+ manifest = json.loads(archive.read("results.json"))
+ self.assertEqual(manifest["results"][0]["filename"], "upload.tmp")
+ self.assertEqual(
+ mock_convert_file.call_args.kwargs["source"].name, "upload.tmp"
+ )
+ finally:
+ saas_web.cleanup_temp_dir(Path(response.path).parent)
+
+ def test_get_ui_includes_batch_upload_form(self):
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ html = response.text
+ self.assertIn('action="/shrink-batch"', html)
+ self.assertIn('id="batch_files"', html)
+ self.assertIn("multiple", html)
+ self.assertIn('accept="audio/*,video/*"', html)
+ self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html)
+ self.assertIn('onchange="updateBatchFilePreview(this)"', html)
+ self.assertIn('id="batch_files_preview"', html)
+ self.assertIn("function updateBatchFilePreview(input)", html)
+
+
+@unittest.skipUnless(
+ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
+)
+class TestApiKeyAuth(unittest.TestCase):
+ """Tests for the opt-in CODEC_CARVER_API_KEYS authentication middleware."""
+
+ def _post_shrink(self, headers=None):
+ """POST a minimal /shrink request and return the response."""
+
+ return client.post(
+ "/shrink",
+ files={"file": ("input.wav", io.BytesIO(b"dummy wav data"), "audio/wav")},
+ data={"target_bytes": 0},
+ headers=headers or {},
+ )
+
+ def test_no_env_var_leaves_endpoints_open(self):
+ with patch.dict(os.environ):
+ os.environ.pop("CODEC_CARVER_API_KEYS", None)
+ response = self._post_shrink()
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Must be greater than 0."},
+ )
+
+ def test_missing_header_rejected_when_keys_configured(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}):
+ response = self._post_shrink()
+
+ self.assertEqual(response.status_code, 401)
+ self.assertEqual(response.json(), {"error": "Invalid or missing API key"})
+ self.assertNotIn("secret-key", response.text)
+
+ def test_wrong_key_rejected(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}):
+ response = self._post_shrink(headers={"X-API-Key": "wrong-key"})
+
+ self.assertEqual(response.status_code, 401)
+ self.assertEqual(response.json(), {"error": "Invalid or missing API key"})
+ self.assertNotIn("secret-key", response.text)
+
+ def test_correct_key_reaches_handler(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}):
+ response = self._post_shrink(headers={"X-API-Key": "secret-key"})
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Must be greater than 0."},
+ )
+
+ def test_get_ui_always_open_without_key(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}):
+ response = client.get("/")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b"Codec Carver SaaS", response.content)
+
+ 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")
+ allowed = client.get("/jobs/missing", headers={"X-API-Key": "secret-key"})
+
+ self.assertEqual(response.status_code, 401)
+ self.assertEqual(response.json(), {"error": "Invalid or missing API key"})
+ self.assertEqual(allowed.status_code, 404)
+
+ def test_multiple_comma_separated_keys_all_valid(self):
+ with patch.dict(
+ os.environ, {"CODEC_CARVER_API_KEYS": "key-one,key-two,key-three"}
+ ):
+ for key in ("key-one", "key-two", "key-three"):
+ response = self._post_shrink(headers={"X-API-Key": key})
+ self.assertEqual(response.status_code, 200, key)
+ rejected = self._post_shrink(headers={"X-API-Key": "key-four"})
+
+ self.assertEqual(rejected.status_code, 401)
+
+ def test_whitespace_around_keys_is_stripped(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": " key-one , key-two "}):
+ response = self._post_shrink(headers={"X-API-Key": "key-one"})
+ self.assertEqual(response.status_code, 200)
+ response = self._post_shrink(headers={"X-API-Key": "key-two"})
+ self.assertEqual(response.status_code, 200)
+ rejected = self._post_shrink(headers={"X-API-Key": " key-one "})
+
+ self.assertEqual(rejected.status_code, 401)
+
+ def test_empty_entries_are_ignored(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "key-one,, ,"}):
+ response = self._post_shrink(headers={"X-API-Key": "key-one"})
+ self.assertEqual(response.status_code, 200)
+ rejected = self._post_shrink(headers={"X-API-Key": ""})
+
+ self.assertEqual(rejected.status_code, 401)
+
+ def test_only_empty_entries_leave_endpoints_open(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": " , ,"}):
+ response = self._post_shrink()
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Must be greater than 0."},
+ )
+
+ def test_get_configured_api_keys_parsing(self):
+ with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": " a ,, b ,"}):
+ self.assertEqual(saas_web.get_configured_api_keys(), ["a", "b"])
+ with patch.dict(os.environ):
+ os.environ.pop("CODEC_CARVER_API_KEYS", None)
+ self.assertEqual(saas_web.get_configured_api_keys(), [])
+
+
+@unittest.skipUnless(
+ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
+)
+class MultiSegmentZipTests(unittest.TestCase):
+ """Long recordings split into multiple segments must all be returned (as a zip)."""
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_multiple_segments_returned_as_zip(self, mock_convert_file):
+ import io as _io
+ import tempfile
+ import zipfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ part1 = Path(temp_dir) / "rec.wav.part0001.flac"
+ part2 = Path(temp_dir) / "rec.wav.part0002.flac"
+ part1.write_bytes(b"segment-one")
+ part2.write_bytes(b"segment-two")
+ r1 = MagicMock(spec=ConversionResult)
+ r1.output_path = part1
+ r2 = MagicMock(spec=ConversionResult)
+ r2.output_path = part2
+ mock_convert_file.return_value = [r1, r2]
+
+ response = client.post(
+ "/shrink",
+ files={"file": ("rec.wav", _io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["content-type"], "application/zip")
+ names = zipfile.ZipFile(_io.BytesIO(response.content)).namelist()
+ self.assertEqual(
+ sorted(names), ["rec.wav.part0001.flac", "rec.wav.part0002.flac"]
+ )
+
+
+@unittest.skipUnless(
+ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
+)
+class JobModelTests(unittest.TestCase):
+ """Async job API: submit -> status -> result, plus all error paths."""
+
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ self._old_store = saas_web.JOB_STORE
+ self.addCleanup(setattr, saas_web, "JOB_STORE", self._old_store)
+ saas_web.JOB_STORE = JobStore(str(Path(self._tmp.name) / "jobs.db"))
+
+ def tearDown(self) -> None:
+ for job in saas_web.JOB_STORE.list_jobs():
+ temp = job.get("temp_dir")
+ if temp:
+ saas_web.cleanup_temp_dir(Path(temp))
+ saas_web.JOB_STORE.delete(job["id"])
+
+ def _create_job(
+ self,
+ job_id: str,
+ status: str,
+ temp_dir: str,
+ output_path: str | None = None,
+ output_name: str | None = None,
+ error: str | None = None,
+ ) -> None:
+ """Insert a job-store row for direct endpoint edge-case tests."""
+
+ saas_web.JOB_STORE.create(job_id, temp_dir=temp_dir, now=saas_web._now())
+ if status != "queued":
+ saas_web.JOB_STORE.set_status(
+ job_id,
+ status,
+ now=saas_web._now(),
+ output_path=output_path,
+ output_name=output_name,
+ error=error,
+ )
+
+ def _make_workspace(self) -> tuple[Path, Path, Path, Path]:
+ temp_dir = Path(tempfile.mkdtemp(prefix="codec_carver_"))
+ input_dir = temp_dir / "input"
+ output_dir = temp_dir / "output"
+ input_dir.mkdir()
+ output_dir.mkdir()
+ source_path = input_dir / "in.wav"
+ source_path.write_bytes(b"wav data")
+ return temp_dir, input_dir, output_dir, source_path
+
+ def test_default_job_store_path_uses_env(self):
+ with patch.dict(
+ saas_web.os.environ,
+ {"CODEC_CARVER_JOB_DB": "custom-jobs.sqlite3"},
+ ):
+ self.assertEqual(
+ saas_web._default_job_store_path(), Path("custom-jobs.sqlite3")
+ )
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_job_lifecycle_submit_status_result(self, mock_convert_file):
+ def fake_convert(**kwargs):
+ # Write the output inside the job's real workspace (output_dir), as
+ # the engine does, so the served path passes the confinement check.
+ output = kwargs["output_dir"] / "out.flac"
+ output.write_bytes(b"audio-bytes")
+ mock_result = MagicMock(spec=ConversionResult)
+ mock_result.output_path = output
+ return [mock_result]
+
+ mock_convert_file.side_effect = fake_convert
+
+ submit = client.post(
+ "/jobs",
+ files={"file": ("in.wav", io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+ self.assertEqual(submit.status_code, 200)
+ job_id = submit.json()["job_id"]
+ self.assertEqual(submit.json()["status"], "queued")
+
+ status = client.get(f"/jobs/{job_id}")
+ self.assertEqual(status.status_code, 200)
+ self.assertEqual(status.json()["status"], "done")
+
+ result = client.get(f"/jobs/{job_id}/result")
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.content, b"audio-bytes")
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_job_result_returns_zip_for_multiple_segments(self, mock_convert_file):
+ import zipfile
+
+ def fake_convert(**kwargs):
+ part1 = kwargs["output_dir"] / "in.wav.part0001.flac"
+ part2 = kwargs["output_dir"] / "in.wav.part0002.flac"
+ part1.write_bytes(b"segment-one")
+ part2.write_bytes(b"segment-two")
+ result1 = MagicMock(spec=ConversionResult)
+ result1.output_path = part1
+ result2 = MagicMock(spec=ConversionResult)
+ result2.output_path = part2
+ return [result1, result2]
+
+ mock_convert_file.side_effect = fake_convert
+
+ submit = client.post(
+ "/jobs",
+ files={"file": ("in.wav", io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+ job_id = submit.json()["job_id"]
+
+ result = client.get(f"/jobs/{job_id}/result")
+
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.headers["content-type"], "application/zip")
+ names = zipfile.ZipFile(io.BytesIO(result.content)).namelist()
+ self.assertEqual(
+ sorted(names), ["in.wav.part0001.flac", "in.wav.part0002.flac"]
+ )
+
+ def test_result_outside_workspace_rejected(self):
+ # A "done" job whose output escaped its workspace must not be served.
+ import tempfile
+
+ workspace = Path(tempfile.mkdtemp(prefix="codec_carver_"))
+ outside_dir = Path(tempfile.mkdtemp())
+ escaped = outside_dir / "escaped.flac"
+ escaped.write_bytes(b"secret")
+ try:
+ self._create_job(
+ "escape",
+ "done",
+ str(workspace),
+ output_path=str(escaped),
+ output_name="escaped.flac",
+ )
+ response = client.get("/jobs/escape/result")
+ self.assertEqual(response.status_code, 410)
+ finally:
+ saas_web.cleanup_temp_dir(workspace)
+ saas_web.cleanup_temp_dir(outside_dir)
+
+ def test_submit_rejects_nonpositive_target(self):
+ response = client.post(
+ "/jobs",
+ files={"file": ("in.wav", io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": 0},
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("greater than 0", response.json()["error"])
+
+ def test_submit_rejects_missing_filename(self):
+ response = saas_web.submit_job(
+ BackgroundTasks(),
+ file=SimpleNamespace(filename="", file=io.BytesIO(b"wav data")),
+ target_bytes=10000,
+ )
+ self.assertEqual(response.status_code, 400)
+
+ @patch("saas_web._persist_upload", side_effect=OSError("disk full"))
+ def test_submit_handles_persist_failure(self, _mock_persist):
+ response = saas_web.submit_job(
+ BackgroundTasks(),
+ file=SimpleNamespace(filename="in.wav", file=io.BytesIO(b"wav data")),
+ target_bytes=10000,
+ )
+ self.assertEqual(response.status_code, 500)
+
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_run_job_cleans_unknown_job_before_processing(self, mock_convert_file):
+ temp_dir, input_dir, output_dir, source_path = self._make_workspace()
+
+ saas_web._run_job(
+ "missing", source_path, input_dir, output_dir, 10000, temp_dir
+ )
+
+ self.assertFalse(temp_dir.exists())
+ mock_convert_file.assert_not_called()
+
+ @patch("saas_web.media_shrinker.convert_file", side_effect=RuntimeError("boom"))
+ def test_run_job_records_failure_on_exception(self, _mock_convert):
+ submit = client.post(
+ "/jobs",
+ files={"file": ("in.wav", io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+ job_id = submit.json()["job_id"]
+ status = client.get(f"/jobs/{job_id}")
+ self.assertEqual(status.json()["status"], "failed")
+ self.assertEqual(status.json()["error"], "Processing failed")
+
+ @patch("saas_web._get_job_store")
+ @patch("saas_web.media_shrinker.convert_file", side_effect=RuntimeError("boom"))
+ def test_run_job_handles_missing_job_while_recording_failure(
+ self, _mock_convert, mock_get_store
+ ):
+ class VanishingFailureStore:
+ def set_status(self, _job_id, status, **_kwargs):
+ if status == "processing":
+ return None
+ raise KeyError("gone")
+
+ mock_get_store.return_value = VanishingFailureStore()
+ temp_dir, input_dir, output_dir, source_path = self._make_workspace()
+
+ saas_web._run_job(
+ "missing-after-error",
+ source_path,
+ input_dir,
+ output_dir,
+ 10000,
+ temp_dir,
+ )
+
+ self.assertFalse(temp_dir.exists())
+
+ @patch("saas_web.media_shrinker.convert_file", return_value=[])
+ def test_run_job_records_failure_on_empty_output(self, _mock_convert):
+ submit = client.post(
+ "/jobs",
+ files={"file": ("in.wav", io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": 10000},
+ )
+ job_id = submit.json()["job_id"]
+ status = client.get(f"/jobs/{job_id}")
+ self.assertEqual(status.json()["status"], "failed")
+ self.assertIn("no output", status.json()["error"])
+
+ @patch("saas_web._get_job_store")
+ @patch("saas_web.media_shrinker.convert_file")
+ def test_run_job_handles_missing_job_while_recording_result(
+ self, mock_convert_file, mock_get_store
+ ):
+ class VanishingResultStore:
+ def set_status(self, _job_id, status, **_kwargs):
+ if status == "processing":
+ return None
+ raise KeyError("gone")
+
+ temp_dir, input_dir, output_dir, source_path = self._make_workspace()
+ output = output_dir / "out.flac"
+ output.write_bytes(b"audio")
+ mock_result = MagicMock(spec=ConversionResult)
+ mock_result.output_path = output
+ mock_convert_file.return_value = [mock_result]
+ mock_get_store.return_value = VanishingResultStore()
+
+ saas_web._run_job(
+ "missing-after-output",
+ source_path,
+ input_dir,
+ output_dir,
+ 10000,
+ temp_dir,
+ )
+
+ self.assertFalse(temp_dir.exists())
+
+ @patch("saas_web._get_job_store")
+ @patch("saas_web.media_shrinker.convert_file", return_value=[])
+ def test_run_job_handles_missing_job_while_recording_empty_output(
+ self, _mock_convert, mock_get_store
+ ):
+ class VanishingEmptyOutputStore:
+ def set_status(self, _job_id, status, **_kwargs):
+ if status == "processing":
+ return None
+ raise KeyError("gone")
+
+ mock_get_store.return_value = VanishingEmptyOutputStore()
+ temp_dir, input_dir, output_dir, source_path = self._make_workspace()
+
+ saas_web._run_job(
+ "missing-after-empty",
+ source_path,
+ input_dir,
+ output_dir,
+ 10000,
+ temp_dir,
+ )
+
+ self.assertFalse(temp_dir.exists())
+
+ @patch("saas_web._get_job_store")
+ @patch("saas_web._persist_upload")
+ def test_submit_handles_job_store_create_failure(
+ self, mock_persist_upload, mock_get_store
+ ):
+ class RejectingStore:
+ def create(self, *_args, **_kwargs):
+ raise ValueError("duplicate")
+
+ temp_dir, input_dir, output_dir, source_path = self._make_workspace()
+ mock_persist_upload.return_value = (
+ temp_dir,
+ input_dir,
+ output_dir,
+ source_path,
+ )
+ mock_get_store.return_value = RejectingStore()
+
+ response = saas_web.submit_job(
+ BackgroundTasks(),
+ file=SimpleNamespace(filename="in.wav", file=io.BytesIO(b"wav data")),
+ target_bytes=10000,
+ )
+
+ self.assertEqual(response.status_code, 500)
+ self.assertFalse(temp_dir.exists())
+
+ def test_status_unknown_job_returns_404(self):
+ response = client.get("/jobs/does-not-exist")
+ self.assertEqual(response.status_code, 404)
+
+ def test_result_unknown_job_returns_404(self):
+ response = client.get("/jobs/does-not-exist/result")
+ self.assertEqual(response.status_code, 404)
+
+ def test_result_not_ready_returns_409(self):
+ self._create_job("pending", "processing", "")
+ response = client.get("/jobs/pending/result")
+ self.assertEqual(response.status_code, 409)
+ self.assertIn("processing", response.json()["error"])
+
+ def test_result_missing_file_returns_410(self):
+ self._create_job(
+ "gone",
+ "done",
+ "",
+ output_path="/nonexistent/output.flac",
+ output_name="output.flac",
+ )
+ response = client.get("/jobs/gone/result")
+ self.assertEqual(response.status_code, 410)
+
+ def test_cleanup_job_removes_workspace(self):
+ import tempfile
+
+ temp_dir = Path(tempfile.mkdtemp(prefix="codec_carver_"))
+ self._create_job("c", "done", str(temp_dir))
+ saas_web._cleanup_job("c")
+ self.assertFalse(temp_dir.exists())
+ self.assertIsNone(saas_web.JOB_STORE.get("c"))
+
+ def test_cleanup_job_tolerates_unknown_job(self):
+ saas_web._cleanup_job("unknown-cleanup-job")
+ self.assertIsNone(saas_web.JOB_STORE.get("unknown-cleanup-job"))
+
+
+@unittest.skipUnless(
+ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
+)
+class UploadValidationTests(unittest.TestCase):
+ """Input hardening surfaced by the SAST review: target bound + content type."""
+
+ def test_shrink_rejects_oversized_target_bytes(self):
+ response = client.post(
+ "/shrink",
+ files={"file": ("in.wav", io.BytesIO(b"wav data"), "audio/wav")},
+ data={"target_bytes": saas_web.MAX_TARGET_BYTES + 1},
+ )
+ self.assertEqual(
+ response.json(),
+ {"error": "Invalid target_bytes value. Exceeds the maximum allowed size."},
+ )
+
+ def test_shrink_rejects_non_media_content_type(self):
+ response = client.post(
+ "/shrink",
+ files={"file": ("shell.php", io.BytesIO(b""), "application/x-php")},
+ data={"target_bytes": 10000},
+ )
+ self.assertEqual(
+ response.json(),
+ {"error": "Unsupported content type; upload an audio or video file."},
+ )
+
+ def test_submit_rejects_non_media_content_type(self):
+ response = client.post(
+ "/jobs",
+ files={"file": ("shell.php", io.BytesIO(b""), "application/x-php")},
+ data={"target_bytes": 10000},
+ )
+ self.assertEqual(response.status_code, 400)
+
+ def test_video_content_type_accepted_by_validator(self):
+ self.assertIsNone(
+ saas_web._validate_request(
+ SimpleNamespace(filename="clip.mp4", content_type="video/mp4"),
+ 10000,
+ )
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/verify_html.py b/verify_html.py
new file mode 100644
index 00000000..c5d10477
--- /dev/null
+++ b/verify_html.py
@@ -0,0 +1,7 @@
+
+from fastapi.testclient import TestClient
+from saas_web import app
+
+client = TestClient(app)
+response = client.get("/")
+print(response.text.find("aria-invalid"))