MCP_HUB_BASE_URL had the same wrong-hostname bug as RAGCORE_BASE_URL earlier this session (itworx-mcp-hub:8000 doesn't resolve; the real container is reachable at the host's own 192.168.10.150:1100) -- fixed live, resolving the Automation page showing "Operationeel" and "Hub Onbereikbaar" simultaneously. Went on to actually publish the "Fleet Ops -- RAGcore Procedure Sync" n8n workflow now that RAGcore is reachable: its own RAGcore Sync Token credential had gone stale from the same rotation as the earlier one, so minted a fresh, dedicated, minimally-scoped (sources:sync only) credential, verified a real manual run (33 synced, 0 failed, result registered) before publishing. That exposed a real, now-stale bug: derive_n8n_status() hardcoded this workflow's evidence to None with a comment explaining it was unpublished -- true when written, false now. The workflow's own result-report callback already writes a real n8n_procedures_synced audit event; wired that in as its evidence source, the same pattern the scheduled scan and error handler already use, instead of a value that could never update itself once the workflow went live. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
267 lines
9.8 KiB
Python
267 lines
9.8 KiB
Python
import uuid
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
def _callback_headers(event_id: str, token: str | None = None):
|
|
settings = get_settings()
|
|
return {
|
|
"Idempotency-Key": event_id,
|
|
"X-Service-Token": token if token is not None else settings.n8n_callback_token,
|
|
}
|
|
|
|
|
|
def test_callback_rejects_wrong_service_token(client):
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/return-callback",
|
|
json={"follow_up": "cleaning"},
|
|
headers=_callback_headers(str(uuid.uuid4()), token="wrong-token"),
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_callback_unknown_event_returns_404(client):
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/return-callback",
|
|
json={"follow_up": "cleaning"},
|
|
headers=_callback_headers(str(uuid.uuid4())),
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_callback_is_idempotent_by_event_id(client, ops_client):
|
|
from sqlalchemy import select
|
|
|
|
from app.core.db import SessionLocal
|
|
from app.models.booking import Booking
|
|
from app.models.outbox import OutboxEvent
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
booking = db.scalar(select(Booking).limit(1))
|
|
event = OutboxEvent(
|
|
event_id=uuid.uuid4(),
|
|
event_type="vehicle.returned.v1",
|
|
aggregate_type="booking",
|
|
aggregate_id=booking.id,
|
|
payload_json={
|
|
"correlation_id": str(uuid.uuid4()),
|
|
"aggregate": {
|
|
"type": "booking",
|
|
"id": str(booking.id),
|
|
"public_ref": booking.public_ref,
|
|
},
|
|
"data": {},
|
|
"aggregate_ref": booking.public_ref,
|
|
},
|
|
occurred_at=db.execute(select(Booking.starts_at).limit(1)).scalar(),
|
|
delivery_status="delivering",
|
|
attempts=1,
|
|
)
|
|
db.add(event)
|
|
db.commit()
|
|
event_id = str(event.event_id)
|
|
finally:
|
|
db.close()
|
|
|
|
first = client.post(
|
|
"/api/v1/integrations/n8n/return-callback",
|
|
json={"follow_up": "cleaning", "summary": "test"},
|
|
headers=_callback_headers(event_id),
|
|
)
|
|
second = client.post(
|
|
"/api/v1/integrations/n8n/return-callback",
|
|
json={"follow_up": "cleaning", "summary": "test"},
|
|
headers=_callback_headers(event_id),
|
|
)
|
|
assert first.status_code == 200
|
|
assert second.status_code == 200
|
|
|
|
audit_events = ops_client.get(
|
|
"/api/v1/audit", params={"action": "n8n_return_followup_recorded"}
|
|
).json()
|
|
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
|
|
assert len(matching) == 1
|
|
|
|
|
|
def test_scheduled_scan_rejects_wrong_service_token(client):
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/scheduled-scan",
|
|
headers={"X-Service-Token": "wrong-token"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_scheduled_scan_requires_service_token_header(client):
|
|
response = client.post("/api/v1/integrations/n8n/scheduled-scan")
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_scheduled_scan_runs_and_returns_counts_by_rule(client, ops_client):
|
|
settings = get_settings()
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/scheduled-scan",
|
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"created": {}} # already-seeded conditions, nothing new
|
|
|
|
audit_events = ops_client.get(
|
|
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
|
).json()
|
|
service_triggered = [e for e in audit_events if e["actor_type"] == "service"]
|
|
assert len(service_triggered) >= 1
|
|
assert service_triggered[0]["actor_label"] == "n8n scheduled scan"
|
|
|
|
|
|
def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
|
settings = get_settings()
|
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
|
first = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
|
second = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
|
assert first.status_code == 200
|
|
assert second.status_code == 200
|
|
assert second.json()["created"] == {}
|
|
|
|
|
|
def _workflow_error_body(execution_id: str, **overrides):
|
|
body = {
|
|
"workflow_id": "mobilityops-return-processing",
|
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
|
"execution_id": execution_id,
|
|
"failed_at": "2026-08-04T10:15:00Z",
|
|
"error_category": "httpError",
|
|
"error_summary": "Callback request failed with status 500",
|
|
"trigger_context": "webhook",
|
|
"correlation_id": None,
|
|
"attempt": 1,
|
|
"retry_action": "n8n will retry automatically",
|
|
}
|
|
body.update(overrides)
|
|
return body
|
|
|
|
|
|
def test_workflow_error_rejects_wrong_service_token(client):
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/workflow-error",
|
|
json=_workflow_error_body(str(uuid.uuid4())),
|
|
headers={"X-Service-Token": "wrong-token"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_workflow_error_rejects_unknown_category(client):
|
|
settings = get_settings()
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/workflow-error",
|
|
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
|
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
|
|
settings = get_settings()
|
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
|
execution_id = str(uuid.uuid4())
|
|
body = _workflow_error_body(execution_id)
|
|
|
|
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
|
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
|
|
|
assert first.status_code == 200
|
|
assert first.json()["status"] == "registered"
|
|
assert second.status_code == 200
|
|
assert second.json()["status"] == "already_registered"
|
|
|
|
audit_events = ops_client.get(
|
|
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
|
).json()
|
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
|
assert len(matching) == 1
|
|
assert matching[0]["after"]["error_category"] == "httpError"
|
|
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
|
|
|
|
|
def test_workflow_error_bounds_summary_length(client):
|
|
settings = get_settings()
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/workflow-error",
|
|
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
|
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_procedures_rejects_wrong_service_token(client):
|
|
response = client.get(
|
|
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_procedures_lists_every_language_with_stable_ids(client):
|
|
settings = get_settings()
|
|
response = client.get(
|
|
"/api/v1/integrations/n8n/procedures",
|
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
|
)
|
|
assert response.status_code == 200
|
|
documents = response.json()["documents"]
|
|
assert len(documents) > 0
|
|
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
|
|
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
|
|
assert len(checkout_docs) == 3 # one per language
|
|
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
|
|
# Same document_id, different language, must not collide on id.
|
|
assert len({d["id"] for d in checkout_docs}) == 3
|
|
|
|
second_response = client.get(
|
|
"/api/v1/integrations/n8n/procedures",
|
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
|
)
|
|
second_ids = {d["id"] for d in second_response.json()["documents"]}
|
|
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
|
|
|
|
|
|
def test_procedures_sync_result_rejects_wrong_service_token(client):
|
|
response = client.post(
|
|
"/api/v1/integrations/n8n/procedures-sync-result",
|
|
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
|
|
headers={"X-Service-Token": "wrong-token"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
|
settings = get_settings()
|
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
|
execution_id = str(uuid.uuid4())
|
|
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
|
|
|
|
first = client.post(
|
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
|
)
|
|
second = client.post(
|
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
|
)
|
|
|
|
assert first.status_code == 200
|
|
assert first.json()["status"] == "registered"
|
|
assert second.status_code == 200
|
|
assert second.json()["status"] == "already_registered"
|
|
|
|
audit_events = ops_client.get(
|
|
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
|
|
).json()
|
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
|
assert len(matching) == 1
|
|
assert matching[0]["after"] == {"synced": 33, "failed": 1}
|
|
|
|
# The workflow's own callback is its evidence -- same pattern the scheduled scan and
|
|
# error handler already use -- so this real report must now show up as run evidence
|
|
# in the integration status, not stay hardcoded to "no evidence yet".
|
|
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
|
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
|
|
assert ragcore_sync["last_seen_at"] is not None
|