generated from john/python-template
ver1 - Step 5 implementation complete.
This commit is contained in:
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user