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