dodil/erp-brick-tax
DODIL ERP brick: tax
tests/test_access.py
"""Who may do what, through both doors (routes and MCP tools), and the machine permissions no wildcard reaches
(module.yaml tests.access)."""
import pytest
from conftest import (
ADMIN,
BILLER,
FULL,
NOBODY,
OWNER,
SYSTEM,
TODAY,
api,
compute,
line,
load_uk,
must,
person,
setup,
tool,
tools,
)
WRITES = [
("POST", "/codes", {"tax_code": "Q", "treatment": "zero"}),
("POST", "/codes/S/schedule-rate", {"rate_pct": "21", "valid_from": "2027-04-01"}),
("POST", "/codes/S/describe-code", {"description": "x"}),
("POST", "/codes/S/retire-code", {}),
("POST", "/rules", {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "R"}),
("POST", "/rules/end", {"category_code": "standard", "treatment_code": "uk_business"}),
("POST", "/registration/deregister", {"effective_from": "2027-06-30"}),
("POST", "/registration/register", {"vat_number": "GB123456789"}),
("POST", "/settings/rounding_method", {"value": "per_line_nearest"}),
("POST", "/treatments/uk_x", {"description": "x"}),
("POST", "/categories/extra", {"description": "x"}),
]
@pytest.mark.parametrize("who", [BILLER, OWNER, NOBODY, SYSTEM])
def test_only_the_tax_admin_writes_codes_rates_rules_registration_and_settings(who):
setup()
for method, path, body in WRITES:
status, got = api(method, path, who, body)
assert status == 403, (path, status, got)
assert api("POST", "/codes/S/schedule-rate", ADMIN, WRITES[1][2])[0] == 200
def test_a_biller_reads_codes_and_a_documents_tax_overrides_with_a_reason_and_cannot_approve():
setup()
must(compute([line(1, "100.00")], document_ref="INV-1"))
assert [c["tax_code"] for c in must(api("GET", "/codes", BILLER))["codes"]] == ["E", "NV", "OS", "R", "RC", "S", "Z"]
assert must(api("GET", "/documents/INV-1", BILLER))["totals"]["vat"] == "20.00"
assert must(api("GET", "/rules", BILLER))["rules"] and must(api("GET", "/registration", BILLER))["recorded"]
must(api("POST", "/documents/INV-1/lines/1/override-line", BILLER, {"tax_code": "Z", "reason": "export"}))
assert api("POST", "/documents/INV-1/lines/1/approve-override", BILLER, {})[0] == 403
assert api("POST", "/parties/7311/set-party-tax", BILLER, {"treatment_code": "uk_consumer", "valid_from": "2027-01-01"})[0] == 200
assert api("GET", "/codes", NOBODY)[0] == 403
def test_a_draft_code_is_not_found_for_a_biller_not_forbidden():
load_uk(activate=False)
assert api("GET", "/codes/S", BILLER)[0] == 404 and api("GET", "/codes/S", ADMIN)[0] == 200
assert api("GET", "/codes/NOPE", ADMIN)[0] == 404
def test_only_tax_settings_controls_changes_the_controls():
setup()
for key, value in (("tax_changes_need_second_person", True), ("override_approval", "self_with_reason"), ("business_timezone", "Europe/Paris")):
assert api("POST", f"/settings/{key}", ADMIN, {"value": value})[0] == 403
assert api("POST", f"/tax-controls/{key}", ADMIN, {"value": value})[0] == 403
assert api("POST", f"/tax-controls/{key}", OWNER, {"value": value})[0] == 200
assert api("POST", "/tax-controls/rounding_method", OWNER, {"value": "per_line_nearest"})[0] == 422 # not a control: the route does not know it
MACHINE = [
("POST", "/codes/load-pack", {}),
("POST", "/documents/compute", {"lines": [{"line_no": 1, "amount": "1.00"}], "tax_point": TODAY, "bp_id": 7311}),
("POST", "/documents/INV-1/freeze", {}),
("POST", "/documents/INV-1/check-contents", {}),
("POST", "/documents/INV-1/lines/1/ai-suggest-code", {}),
]
@pytest.mark.parametrize("grants", [["*:*:*"], ["tax:*:*"], ["tax:document_tax:*"], ["tax:code:*", "tax:history:*"],
["tax:code:read", "tax:document_tax:override"]])
def test_a_wildcard_grant_never_reaches_a_machine_permission(grants):
setup()
holder = person("wild@shop.example", grants)
for method, path, body in MACHINE:
assert api(method, path, holder, body)[0] == 403, (grants, path)
assert api("POST", "/documents/compute", BILLER, MACHINE[1][2])[0] == 403 # not even the biller
assert api("POST", "/documents/compute", SYSTEM, MACHINE[1][2])[0] == 200 # the exact grant does
if grants == ["*:*:*"]:
assert api("GET", "/codes", holder)[0] == 200 and api("POST", "/codes/S/schedule-rate", holder,
{"rate_pct": "21", "valid_from": "2027-04-01"})[0] == 200 # persons keep their reach
def test_the_machine_permissions_are_declared_exactly():
from brick_tax.application.use_cases import TAX
assert set(TAX.machine_permissions) == {
"tax:code:seed", "tax:document_tax:compute", "tax:document_tax:suggest", "tax:history:import", "tax:obligation:sync",
"tax:tax_point:read", "tax:bad_debt:suggest", "tax:cis:read", "tax:cis:deduct", "tax:cis:record_suffered",
"tax:import_vat:record", "tax:vat_number:check"}
def test_the_default_roles_are_the_specs_actors_permission_for_permission():
"""Every permission a default role holds is one the signed spec's actor holds (slice B completes the v4 lists)."""
import pathlib
import yaml
from conftest import _ROLES, METHOD_DIR
spec = yaml.safe_load((pathlib.Path(METHOD_DIR) / "modules" / "tax" / "module.yaml").read_text())
actors = {a["id"]: set(a["permissions"]) for a in spec["actors"]}
for role, perms in _ROLES.items():
assert set(perms) <= actors[role], (role, sorted(set(perms) - actors[role]))
assert "tax:settings:controls" not in _ROLES["tax_admin"] and "tax:settings:controls" in _ROLES["owner"]
# ── the agent door: the same use cases, nothing more ─────────────────────────────────────────
def test_the_tools_are_use_cases_never_table_crud():
names = tools(SYSTEM)
assert {"compute_tax", "freeze_document_tax", "check_contents", "ai_suggest_code", "accept_tax_suggestion", "override_line",
"approve_override", "schedule_rate", "set_rule", "register", "get_document_tax", "load_pack", "migrate_from_odoo", "reconcile_tax_migration"} <= set(names)
assert not [n for n in names if any(w in n for w in ("table", "upsert", "insert", "delete", "tax_rate", "row"))]
def test_a_tool_answers_what_the_route_answers_and_refuses_with_the_same_words():
setup()
body = {"lines": [line(1, "100.00")], "tax_point": TODAY, "bp_id": "7311"}
routed = must(api("POST", "/documents/compute", SYSTEM, body))
kind, tooled = tool(SYSTEM, "compute_tax", body)
assert kind == "ok" and tooled == routed
kind, why = tool(ADMIN, "schedule_rate", {"tax_code": "S", "rate_pct": "21", "valid_from": TODAY})
status, got = api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": TODAY})
assert kind == "refused" and got["detail"] in why and status == 409
assert tool(BILLER, "compute_tax", body)[0] == "refused"
def test_a_whole_story_through_the_agent_door():
load_uk(activate=False)
assert tool(ADMIN, "activate_code", {"tax_code": "S"})[1]["lifecycle"] == "active"
kind, got = tool(ADMIN, "record_registered", {"vat_number": "GB123456789", "effective_from": "2011-01-04"})
assert kind == "ok" and got["status"] == "registered"
assert tool(ADMIN, "set_rule", {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "S"})[0] == "ok"
assert tool(BILLER, "set_party_tax", {"bp_id": "7311", "treatment_code": "uk_business"})[0] == "ok"
kind, out = tool(SYSTEM, "compute_tax", {"lines": [line(1, "100.00")], "tax_point": TODAY, "bp_id": "7311", "document_ref": "INV-M"})
assert kind == "ok" and out["totals"]["vat"] == "20.00"
assert tool(SYSTEM, "freeze_document_tax", {"document_ref": "INV-M", "contents": FULL})[1]["status"] == "frozen"
def test_the_process_is_served_as_bpmn():
status, body = api("GET", "/process/bpmn", BILLER)
assert status == 200