M15: synchronize contracts and acceptance
This commit is contained in:
@@ -2356,3 +2356,45 @@ evidence yet."
|
|||||||
execution ID visible.
|
execution ID visible.
|
||||||
- Exact next action: regenerate the checked-in OpenAPI contract, add E2E coverage for the
|
- 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.
|
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")
|
||||||
@@ -13,6 +13,7 @@ from app.core.db import SessionLocal
|
|||||||
from app.core.security import SessionPayload, read_session_token
|
from app.core.security import SessionPayload, read_session_token
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas import CurrentUser, Role
|
from app.schemas import CurrentUser, Role
|
||||||
|
from app.services.sessions import is_session_revoked
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
|
_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:
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
|
||||||
token = request.cookies.get(settings.session_cookie_name)
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
payload: SessionPayload | None = read_session_token(token) if token else None
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
|
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
|
||||||
# signed demo session until the reset endpoint clears its cookie keeps existing demo
|
# signed demo session until the reset endpoint clears its cookie keeps existing demo
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.core.security import (
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas import CurrentUser, PasswordLoginRequest
|
from app.schemas import CurrentUser, PasswordLoginRequest
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.sessions import revoke_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -37,6 +38,7 @@ def _set_session(response: Response, user: User) -> None:
|
|||||||
SessionPayload(
|
SessionPayload(
|
||||||
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
|
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
|
||||||
display_name=user.display_name, issued_at=int(time.time()),
|
display_name=user.display_name, issued_at=int(time.time()),
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
response.set_cookie(
|
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:
|
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||||
token = request.cookies.get(settings.session_cookie_name)
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
payload = read_session_token(token) if token else None
|
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(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
|||||||
from app.seed_loader import reset_and_seed
|
from app.seed_loader import reset_and_seed
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
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"])
|
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -48,6 +49,7 @@ def demo_login(
|
|||||||
role=user.role,
|
role=user.role,
|
||||||
display_name=user.display_name,
|
display_name=user.display_name,
|
||||||
issued_at=int(time.time()),
|
issued_at=int(time.time()),
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -85,7 +87,8 @@ def get_session(
|
|||||||
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||||
token = request.cookies.get(settings.session_cookie_name)
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
payload = read_session_token(token) if token else None
|
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(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class SessionPayload:
|
|||||||
role: str
|
role: str
|
||||||
display_name: str
|
display_name: str
|
||||||
issued_at: int
|
issued_at: int
|
||||||
|
session_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _sign(data: bytes) -> str:
|
def _sign(data: bytes) -> str:
|
||||||
@@ -27,6 +28,11 @@ def _sign(data: bytes) -> str:
|
|||||||
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
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:
|
def create_session_token(payload: SessionPayload) -> str:
|
||||||
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
|
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
|
||||||
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
|
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.models.idempotency import IdempotencyRecord
|
|||||||
from app.models.inspection import Inspection
|
from app.models.inspection import Inspection
|
||||||
from app.models.maintenance import MaintenanceRecord
|
from app.models.maintenance import MaintenanceRecord
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.models.revoked_session import RevokedSession
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ __all__ = [
|
|||||||
"Inspection",
|
"Inspection",
|
||||||
"MaintenanceRecord",
|
"MaintenanceRecord",
|
||||||
"OutboxEvent",
|
"OutboxEvent",
|
||||||
|
"RevokedSession",
|
||||||
"User",
|
"User",
|
||||||
"Vehicle",
|
"Vehicle",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -19,6 +19,7 @@ from app.models.idempotency import IdempotencyRecord
|
|||||||
from app.models.inspection import Inspection
|
from app.models.inspection import Inspection
|
||||||
from app.models.maintenance import MaintenanceRecord
|
from app.models.maintenance import MaintenanceRecord
|
||||||
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
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.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.services.audit import record_audit_event
|
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:
|
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
|
||||||
for model in (
|
for model in (
|
||||||
|
RevokedSession,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
IdempotencyRecord,
|
IdempotencyRecord,
|
||||||
DataQualityIssue,
|
DataQualityIssue,
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -62,6 +62,34 @@ def test_logout_invalidates_session(ops_client):
|
|||||||
assert after.status_code == 401
|
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):
|
def test_logout_without_a_session_is_safe(client):
|
||||||
response = client.post("/api/v1/demo/logout")
|
response = client.post("/api/v1/demo/logout")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
+3955
-216
File diff suppressed because it is too large
Load Diff
@@ -135,6 +135,27 @@ sed -i \
|
|||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
|
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
|
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
|
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
|
existing n8n so the production webhook is registered. The imported configuration remains
|
||||||
|
|||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm ci && npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -88,11 +88,18 @@ test("attention queue row opens the correct record on a mobile viewport tap", as
|
|||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
const row = page.locator(".attention-list li.row-clickable").first();
|
const row = page.locator(".attention-list li.row-clickable").first();
|
||||||
await expect(row).toBeVisible();
|
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 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
|
// 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.
|
// 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)\//);
|
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
|
||||||
await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
|
|
||||||
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
|
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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/)
|
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
|
||||||
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
||||||
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
||||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
|
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 () => {
|
await test.step("8. inspect audit entries", async () => {
|
||||||
|
|||||||
@@ -237,17 +237,17 @@ test.describe("knowledge base is grounded in the operator's own language", () =>
|
|||||||
{
|
{
|
||||||
lang: "nl-BE",
|
lang: "nl-BE",
|
||||||
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||||
sourceHint: /schadeafhandeling/i,
|
sourceHint: /zichtbare of gemelde schade/i,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
lang: "en-GB",
|
lang: "en-GB",
|
||||||
question: "What should I do when a vehicle returns with damage?",
|
question: "What should I do when a vehicle returns with damage?",
|
||||||
sourceHint: /damage/i,
|
sourceHint: /visible or reported damage/i,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
lang: "fr-BE",
|
lang: "fr-BE",
|
||||||
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
|
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.goto("/knowledge");
|
||||||
await page.locator("#knowledge-question").fill(question);
|
await page.locator("#knowledge-question").fill(question);
|
||||||
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
|
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 loginAsOpsManager(page, "nl-BE");
|
||||||
await page.goto("/automation");
|
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();
|
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
|
// 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
|
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
|
||||||
// the wrong one.
|
// 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 failedJobRow.getByText("Technische details").click();
|
||||||
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
|
|||||||
await expect(page).toHaveURL(/\/knowledge$/);
|
await expect(page).toHaveURL(/\/knowledge$/);
|
||||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
|
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 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();
|
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 page.goto("/audit");
|
||||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
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 () => {
|
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ async function resetDemoData(request: APIRequestContext) {
|
|||||||
await request.post("/api/v1/demo/reset");
|
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" });
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
// 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
|
// 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).
|
// 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.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
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();
|
expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy();
|
||||||
|
|
||||||
await page.getByLabel("Status").selectOption("");
|
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();
|
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||||
const attentionCells = await page.locator(".data-table tbody tr td:nth-child(6)").allTextContents();
|
const attentionCells = await page.locator(".data-table tbody tr td:nth-child(6)").allTextContents();
|
||||||
expect(attentionCells.every((c) => c.includes("Needs attention"))).toBeTruthy();
|
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 }) => {
|
test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
|
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
|
||||||
await page.goto("/data-quality");
|
await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue(
|
||||||
await page
|
"missing_required_field",
|
||||||
.getByRole("combobox", { name: "Rule type", exact: true })
|
);
|
||||||
.selectOption("missing_required_field");
|
|
||||||
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
|
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 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 page.getByRole("button", { name: "Defer" }).click();
|
||||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
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 page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||||
|
|
||||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||||
expect((await booking.json()).status).toBe("blocked");
|
return (await booking.json()).status;
|
||||||
|
})
|
||||||
|
.toBe("blocked");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
|
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/);
|
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 input.fill("What must I do when a vehicle returns with damage?");
|
||||||
await page.getByRole("button", { name: "Ask" }).click();
|
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("");
|
await expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switch role button logs out and returns to login", async ({ page }) => {
|
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$/);
|
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 }) => {
|
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await page.getByRole("button", { name: "Switch role" }).click();
|
await switchRole(page);
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
|
||||||
// Directly re-requesting a protected route after logout must not restore access from a
|
// 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 ({
|
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByRole("button", { name: "Switch role" }).click();
|
await switchRole(page);
|
||||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
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 ({
|
test("rental employee direct API access to manager-only endpoints is rejected", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByRole("button", { name: "Switch role" }).click();
|
await switchRole(page);
|
||||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
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();
|
||||||
|
});
|
||||||
@@ -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 }) => {
|
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");
|
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
|
||||||
expect(vehicles.ok()).toBeTruthy();
|
expect(vehicles.ok()).toBeTruthy();
|
||||||
const vehiclePage = await vehicles.json();
|
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();
|
const auditPage = await audit.json();
|
||||||
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
||||||
|
|
||||||
|
await login(page);
|
||||||
await page.goto("/vehicles?status=maintenance&page=1");
|
await page.goto("/vehicles?status=maintenance&page=1");
|
||||||
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
||||||
await expect(page).toHaveURL(/status=maintenance/);
|
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 }) => {
|
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto("/login");
|
await login(page);
|
||||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
|
||||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||||
|
|||||||
@@ -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 }) => {
|
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" });
|
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||||
|
await expect(async () => {
|
||||||
|
await page.keyboard.press("Control+k");
|
||||||
await expect(search).toBeFocused();
|
await expect(search).toBeFocused();
|
||||||
|
}).toPass({ timeout: 5_000 });
|
||||||
await search.fill("MO-024");
|
await search.fill("MO-024");
|
||||||
const result = page.getByRole("option", { name: /MO-024/ });
|
const result = page.getByRole("option", { name: /MO-024/ });
|
||||||
await expect(result).toBeVisible();
|
await expect(result).toBeVisible();
|
||||||
|
|||||||
Generated
+511
-1350
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,8 @@
|
|||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
"@vitejs/plugin-react": "6.0.5",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "5.4.21"
|
"vite": "8.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const KNOWN_CODES = new Set([
|
|||||||
"NO_CONFLICT_DETECTED",
|
"NO_CONFLICT_DETECTED",
|
||||||
"RECOMMENDATION_STALE",
|
"RECOMMENDATION_STALE",
|
||||||
"UNAUTHORIZED_SERVICE",
|
"UNAUTHORIZED_SERVICE",
|
||||||
|
"UNKNOWN_WORKFLOW",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
|
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export function Layout() {
|
|||||||
<div className="topbar-meta">
|
<div className="topbar-meta">
|
||||||
{demoMode && <DemoGuideTrigger />}
|
{demoMode && <DemoGuideTrigger />}
|
||||||
{demoMode && <DemoBadge />}
|
{demoMode && <DemoBadge />}
|
||||||
|
{user && <LanguageSwitcher compact />}
|
||||||
{user && (
|
{user && (
|
||||||
<details className="operator-menu">
|
<details className="operator-menu">
|
||||||
<summary className="operator">
|
<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>
|
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="operator-popover">
|
<div className="operator-popover">
|
||||||
<LanguageSwitcher compact />
|
|
||||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||||
<button className="operator-logout" type="button" onClick={handleLogout}>
|
<button className="operator-logout" type="button" onClick={handleLogout}>
|
||||||
<Icon name="logout" /> {t("switchRole")}
|
<Icon name="logout" /> {t("switchRole")}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
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";
|
import type { CurrentUser, Role, SystemStatus } from "../api/types";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -93,13 +93,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
setUser(null);
|
|
||||||
cacheUser(null);
|
|
||||||
try {
|
try {
|
||||||
|
// 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");
|
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 {
|
} catch {
|
||||||
// Best effort: the cookie is cleared server-side when it works, and the client has
|
// 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": {
|
"UNAUTHORIZED_SERVICE": {
|
||||||
"title": "Service authorisation failed",
|
"title": "Service authorisation failed",
|
||||||
"explanation": "This automated request could not be authorised."
|
"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": {
|
"http": {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"quality": "Data quality",
|
"quality": "Data quality",
|
||||||
"knowledge": "Knowledge",
|
"knowledge": "Knowledge",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"audit": "Audit trail"
|
"audit": "Audit trail",
|
||||||
|
"users": "Users"
|
||||||
},
|
},
|
||||||
"primaryNavLabel": "Primary navigation",
|
"primaryNavLabel": "Primary navigation",
|
||||||
"mobileNavLabel": "Mobile navigation",
|
"mobileNavLabel": "Mobile navigation",
|
||||||
@@ -41,8 +42,7 @@
|
|||||||
"quality": "Quality workbench",
|
"quality": "Quality workbench",
|
||||||
"knowledge": "Procedure assistant",
|
"knowledge": "Procedure assistant",
|
||||||
"integrations": "Automation and integration status",
|
"integrations": "Automation and integration status",
|
||||||
"audit": "Audit history",
|
"audit": "Audit history"
|
||||||
"users": "Users"
|
|
||||||
},
|
},
|
||||||
"switchRole": "Switch role",
|
"switchRole": "Switch role",
|
||||||
"switchRoleTitle": "Switch demo role",
|
"switchRoleTitle": "Switch demo role",
|
||||||
|
|||||||
@@ -142,6 +142,10 @@
|
|||||||
"UNAUTHORIZED_SERVICE": {
|
"UNAUTHORIZED_SERVICE": {
|
||||||
"title": "Échec de l'autorisation du service",
|
"title": "Échec de l'autorisation du service",
|
||||||
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
|
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
|
||||||
|
},
|
||||||
|
"UNKNOWN_WORKFLOW": {
|
||||||
|
"title": "Workflow d’automatisation inconnu",
|
||||||
|
"explanation": "Ce workflow ne fait pas partie des quatre automatisations gérées."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"quality": "Qualité des données",
|
"quality": "Qualité des données",
|
||||||
"knowledge": "Connaissances",
|
"knowledge": "Connaissances",
|
||||||
"integrations": "Intégrations",
|
"integrations": "Intégrations",
|
||||||
"audit": "Piste d'audit"
|
"audit": "Piste d'audit",
|
||||||
|
"users": "Utilisateurs"
|
||||||
},
|
},
|
||||||
"primaryNavLabel": "Navigation principale",
|
"primaryNavLabel": "Navigation principale",
|
||||||
"mobileNavLabel": "Navigation mobile",
|
"mobileNavLabel": "Navigation mobile",
|
||||||
@@ -41,8 +42,7 @@
|
|||||||
"quality": "Atelier qualité",
|
"quality": "Atelier qualité",
|
||||||
"knowledge": "Assistant de procédures",
|
"knowledge": "Assistant de procédures",
|
||||||
"integrations": "Statut d'automatisation et d'intégration",
|
"integrations": "Statut d'automatisation et d'intégration",
|
||||||
"audit": "Historique d’audit",
|
"audit": "Historique d’audit"
|
||||||
"users": "Utilisateurs"
|
|
||||||
},
|
},
|
||||||
"switchRole": "Changer de rôle",
|
"switchRole": "Changer de rôle",
|
||||||
"switchRoleTitle": "Changer de rôle de démo",
|
"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." },
|
"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": {
|
"UNAUTHORIZED_SERVICE": {
|
||||||
"title": "Dienstautorisatie mislukt",
|
"title": "Dienstautorisatie mislukt",
|
||||||
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
|
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
|
||||||
|
},
|
||||||
|
"UNKNOWN_WORKFLOW": {
|
||||||
|
"title": "Onbekende automatiseringsworkflow",
|
||||||
|
"explanation": "Deze workflow hoort niet bij de vier beheerde automatiseringsflows."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"http": {
|
"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." },
|
"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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function BookingDetail() {
|
|||||||
const { t } = useTranslation(["bookings", "returns", "errors"]);
|
const { t } = useTranslation(["bookings", "returns", "errors"]);
|
||||||
const { formatDateTime, formatNumber } = useLocaleFormat();
|
const { formatDateTime, formatNumber } = useLocaleFormat();
|
||||||
const { publicRef } = useParams<{ publicRef: string }>();
|
const { publicRef } = useParams<{ publicRef: string }>();
|
||||||
const { manifest } = useDemoManifest();
|
const { manifest, loading: manifestLoading } = useDemoManifest();
|
||||||
const [booking, setBooking] = useState<Booking | null>(null);
|
const [booking, setBooking] = useState<Booking | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [returnResult, setReturnResult] = useState<RegisterReturnResult | 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
|
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
|
||||||
from its very first render -- never updated asynchronously after mount, which
|
from its very first render -- never updated asynchronously after mount, which
|
||||||
previously raced with anyone already typing into the field. */}
|
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")} />
|
<LoadingState label={t("returns:scenario.preparing")} />
|
||||||
)}
|
)}
|
||||||
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
|
{!returnResult && booking.status === "active" && !manifestLoading && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
|
||||||
<ReturnForm
|
<ReturnForm
|
||||||
bookingRef={booking.public_ref}
|
bookingRef={booking.public_ref}
|
||||||
onRegistered={handleRegistered}
|
onRegistered={handleRegistered}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user