dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_access.py
"""Who may do what (module.yaml tests.access), through both doors."""
import yaml
from conftest import (
ACCOUNTANT,
ACCOUNTANT2,
AUDITOR,
BOOKKEEPER,
CONSOLIDATION,
CONTROLLER,
HERE,
OWNER,
POSTER,
api,
must,
on_day,
person,
refused,
tool,
tools,
)
from world import account_id, books, close_through, invoice, line, post
ROLES = yaml.safe_load((HERE.parents[0] / "src" / "brick_gl" / "roles.yaml").read_text())
MACHINE = {"gl:journal:post", "gl:balance:derive", "gl:carry_forward:derive", "gl:resplit:post", "gl:close:check", "gl:group_data:extract",
"gl:statement:materialise", "gl:journal:check", "gl:number_series:claim", "gl:migration:load", "gl:migration:reconcile"}
def test_the_controls_are_never_held_by_whoever_they_control_and_machine_permissions_by_no_person_role():
roles = ROLES["roles"]
for r, perms in roles.items():
if r != "owner":
assert "gl:settings:controls" not in perms and "gl:migration:sign" not in perms, r
if r != "system":
assert not (MACHINE & set(perms)), (r, MACHINE & set(perms))
assert ROLES["service_clients"] == ["system"]
assert set(roles["owner"]) >= {"gl:settings:controls", "gl:migration:sign"}
def test_a_request_with_no_identity_is_rejected():
books()
for method, path in (("GET", "/journals"), ("POST", "/journal-entry/journals"), ("GET", "/periods/open"), ("GET", "/reports/trial-balance")):
assert api(method, path, {}, {})[0] == 401, path
def test_a_bookkeeper_cannot_approve_reverse_close_reopen_or_change_the_chart_and_the_map():
books()
jid = invoice("INV-1")[1]["journal_id"]
p = must(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-10-20", "lines": [line(account="6100", debit="1.00"), line(account="2100", credit="1.00")]}))
attempts = [
("POST", f"/journals/{p['journal_id']}/approve-manual", {}), ("POST", f"/journals/{jid}/reverse-journal", {"reason": "x"}),
("POST", "/periods/2026-01/close-period", {}), ("POST", "/periods/2026-01/reopen-period", {"reason": "x"}),
("POST", "/accounts", {"chart_id": "MAIN", "code": "9999", "type": "asset"}), ("POST", "/charts", {"chart_id": "X"}),
("POST", "/account-map", {"chart_id": "MAIN", "purpose": "revenue", "account_id": str(account_id("4000")), "valid_from": "2026-12-01"}),
("POST", "/fiscal-years/open-year", {"fiscal_year": "FY2027"}), ("POST", "/companies", {"company_code": "ZZ"}),
("POST", "/fx-rates", {"from_currency": "EUR", "rate": "1.1", "rate_date": "2026-10-01"}), ("POST", "/statement-versions", {"fsv_id": "X", "name": "x", "kind": "balance_sheet_pl", "chart_id": "MAIN", "nodes": [{"node_id": "A", "label": "a"}], "assignments": []}),
]
for m, path, body in attempts:
assert api(m, path, BOOKKEEPER, body)[0] == 403, path
assert tool(BOOKKEEPER, "reverse_journal", {"journal_id": jid, "reason": "x"})[0] == "refused"
def test_an_auditor_cannot_post_propose_or_change_anything():
books()
jid = invoice("INV-1")[1]["journal_id"]
writes = [("POST", "/journals/propose-manual", {"journal_date": "2026-10-20", "lines": [line(account="6100", debit="1.00"), line(account="2100", credit="1.00")]}),
("POST", f"/journals/{jid}/reverse-journal", {"reason": "x"}), ("POST", "/accounts", {"chart_id": "MAIN", "code": "9", "type": "asset"}),
("POST", "/companies", {"company_code": "ZZ"}), ("POST", "/periods/2026-01/close-period", {}), ("POST", "/recurring", {"recurring_id": "R", "first_period": "2026-01", "lines": [line(account="6100", debit="1.00")]}),
("POST", "/budgets", {"budget_id": "B", "fiscal_year": "FY2026", "lines": []}), ("POST", "/controls", {"key": "gap_free_numbers", "value": True}),
("POST", "/fx-rates", {"from_currency": "EUR", "rate": "1.1", "rate_date": "2026-10-01"})]
for m, path, body in writes:
assert api(m, path, AUDITOR, body)[0] == 403, path
assert api("GET", f"/journals/{jid}", AUDITOR)[0] == 200 and api("GET", "/reports/trial-balance", AUDITOR)[0] == 200
assert api("POST", "/journal-entry/journals", AUDITOR, {"source_module": "x"})[0] in (403, 422)
def test_settings_write_cannot_change_a_control_and_the_machine_routes_refuse_every_person_and_wildcard():
books()
refused(api("POST", "/settings", ACCOUNTANT, {"key": "gap_free_numbers", "value": False}), 403, "control")
refused(api("POST", "/controls", ACCOUNTANT, {"key": "gap_free_numbers", "value": False}), 403)
assert must(api("POST", "/settings", ACCOUNTANT, {"key": "ic_tolerance", "value": "2.00"}))["value"] == "2.00"
wild = person("wild@firm.example", ["*:*:*"])
gl_wild = person("glwild@firm.example", ["gl:*:*"])
body = {"source_module": "ar", "source_document": "INV-1", "event": "issue", "journal_date": "2026-10-15", "currency": "GBP",
"lines": [line(account="1100", debit="1.00"), line(account="4000", credit="1.00")]}
for who in (ACCOUNTANT, OWNER, BOOKKEEPER, CONTROLLER, AUDITOR, wild, gl_wild):
assert api("POST", "/journal-entry/journals", who, body)[0] == 403
assert api("GET", "/group-reporting/balances", who, {"company_code": "UK01", "ledger_id": "LEAD", "period_id": "2026-10"})[0] == 403
assert api("POST", "/journals/post-pending-reversals", gl_wild, {})[0] == 403
assert api("GET", "/journals", wild)[0] == 200 # a wildcard still reaches every PERSON route
assert api("POST", "/fiscal-years/derive-carry-forward", wild, {"fiscal_year": "FY2026"})[0] in (403, 404, 405)
# exact grants reach exactly their own route
assert api("POST", "/journal-entry/journals", POSTER, {**body, "journal_id": str(__import__("erp_platform.seam", fromlist=["derived_id"]).derived_id("INV-1", "issue"))})[0] == 200
assert api("GET", "/reports/trial-balance", POSTER)[0] == 403 # ar's client holds gl:journal:post and gl:period:read, nothing else
assert api("GET", "/periods/open", POSTER, {"date": "2026-10-15"})[0] == 200
def test_consolidations_client_is_the_only_one_that_extracts_group_data():
books()
for who in (ACCOUNTANT, AUDITOR, SYSTEM_NO_GROUP, POSTER):
assert api("GET", "/group-reporting/balances", who, {"company_code": "UK01", "ledger_id": "LEAD", "period_id": "2026-10"})[0] == 403
assert api("GET", "/group-reporting/balances", CONSOLIDATION, {"company_code": "UK01", "ledger_id": "LEAD", "period_id": "2026-10"})[0] == 200
assert api("GET", "/group-reporting/balances", CONSOLIDATION, {"company_code": "NOPE", "ledger_id": "LEAD", "period_id": "2026-10"})[0] == 404
SYSTEM_NO_GROUP = person("system-no-group@svc.example", [p for p in ROLES["roles"]["system"] if p != "gl:group_data:extract"])
def test_a_controller_simulates_and_posts_but_does_not_approve_and_an_accountant_cannot_approve_what_they_wrote_started_or_completed():
books()
assert api("POST", "/allocation-cycles/X/approve-allocation-cycle", CONTROLLER, {})[0] == 403
assert api("POST", "/allocation-cycles/X/simulate-allocation", CONTROLLER, {"period_id": "2026-10"})[0] == 404 # permitted: the cycle is unknown
assert api("POST", "/allocation-cycles/X/simulate-allocation", ACCOUNTANT, {"period_id": "2026-10"})[0] == 403
assert api("POST", "/allocation-cycles/X/post-allocation", CONTROLLER, {"period_id": "2026-10"})[0] == 404
assert api("POST", "/budgets/B/approve-budget", CONTROLLER, {})[0] == 403
assert api("POST", "/recurring/R/approve-recurring", BOOKKEEPER, {})[0] == 403
assert api("POST", "/accruals/A/approve-accrual", BOOKKEEPER, {})[0] == 403
assert api("POST", "/close-runs/UK01:2026-10:month/sign-off-close", BOOKKEEPER, {})[0] == 403
def test_only_the_audit_export_permission_produces_a_file_and_only_controls_write_company_access():
books()
for who in (BOOKKEEPER, CONTROLLER, OWNER):
assert api("POST", "/audit/export-fec", who, {"fiscal_year": "FY2026", "siren": "123456789"})[0] == 403
assert api("POST", "/audit/export-saft", who, {"fiscal_year": "FY2026"})[0] == 403
assert api("POST", "/audit/export-saft", ACCOUNTANT, {"fiscal_year": "FY2026"})[0] == 200
greedy = person("greedy@firm.example", ["gl:settings:write", "gl:company:write", "gl:company:read"])
assert api("POST", "/company-access", greedy, {"principal_sub": "s", "company_code": "UK01"})[0] == 403
assert api("POST", "/company-access", OWNER, {"principal_sub": "s", "company_code": "UK01"})[0] == 200
def test_the_mcp_tools_answer_what_the_routes_answer_including_404_for_another_companys_rows():
books()
must(api("POST", "/companies", ACCOUNTANT, {"company_code": "DE01", "name": "GmbH", "functional_currency": "EUR", "chart_id": "MAIN"}))
uk = invoice("INV-1")[1]["journal_id"]
de = person("de@firm.example", ["gl:journal:read", "gl:company:read", "gl:report:read"], sub="sub-de")
must(api("POST", "/company-access", OWNER, {"principal_sub": "sub-de", "company_code": "DE01", "valid_from": "2026-10-23"}))
with on_day("2026-10-23T09:00:00+00:00"):
assert api("GET", f"/journals/{uk}", de)[0] == 404
status, text = tool(de, "get_journal", {"journal_id": uk})
assert status == "refused" and text.startswith("404") and "no journal" in text
assert tool(de, "get_company", {"company_code": "UK01"})[0] == "refused"
assert tool(de, "get_company", {"company_code": "DE01"})[0] == "ok"
names = tools(ACCOUNTANT)
for must_have in ("post_from_source", "propose_manual", "approve_manual", "reject_manual", "reverse_journal", "open_year", "close_period",
"reopen_period", "close_year", "run_recurring_entries", "run_accruals", "simulate_allocation", "post_allocation",
"load_gl_from_odoo", "sign_gl_reconciliation", "sign_off_close", "match_intercompany", "propose_cross_company"):
assert must_have in names, must_have
_ = (close_through, ACCOUNTANT2, post)