M41: harden trust boundaries and delivery
MobilityOps acceptance / backend (push) Failing after 47s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-21 17:06:59 +02:00
parent a830e8a2d0
commit 24dcb3494c
38 changed files with 699 additions and 113 deletions
+11 -4
View File
@@ -1,19 +1,26 @@
FROM python:3.12-slim
FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY backend/requirements.lock ./
RUN pip install --no-cache-dir -r requirements.lock
COPY backend/requirements-prod.lock ./
RUN pip install --no-cache-dir -r requirements-prod.lock
COPY backend/pyproject.toml ./
COPY backend/app ./app
COPY backend/alembic ./alembic
COPY backend/alembic.ini ./
COPY backend/tests ./tests
COPY seed ./seed
COPY knowledge ./knowledge
COPY backend/entrypoint.sh ./entrypoint.sh
RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \
&& addgroup --system app && adduser --system --ingroup app --home /app app \
&& chown -R app:app /app
FROM runtime-base AS test
COPY backend/requirements.lock ./requirements.lock
RUN pip install --no-cache-dir -r requirements.lock
COPY backend/tests ./tests
USER app
FROM runtime-base AS runtime
# Run migrations and the API as an unprivileged user; nothing here needs root.
USER app
EXPOSE 8000
@@ -0,0 +1,27 @@
"""enforce one open issue per detected condition
Revision ID: 4f2b9c8d7e61
Revises: 0a4c1d2e3f5b
"""
from alembic import op
import sqlalchemy as sa
revision = "4f2b9c8d7e61"
down_revision = "0a4c1d2e3f5b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"uq_data_quality_one_open_condition",
"data_quality_issues",
["rule_type", "entity_type", "entity_id"],
unique=True,
postgresql_where=sa.text("status = 'open'"),
)
def downgrade() -> None:
op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues")
+2 -2
View File
@@ -67,7 +67,7 @@ def require_operations_manager(
@dataclass(frozen=True)
class McpClientContext:
client_id: str
reported_client_id: str
tenant: str
@@ -91,4 +91,4 @@ def require_mcp_service_token(
)
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
return McpClientContext(client_id=x_client_id, tenant=settings.ragcore_tenant)
return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)
+4 -3
View File
@@ -38,10 +38,11 @@ _login_limiter = (
def _client_key(request: Request) -> str:
# The API sits behind the web container's reverse proxy in every documented
# deployment; honour the first hop of X-Forwarded-For when present.
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
# attacker-controlled leading value must never select a fresh limiter bucket.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
return forwarded.split(",")[-1].strip()
return request.client.host if request.client else "unknown"
@@ -143,7 +144,7 @@ def _allowed_oidc_email(email: str) -> bool:
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is False:
if not subject or not email or claims.get("email_verified") is not True:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed")
+33 -1
View File
@@ -1,20 +1,32 @@
from __future__ import annotations
import hashlib
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.core.ratelimit import SlidingWindowLimiter
from app.models.audit import AuditEvent
from app.schemas import CurrentUser
from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
settings = get_settings()
_question_limiter = (
SlidingWindowLimiter(
max_requests=settings.knowledge_max_requests,
window_seconds=settings.knowledge_rate_limit_window_seconds,
)
if settings.knowledge_max_requests > 0
else None
)
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
@@ -32,9 +44,29 @@ class KnowledgeFeedbackRequest(BaseModel):
@router.post("/questions", response_model=GroundedAnswer)
def ask_question(
body: AskQuestionRequest,
request: Request,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> GroundedAnswer:
if _question_limiter is not None:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = (
forwarded.split(",")[-1].strip()
if forwarded
else request.client.host if request.client else "unknown"
)
token = request.cookies.get(settings.session_cookie_name, "")
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
retry_after = max(
_question_limiter.consume(f"ip:{client_ip}"),
_question_limiter.consume(f"session:{session_key}"),
)
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many knowledge questions. Try again later.",
headers={"Retry-After": str(retry_after)},
)
correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id, body.language)
+16 -8
View File
@@ -44,7 +44,7 @@ def get_correlation_id(
def _audit_service_request(
db: Session,
*,
client_id: str,
reported_client_id: str,
tool: str,
status_label: str,
correlation_id: str,
@@ -53,11 +53,19 @@ def _audit_service_request(
record_audit_event(
db,
actor_type="service",
actor_label=client_id,
# The shared service token authenticates the Hub, not the caller identity that
# the Hub reports in a header. Keep attribution authoritative and retain the
# reported value only as explicitly non-authenticated diagnostic metadata.
actor_label="itworx-mcp-hub",
action="mcp_tool_request",
entity_type="mcp_tool",
correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label, **(metadata or {})},
metadata={
"tool": tool,
"status": status_label,
"reported_client_id": reported_client_id,
**(metadata or {}),
},
)
db.commit()
@@ -79,7 +87,7 @@ def operations_summary(
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
@@ -103,7 +111,7 @@ def attention_vehicles(
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
@@ -124,7 +132,7 @@ def vehicle_details(
if vehicle is None:
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details",
status_label="not_found",
correlation_id=correlation_id,
@@ -146,7 +154,7 @@ def vehicle_details(
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
@@ -182,7 +190,7 @@ def search_knowledge(
response.headers["X-Sources-Returned"] = str(len(answer.sources))
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state,
correlation_id=correlation_id,
+10 -4
View File
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
ragcore_api_token: str = ""
ragcore_space_id: str = ""
ragcore_http_timeout_seconds: float = 5.0
# Search fallback is only labelled grounded above this explicit retrieval threshold.
ragcore_min_search_score: float = 0.05
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
n8n_callback_token: str = "replace-me-n8n-callback-token"
@@ -67,6 +69,8 @@ class Settings(BaseSettings):
# Failed password logins per client IP before a temporary 429 (0 disables).
login_max_failures: int = 10
login_failure_window_seconds: int = 900
knowledge_max_requests: int = 30
knowledge_rate_limit_window_seconds: int = 60
metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555
@@ -86,13 +90,11 @@ INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
def insecure_default_secrets(settings: "Settings") -> list[str]:
"""Return the names of secret settings that still carry their placeholder value.
Only secrets that actually guard something in the given deployment are reported:
``mcp_hub_service_token`` is irrelevant while MCP Hub registration is disabled.
MCP routes are always mounted, independently of the Hub reachability-status flag, so
their inbound token must always be non-placeholder in production.
"""
insecure: list[str] = []
for name, placeholder in INSECURE_DEFAULT_SECRETS:
if name == "mcp_hub_service_token" and not settings.mcp_hub_registration_enabled:
continue
value = getattr(settings, name)
if not value or value == placeholder or value.startswith("replace-me"):
insecure.append(name)
@@ -112,4 +114,8 @@ def get_settings() -> Settings:
+ ", ".join(insecure)
+ ". Set real values in the environment (see .env.example)."
)
if not settings.mobilityops_public_url.lower().startswith("https://"):
raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.")
if not settings.session_cookie_secure:
raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.")
return settings
+23
View File
@@ -47,3 +47,26 @@ class FailedAttemptLimiter:
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
class SlidingWindowLimiter:
"""Thread-safe request limiter where every accepted request consumes capacity."""
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
self.max_requests = max_requests
self.window_seconds = window_seconds
self._requests: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def consume(self, key: str) -> int:
"""Record an accepted request, or return the seconds until capacity is available."""
now = time.monotonic()
with self._lock:
bucket = self._requests.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= self.max_requests:
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
bucket.append(now)
return 0
+9 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -37,6 +37,14 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name="ck_data_quality_status",
),
Index("ix_data_quality_work_queue", "status", "due_at", "severity"),
Index(
"uq_data_quality_one_open_condition",
"rule_type",
"entity_type",
"entity_id",
unique=True,
postgresql_where=text("status = 'open'"),
),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
+17 -6
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher
from sqlalchemy import select, update
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from app.core.errors import AppError
@@ -26,6 +26,7 @@ from app.services.vehicle_status import (
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DUPLICATE_THRESHOLD = 70
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
@@ -356,6 +357,9 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
def run_scan(
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
) -> ScanResult:
# The check-then-insert work below spans several rules. Serialise whole scans at the
# database boundary so API and n8n triggers cannot both observe an empty condition.
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
scan = ScanResult()
_scan_duplicate_customers(db, scan)
_scan_missing_required_fields(db, scan)
@@ -375,8 +379,13 @@ def run_scan(
return scan
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
def _load_open_issue(
db: Session, public_ref: str, *, lock: bool = True
) -> DataQualityIssue:
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
if lock:
statement = statement.with_for_update()
issue = db.scalar(statement)
if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open":
@@ -707,8 +716,10 @@ def resolve_booking_overlap(
return issue
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
def _load_vehicle_status_conflict_issue(
db: Session, public_ref: str, *, lock: bool = True
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref, lock=lock)
if issue.rule_type != "vehicle_status_conflict":
raise AppError(
"NOT_A_STATUS_CONFLICT_ISSUE",
@@ -724,7 +735,7 @@ def preview_vehicle_status_recommendation(
"""Non-mutating: computes and returns the recommendation only. Never resolves the
issue, never writes an audit event, never queues automation -- safe to call as often
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
issue = _load_vehicle_status_conflict_issue(db, public_ref)
issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
if vehicle is None:
raise AppError(
+37 -5
View File
@@ -92,6 +92,17 @@ def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) ->
)
def _retrieval_score(result: dict) -> float | None:
scores = result.get("scores")
if not isinstance(scores, dict):
return None
for name in ("rerank", "fused"):
value = scores.get(name)
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
return None
class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
@@ -338,6 +349,8 @@ class RAGcoreKnowledgeProvider:
try:
results = body.get("results", [])
if not isinstance(results, list):
raise TypeError("results must be a list")
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
@@ -351,10 +364,21 @@ class RAGcoreKnowledgeProvider:
except (TypeError, KeyError, ValueError):
return unavailable
sources = _deduplicate_sources(sources)
all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question)
sources = _rank_sources_for_concepts(sources, concepts)
if not sources:
qualified_sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
]
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
if not all_sources:
return GroundedAnswer(
answer="",
evidence_state="insufficient",
@@ -363,11 +387,19 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id,
)
if not concepts:
damage_evidence = any(
term
in (
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
).casefold()
for source in sources
for term in _DOMAIN_CONCEPTS["damage"]
)
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=sources,
sources=all_sources,
provider=self.name,
correlation_id=correlation_id,
)
+173
View File
@@ -0,0 +1,173 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --constraint=requirements.lock --output-file=requirements-prod.lock pyproject.toml
#
alembic==1.18.5
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
annotated-doc==0.0.5
# via
# -c requirements.lock
# fastapi
annotated-types==0.8.0
# via
# -c requirements.lock
# pydantic
anyio==4.14.2
# via
# -c requirements.lock
# httpx
# starlette
# watchfiles
authlib==1.7.2
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
certifi==2026.7.22
# via
# -c requirements.lock
# httpcore
# httpx
cffi==2.1.1
# via
# -c requirements.lock
# cryptography
click==8.4.2
# via
# -c requirements.lock
# uvicorn
cryptography==50.0.0
# via
# -c requirements.lock
# authlib
# joserfc
fastapi==0.141.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
greenlet==3.5.4
# via
# -c requirements.lock
# sqlalchemy
h11==0.16.0
# via
# -c requirements.lock
# httpcore
# uvicorn
httpcore==1.0.9
# via
# -c requirements.lock
# httpx
httptools==0.8.0
# via
# -c requirements.lock
# uvicorn
httpx==0.28.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
idna==3.18
# via
# -c requirements.lock
# anyio
# httpx
itsdangerous==2.2.0
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
joserfc==1.7.4
# via
# -c requirements.lock
# authlib
mako==1.3.12
# via
# -c requirements.lock
# alembic
markupsafe==3.0.3
# via
# -c requirements.lock
# mako
prometheus-client==0.26.0
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
psycopg[binary]==3.3.4
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
psycopg-binary==3.3.4
# via
# -c requirements.lock
# psycopg
pycparser==3.0
# via
# -c requirements.lock
# cffi
pydantic==2.13.4
# via
# -c requirements.lock
# fastapi
# pydantic-settings
pydantic-core==2.46.4
# via
# -c requirements.lock
# pydantic
pydantic-settings==2.14.2
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
python-dotenv==1.2.2
# via
# -c requirements.lock
# pydantic-settings
# uvicorn
pyyaml==6.0.3
# via
# -c requirements.lock
# uvicorn
sqlalchemy==2.0.51
# via
# -c requirements.lock
# alembic
# mobilityops-api (pyproject.toml)
starlette==1.3.1
# via
# -c requirements.lock
# fastapi
typing-extensions==4.16.0
# via
# -c requirements.lock
# alembic
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# -c requirements.lock
# fastapi
# pydantic
# pydantic-settings
uvicorn[standard]==0.52.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
uvloop==0.22.1
# via
# -c requirements.lock
# uvicorn
watchfiles==1.2.0
# via
# -c requirements.lock
# uvicorn
websockets==17.0.1
# via
# -c requirements.lock
# uvicorn
+28 -1
View File
@@ -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
)
+147 -7
View File
@@ -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()
+44
View File
@@ -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"
+7 -1
View File
@@ -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