M39: harden application and acceptance gates
This commit is contained in:
+5
-1
@@ -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")
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user