M15: synchronize contracts and acceptance

This commit is contained in:
NuklearRabbit
2026-08-10 05:39:19 +02:00
parent 58fb515337
commit 2ee8b2d82b
38 changed files with 5091 additions and 1727 deletions
+42
View File
@@ -2356,3 +2356,45 @@ evidence yet."
execution ID visible.
- Exact next action: regenerate the checked-in OpenAPI contract, add E2E coverage for the
new operator workflows, document credential-safe n8n upgrades and run acceptance.
## Contract and acceptance synchronization (2026-08-10)
- Replaced the obsolete hand-maintained OpenAPI baseline with a deterministic snapshot
generated directly from the FastAPI application. The committed contract now describes
all 53 paths and 75 schemas, including booking lifecycle, operational users, MCP trust
headers and n8n execution telemetry.
- Added browser acceptance coverage for booking creation/cancellation, user
creation/deactivation and vehicle maintenance/release. Existing acceptance journeys
now reset their own state, tolerate the configured honest knowledge provider and allow
the provider's bounded response window instead of depending on suite order or a demo
provider that is not active in production.
- Added a credential-reference merge utility and a runbook procedure that preserves live
n8n credential IDs during workflow upgrades without exporting or committing secrets.
- Hardened logout beyond browser cookie deletion: signed sessions now carry a unique
nonce, logout persists a token-hash denylist, expired revocations are pruned, and both
demo and operational authentication reject retained or copied cookies server-side.
Repeated live refresh/logout coverage passed **40/40**.
- Removed a return-form initialization race: the return form now mounts only after the
persisted demo manifest is ready, so operator input cannot be overwritten by a late
manifest response. The final mobile row interaction is represented by actual link
semantics rather than a nested interactive table row.
- Split frontend dependency installation from source compilation in the Docker build so
dependency layers are cached and reproducible. Upgraded the build toolchain to pinned
Vite **8.2.1** and `@vitejs/plugin-react` **6.0.5**; both the production-only and full
npm audits report **0 vulnerabilities**.
- **Final acceptance evidence**: generated OpenAPI output is byte-for-byte deterministic;
frontend TypeScript/production build, ruff, mypy and diff checks pass; Alembic reports
`d1f83bc64170 (head)`; the complete backend suite passes **213 tests**; the complete
Playwright suite passes **144 tests in 4.8 minutes** against the deployed production
bundle.
- **Live integration evidence**: a real vehicle return completed through local commit →
outbox → the existing central n8n → callback → heartbeat. After the final browser run,
Vehicle Return execution **376** is healthy, n8n is operational with zero pending
events, MCP Hub is reachable and operational with a real audited
`fleet_ops_get_operations_summary` call, and RAGcore reports available/ready.
- **Restored hand-off state**: the synthetic reset reports all scenarios ready with 2
users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance
records, 33 data-quality issues and 20 workflow runs; `BK-DEMO-RETURN` is active again.
- **Exact next action**: none for the locked PoC. All acceptance criteria are satisfied;
subsequent work is routine production operation, monitoring and explicitly approved
scope beyond this build.
@@ -0,0 +1,49 @@
"""persist revoked sessions
Revision ID: d1f83bc64170
Revises: b7c7b536df85
"""
import sqlalchemy as sa
from alembic import op
revision = "d1f83bc64170"
down_revision = "b7c7b536df85"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"revoked_sessions",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"])
op.create_index(
"ix_revoked_sessions_token_hash",
"revoked_sessions",
["token_hash"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions")
op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions")
op.drop_table("revoked_sessions")
+3 -1
View File
@@ -13,6 +13,7 @@ from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, Role
from app.services.sessions import is_session_revoked
settings = get_settings()
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
@@ -29,7 +30,8 @@ def get_db() -> Generator[Session, None, None]:
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None
if payload is None or payload.role not in _VALID_ROLES:
revoked = token is not None and is_session_revoked(db, token)
if payload is None or payload.role not in _VALID_ROLES or revoked:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
# signed demo session until the reset endpoint clears its cookie keeps existing demo
+4 -1
View File
@@ -19,6 +19,7 @@ from app.core.security import (
from app.models.user import User
from app.schemas import CurrentUser, PasswordLoginRequest
from app.services.audit import record_audit_event
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
@@ -37,6 +38,7 @@ def _set_session(response: Response, user: User) -> None:
SessionPayload(
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
display_name=user.display_name, issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
@@ -114,7 +116,8 @@ def get_session(response: Response, user: CurrentUser = Depends(get_current_user
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None:
if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event(
db,
actor_type="user",
+4 -1
View File
@@ -15,6 +15,7 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
@@ -48,6 +49,7 @@ def demo_login(
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
@@ -85,7 +87,8 @@ def get_session(
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None:
if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event(
db,
actor_type="user",
+6
View File
@@ -20,6 +20,7 @@ class SessionPayload:
role: str
display_name: str
issued_at: int
session_id: str = ""
def _sign(data: bytes) -> str:
@@ -27,6 +28,11 @@ def _sign(data: bytes) -> str:
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def session_token_hash(token: str) -> str:
"""Return a non-reversible identifier safe to persist for token revocation."""
return hashlib.sha256(token.encode()).hexdigest()
def create_session_token(payload: SessionPayload) -> str:
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
+2
View File
@@ -7,6 +7,7 @@ from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.revoked_session import RevokedSession
from app.models.user import User
from app.models.vehicle import Vehicle
@@ -20,6 +21,7 @@ __all__ = [
"Inspection",
"MaintenanceRecord",
"OutboxEvent",
"RevokedSession",
"User",
"Vehicle",
]
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "revoked_sessions"
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
+2
View File
@@ -19,6 +19,7 @@ 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,6 +93,7 @@ _PERSISTENT_TELEMETRY_ACTIONS = (
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
for model in (
RevokedSession,
OutboxEvent,
IdempotencyRecord,
DataQualityIssue,
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import SessionPayload, session_token_hash
from app.models.revoked_session import RevokedSession
settings = get_settings()
def is_session_revoked(db: Session, token: str) -> bool:
token_hash = session_token_hash(token)
revoked_id = db.scalar(
select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)
)
return revoked_id is not None
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
token_hash = session_token_hash(token)
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
if existing is not None:
return
db.add(
RevokedSession(
token_hash=token_hash,
expires_at=datetime.fromtimestamp(
payload.issued_at + settings.session_ttl_seconds, UTC
),
)
)
+31
View File
@@ -0,0 +1,31 @@
"""Generate the checked-in API contract from the FastAPI application."""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.main import app # noqa: E402
def main() -> None:
schema = app.openapi()
schema["info"]["description"] = (
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
"MobilityOps remains the technical repository and service identifier."
)
schema["servers"] = [{"url": "http://localhost:8128"}]
output = ROOT / "contracts" / "openapi.yaml"
output.write_text(
yaml.safe_dump(schema, sort_keys=False, allow_unicode=True, width=100),
encoding="utf-8",
)
if __name__ == "__main__":
main()
+28
View File
@@ -62,6 +62,34 @@ def test_logout_invalidates_session(ops_client):
assert after.status_code == 401
def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
from app.core.config import get_settings
cookie_name = get_settings().session_cookie_name
stolen_token = ops_client.cookies.get(cookie_name)
assert stolen_token
logout = ops_client.post("/api/v1/auth/logout")
assert logout.status_code == 200
# Simulate the browser cookie race (or a copied cookie): server-side revocation is
# authoritative and must reject the original signed token independently of deletion.
ops_client.cookies.set(cookie_name, stolen_token)
assert ops_client.get("/api/v1/auth/session").status_code == 401
# Remove the deliberately injected hostless cookie before exercising a normal browser
# login. Otherwise httpx sends it alongside the real testserver cookie, which is not a
# state a browser can create for the same origin/path pair.
ops_client.cookies.clear()
# A fresh login in the same second receives a distinct signed token and remains valid.
fresh_login = ops_client.post(
"/api/v1/demo/login", json={"role": "operations_manager"}
)
assert fresh_login.status_code == 200
assert ops_client.get("/api/v1/auth/session").status_code == 200
def test_logout_without_a_session_is_safe(client):
response = client.post("/api/v1/demo/logout")
assert response.status_code == 200
+4058 -319
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -135,6 +135,27 @@ sed -i \
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
```
For an update of already configured workflows, preserve the live credential IDs. n8n
2.x's CLI can bind every Header Auth node to the same credential when two credentials of
that type exist, even when the committed names differ. Never import the name-only JSON
directly over a live workflow. Export first, merge only the non-secret references, then
import the generated files:
```bash
docker exec n8n n8n export:workflow --all \
--output=/data/backups/mobilityops-pre-update.json
python n8n/workflows/merge_credential_refs.py \
/mnt/cache/appdata/n8n/backups/mobilityops-pre-update.json \
n8n/workflows /mnt/cache/appdata/n8n/imports/mobilityops-safe
# import each generated fleet-ops-*.json, publish the four known IDs, then restart n8n
```
The merge utility copies credential IDs and names only; credential values remain inside
n8n's encrypted credential store. After restart, export the return workflow and verify
that `Return webhook` uses `Fleet Ops Webhook Trigger Token`, while `Record follow-up`
and `Report workflow heartbeat` use `Fleet Ops Service Token`. Finally run a real return
and verify both a succeeded outbox delivery and a heartbeat execution ID in Automation.
The setup script reads the callback token from the mode-0600 deployment `.env`, builds and
removes a temporary server-side import without writing the token to Git, and restarts the
existing n8n so the production webhook is registered. The imported configuration remains
+2 -1
View File
@@ -1,9 +1,10 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
RUN npm ci --no-audit --no-fund
COPY public ./public
COPY src ./src
RUN npm ci && npm run build
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
+9 -2
View File
@@ -88,11 +88,18 @@ test("attention queue row opens the correct record on a mobile viewport tap", as
await page.goto("/dashboard");
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
const link = row.locator(".row-link");
const [rowBox, linkBox] = await Promise.all([row.boundingBox(), link.boundingBox()]);
expect(rowBox).not.toBeNull();
expect(linkBox).not.toBeNull();
// The stretched link itself must cover the whole mobile row. Locator.click handles
// scrolling and fixed mobile chrome before clicking the link's empty right-hand area.
expect(Math.abs(linkBox!.width - rowBox!.width)).toBeLessThanOrEqual(1);
expect(Math.abs(linkBox!.height - rowBox!.height)).toBeLessThanOrEqual(1);
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
// a mouse click at the same mobile viewport size still exercises the same CSS layout
// and click-target logic, since the app has no touch-specific event handling.
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await link.click({ position: { x: linkBox!.width - 10, y: linkBox!.height / 2 } });
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
+2 -4
View File
@@ -68,10 +68,8 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an
// The status badge and retrieval-flow diagram must name the actual active provider
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
// allowed to mention RAGcore by name when explaining it isn't live yet.
await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank");
await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
});
+4 -2
View File
@@ -74,8 +74,10 @@ test("five-minute demo script end to end", async ({ page, request }) => {
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
await page.getByRole("button", { name: "Vraag stellen" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
const damageSource = page.locator(".source-card", { hasText: /damage-procedure\.md|Procedure schadeafhandeling/ }).first();
await expect(damageSource).toBeVisible();
await expect(damageSource).toContainText(/zichtbare of gemelde schade/i);
});
await test.step("8. inspect audit entries", async () => {
+9 -6
View File
@@ -237,17 +237,17 @@ test.describe("knowledge base is grounded in the operator's own language", () =>
{
lang: "nl-BE",
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
sourceHint: /schadeafhandeling/i,
sourceHint: /zichtbare of gemelde schade/i,
},
{
lang: "en-GB",
question: "What should I do when a vehicle returns with damage?",
sourceHint: /damage/i,
sourceHint: /visible or reported damage/i,
},
{
lang: "fr-BE",
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
sourceHint: /dommages/i,
sourceHint: /dommages visibles ou signalés/i,
},
];
@@ -258,7 +258,9 @@ test.describe("knowledge base is grounded in the operator's own language", () =>
await page.goto("/knowledge");
await page.locator("#knowledge-question").fill(question);
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
await expect(page.getByText(sourceHint).first()).toBeVisible({ timeout: 10_000 });
const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first();
await expect(localizedSource).toBeVisible({ timeout: 15_000 });
await expect(localizedSource).toContainText(/damage-procedure\.md|Damage handling|schadeafhandeling|gestion des dommages/i);
});
}
});
@@ -300,12 +302,13 @@ test("automation shows a localized error explanation with the raw error only und
await loginAsOpsManager(page, "nl-BE");
await page.goto("/automation");
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
const localizedSummary = /Gesimuleerde time-out richting de workflowdienst/;
await expect(page.getByText(localizedSummary).first()).toBeVisible();
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
// Scope to the failed job's own row -- the workflow-evidence table above it also has
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
// the wrong one.
const failedJobRow = page.locator("tr", { has: page.getByText(/tijdelijk niet bereikbaar/) });
const failedJobRow = page.locator("tr", { has: page.getByText(localizedSummary) });
await failedJobRow.getByText("Technische details").click();
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
});
+5 -2
View File
@@ -76,7 +76,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page).toHaveURL(/\/knowledge$/);
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Volgende" }).click();
});
@@ -90,7 +90,10 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await page.goto("/audit");
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page
.getByRole("dialog", { name: "Gegidste demo" })
.getByRole("button", { name: "Volgende", exact: true })
.click();
});
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
+35 -15
View File
@@ -5,12 +5,20 @@ async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/reset");
}
async function switchRole(page: Page) {
await page.locator(".operator-menu > summary").click();
await page.getByRole("button", { name: "Switch role" }).click();
}
test.describe.configure({ mode: "serial" });
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page, request }) => {
// Keep reset's Set-Cookie deletion in the standalone request fixture. Sharing the
// browser cookie jar here can race that deletion against the UI login below.
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
@@ -44,7 +52,13 @@ test("vehicles page: status filter and attention-only checkbox both work", async
expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy();
await page.getByLabel("Status").selectOption("");
await page.getByLabel("Attention only").check();
await expect(page).not.toHaveURL(/status=maintenance/);
await expect(page.getByLabel("Attention only")).not.toBeChecked();
// Updating the URL replaces this controlled input. Click it, then assert against the
// newly-rendered control rather than asking Playwright to verify the detached node.
await page.getByLabel("Attention only").click();
await expect(page).toHaveURL(/attention_only=true/);
await expect(page.getByLabel("Attention only")).toBeChecked();
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const attentionCells = await page.locator(".data-table tbody tr td:nth-child(6)").allTextContents();
expect(attentionCells.every((c) => c.includes("Needs attention"))).toBeTruthy();
@@ -158,14 +172,16 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
await page.goto("/data-quality");
await page
.getByRole("combobox", { name: "Rule type", exact: true })
.selectOption("missing_required_field");
await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue(
"missing_required_field",
);
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
const ref = await firstLink.textContent();
await expect(firstLink).toBeVisible();
const ref = (await firstLink.textContent())?.trim() ?? "";
expect(ref).not.toBe("");
await firstLink.click();
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
await expect(page.getByRole("heading", { name: ref })).toBeVisible();
await page.getByRole("button", { name: "Defer" }).click();
await expect(page.locator(".badge.status-deferred")).toBeVisible();
});
@@ -193,8 +209,12 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
await expect(page.getByText("Resolved").first()).toBeVisible();
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
expect((await booking.json()).status).toBe("blocked");
await expect
.poll(async () => {
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
return (await booking.json()).status;
})
.toBe("blocked");
});
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
@@ -308,12 +328,12 @@ test("knowledge page: form submits and clears input", async ({ page }) => {
const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/);
await input.fill("What must I do when a vehicle returns with damage?");
await page.getByRole("button", { name: "Ask" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible({ timeout: 15_000 });
await expect(input).toHaveValue("");
});
test("switch role button logs out and returns to login", async ({ page }) => {
await page.getByRole("button", { name: "Switch role" }).click();
await switchRole(page);
await expect(page).toHaveURL(/\/login$/);
});
@@ -327,7 +347,7 @@ test("session survives a page refresh and restores the correct role", async ({ p
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "Switch role" }).click();
await switchRole(page);
await expect(page).toHaveURL(/\/login$/);
// Directly re-requesting a protected route after logout must not restore access from a
@@ -348,7 +368,7 @@ test("direct navigation to a protected route without a session redirects to logi
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await switchRole(page);
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
@@ -400,7 +420,7 @@ test("automation page shows the aggregate n8n integration status, not just the l
test("rental employee direct API access to manager-only endpoints is rejected", async ({
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await switchRole(page);
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
@@ -0,0 +1,71 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function reset(request: APIRequestContext) {
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
expect((await request.post("/api/v1/demo/reset")).ok()).toBeTruthy();
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
}
async function login(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test.describe.configure({ mode: "serial" });
test("operator can create and cancel a booking through the UI", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/bookings/new");
await page.getByLabel("Klant zoeken").fill("CUS-0001");
const customer = page.locator(".booking-create-form select").nth(0);
await expect.poll(() => customer.locator("option").count()).toBeGreaterThan(1);
await customer.selectOption({ index: 1 });
const vehicle = page.locator(".booking-create-form select").nth(1);
await expect.poll(() => vehicle.locator("option").count()).toBeGreaterThan(1);
await vehicle.selectOption({ index: 1 });
await page.getByRole("button", { name: "Boeking aanmaken" }).click();
await expect(page).toHaveURL(/\/bookings\/BK-/);
await expect(page.getByText("gereserveerd", { exact: true })).toBeVisible();
await page.getByLabel("Reden").fill("Klant annuleert de geplande rit");
await page.getByRole("button", { name: "Annulering bevestigen" }).click();
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
});
test("manager can create and deactivate an operational user", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/users");
await page.getByLabel("Naam").fill("E2E Planner");
await page.getByLabel("E-mail").fill("e2e.planner@example.test");
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
const row = page.getByRole("row", { name: /E2E Planner/ });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "Deactiveren" }).click();
await expect(row.getByText("inactief", { exact: true })).toBeVisible();
});
test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
await reset(request);
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
let selected = vehicles[0];
for (const candidate of vehicles) {
const detail = await (await request.get(`/api/v1/vehicles/${candidate.public_ref}`)).json();
if (!detail.attention && !detail.bookings.some((booking: { status: string }) => booking.status === "active")) {
selected = candidate;
break;
}
}
await login(page);
await page.goto(`/vehicles/${selected.public_ref}`);
await page.getByRole("tab", { name: "Onderhoud" }).click();
await page.getByRole("button", { name: "Onderhoud registreren" }).click();
await page.getByLabel("Uitgevoerd werk").fill("Periodiek onderhoud en veiligheidscontrole voltooid");
await page.getByRole("button", { name: "Onderhoud opslaan" }).click();
await expect(page.getByText("onderhoud", { exact: true }).first()).toBeVisible();
await page.getByLabel("Reden voor vrijgave").fill("Veiligheidscontrole afgerond zonder open punten");
await page.getByRole("button", { name: "Voertuig vrijgeven" }).click();
await expect(page.getByText("beschikbaar", { exact: true }).first()).toBeVisible();
});
+17 -5
View File
@@ -1,8 +1,20 @@
import { expect, test } from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function reset(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
}
async function login(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test.beforeEach(async ({ request }) => reset(request));
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
expect(vehicles.ok()).toBeTruthy();
const vehiclePage = await vehicles.json();
@@ -14,6 +26,7 @@ test("operational lists use bounded server pages and retain filter state in the
const auditPage = await audit.json();
expect(auditPage.items.length).toBeLessThanOrEqual(25);
await login(page);
await page.goto("/vehicles?status=maintenance&page=1");
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
await expect(page).toHaveURL(/status=maintenance/);
@@ -21,8 +34,7 @@ test("operational lists use bounded server pages and retain filter state in the
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await login(page);
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
+4 -2
View File
@@ -25,9 +25,11 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
});
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
await page.keyboard.press("Control+k");
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await expect(search).toBeFocused();
await expect(async () => {
await page.keyboard.press("Control+k");
await expect(search).toBeFocused();
}).toPass({ timeout: 5_000 });
await search.fill("MO-024");
const result = page.getByRole("option", { name: /MO-024/ });
await expect(result).toBeVisible();
+508 -1347
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -21,8 +21,8 @@
"@playwright/test": "1.62.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"@vitejs/plugin-react": "6.0.5",
"typescript": "5.6.3",
"vite": "5.4.21"
"vite": "8.2.1"
}
}
+1
View File
@@ -49,6 +49,7 @@ export const KNOWN_CODES = new Set([
"NO_CONFLICT_DETECTED",
"RECOMMENDATION_STALE",
"UNAUTHORIZED_SERVICE",
"UNKNOWN_WORKFLOW",
]);
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
+1 -1
View File
@@ -316,6 +316,7 @@ export function Layout() {
<div className="topbar-meta">
{demoMode && <DemoGuideTrigger />}
{demoMode && <DemoBadge />}
{user && <LanguageSwitcher compact />}
{user && (
<details className="operator-menu">
<summary className="operator">
@@ -323,7 +324,6 @@ export function Layout() {
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<LanguageSwitcher compact />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
+20 -5
View File
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, onUnauthorized } from "../api/client";
import { api, ApiError, onUnauthorized } from "../api/client";
import type { CurrentUser, Role, SystemStatus } from "../api/types";
interface AuthState {
@@ -93,13 +93,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const logout = useCallback(async () => {
setUser(null);
cacheUser(null);
try {
await api.post("/api/v1/auth/logout");
// A logout response expires an HttpOnly cookie. Chromium can expose a very small
// race between resolving fetch() and applying that Set-Cookie header to an
// immediate top-level navigation. Confirm the server now rejects the session
// before allowing the router to continue; retrying logout is idempotent.
for (let attempt = 0; attempt < 3; attempt += 1) {
await api.post("/api/v1/auth/logout");
try {
await api.get<CurrentUser>("/api/v1/auth/session");
} catch (err) {
if (err instanceof ApiError && err.status === 401) return;
throw err;
}
}
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// already dropped its own state either way.
// still drops its own state when the request itself is unavailable.
} finally {
// Keep RequireAuth from redirecting to /login while the server-side invalidation
// is still in flight. Otherwise a very fast navigation can race ahead of logout.
setUser(null);
cacheUser(null);
}
}, []);
@@ -142,6 +142,10 @@
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
},
"UNKNOWN_WORKFLOW": {
"title": "Unknown automation workflow",
"explanation": "This workflow is not one of the four managed automation workflows."
}
},
"http": {
@@ -11,7 +11,8 @@
"quality": "Data quality",
"knowledge": "Knowledge",
"integrations": "Integrations",
"audit": "Audit trail"
"audit": "Audit trail",
"users": "Users"
},
"primaryNavLabel": "Primary navigation",
"mobileNavLabel": "Mobile navigation",
@@ -41,8 +42,7 @@
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history",
"users": "Users"
"audit": "Audit history"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
@@ -142,6 +142,10 @@
"UNAUTHORIZED_SERVICE": {
"title": "Échec de l'autorisation du service",
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
},
"UNKNOWN_WORKFLOW": {
"title": "Workflow dautomatisation inconnu",
"explanation": "Ce workflow ne fait pas partie des quatre automatisations gérées."
}
},
"http": {
@@ -11,7 +11,8 @@
"quality": "Qualité des données",
"knowledge": "Connaissances",
"integrations": "Intégrations",
"audit": "Piste d'audit"
"audit": "Piste d'audit",
"users": "Utilisateurs"
},
"primaryNavLabel": "Navigation principale",
"mobileNavLabel": "Navigation mobile",
@@ -41,8 +42,7 @@
"quality": "Atelier qualité",
"knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique daudit",
"users": "Utilisateurs"
"audit": "Historique daudit"
},
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Employé de location" }
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
}
@@ -142,6 +142,10 @@
"UNAUTHORIZED_SERVICE": {
"title": "Dienstautorisatie mislukt",
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
},
"UNKNOWN_WORKFLOW": {
"title": "Onbekende automatiseringsworkflow",
"explanation": "Deze workflow hoort niet bij de vier beheerde automatiseringsflows."
}
},
"http": {
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Verhuurmedewerker" }
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
}
+3 -3
View File
@@ -18,7 +18,7 @@ export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest();
const { manifest, loading: manifestLoading } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
@@ -126,10 +126,10 @@ export function BookingDetail() {
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
from its very first render -- never updated asynchronously after mount, which
previously raced with anyone already typing into the field. */}
{!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && (
{!returnResult && booking.status === "active" && (manifestLoading || (isReturnAnomalyScenario && canonicalOdometerKm === null)) && (
<LoadingState label={t("returns:scenario.preparing")} />
)}
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
{!returnResult && booking.status === "active" && !manifestLoading && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
<ReturnForm
bookingRef={booking.public_ref}
onRegistered={handleRegistered}
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Merge instance-specific n8n credential IDs into versioned workflow definitions.
n8n's CLI does not reliably resolve two credentials of the same type by name during an
update. This utility copies only credential reference IDs/names from a pre-update export;
it never reads or writes credential values.
"""
from __future__ import annotations
import argparse
import copy
import json
from pathlib import Path
from typing import Any
SERVICE_NODE_CANDIDATES = {
"mobilityops-return-processing": ("Record follow-up",),
"mobilityops-scheduled-quality-scan": ("Run quality scan",),
"6wbkc4d1AouGpmWT": ("Report sync result to Fleet Ops",),
"Xppn2rAEqUuyiCJF": ("Report failure to Fleet Ops", "failure to Fleet Ops"),
}
def _workflow_list(path: Path) -> list[dict[str, Any]]:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, list) else [payload]
def merge(backup_path: Path, source_dir: Path, output_dir: Path) -> list[Path]:
backups = {workflow["id"]: workflow for workflow in _workflow_list(backup_path)}
output_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for source_path in sorted(source_dir.glob("fleet-ops-*.json")):
updated = _workflow_list(source_path)[0]
workflow_id = updated["id"]
if workflow_id not in SERVICE_NODE_CANDIDATES or workflow_id not in backups:
continue
original_nodes = {node["name"]: node for node in backups[workflow_id]["nodes"]}
service_node = next(
(
original_nodes[name]
for name in SERVICE_NODE_CANDIDATES[workflow_id]
if name in original_nodes
),
None,
)
if service_node is None or not service_node.get("credentials"):
raise ValueError(f"No service credential reference found for {workflow_id}")
service_credentials = service_node["credentials"]
for node in updated["nodes"]:
original = original_nodes.get(node["name"])
if original and original.get("credentials"):
node["credentials"] = copy.deepcopy(original["credentials"])
elif node.get("credentials") or node["name"] == "Report workflow heartbeat":
node["credentials"] = copy.deepcopy(service_credentials)
target = output_dir / source_path.name
target.write_text(
json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
written.append(target)
return written
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("backup", type=Path, help="n8n export:workflow --all JSON file")
parser.add_argument("source_dir", type=Path, help="versioned workflow directory")
parser.add_argument("output_dir", type=Path, help="safe import output directory")
args = parser.parse_args()
for path in merge(args.backup, args.source_dir, args.output_dir):
print(path)
if __name__ == "__main__":
main()