M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-17 03:17:44 +02:00
parent a9f48d6880
commit ae39a8947f
62 changed files with 5277 additions and 202 deletions
+5 -1
View File
@@ -11,6 +11,10 @@ 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
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
# Run migrations and the API as an unprivileged user; nothing here needs root.
USER app
EXPOSE 8000
CMD ["./entrypoint.sh"]
@@ -0,0 +1,28 @@
"""idempotency request fingerprint
Revision ID: 0a4c1d2e3f5b
Revises: c24f6a9d013e
Create Date: 2026-08-16 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0a4c1d2e3f5b"
down_revision: Union[str, None] = "c24f6a9d013e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"idempotency_records",
sa.Column("request_fingerprint", sa.String(length=64), nullable=True),
)
def downgrade() -> None:
op.drop_column("idempotency_records", "request_fingerprint")
+10 -3
View File
@@ -44,6 +44,13 @@ _ROUTE_TEMPLATES: dict[str, str] = {
}
def _as_utc(value: datetime | None) -> datetime | None:
"""Treat naive query datetimes as UTC so they compare safely with aware values."""
if value is None:
return None
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
@router.get("/export.csv")
def export_audit_csv(
occurred_from: datetime | None = Query(default=None),
@@ -51,8 +58,8 @@ def export_audit_csv(
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> Response:
end = occurred_to or datetime.now(UTC)
start = occurred_from or end - timedelta(days=30)
end = _as_utc(occurred_to) or datetime.now(UTC)
start = _as_utc(occurred_from) or end - timedelta(days=30)
if end <= start or end - start > timedelta(days=90):
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
events = db.scalars(
@@ -133,7 +140,7 @@ def list_audit_events(
action: str | None = Query(default=None),
entity_type: str | None = Query(default=None),
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
correlation_id: str | None = Query(default=None),
correlation_id: uuid.UUID | None = Query(default=None),
occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
+38
View File
@@ -11,6 +11,7 @@ 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 FailedAttemptLimiter
from app.core.security import (
SessionPayload,
create_session_token,
@@ -25,6 +26,25 @@ from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
_login_limiter = (
FailedAttemptLimiter(
max_failures=settings.login_max_failures,
window_seconds=settings.login_failure_window_seconds,
)
if settings.login_max_failures > 0
else None
)
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.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
oauth = OAuth()
if settings.oidc_enabled and settings.oidc_issuer_url:
oauth.register(
@@ -139,6 +159,11 @@ def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.external_subject not in (None, subject):
raise HTTPException(status_code=409, detail="Email is linked to another identity")
if user is not None and claims.get("email_verified") is not True:
# Linking an existing local account (possibly the bootstrap admin) purely on an
# email match requires the IdP to explicitly assert the address is verified;
# an absent claim is treated as unverified.
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
created = user is None
if created:
if not settings.oidc_auto_provision:
@@ -223,6 +248,7 @@ async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Resp
@router.post("/login", response_model=CurrentUser)
def password_login(
body: PasswordLoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> CurrentUser:
@@ -231,9 +257,21 @@ def password_login(
status_code=status.HTTP_404_NOT_FOUND,
detail="Password login is unavailable in demo mode",
)
limiter_key = _client_key(request)
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed login attempts. Try again later.",
headers={"Retry-After": str(retry_after)},
)
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
if user is None or not user.active or not verify_password(body.password, user.password_hash):
if _login_limiter:
_login_limiter.record_failure(limiter_key)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if _login_limiter:
_login_limiter.reset(limiter_key)
_set_session(response, user)
record_audit_event(
db,
+10 -3
View File
@@ -70,7 +70,9 @@ def list_bookings(
if vehicle_ref:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None:
return []
if page is None:
return []
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
if starts_from:
stmt = stmt.where(Booking.ends_at >= starts_from)
@@ -445,8 +447,13 @@ def cancel_booking(
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(status_code=409, detail="Only a reserved booking can be cancelled")
if booking.status not in ("reserved", "blocked"):
# A booking blocked at checkout (damage, technical warning, ...) has no other exit:
# it never became active, so it can neither be returned nor completed. Cancelling
# it (audited, with a reason) is the only way to close the file.
raise HTTPException(
status_code=409, detail="Only a reserved or blocked booking can be cancelled"
)
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
if customer is None or vehicle is None:
+15 -4
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from datetime import date, datetime
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends
from sqlalchemy import select
@@ -30,10 +31,20 @@ settings = get_settings()
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _local_tz() -> ZoneInfo:
return ZoneInfo(settings.demo_timezone)
def _today() -> date:
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
return datetime.now(UTC).date()
# Timestamps are stored in UTC but the operational day is the local (Europe/Brussels)
# calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer.
return datetime.now(_local_tz()).date()
def _local_date(value: datetime) -> date:
return value.astimezone(_local_tz()).date()
@router.get("", response_model=DashboardOut)
@@ -97,7 +108,7 @@ def get_dashboard(
for b in bookings:
vehicle = vehicles_by_id.get(b.vehicle_id)
vehicle_ref = vehicle.public_ref if vehicle else ""
if b.starts_at.date() == today and b.status in ("reserved", "active"):
if _local_date(b.starts_at) == today and b.status in ("reserved", "active"):
today_items.append(
TodayItem(
kind="departure",
@@ -106,7 +117,7 @@ def get_dashboard(
scheduled_at=b.starts_at,
)
)
if b.ends_at.date() == today and b.status in ("active", "returned"):
if _local_date(b.ends_at) == today and b.status in ("active", "returned"):
today_items.append(
TodayItem(
kind="return",
+5
View File
@@ -75,6 +75,7 @@ def list_issues(
severity: str | None = Query(default=None),
assigned_to_ref: str | None = Query(default=None),
overdue: bool | None = Query(default=None),
demo_only: bool | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db),
@@ -107,6 +108,10 @@ def list_issues(
DataQualityIssue.status == "open",
DataQualityIssue.due_at < datetime.now(UTC),
)
if demo_only is True:
# Server-side so the guided demo scenarios are found on any page, not only the
# 25 rows currently loaded in the browser.
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
issues = db.scalars(
+4
View File
@@ -111,6 +111,10 @@ def demo_reset(
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
global _last_reset_monotonic
if not settings.mobilityops_demo_mode:
# Outside demo mode the reset endpoint must not exist at all: it wipes
# operational data and replaces it with synthetic records.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
if not settings.demo_allow_reset:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
+19 -18
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
import hmac
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Header
from sqlalchemy import select
@@ -21,6 +21,7 @@ from app.schemas import (
ProcedureListOut,
ProcedureSyncResultIn,
ProcedureSyncResultResult,
ReturnCallbackIn,
ScanResultOut,
WorkflowErrorReportIn,
WorkflowErrorReportResult,
@@ -42,6 +43,14 @@ _CANONICAL_WORKFLOW_NAMES = frozenset(
)
def _require_service_token(service_token: str) -> None:
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
if not hmac.compare_digest(
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
):
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
def workflow_heartbeat(
body: N8nHeartbeatIn,
@@ -49,8 +58,7 @@ def workflow_heartbeat(
db: Session = Depends(get_db),
) -> N8nHeartbeatResult:
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
_require_service_token(service_token)
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
already_recorded = (
@@ -87,13 +95,12 @@ def workflow_heartbeat(
@router.post("/return-callback")
def return_callback(
body: dict[str, Any],
body: ReturnCallbackIn,
idempotency_key: str = Header(..., alias="Idempotency-Key"),
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> dict:
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
_require_service_token(service_token)
try:
event_id = uuid.UUID(idempotency_key)
@@ -124,10 +131,8 @@ def return_callback(
actor_label="n8n",
action="n8n_return_followup_recorded",
entity_type="booking",
correlation_id=uuid.UUID(body.get("correlation_id"))
if body.get("correlation_id")
else None,
after={"follow_up": body.get("follow_up"), "summary": body.get("summary")},
correlation_id=body.correlation_id,
after={"follow_up": body.follow_up, "summary": body.summary},
metadata={"event_id": str(event_id)},
)
db.commit()
@@ -148,8 +153,7 @@ def scheduled_scan(
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
doesn't already have one open, so a duplicate or overlapping trigger does no
duplicate domain work -- it just reports zero new issues for anything already known."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
_require_service_token(service_token)
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
return ScanResultOut(created=result.created)
@@ -165,8 +169,7 @@ def workflow_error(
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
error report (e.g. after a timed-out response), so this must not double-record."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
_require_service_token(service_token)
already_recorded = (
db.scalar(
@@ -218,8 +221,7 @@ def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -
Markdown file Fleet Ops ships, across every supported language, with a stable
per-document id (source_id) and a content hash so the caller can detect changes
without re-fetching content it already has."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
_require_service_token(service_token)
documents = [
ProcedureDocumentOut(
@@ -245,8 +247,7 @@ def procedures_sync_result(
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
_require_service_token(service_token)
already_recorded = (
db.scalar(
+41 -1
View File
@@ -64,12 +64,52 @@ class Settings(BaseSettings):
oidc_auto_provision: bool = True
oidc_default_role: str = "rental_employee"
log_level: str = "INFO"
# Failed password logins per client IP before a temporary 429 (0 disables).
login_max_failures: int = 10
login_failure_window_seconds: int = 900
metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555
privacy_audit_export_max_rows: int = 10000
# Secrets that guard *inbound* trust (session cookies, service callbacks). Running
# production with any of these at their placeholder value means forged sessions or
# unauthenticated writes, so startup refuses.
INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
("app_secret", "replace-in-production"),
("n8n_callback_token", "replace-me-n8n-callback-token"),
("mcp_hub_service_token", "replace-me-mcp-hub-token"),
)
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.
"""
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)
return insecure
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = Settings()
if settings.mobilityops_env.lower() == "production":
insecure = insecure_default_secrets(settings)
if insecure:
# Refuse to boot rather than run production with forgeable session cookies
# or guessable service tokens. Development/test/demo keep the defaults.
raise RuntimeError(
"Refusing to start in production with placeholder secrets: "
+ ", ".join(insecure)
+ ". Set real values in the environment (see .env.example)."
)
return settings
+10 -1
View File
@@ -74,10 +74,19 @@ def correlation_id_for(request: Request) -> str:
return str(uuid.uuid4())
UNMATCHED_ROUTE_LABEL = "<unmatched>"
def route_label(request: Request) -> str:
"""Return the route *template* for metrics labels.
Unmatched paths (404 probes, scanners) must not become their own label value:
every distinct URL would otherwise create a new Prometheus time series and the
metric cardinality would grow without bound.
"""
route = request.scope.get("route")
path = getattr(route, "path", None)
return str(path or request.url.path)
return str(path) if path else UNMATCHED_ROUTE_LABEL
def request_started() -> float:
+49
View File
@@ -0,0 +1,49 @@
"""Small in-process failed-attempt limiter for credential endpoints.
Fleet Ops runs as a single API process per deployment, so an in-memory sliding window
is sufficient to blunt online password guessing (and the scrypt CPU amplification that
comes with it) without adding Redis. Only *failed* attempts count, so legitimate users
and the automated test suite are never throttled.
"""
from __future__ import annotations
import threading
import time
from collections import deque
class FailedAttemptLimiter:
def __init__(self, *, max_failures: int, window_seconds: float) -> None:
self.max_failures = max_failures
self.window_seconds = window_seconds
self._failures: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def _prune(self, key: str, now: float) -> deque[float]:
bucket = self._failures.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if not bucket:
self._failures.pop(key, None)
return bucket
def retry_after_seconds(self, key: str) -> int:
"""Return >0 seconds to wait when the key is currently blocked, else 0."""
now = time.monotonic()
with self._lock:
bucket = self._prune(key, now)
if len(bucket) < self.max_failures:
return 0
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
def record_failure(self, key: str) -> None:
now = time.monotonic()
with self._lock:
self._prune(key, now)
self._failures.setdefault(key, deque()).append(now)
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
+1 -1
View File
@@ -23,4 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id")
)
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+3
View File
@@ -15,5 +15,8 @@ class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
)
# SHA-256 of the canonical request body. Replaying a key with a *different* body is
# a client bug and must be rejected instead of silently answered with the old result.
request_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
response_status: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict] = mapped_column(JSONB, nullable=False)
+16 -1
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
Role = Literal["operations_manager", "rental_employee"]
@@ -423,6 +424,20 @@ class N8nWorkflowEvidence(BaseModel):
last_execution_id: str | None = None
class ReturnCallbackIn(BaseModel):
"""Body of the n8n return follow-up callback.
n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored;
only the fields we persist are validated and bounded.
"""
model_config = ConfigDict(extra="ignore")
correlation_id: uuid.UUID | None = None
follow_up: str | None = Field(default=None, max_length=200)
summary: str | None = Field(default=None, max_length=2000)
class N8nHeartbeatIn(BaseModel):
workflow_id: str = Field(min_length=1, max_length=120)
workflow_name: str = Field(min_length=1, max_length=200)
+2 -2
View File
@@ -19,7 +19,6 @@ from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.revoked_session import RevokedSession
from app.models.user import User
from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event
@@ -92,8 +91,9 @@ _PERSISTENT_TELEMETRY_ACTIONS = (
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
# RevokedSession is intentionally NOT cleared: it has no FK to users and wiping it
# would silently re-validate cookies that were logged out before the reset.
for model in (
RevokedSession,
OutboxEvent,
IdempotencyRecord,
DataQualityIssue,
+73 -28
View File
@@ -130,7 +130,12 @@ def _open_issue(
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
customers = list(
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
db.scalars(
select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all()
)
customers.sort(key=lambda c: c.public_ref)
# The threshold cannot be reached without an exact email (60 points) or phone
@@ -170,9 +175,7 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
@@ -191,8 +194,13 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
# Anonymised customers have had their contact data removed on purpose; flagging
# them as "missing required field" would only be resolvable by re-entering PII.
for customer in db.scalars(
select(Customer).where(Customer.merged_into_customer_id.is_(None))
select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all():
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)]
if not customer.email and not customer.phone:
@@ -519,6 +527,30 @@ def resolve_odometer_regression(
"This issue is not an odometer_regression issue.",
status_code=409,
)
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
booking: Booking | None = None
if body.decision != "retain_canonical":
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None:
raise AppError(
@@ -539,19 +571,7 @@ def resolve_odometer_regression(
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
)
else:
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
assert booking is not None and body.corrected_odometer_km is not None
# Never silently lower the canonical odometer: a correction must be at or above
# the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km:
@@ -563,13 +583,6 @@ def resolve_odometer_regression(
),
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
before = {
"booking_end_odometer_km": booking.end_odometer_km,
@@ -810,6 +823,16 @@ def apply_recommended_status(
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
# Mirrors the column lengths in app/models/customer.py so an override can never fail with
# a database DataError (500) instead of a validation error.
_MERGEABLE_FIELD_MAX_LENGTH = {
"first_name": 80,
"last_name": 80,
"email": 200,
"phone": 40,
"postal_code": 20,
"city": 120,
}
def merge_customers(
@@ -839,12 +862,26 @@ def merge_customers(
)
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
survivor = db.scalar(select(Customer).where(Customer.public_ref == survivor_ref))
loser = db.scalar(select(Customer).where(Customer.public_ref == loser_ref))
# Lock both rows in a deterministic order (by public_ref) so two concurrent merges
# touching the same customers serialise instead of deadlocking or double-merging.
survivor = None
loser = None
for ref in sorted((survivor_ref, loser_ref)):
customer = db.scalar(select(Customer).where(Customer.public_ref == ref).with_for_update())
if ref == survivor_ref:
survivor = customer
else:
loser = customer
if survivor is None or loser is None:
raise AppError(
"CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404
)
if survivor.merged_into_customer_id is not None or loser.merged_into_customer_id is not None:
raise AppError(
"CUSTOMER_ALREADY_MERGED",
"One of the customers has already been merged into another record.",
status_code=409,
)
before = {
"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS},
@@ -856,7 +893,15 @@ def merge_customers(
raise AppError(
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
)
setattr(survivor, field_name, value)
cleaned = value.strip() if isinstance(value, str) else value
max_length = _MERGEABLE_FIELD_MAX_LENGTH[field_name]
if not cleaned or len(cleaned) > max_length:
raise AppError(
"INVALID_FIELD_OVERRIDE",
f"Field '{field_name}' must be 1 to {max_length} characters.",
status_code=422,
)
setattr(survivor, field_name, cleaned)
rewired = db.execute(
update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id)
+21 -12
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import threading
import time
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Literal
from typing import Any, Literal
import httpx
from sqlalchemy import func, select
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -38,6 +39,22 @@ _STALE_AFTER = {
}
def _latest_rows_per_workflow(db: Session, action: str) -> Sequence[Row[Any]]:
"""Return the most recent audit row per workflow name for one action.
Heartbeats arrive on every scheduled run, so loading *all* rows and picking the
latest in Python would grow linearly with deployment age. ``DISTINCT ON`` lets
PostgreSQL return exactly one (latest) row per workflow instead.
"""
workflow_name = AuditEvent.after_json["workflow_name"].astext
return db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == action)
.distinct(workflow_name)
.order_by(workflow_name, AuditEvent.occurred_at.desc())
).all()
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
counts: dict[str, int] = dict(
db.execute(
@@ -144,11 +161,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
}
heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
heartbeat_rows = db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == "n8n_workflow_heartbeat")
.order_by(AuditEvent.occurred_at.desc())
).all()
heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat")
for occurred_at, after, metadata in heartbeat_rows:
workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow:
@@ -159,11 +172,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
)
failure_by_workflow: dict[str, tuple[datetime, str | None]] = {}
failure_rows = db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == "n8n_workflow_failure_registered")
.order_by(AuditEvent.occurred_at.desc())
).all()
failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered")
for occurred_at, after, metadata in failure_rows:
workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow:
@@ -148,6 +148,8 @@ class RAGcoreKnowledgeProvider:
with self._client() as client:
response = client.get("/health/ready")
body = response.json()
if not isinstance(body, dict):
raise ValueError("health response is not a JSON object")
available = response.status_code == 200 and body.get("status") == "ok"
detail = (
"RAGcore reachable and ready."
+34 -10
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -171,6 +173,33 @@ def preview_vehicle_return(
return booking, vehicle, evaluation
def request_fingerprint(body: RegisterReturnRequest) -> str:
canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _replay_or_reject(
db: Session,
existing: IdempotencyRecord,
booking_ref: str,
fingerprint: str,
) -> tuple[int, dict]:
booking = db.get(Booking, existing.booking_id)
if booking is None or booking.public_ref != booking_ref:
raise AppError(
"IDEMPOTENCY_KEY_REUSED",
"This idempotency key was already used for a different booking.",
status_code=409,
)
if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint:
raise AppError(
"IDEMPOTENCY_KEY_REUSED",
"This idempotency key was already used with a different request body.",
status_code=409,
)
return existing.response_status, existing.response_body
def register_vehicle_return(
db: Session,
booking_ref: str,
@@ -178,18 +207,12 @@ def register_vehicle_return(
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]:
fingerprint = request_fingerprint(body)
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
booking = db.get(Booking, existing.booking_id)
if booking is None or booking.public_ref != booking_ref:
raise AppError(
"IDEMPOTENCY_KEY_REUSED",
"This idempotency key was already used for a different booking.",
status_code=409,
)
return existing.response_status, existing.response_body
return _replay_or_reject(db, existing, booking_ref, fingerprint)
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
@@ -199,7 +222,7 @@ def register_vehicle_return(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return existing.response_status, existing.response_body
return _replay_or_reject(db, existing, booking_ref, fingerprint)
if booking.status != "active":
raise AppError(
@@ -336,6 +359,7 @@ def register_vehicle_return(
IdempotencyRecord(
idempotency_key=idempotency_key,
booking_id=booking.id,
request_fingerprint=fingerprint,
response_status=201,
response_body=response_body,
)
@@ -349,7 +373,7 @@ def register_vehicle_return(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return existing.response_status, existing.response_body
return _replay_or_reject(db, existing, booking_ref, fingerprint)
raise
return 201, response_body
+2 -3
View File
@@ -15,7 +15,6 @@ dependencies = [
"psycopg[binary]>=3.2,<4",
"alembic>=1.13,<2",
"httpx>=0.27,<1",
"httpx2>=2.10,<3",
"authlib>=1.6,<2",
"itsdangerous>=2.2,<3",
"prometheus-client>=0.24,<1"
@@ -24,7 +23,8 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=8,<9",
"pytest-asyncio>=0.24,<1",
# Starlette's TestClient (>=1.0) prefers httpx2 and warns when only httpx is present.
"httpx2>=2.10,<3",
"ruff>=0.8,<1",
"mypy>=1.13,<2"
]
@@ -34,7 +34,6 @@ packages = ["app"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
-4
View File
@@ -95,10 +95,6 @@ pydantic-settings==2.14.2
pygments==2.20.0
# via pytest
pytest==8.4.2
# via
# mobilityops-api (pyproject.toml)
# pytest-asyncio
pytest-asyncio==0.26.0
# via mobilityops-api (pyproject.toml)
python-dotenv==1.2.2
# via
+196
View File
@@ -0,0 +1,196 @@
"""Regression tests for the hardening pass (security, robustness, data-quality edge cases)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
from sqlalchemy import delete, select
from app.core.config import Settings, get_settings, insecure_default_secrets
from app.core.db import SessionLocal
from app.core.observability import UNMATCHED_ROUTE_LABEL
from app.core.ratelimit import FailedAttemptLimiter
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 tests.test_return import _activate_booking, _return_body
def test_insecure_defaults_are_detected_only_for_relevant_secrets():
defaults = Settings(_env_file=None)
assert "app_secret" in insecure_default_secrets(defaults)
assert "mcp_hub_service_token" not in insecure_default_secrets(defaults)
hardened = Settings(
_env_file=None,
app_secret="x" * 32,
n8n_callback_token="c" * 32,
mcp_hub_registration_enabled=True,
)
assert insecure_default_secrets(hardened) == ["mcp_hub_service_token"]
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
monkeypatch.setenv("MOBILITYOPS_ENV", "production")
monkeypatch.setenv("APP_SECRET", "replace-in-production")
get_settings.cache_clear()
try:
with pytest.raises(RuntimeError, match="placeholder secrets"):
get_settings()
finally:
get_settings.cache_clear()
monkeypatch.undo()
get_settings.cache_clear()
# The cached settings the running app relies on must be intact afterwards.
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):
assert limiter.retry_after_seconds("1.2.3.4") == 0
limiter.record_failure("1.2.3.4")
assert limiter.retry_after_seconds("1.2.3.4") > 0
assert limiter.retry_after_seconds("5.6.7.8") == 0
limiter.reset("1.2.3.4")
assert limiter.retry_after_seconds("1.2.3.4") == 0
def test_audit_export_accepts_naive_datetimes(ops_client):
response = ops_client.get(
"/api/v1/audit/export.csv",
params={"occurred_from": "2026-01-01T00:00:00", "occurred_to": "2026-01-15T00:00:00"},
)
assert response.status_code == 200
def test_audit_list_rejects_non_uuid_correlation_id(ops_client):
response = ops_client.get("/api/v1/audit", params={"correlation_id": "not-a-uuid"})
assert response.status_code == 422
def test_unmatched_routes_do_not_create_metric_series(client):
probe = f"/api/v1/does-not-exist-{uuid.uuid4().hex}"
assert client.get(probe).status_code == 404
metrics = client.get("/metrics").text
assert probe not in metrics
assert UNMATCHED_ROUTE_LABEL in metrics
def test_bookings_page_for_unknown_vehicle_keeps_page_shape(ops_client):
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-NOPE", "page": 1})
assert response.status_code == 200
assert response.json() == {
"items": [],
"page": 1,
"page_size": 25,
"total": 0,
"total_pages": 1,
}
def test_return_idempotency_key_rejects_different_body(ops_client):
booking_ref = _activate_booking("MO-011", start_odometer_km=30000)
key = "test-return-fingerprint-001"
first = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=30500),
headers={"Idempotency-Key": key},
)
assert first.status_code == 201
replay = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=30500),
headers={"Idempotency-Key": key},
)
assert replay.status_code == 201
mismatch = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=30999),
headers={"Idempotency-Key": key},
)
assert mismatch.status_code == 409
assert mismatch.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
def test_return_callback_rejects_malformed_correlation_id(client):
response = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning", "correlation_id": "nope"},
headers={
"Idempotency-Key": str(uuid.uuid4()),
"X-Service-Token": get_settings().n8n_callback_token,
},
)
assert response.status_code == 422
def test_blocked_checkout_booking_can_be_cancelled(ops_client):
window = {"starts_at": "2051-03-01T10:00:00Z", "ends_at": "2051-03-02T12:00:00Z"}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
vehicle_option = next(item for item in available if item["operational_status"] == "available")
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
booking = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0002",
"vehicle_ref": vehicle["public_ref"],
"requirements_complete": True,
**window,
},
).json()
checkout = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
"start_odometer_km": vehicle["odometer_km"],
"fuel_level_percent": 80,
"cleanliness_ok": True,
"damage_reported": True,
"technical_warning": False,
},
)
assert checkout.status_code == 200
assert checkout.json()["booking_status"] == "blocked"
cancelled = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/cancel",
json={"reason": "Vehicle damaged at departure inspection"},
)
assert cancelled.status_code == 200
assert cancelled.json()["status"] == "cancelled"
def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-ANON-SCAN",
first_name="Anoniem",
last_name="Klant",
email=None,
phone=None,
postal_code=None,
city=None,
date_of_birth=None,
anonymized_at=datetime.now(UTC),
)
db.add(customer)
db.commit()
customer_id = customer.id
try:
with SessionLocal() as db:
run_scan(db)
db.commit()
flagged = db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.entity_type == "customer",
DataQualityIssue.entity_id == customer_id,
)
)
assert flagged is None
finally:
with SessionLocal() as db:
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == customer_id))
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit()
+88
View File
@@ -0,0 +1,88 @@
"""Guard against model/migration drift.
The functional suite builds its schema with ``Base.metadata.create_all`` for speed, so a
column or index added to a model but never written into an Alembic migration would only
surface on the first real deployment. This test runs the migration chain from an empty
database and asserts that Alembic's autogenerate sees nothing left to do.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from alembic.autogenerate import compare_metadata
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from alembic import command
from app.core.config import get_settings
from app.models import Base
BACKEND_DIR = Path(__file__).resolve().parents[1]
SCRATCH_DB = "mobilityops_migration_check"
@pytest.fixture(scope="module")
def migrated_database_url() -> str:
settings = get_settings()
base_url = make_url(settings.database_url)
admin_engine = create_engine(
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
)
with admin_engine.connect() as conn:
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}"'))
conn.execute(text(f'CREATE DATABASE "{SCRATCH_DB}"'))
scratch_url = base_url.set(database=SCRATCH_DB).render_as_string(hide_password=False)
try:
yield scratch_url
finally:
admin_engine.dispose()
admin_engine = create_engine(
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
)
with admin_engine.connect() as conn:
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)'))
admin_engine.dispose()
def _alembic_config(database_url: str) -> Config:
config = Config(str(BACKEND_DIR / "alembic.ini"))
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
config.set_main_option("sqlalchemy.url", database_url)
return config
def test_migrations_upgrade_from_empty_and_match_models(migrated_database_url: str) -> None:
config = _alembic_config(migrated_database_url)
# env.py reads DATABASE_URL from settings; override it for the scratch database.
import os
previous = os.environ.get("DATABASE_URL")
os.environ["DATABASE_URL"] = migrated_database_url
get_settings.cache_clear()
try:
command.upgrade(config, "head")
finally:
if previous is None:
os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = previous
get_settings.cache_clear()
engine = create_engine(migrated_database_url)
try:
with engine.connect() as conn:
context = MigrationContext.configure(
conn, opts={"compare_type": True, "compare_server_default": False}
)
diff = compare_metadata(context, Base.metadata)
finally:
engine.dispose()
assert diff == [], (
"Models and Alembic migrations have drifted; write a migration for:\n"
+ "\n".join(repr(entry) for entry in diff)
)