diff --git a/README.md b/README.md index d11c094..65098ae 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,13 @@ Optional settings (defaults shown): DATABASE_URL=sqlite:///./transcription.db UPLOAD_DIR=./uploads PROMPT_DIR=./prompts +MAX_UPLOAD_BYTES=15728640 +OPERATOR_ACCESS_ENABLED=false +OPERATOR_USERNAME=operator +# OPERATOR_PASSWORD=replace_with_secure_value ``` + ### 3) Run the app ```bash @@ -67,6 +72,23 @@ MIGRATION_AUTO_APPLY_ON_STARTUP=false VALIDATE_SCHEMA_ON_STARTUP=true ``` +### Step 5 security settings + +Use this baseline for trusted private-network operation: + +```env +OPERATOR_ACCESS_ENABLED=true +OPERATOR_USERNAME=operator +OPERATOR_PASSWORD=replace_with_strong_local_secret +MAX_UPLOAD_BYTES=15728640 +``` + +Notes: +- `/healthz` remains unauthenticated for operational checks. +- `/ui` and `/api` require HTTP Basic credentials when operator access is enabled. +- Keep `OPERATOR_PASSWORD` in environment variables only (never commit secrets). + + ## How to navigate the GUI diff --git a/docs/ver1/ver1-step5-results.md b/docs/ver1/ver1-step5-results.md index b7b9b06..b6d41b7 100644 --- a/docs/ver1/ver1-step5-results.md +++ b/docs/ver1/ver1-step5-results.md @@ -2,15 +2,18 @@ ## Summary -Step 5 implementation status: **in progress**. +Step 5 implementation status: **complete**. This document records completed private-network safety controls, validation evidence, and residual risks for Ver1 Step 5. Implemented in this step: -1. _TBD_ -2. _TBD_ -3. _TBD_ +1. Added private-network security assumptions and control matrix (`docs/ver1/ver1-step5-security-assumptions.md`). +2. Implemented optional single-operator access control for `/ui*` and `/api*` via HTTP Basic auth. +3. Added upload-size guardrails (`MAX_UPLOAD_BYTES`) and config fail-fast validation for operator credential requirements. +4. Hardened unexpected-error user-facing messaging to reduce sensitive detail leakage. +5. Added Step 5 tests for access control, security settings, and upload size boundaries. +6. Executed dependency/security scans (`pip-audit`, `bandit`) with no critical/high findings. --- @@ -18,23 +21,61 @@ Implemented in this step: ### 1) Security assumptions and threat model -_TBD_ +Completed. + +- Added `docs/ver1/ver1-step5-security-assumptions.md` defining: + - trusted private-network deployment assumptions + - single-operator usage model + - explicit out-of-scope classes (enterprise IAM, internet-facing zero-trust, multi-tenant controls) +- Added Step 5 control/ownership matrix and residual-risk notes. ### 2) Single-operator access control baseline -_TBD_ +Completed. + +- New module: `src/transcription/security.py` + - `is_protected_path(...)` protects `/ui*` and `/api*` + - `enforce_request_access(...)` enforces optional operator auth + - robust Basic auth parsing and safe denial responses via `AccessDeniedError` +- App middleware added in `src/transcription/app.py`: + - enforces auth on protected paths + - returns consistent `401` envelope and `WWW-Authenticate: Basic` for denied requests +- Health endpoint `/healthz` remains intentionally unauthenticated. ### 3) Input validation and safe-output hardening -_TBD_ +Completed baseline. + +- `src/transcription/services/upload.py` + - added size-based validation guard (`max_upload_bytes`) + - emits `user_input_error` with actionable guidance on over-limit uploads +- `src/transcription/errors.py` + - `classify_unexpected_error(...)` now returns operation-only message without embedding raw exception text + - preserves traceability via existing `error_id` and taxonomy while reducing accidental sensitive leak risk ### 4) Secret handling and configuration safety -_TBD_ +Completed baseline. + +- `src/transcription/config.py` additions: + - `max_upload_bytes` (default `15 * 1024 * 1024`) + - `operator_access_enabled` (default `False`) + - `operator_username` (default `operator`) + - `operator_password` (optional, required when auth enabled) +- Added settings validator enforcing fail-fast config safety: + - raises validation error if `OPERATOR_ACCESS_ENABLED=true` and `OPERATOR_PASSWORD` unset +- `README.md` updated with Step 5 security env settings and explicit secret-handling guidance. ### 5) Dependency/security scanning baseline -_TBD_ +Completed. + +- Dependency vulnerability scan: + - `uvx pip-audit` + - Result: **No known vulnerabilities found** +- Static security scan: + - `uvx bandit -r src/transcription` + - Result: **No issues identified** (0 low/medium/high) --- @@ -42,27 +83,41 @@ _TBD_ ### Added/Updated Tests -1. _TBD_ -2. _TBD_ -3. _TBD_ +1. `tests/api/test_access_control.py` + - unauthorized protected API denied (`401` + challenge) + - invalid credentials denied + - valid credentials accepted + - `/ui` protected when auth enabled + - `/healthz` remains unprotected +2. `tests/services/test_upload.py` + - added rejection test for payloads above `MAX_UPLOAD_BYTES` +3. `tests/test_config.py` + - added security defaults assertions + - added fail-fast assertion for missing `OPERATOR_PASSWORD` when auth enabled +4. `tests/test_errors.py` + - updated expectations for sanitized unexpected-error message behavior +5. Updated integration expectations where failure detail should no longer include raw exception text: + - `tests/services/test_worker.py` + - `tests/integration/test_pipeline_flow.py` +6. `tests/test_app.py` updated for new middleware wiring. ### Validation Runs Run and record outcomes: -- `uv run pytest --collect-only -q` -> _TBD_ -- `uv run pytest -m unit -q` -> _TBD_ -- `uv run pytest -m "not external" -q` -> _TBD_ -- `uv run pytest -q` -> _TBD_ +- `uv run pytest --collect-only -q` -> passed +- `uv run pytest -m unit -q` -> passed +- `uv run pytest -m "not external" -q` -> passed +- `uv run pytest -q` -> passed ### Security Scan Evidence Record scan commands and outcomes: -- dependency scan command(s): _TBD_ -- static/security lint command(s): _TBD_ -- critical/high findings: _TBD_ -- remediation/defer decisions: _TBD_ +- dependency scan command(s): `uvx pip-audit` +- static/security lint command(s): `uvx bandit -r src/transcription` +- critical/high findings: none +- remediation/defer decisions: no remediations required for Step 5 baseline --- @@ -70,11 +125,11 @@ Record scan commands and outcomes: | Step 5 Area | REQ Coverage | Status | Evidence | | --- | --- | --- | --- | -| Private-network and single-operator safety posture | REQ-9 | _TBD_ | _TBD_ | -| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | _TBD_ | _TBD_ | -| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | _TBD_ | _TBD_ | -| Config and startup safety controls | REQ-8, REQ-10 | _TBD_ | _TBD_ | -| Persistence and domain integrity continuity | REQ-11, REQ-12 | _TBD_ | _TBD_ | +| Private-network and single-operator safety posture | REQ-9 | met | `docs/ver1/ver1-step5-security-assumptions.md`, README security section | +| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | met | `src/transcription/security.py`, `src/transcription/app.py`, `tests/api/test_access_control.py` | +| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | met | `src/transcription/services/upload.py`, `src/transcription/errors.py`, updated tests | +| Config and startup safety controls | REQ-8, REQ-10 | met | `src/transcription/config.py`, `tests/test_config.py`, `README.md` | +| Persistence and domain integrity continuity | REQ-11, REQ-12 | met (no regressions) | full test lane pass including integration and worker flows | --- @@ -82,32 +137,35 @@ Record scan commands and outcomes: - `docs/ver1/ver1-step5.md` - `docs/ver1/ver1-step5-results.md` -- _TBD additional artifacts_ +- `docs/ver1/ver1-step5-security-assumptions.md` +- `src/transcription/security.py` +- `tests/api/test_access_control.py` --- ## Risks, Exceptions, and Follow-Ups -1. _TBD_ -2. _TBD_ -3. _TBD_ +1. Basic auth is intentionally right-sized for trusted private-network use; if deployment posture changes, stronger identity controls are required. +2. Current model remains single shared operator credential (no per-user audit identity). +3. No built-in brute-force/rate-limit controls in Step 5 scope; evaluate in future hardening if threat model expands. Open follow-ups to carry forward: -- _TBD_ +- Consider stronger auth/session model if system becomes multi-user or internet-accessible. +- Consider request throttling/rate limiting if threat model changes. --- ## Step 5 Exit Assessment -- Private-network assumptions and controls: **_TBD_** -- Access-control baseline effectiveness: **_TBD_** -- Validation and safe-output safety: **_TBD_** -- Secret handling and config safety: **_TBD_** -- Dependency/security risk closure: **_TBD_** -- Test and regression safety: **_TBD_** +- Private-network assumptions and controls: **met** +- Access-control baseline effectiveness: **met** +- Validation and safe-output safety: **met (baseline)** +- Secret handling and config safety: **met** +- Dependency/security risk closure: **met (no critical/high findings)** +- Test and regression safety: **met** -Step 5 completion status: **_TBD_** +Step 5 completion status: **complete** --- diff --git a/docs/ver1/ver1-step5-security-assumptions.md b/docs/ver1/ver1-step5-security-assumptions.md new file mode 100644 index 0000000..44ce019 --- /dev/null +++ b/docs/ver1/ver1-step5-security-assumptions.md @@ -0,0 +1,51 @@ +# Ver1 Step 5 Security Assumptions (Private-Network Baseline) + +## Operating Model + +This system is operated as: + +1. single operator +2. trusted private network +3. non-public deployment (no direct internet exposure for UI/API) + +Out of scope for Step 5: + +- enterprise IAM/SSO/RBAC +- internet-facing zero-trust edge controls +- multi-tenant user isolation + +## Step 5 Controls and Ownership + +| Control | Boundary Owner | Verification | +| --- | --- | --- | +| Optional operator authentication for `/ui*` and `/api*` routes | `src/transcription/security.py`, `src/transcription/app.py` | `tests/api/test_access_control.py` | +| Unauthorized contract (`401` + safe envelope + `WWW-Authenticate`) | `src/transcription/api/errors.py` | `tests/api/test_access_control.py` | +| Upload size guard (`MAX_UPLOAD_BYTES`) | `src/transcription/services/upload.py`, `src/transcription/config.py` | `tests/services/test_upload.py` | +| Fail-fast auth config when enabled | `src/transcription/config.py` | `tests/test_config.py` | +| Safe unexpected error messaging (reduced leak surface) | `src/transcription/errors.py` | `tests/test_errors.py`, worker/integration failure tests | + +## Access-Control Policy (Step 5) + +- Health endpoint (`/healthz`) remains unauthenticated for operability checks. +- When `OPERATOR_ACCESS_ENABLED=true`, protected paths require HTTP Basic auth: + - `/ui` + - `/ui/...` + - `/api/...` +- Credentials are runtime-configured: + - `OPERATOR_USERNAME` (default `operator`) + - `OPERATOR_PASSWORD` (required when access is enabled) + +## Secrets Policy + +- Secrets must be provided via runtime environment variables. +- Secrets must not be committed to source control. +- Secrets must not be logged. +- Example secret values in docs must always be placeholders. + +## Residual Risks (Accepted for Step 5) + +1. HTTP Basic credentials are suitable only for trusted private-network deployment. +2. No per-user identity model (single shared operator credential). +3. No advanced brute-force/rate-limit controls in Step 5 scope. + +These are carried forward for future hardening only if deployment posture changes. \ No newline at end of file diff --git a/src/transcription/api/errors.py b/src/transcription/api/errors.py index 91d803c..2d4990a 100644 --- a/src/transcription/api/errors.py +++ b/src/transcription/api/errors.py @@ -8,6 +8,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from transcription.errors import AppError, ErrorCategory, build_error_envelope +from transcription.security import AccessDeniedError logger = logging.getLogger(__name__) @@ -31,6 +32,12 @@ def _status_for(error: AppError) -> int: def register_error_handlers(app: FastAPI) -> None: """Register API exception handlers on the app.""" + @app.exception_handler(AccessDeniedError) + async def access_denied_handler(_request: Request, exc: AccessDeniedError) -> JSONResponse: + envelope = build_error_envelope(exc) + headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None + return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers) + @app.exception_handler(AppError) async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: envelope = build_error_envelope(exc) diff --git a/src/transcription/app.py b/src/transcription/app.py index cfd8c6a..84a9460 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -5,7 +5,8 @@ from __future__ import annotations from contextlib import asynccontextmanager from threading import Event, Thread -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from transcription.api.errors import register_error_handlers from transcription.api.health import router as health_router @@ -18,7 +19,9 @@ from transcription.db import ( should_bootstrap_schema, validate_schema_compatibility, ) +from transcription.errors import build_error_envelope from transcription.migrations import apply_pending_migrations +from transcription.security import AccessDeniedError, enforce_request_access from transcription.ui import register_pages from transcription.worker import run_worker_loop @@ -83,6 +86,19 @@ async def _lifespan(app: FastAPI): def create_app() -> FastAPI: """Create and configure the FastAPI application.""" app = FastAPI(title="Transcription", lifespan=_lifespan) + + @app.middleware("http") + async def operator_access_middleware(request: Request, call_next): + settings = get_settings() + try: + enforce_request_access(request=request, settings=settings) + except AccessDeniedError as exc: + envelope = build_error_envelope(exc) + headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None + return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers) + + return await call_next(request) + register_error_handlers(app) register_pages(app) app.include_router(health_router) diff --git a/src/transcription/config.py b/src/transcription/config.py index 50be5e5..ea13748 100644 --- a/src/transcription/config.py +++ b/src/transcription/config.py @@ -12,6 +12,7 @@ from functools import lru_cache from pathlib import Path from typing import Literal +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -46,10 +47,24 @@ class Settings(BaseSettings): upload_dir: Path = Path("./uploads") prompt_dir: Path = Path("./prompts") + # --- upload safety --- + max_upload_bytes: int = 15 * 1024 * 1024 + + # --- single-operator access control --- + operator_access_enabled: bool = False + operator_username: str = "operator" + operator_password: str | None = None + # --- worker reliability --- worker_max_retries: int = 0 worker_retry_backoff_seconds: float = 0.0 + @model_validator(mode="after") + def _validate_operator_access_settings(self) -> "Settings": + if self.operator_access_enabled and not self.operator_password: + raise ValueError("OPERATOR_PASSWORD is required when OPERATOR_ACCESS_ENABLED=true") + return self + LOGGING_CONFIG: dict[str, object] = { "version": 1, diff --git a/src/transcription/errors.py b/src/transcription/errors.py index a4b429c..75d2f0f 100644 --- a/src/transcription/errors.py +++ b/src/transcription/errors.py @@ -70,8 +70,9 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope: def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError: """Normalize unknown exceptions into internal_unexpected_error.""" + _ = exc return AppError( - f"Unexpected error during {operation}: {exc}", + f"Unexpected error during {operation}", category=ErrorCategory.INTERNAL_UNEXPECTED, suggestion="Retry once. If it persists, review logs and report the error reference id.", retriable=False, diff --git a/src/transcription/security.py b/src/transcription/security.py new file mode 100644 index 0000000..f706cfe --- /dev/null +++ b/src/transcription/security.py @@ -0,0 +1,81 @@ +"""Step 5 single-operator access control helpers.""" + +from __future__ import annotations + +import base64 +import binascii +import secrets + +from fastapi import Request + +from transcription.config import Settings +from transcription.errors import AppError, ErrorCategory + + +class AccessDeniedError(AppError): + """Raised when a request is not authorized for operator actions.""" + + def __init__(self, message: str, *, suggestion: str, should_challenge: bool = True) -> None: + super().__init__(message, category=ErrorCategory.USER_INPUT, suggestion=suggestion) + self.should_challenge = should_challenge + + +def is_protected_path(path: str) -> bool: + """Return True when a request path requires operator authentication.""" + return path == "/ui" or path.startswith("/ui/") or path.startswith("/api") + + +def enforce_request_access(*, request: Request, settings: Settings) -> None: + """Enforce basic operator access control for protected paths.""" + if not settings.operator_access_enabled or not is_protected_path(request.url.path): + return + + if not settings.operator_password: + raise AppError( + "Operator authentication is enabled but credentials are not configured", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Set OPERATOR_PASSWORD in the runtime environment and restart the app.", + ) + + authorization = request.headers.get("Authorization") + username, password = _parse_basic_authorization_header(authorization) + + valid_username = secrets.compare_digest(username, settings.operator_username) + valid_password = secrets.compare_digest(password, settings.operator_password) + if not (valid_username and valid_password): + raise AccessDeniedError( + "Invalid operator credentials", + suggestion="Provide valid operator credentials and retry.", + ) + + +def _parse_basic_authorization_header(value: str | None) -> tuple[str, str]: + if not value: + raise AccessDeniedError( + "Operator authentication required", + suggestion="Provide HTTP Basic operator credentials and retry.", + ) + + scheme, _, token = value.partition(" ") + if scheme.lower() != "basic" or not token: + raise AccessDeniedError( + "Operator authentication required", + suggestion="Provide HTTP Basic operator credentials and retry.", + ) + + try: + decoded = base64.b64decode(token, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as exc: + raise AccessDeniedError( + "Invalid authentication header", + suggestion="Provide HTTP Basic operator credentials and retry.", + ) from exc + + username, sep, password = decoded.partition(":") + if not sep or not username: + raise AccessDeniedError( + "Invalid authentication header", + suggestion="Provide HTTP Basic operator credentials and retry.", + ) + + return username, password \ No newline at end of file diff --git a/src/transcription/services/upload.py b/src/transcription/services/upload.py index 0191402..b571481 100644 --- a/src/transcription/services/upload.py +++ b/src/transcription/services/upload.py @@ -42,7 +42,11 @@ def create_upload_job( ) -> UploadJobResult: """Persist an uploaded file and create document/job records.""" runtime_settings = settings or get_settings() - _validate_upload(filename=filename, file_bytes=file_bytes) + _validate_upload( + filename=filename, + file_bytes=file_bytes, + max_upload_bytes=runtime_settings.max_upload_bytes, + ) upload_dir = runtime_settings.upload_dir upload_dir.mkdir(parents=True, exist_ok=True) @@ -87,7 +91,7 @@ def create_upload_job( ) -def _validate_upload(*, filename: str, file_bytes: bytes) -> None: +def _validate_upload(*, filename: str, file_bytes: bytes, max_upload_bytes: int) -> None: if not file_bytes: raise UploadError( "Upload payload is empty", @@ -95,6 +99,13 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None: suggestion="Select a non-empty file and try again.", ) + if len(file_bytes) > max_upload_bytes: + raise UploadError( + f"Upload exceeds maximum allowed size ({max_upload_bytes} bytes)", + category=ErrorCategory.USER_INPUT, + suggestion="Upload a smaller file or increase MAX_UPLOAD_BYTES for this deployment.", + ) + safe_name = Path(filename).name if not safe_name: raise UploadError( diff --git a/tests/api/test_access_control.py b/tests/api/test_access_control.py new file mode 100644 index 0000000..6409bdc --- /dev/null +++ b/tests/api/test_access_control.py @@ -0,0 +1,128 @@ +"""Tests for Step 5 operator access control behavior.""" + +from __future__ import annotations + +import base64 +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient +import pytest + +from transcription.api.errors import register_error_handlers +from transcription.errors import build_error_envelope +from transcription.security import AccessDeniedError, enforce_request_access + + +def _basic_header(username: str, password: str) -> str: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + return f"Basic {token}" + + +def _build_app(*, settings) -> FastAPI: + app = FastAPI() + register_error_handlers(app) + + @app.middleware("http") + async def operator_access_middleware(request, call_next): + try: + enforce_request_access(request=request, settings=settings) + except AccessDeniedError as exc: + envelope = build_error_envelope(exc) + headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None + return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers) + return await call_next(request) + + @app.get("/healthz") + def healthz(): + return {"status": "ok"} + + @app.get("/api/jobs") + def get_jobs(): + return [{"id": "demo"}] + + @app.get("/ui") + def ui_root(): + return {"ok": True} + + return app + + +@pytest.mark.integration +class TestAccessControl: + """Verify protected routes enforce operator auth when enabled.""" + + def test_protected_api_requires_credentials(self): + settings = SimpleNamespace( + operator_access_enabled=True, + operator_username="operator", + operator_password="secret", + ) + client = TestClient(_build_app(settings=settings), raise_server_exceptions=False) + + response = client.get("/api/jobs") + + assert response.status_code == 401 + assert response.headers.get("WWW-Authenticate") == "Basic" + payload = response.json() + assert payload["category"] == "user_input_error" + assert payload["suggestion"] + + def test_protected_api_rejects_invalid_credentials(self): + settings = SimpleNamespace( + operator_access_enabled=True, + operator_username="operator", + operator_password="secret", + ) + client = TestClient(_build_app(settings=settings), raise_server_exceptions=False) + + response = client.get( + "/api/jobs", + headers={"Authorization": _basic_header("operator", "wrong")}, + ) + + assert response.status_code == 401 + payload = response.json() + assert payload["message"] == "Invalid operator credentials" + + def test_protected_api_allows_valid_credentials(self): + settings = SimpleNamespace( + operator_access_enabled=True, + operator_username="operator", + operator_password="secret", + ) + client = TestClient(_build_app(settings=settings), raise_server_exceptions=False) + + response = client.get( + "/api/jobs", + headers={"Authorization": _basic_header("operator", "secret")}, + ) + + assert response.status_code == 200 + assert response.json() == [{"id": "demo"}] + + def test_protected_ui_path_requires_credentials(self): + settings = SimpleNamespace( + operator_access_enabled=True, + operator_username="operator", + operator_password="secret", + ) + client = TestClient(_build_app(settings=settings), raise_server_exceptions=False) + + response = client.get("/ui") + + assert response.status_code == 401 + + def test_healthz_is_not_protected(self): + settings = SimpleNamespace( + operator_access_enabled=True, + operator_username="operator", + operator_password="secret", + ) + client = TestClient(_build_app(settings=settings), raise_server_exceptions=False) + + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} \ No newline at end of file diff --git a/tests/integration/test_pipeline_flow.py b/tests/integration/test_pipeline_flow.py index 4e83315..7f81ed2 100644 --- a/tests/integration/test_pipeline_flow.py +++ b/tests/integration/test_pipeline_flow.py @@ -71,6 +71,6 @@ class TestPipelineFailureFlow: assert job.status == JobStatus.FAILED assert transcript is not None assert transcript.text is None - assert "pipeline provider failure" in transcript.error_detail + assert "pipeline provider failure" not in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail assert "error_id=" in transcript.error_detail diff --git a/tests/services/test_upload.py b/tests/services/test_upload.py index 854d4d5..2bc7d7c 100644 --- a/tests/services/test_upload.py +++ b/tests/services/test_upload.py @@ -41,6 +41,24 @@ class TestUploadValidation: assert exc_info.value.category.value == "user_input_error" assert "jpg" in exc_info.value.suggestion.lower() + def test_rejects_payload_exceeding_max_upload_bytes(self, session, tmp_path: Path): + """create_upload_job rejects payloads above configured size limit.""" + settings = Settings( + openrouter_api_key="test-key", + upload_dir=tmp_path, + max_upload_bytes=3, + ) + with pytest.raises(UploadError) as exc_info: + create_upload_job( + filename="scan.jpg", + file_bytes=b"1234", + session=session, + settings=settings, + ) + + assert exc_info.value.category.value == "user_input_error" + assert "smaller file" in exc_info.value.suggestion.lower() + @pytest.mark.integration class TestUploadPersistence: diff --git a/tests/services/test_worker.py b/tests/services/test_worker.py index 4a636ac..2c723a9 100644 --- a/tests/services/test_worker.py +++ b/tests/services/test_worker.py @@ -118,7 +118,7 @@ class TestWorkerFailurePath: assert job.status == JobStatus.FAILED assert transcript is not None assert transcript.text is None - assert "provider failure" in transcript.error_detail + assert "provider failure" not in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail assert "error_id=" in transcript.error_detail assert "suggestion=" in transcript.error_detail @@ -148,7 +148,7 @@ class TestWorkerFailurePath: assert len(transcripts) == 1 assert transcripts[0].id == existing.id assert transcripts[0].text is None - assert "provider failure" in transcripts[0].error_detail + assert "provider failure" not in transcripts[0].error_detail assert "[internal_unexpected_error]" in transcripts[0].error_detail assert "error_id=" in transcripts[0].error_detail diff --git a/tests/test_app.py b/tests/test_app.py index cdc0132..bff45c2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -26,6 +26,8 @@ class TestAppLifespan: calls = [] monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging")) + monkeypatch.setattr("transcription.app.get_settings", lambda: object()) + monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None) monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema")) monkeypatch.setattr( "transcription.app.initialize_database_runtime", @@ -65,6 +67,8 @@ class TestAppLifespan: calls = [] monkeypatch.setattr("transcription.app.setup_logging", lambda: None) + monkeypatch.setattr("transcription.app.get_settings", lambda: object()) + monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None) monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None) monkeypatch.setattr( "transcription.app.initialize_database_runtime", diff --git a/tests/test_config.py b/tests/test_config.py index 44e2fcc..360f79e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -73,6 +73,23 @@ class TestMigrationSafetySettings: assert settings.validate_schema_on_startup is True +class TestSecuritySettings: + """Verify Step 5 security-related settings behavior.""" + + def test_security_defaults(self): + """Security controls default to disabled auth and bounded upload size.""" + settings = _make_settings() + assert settings.max_upload_bytes == 15 * 1024 * 1024 + assert settings.operator_access_enabled is False + assert settings.operator_username == "operator" + assert settings.operator_password is None + + def test_operator_password_required_when_access_enabled(self): + """Enabling operator access requires OPERATOR_PASSWORD.""" + with pytest.raises(ValidationError): + _make_settings(operator_access_enabled=True, operator_password=None) + + class TestWorkerReliabilitySettings: """Verify worker retry settings defaults.""" diff --git a/tests/test_errors.py b/tests/test_errors.py index 454f2b5..785226b 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -38,6 +38,6 @@ class TestAppErrorHelpers: assert isinstance(err, AppError) assert err.category == ErrorCategory.INTERNAL_UNEXPECTED assert "unit.test" in err.message - assert "boom" in err.message + assert "boom" not in err.message assert err.suggestion assert err.error_id