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
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
+95 -37
View File
@@ -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**
---
@@ -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 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)
+17 -1
View File
@@ -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)
+15
View File
@@ -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,
+2 -1
View File
@@ -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,
+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:
"""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(
+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 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
+18
View File
@@ -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:
+2 -2
View File
@@ -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
+4
View File
@@ -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",
+17
View File
@@ -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."""
+1 -1
View File
@@ -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