ver1 - Step 5 implementation complete.

This commit is contained in:
Jim Lancaster
2026-06-26 17:51:02 -05:00
parent c9682b0399
commit e90dbe4958
16 changed files with 474 additions and 45 deletions
+22
View File
@@ -34,8 +34,13 @@ Optional settings (defaults shown):
DATABASE_URL=sqlite:///./transcription.db DATABASE_URL=sqlite:///./transcription.db
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts PROMPT_DIR=./prompts
MAX_UPLOAD_BYTES=15728640
OPERATOR_ACCESS_ENABLED=false
OPERATOR_USERNAME=operator
# OPERATOR_PASSWORD=replace_with_secure_value
``` ```
### 3) Run the app ### 3) Run the app
```bash ```bash
@@ -67,6 +72,23 @@ MIGRATION_AUTO_APPLY_ON_STARTUP=false
VALIDATE_SCHEMA_ON_STARTUP=true 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 ## How to navigate the GUI
+95 -37
View File
@@ -2,15 +2,18 @@
## Summary ## 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. This document records completed private-network safety controls, validation evidence, and residual risks for Ver1 Step 5.
Implemented in this step: Implemented in this step:
1. _TBD_ 1. Added private-network security assumptions and control matrix (`docs/ver1/ver1-step5-security-assumptions.md`).
2. _TBD_ 2. Implemented optional single-operator access control for `/ui*` and `/api*` via HTTP Basic auth.
3. _TBD_ 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 ### 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 ### 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 ### 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 ### 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 ### 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 ### Added/Updated Tests
1. _TBD_ 1. `tests/api/test_access_control.py`
2. _TBD_ - unauthorized protected API denied (`401` + challenge)
3. _TBD_ - 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 ### Validation Runs
Run and record outcomes: Run and record outcomes:
- `uv run pytest --collect-only -q` -> _TBD_ - `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> _TBD_ - `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> _TBD_ - `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> _TBD_ - `uv run pytest -q` -> passed
### Security Scan Evidence ### Security Scan Evidence
Record scan commands and outcomes: Record scan commands and outcomes:
- dependency scan command(s): _TBD_ - dependency scan command(s): `uvx pip-audit`
- static/security lint command(s): _TBD_ - static/security lint command(s): `uvx bandit -r src/transcription`
- critical/high findings: _TBD_ - critical/high findings: none
- remediation/defer decisions: _TBD_ - 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 | | Step 5 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Private-network and single-operator safety posture | REQ-9 | _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 | _TBD_ | _TBD_ | | 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 | _TBD_ | _TBD_ | | 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 | _TBD_ | _TBD_ | | 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 | _TBD_ | _TBD_ | | 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.md`
- `docs/ver1/ver1-step5-results.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 ## Risks, Exceptions, and Follow-Ups
1. _TBD_ 1. Basic auth is intentionally right-sized for trusted private-network use; if deployment posture changes, stronger identity controls are required.
2. _TBD_ 2. Current model remains single shared operator credential (no per-user audit identity).
3. _TBD_ 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: 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 ## Step 5 Exit Assessment
- Private-network assumptions and controls: **_TBD_** - Private-network assumptions and controls: **met**
- Access-control baseline effectiveness: **_TBD_** - Access-control baseline effectiveness: **met**
- Validation and safe-output safety: **_TBD_** - Validation and safe-output safety: **met (baseline)**
- Secret handling and config safety: **_TBD_** - Secret handling and config safety: **met**
- Dependency/security risk closure: **_TBD_** - Dependency/security risk closure: **met (no critical/high findings)**
- Test and regression safety: **_TBD_** - Test and regression safety: **met**
Step 5 completion status: **_TBD_** Step 5 completion status: **complete**
--- ---
@@ -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.
+7
View File
@@ -8,6 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from transcription.errors import AppError, ErrorCategory, build_error_envelope from transcription.errors import AppError, ErrorCategory, build_error_envelope
from transcription.security import AccessDeniedError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +32,12 @@ def _status_for(error: AppError) -> int:
def register_error_handlers(app: FastAPI) -> None: def register_error_handlers(app: FastAPI) -> None:
"""Register API exception handlers on the app.""" """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) @app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
envelope = build_error_envelope(exc) envelope = build_error_envelope(exc)
+17 -1
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from threading import Event, Thread 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.errors import register_error_handlers
from transcription.api.health import router as health_router from transcription.api.health import router as health_router
@@ -18,7 +19,9 @@ from transcription.db import (
should_bootstrap_schema, should_bootstrap_schema,
validate_schema_compatibility, validate_schema_compatibility,
) )
from transcription.errors import build_error_envelope
from transcription.migrations import apply_pending_migrations from transcription.migrations import apply_pending_migrations
from transcription.security import AccessDeniedError, enforce_request_access
from transcription.ui import register_pages from transcription.ui import register_pages
from transcription.worker import run_worker_loop from transcription.worker import run_worker_loop
@@ -83,6 +86,19 @@ async def _lifespan(app: FastAPI):
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application.""" """Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan) 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_error_handlers(app)
register_pages(app) register_pages(app)
app.include_router(health_router) app.include_router(health_router)
+15
View File
@@ -12,6 +12,7 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -46,10 +47,24 @@ class Settings(BaseSettings):
upload_dir: Path = Path("./uploads") upload_dir: Path = Path("./uploads")
prompt_dir: Path = Path("./prompts") 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 reliability ---
worker_max_retries: int = 0 worker_max_retries: int = 0
worker_retry_backoff_seconds: float = 0.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] = { LOGGING_CONFIG: dict[str, object] = {
"version": 1, "version": 1,
+2 -1
View File
@@ -70,8 +70,9 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError: def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize unknown exceptions into internal_unexpected_error.""" """Normalize unknown exceptions into internal_unexpected_error."""
_ = exc
return AppError( return AppError(
f"Unexpected error during {operation}: {exc}", f"Unexpected error during {operation}",
category=ErrorCategory.INTERNAL_UNEXPECTED, category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.", suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False, retriable=False,
+81
View File
@@ -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
+13 -2
View File
@@ -42,7 +42,11 @@ def create_upload_job(
) -> UploadJobResult: ) -> UploadJobResult:
"""Persist an uploaded file and create document/job records.""" """Persist an uploaded file and create document/job records."""
runtime_settings = settings or get_settings() 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 = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True) 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: if not file_bytes:
raise UploadError( raise UploadError(
"Upload payload is empty", "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.", 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 safe_name = Path(filename).name
if not safe_name: if not safe_name:
raise UploadError( raise UploadError(
+128
View File
@@ -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"}
+1 -1
View File
@@ -71,6 +71,6 @@ class TestPipelineFailureFlow:
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert transcript is not None assert transcript is not None
assert transcript.text is 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 "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail assert "error_id=" in transcript.error_detail
+18
View File
@@ -41,6 +41,24 @@ class TestUploadValidation:
assert exc_info.value.category.value == "user_input_error" assert exc_info.value.category.value == "user_input_error"
assert "jpg" in exc_info.value.suggestion.lower() 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 @pytest.mark.integration
class TestUploadPersistence: class TestUploadPersistence:
+2 -2
View File
@@ -118,7 +118,7 @@ class TestWorkerFailurePath:
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert transcript is not None assert transcript is not None
assert transcript.text is 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 "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail assert "error_id=" in transcript.error_detail
assert "suggestion=" in transcript.error_detail assert "suggestion=" in transcript.error_detail
@@ -148,7 +148,7 @@ class TestWorkerFailurePath:
assert len(transcripts) == 1 assert len(transcripts) == 1
assert transcripts[0].id == existing.id assert transcripts[0].id == existing.id
assert transcripts[0].text is None 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 "[internal_unexpected_error]" in transcripts[0].error_detail
assert "error_id=" in transcripts[0].error_detail assert "error_id=" in transcripts[0].error_detail
+4
View File
@@ -26,6 +26,8 @@ class TestAppLifespan:
calls = [] calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging")) 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.create_all", lambda **_kwargs: calls.append("schema"))
monkeypatch.setattr( monkeypatch.setattr(
"transcription.app.initialize_database_runtime", "transcription.app.initialize_database_runtime",
@@ -65,6 +67,8 @@ class TestAppLifespan:
calls = [] calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: None) 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.create_all", lambda **_kwargs: None)
monkeypatch.setattr( monkeypatch.setattr(
"transcription.app.initialize_database_runtime", "transcription.app.initialize_database_runtime",
+17
View File
@@ -73,6 +73,23 @@ class TestMigrationSafetySettings:
assert settings.validate_schema_on_startup is True 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: class TestWorkerReliabilitySettings:
"""Verify worker retry settings defaults.""" """Verify worker retry settings defaults."""
+1 -1
View File
@@ -38,6 +38,6 @@ class TestAppErrorHelpers:
assert isinstance(err, AppError) assert isinstance(err, AppError)
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
assert "unit.test" in err.message assert "unit.test" in err.message
assert "boom" in err.message assert "boom" not in err.message
assert err.suggestion assert err.suggestion
assert err.error_id assert err.error_id