Files
MobilityOps/backend/tests/test_data_quality.py
T
NuklearRabbit 760f3b6ee2 fix(auth): enforce role boundaries on data quality and audit
The data-quality workbench (list, detail, defer, reject) and the audit trail
had no role gate at all beyond authentication -- confirmed live, a Rental
Employee session could list and resolve data-quality issues and read the
full audit trail through both the API and the UI, with only merge-customers
and scan already restricted.

Per the role matrix, both areas are Operations-Manager-only. Gate the
remaining data-quality and audit endpoints with require_operations_manager,
hide their nav items for Rental Employee, show the same restricted-message
pattern Automation.tsx already used for direct URL access, and stop the
dashboard from linking into now-restricted areas for that role.
2026-08-02 04:52:01 +02:00

127 lines
4.7 KiB
Python

def test_list_includes_all_five_rule_types(ops_client):
response = ops_client.get("/api/v1/data-quality/issues")
assert response.status_code == 200
issues = response.json()
rule_types = {i["rule_type"] for i in issues}
assert rule_types == {
"possible_duplicate_customer",
"missing_required_field",
"odometer_regression",
"booking_overlap",
"vehicle_status_conflict",
}
def test_scan_is_idempotent_once_seeded(ops_client):
# The session fixture already ran a scan as part of seeding; running again
# must not create duplicate open issues for the same (rule_type, entity).
response = ops_client.post("/api/v1/data-quality/scan")
assert response.status_code == 200
assert response.json()["created"] == {}
def test_scan_requires_operations_manager(employee_client):
response = employee_client.post("/api/v1/data-quality/scan")
assert response.status_code == 403
def test_list_issues_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/data-quality/issues")
assert response.status_code == 403
def test_get_issue_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 403
def test_defer_requires_operations_manager(employee_client):
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/defer")
assert response.status_code == 403
def test_reject_requires_operations_manager(employee_client):
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/reject")
assert response.status_code == 403
def test_s2_duplicate_customer_issue_detail(ops_client):
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 200
body = response.json()
assert body["entity_ref"] == "CUS-0012"
assert body["entity_snapshot"]["public_ref"] == "CUS-0012"
assert [s["public_ref"] for s in body["related_snapshots"]] == ["CUS-0178"]
def test_s4_booking_overlap_issue_detail(ops_client):
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP")
assert response.status_code == 200
body = response.json()
assert body["entity_ref"] == "MO-016"
assert set(body["evidence"]["related_refs"]) == {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"}
def test_defer_then_reject_are_rejected_on_closed_issue(ops_client):
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "missing_required_field", "status": "open"},
).json()
target = issues[0]["public_ref"]
deferred = ops_client.post(f"/api/v1/data-quality/issues/{target}/defer")
assert deferred.status_code == 200
assert deferred.json()["status"] == "deferred"
again = ops_client.post(f"/api/v1/data-quality/issues/{target}/reject")
assert again.status_code == 409
assert again.json()["error"]["code"] == "ISSUE_NOT_OPEN"
def test_merge_customers_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
json={"survivor_ref": "CUS-0012"},
)
assert response.status_code == 403
def test_merge_customers_rejects_unrelated_survivor(ops_client):
response = ops_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
json={"survivor_ref": "CUS-0099"},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "INVALID_SURVIVOR"
def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
before_bookings = ops_client.get(
"/api/v1/bookings", params={"vehicle_ref": "MO-001"}
) # warm the client session; irrelevant vehicle, just a cheap authenticated call
assert before_bookings.status_code == 200
response = ops_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
json={"survivor_ref": "CUS-0012", "field_overrides": {"city": "Turnhout"}},
)
assert response.status_code == 200
body = response.json()
assert body["survivor_ref"] == "CUS-0012"
assert body["loser_ref"] == "CUS-0178"
issue = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE").json()
assert issue["status"] == "resolved"
audit_events = ops_client.get(
"/api/v1/audit", params={"action": "customer_merged"}
).json()
assert len(audit_events) >= 1
# Already-resolved issue cannot be merged again.
replay = ops_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
json={"survivor_ref": "CUS-0012"},
)
assert replay.status_code == 409