M41: harden trust boundaries and delivery
This commit is contained in:
@@ -46,6 +46,29 @@ def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
|
||||
assert session.json()["role"] == "rental_employee"
|
||||
|
||||
|
||||
def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch):
|
||||
import app.api.routers.auth as auth_router
|
||||
|
||||
class FakeClient:
|
||||
async def authorize_access_token(self, _request):
|
||||
return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}}
|
||||
|
||||
class FakeOAuth:
|
||||
def create_client(self, _name):
|
||||
return FakeClient()
|
||||
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||
|
||||
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||
assert response.status_code == 401
|
||||
assert client.get("/api/v1/auth/session").status_code == 401
|
||||
|
||||
|
||||
def test_demo_login_grants_access(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 200
|
||||
@@ -173,4 +196,8 @@ def test_demo_reset_preserves_integration_telemetry(ops_client, client):
|
||||
|
||||
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
|
||||
events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert any(event["actor_label"].endswith(":reset-probe") for event in events)
|
||||
assert any(
|
||||
event["actor_label"] == "itworx-mcp-hub"
|
||||
and event["metadata"].get("reported_client_id", "").endswith(":reset-probe")
|
||||
for event in events
|
||||
)
|
||||
|
||||
@@ -3,33 +3,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.config import Settings, get_settings, insecure_default_secrets
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.errors import AppError
|
||||
from app.core.observability import UNMATCHED_ROUTE_LABEL
|
||||
from app.core.ratelimit import FailedAttemptLimiter
|
||||
from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.services.data_quality import run_scan
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.data_quality import defer_issue, run_scan
|
||||
from tests.test_return import _activate_booking, _return_body
|
||||
|
||||
|
||||
def test_insecure_defaults_are_detected_only_for_relevant_secrets():
|
||||
def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled():
|
||||
defaults = Settings(_env_file=None)
|
||||
assert "app_secret" in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" not in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" in insecure_default_secrets(defaults)
|
||||
hardened = Settings(
|
||||
_env_file=None,
|
||||
app_secret="x" * 32,
|
||||
n8n_callback_token="c" * 32,
|
||||
mcp_hub_registration_enabled=True,
|
||||
mcp_hub_service_token="m" * 32,
|
||||
)
|
||||
assert insecure_default_secrets(hardened) == ["mcp_hub_service_token"]
|
||||
assert insecure_default_secrets(hardened) == []
|
||||
|
||||
|
||||
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||
@@ -47,6 +51,37 @@ def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("public_url", "secure_cookie", "message"),
|
||||
[
|
||||
("http://fleetops.example.test", "true", "must use HTTPS"),
|
||||
("https://fleetops.example.test", "false", "must be true"),
|
||||
],
|
||||
)
|
||||
def test_production_refuses_cleartext_or_insecure_session_cookie(
|
||||
monkeypatch, public_url, secure_cookie, message
|
||||
):
|
||||
values = {
|
||||
"MOBILITYOPS_ENV": "production",
|
||||
"APP_SECRET": "a" * 32,
|
||||
"N8N_CALLBACK_TOKEN": "c" * 32,
|
||||
"MCP_HUB_SERVICE_TOKEN": "m" * 32,
|
||||
"MOBILITYOPS_PUBLIC_URL": public_url,
|
||||
"SESSION_COOKIE_SECURE": secure_cookie,
|
||||
}
|
||||
for name, value in values.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
get_settings()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
@@ -58,6 +93,29 @@ def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||
|
||||
|
||||
def test_sliding_window_limiter_counts_successes_and_isolates_keys():
|
||||
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") > 0
|
||||
assert limiter.consume("session-b") == 0
|
||||
|
||||
|
||||
def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.routers.auth import _client_key
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")],
|
||||
"client": ("172.20.0.3", 12345),
|
||||
}
|
||||
)
|
||||
assert _client_key(request) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_audit_export_accepts_naive_datetimes(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
@@ -194,3 +252,85 @@ def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition():
|
||||
barrier = Barrier(2)
|
||||
|
||||
def scan() -> None:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
run_scan(db)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
list(executor.map(lambda _index: scan(), range(2)))
|
||||
|
||||
with SessionLocal() as db:
|
||||
duplicates = db.execute(
|
||||
select(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
func.count(DataQualityIssue.id),
|
||||
)
|
||||
.where(DataQualityIssue.status == "open")
|
||||
.group_by(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
)
|
||||
.having(func.count(DataQualityIssue.id) > 1)
|
||||
).all()
|
||||
assert duplicates == []
|
||||
|
||||
|
||||
def test_concurrent_issue_resolution_records_exactly_one_decision():
|
||||
issue_id = uuid.uuid4()
|
||||
issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}"
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
DataQualityIssue(
|
||||
id=issue_id,
|
||||
public_ref=issue_ref,
|
||||
rule_type="missing_required_field",
|
||||
entity_type="customer",
|
||||
entity_id=uuid.uuid4(),
|
||||
severity="low",
|
||||
status="open",
|
||||
evidence_json={},
|
||||
proposed_action_json={},
|
||||
detected_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
actor = CurrentUser(
|
||||
public_ref="USR-RACE", display_name="Race Manager", role="operations_manager"
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def resolve() -> str:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
try:
|
||||
return defer_issue(db, issue_ref, actor).status
|
||||
except AppError as exc:
|
||||
return exc.code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(lambda _index: resolve(), range(2)))
|
||||
assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"]
|
||||
with SessionLocal() as db:
|
||||
decisions = db.scalar(
|
||||
select(func.count(AuditEvent.id)).where(
|
||||
AuditEvent.entity_id == issue_id,
|
||||
AuditEvent.action == "data_quality_issue_deferred",
|
||||
)
|
||||
)
|
||||
assert decisions == 1
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id))
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id))
|
||||
db.commit()
|
||||
|
||||
@@ -7,6 +7,7 @@ import httpx
|
||||
|
||||
from app.api.routers import knowledge as knowledge_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.services.knowledge import KnowledgeHealth
|
||||
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||
from app.services.knowledge.procedures import iter_procedure_documents
|
||||
@@ -140,6 +141,16 @@ def test_ask_question_requires_authentication(client):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_knowledge_questions_are_bounded_per_session_and_client(ops_client, monkeypatch):
|
||||
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
|
||||
monkeypatch.setattr(knowledge_router, "_question_limiter", limiter)
|
||||
body = {"question": "What is the vehicle return procedure?"}
|
||||
assert ops_client.post("/api/v1/knowledge/questions", json=body).status_code == 200
|
||||
blocked = ops_client.post("/api/v1/knowledge/questions", json=body)
|
||||
assert blocked.status_code == 429
|
||||
assert int(blocked.headers["retry-after"]) >= 1
|
||||
|
||||
|
||||
def test_ask_question_is_audited_without_leaking_full_text(ops_client):
|
||||
ops_client.post(
|
||||
"/api/v1/knowledge/questions",
|
||||
@@ -695,5 +706,38 @@ def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE")
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert any(source.document_id == "damage-procedure" for source in answer.sources)
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_accepts_relevant_damage_evidence_above_threshold(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body()
|
||||
search["results"].append(
|
||||
{
|
||||
"citation": {
|
||||
"document_id": "damage-procedure",
|
||||
"document_version_id": "version-2",
|
||||
"title": "damage-procedure.md",
|
||||
"section": "Damage",
|
||||
"excerpt": "Record damage and keep the vehicle blocked.",
|
||||
},
|
||||
"rank": 1,
|
||||
"scores": {"fused": 0.5},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, search),
|
||||
}
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Wat moet ik doen bij schade?", "strong-damage", "nl-BE")
|
||||
assert answer.evidence_state == "grounded"
|
||||
assert answer.sources[0].document_id == "damage-procedure"
|
||||
|
||||
@@ -96,6 +96,8 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert len(events) >= 1
|
||||
assert events[0]["actor_type"] == "service"
|
||||
assert events[0]["actor_label"] == "itworx-mcp-hub"
|
||||
assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1")
|
||||
|
||||
|
||||
def test_search_knowledge_respects_requested_locale(client):
|
||||
@@ -135,7 +137,11 @@ def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_
|
||||
)
|
||||
assert response.status_code == 200
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
matching = [e for e in events if e["actor_label"].endswith(":no-correlation-probe")]
|
||||
matching = [
|
||||
e
|
||||
for e in events
|
||||
if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe")
|
||||
]
|
||||
assert len(matching) >= 1
|
||||
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
|
||||
|
||||
|
||||
Reference in New Issue
Block a user