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
+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