dodil/erp-brick-payroll
DODIL ERP brick: payroll
tests/test_doors.py
"""Both doors over the real app: the same use case answers the same through the API and through MCP (the agent door), a payslip
of another person is a 404, a missing permission a 403 naming it, no identity a 401, and the routes only another module calls
are not tools."""
import anyio
import httpx
from conftest import ADMIN, EMP4410, MANAGER, NOBODY, api, app, onboard, open_run, refused, setup_company, tool, tools
TOOLS = ["approve_approver_payslip", "approve_pay_run", "calculate_pay_run", "cancel_pay_run", "complete_manual_submission", "complete_payroll_details", "discard_ytd", "enrol_pension",
"get_p45_document", "get_pay_run", "get_payroll_employee", "get_payslip", "get_payslip_document", "get_ytd_load", "list_my_payslips", "list_pay_runs",
"list_payroll_employees", "list_rti_submissions", "list_statutory_rates", "list_ytd_loads", "open_pay_run", "put_pay_schedule", "put_paye_scheme", "put_pension_scheme",
"record_p45_in", "record_starter_declaration", "resend_fps", "sign_ytd", "use_emergency_code"]
def test_the_agent_door_offers_the_use_cases_and_never_the_seam_only_routes():
assert tools(ADMIN) == TOOLS
for seam_only in ("confirm_payout", "drain_outbox", "load_ytd", "reconcile_ytd", "run_eps", "show_process_bpmn"):
assert seam_only not in tools(ADMIN)
def test_a_run_is_opened_calculated_and_approved_through_the_agent_door_alone(nb):
setup_company()
status, run = tool(ADMIN, "open_pay_run", {"company_code": "UK01", "schedule_code": "M", "period": "2026-11"})
assert status == "ok" and run["pay_run_id"] == "UK01-M-2026-27-M08"
status, text = tool(ADMIN, "calculate_pay_run", {"pay_run_id": run["pay_run_id"]})
assert status == "refused" and "has no tax code or NI category" in text
onboard(run["pay_run_id"])
assert tool(MANAGER, "approve_pay_run", {"pay_run_id": run["pay_run_id"]})[0] == "refused" # own payslip first
from conftest import PEER
assert tool(PEER, "approve_approver_payslip", {"pay_run_id": run["pay_run_id"], "payroll_id": "2101-1"})[0] == "ok"
status, got = tool(MANAGER, "approve_pay_run", {"pay_run_id": run["pay_run_id"]})
assert status == "ok" and got["status"] == "approved" and got["journal_status"] == "posted"
def test_the_two_doors_agree_on_every_refusal(nb):
setup_company()
run = open_run()["pay_run_id"]
onboard(run)
cases = [(ADMIN, "approve_pay_run", {"pay_run_id": run}, "POST", f"/pay-runs/{run}/approve", None),
(MANAGER, "approve_pay_run", {"pay_run_id": run}, "POST", f"/pay-runs/{run}/approve", None),
(EMP4410, "get_payslip", {"pay_run_id": run, "payroll_id": "2101-1"}, "GET", f"/pay-runs/{run}/payslips/2101-1", None),
(MANAGER, "cancel_pay_run", {"pay_run_id": run, "reason": ""}, "POST", f"/pay-runs/{run}/cancel", {"reason": ""})]
for who, name, args, method, path, body in cases:
status, text = tool(who, name, args)
code, answer = api(method, path, who, body)
assert status == "refused" and code in (403, 404, 409, 422), (name, code, answer)
assert answer["detail"] in text or text in answer["detail"], (name, text, answer)
def test_no_identity_is_a_401_and_no_permission_a_403_naming_it():
async def bare():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://brick") as c:
return [(await c.get("/pay-runs")).status_code, (await c.post("/pay-runs", json={})).status_code]
assert anyio.run(bare) == [401, 401]
refused(api("GET", "/pay-runs", NOBODY), 403, "prl:pay_run:read")
refused(api("POST", "/pay-runs", EMP4410, {"company_code": "UK01", "schedule_code": "M", "period": "2026-11"}), 403, "prl:pay_run:open")
refused(api("POST", "/outbox/drain", MANAGER), 403, "prl:journal:post_auto")