dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

tests/test_access.py

"""Access from the permission matrix: every route is refused (403) to a caller holding everything BUT its permission; an
authorised caller asking for something that is not there gets 404; the machine permissions go to no person; a custodian
sees only their own location; self-approval is refused under a second identity for every approval."""
import pytest
from conftest import (
    ACCOUNTANT,
    ALL,
    APPROVER,
    CUSTODIAN,
    GL_CLIENT,
    OWNER,
    PURCHASING,
    ROLES,
    TAX,
    acquire,
    api,
    create_asset,
    must,
    on_day,
    person,
    setup_policy,
)

from brick_fixed_assets.application import use_cases

CASES = [u for u in use_cases.FA.use_cases if u.permission and u.path_params is not None]
DATES = ("date", "period_start", "period_end", "as_of", "on")


def sample(name, hint):
    """A syntactically valid value for a required input, so that the permission — not the schema — decides the answer."""
    h = repr(hint)
    if name in ("period_id",):
        return "2026-01"
    if name.endswith("_date") or name in ("period_start", "period_end", "as_of", "valid_from", "on"):
        return "2026-01-01"
    if name == "evidence_sha256":
        return "a" * 64
    if h.startswith("list[") or "list[" in h.split("|")[0][:20]:
        return []
    if "bool" in h:
        return False
    if "Decimal" in h:
        return "1.00"
    if "int" in h and "str" not in h[:40]:
        return 2026 if name == "fiscal_year" else 1
    if name == "currency":
        return "GBP"
    return "X"


def body_for(uc):
    return {n: sample(n, h) for n, h, d in uc.inputs if d is __import__("inspect")._empty and n not in (uc.path_params or ())}


def fill(path, names, uc=None):
    hints = {n: h for n, h, _ in (uc.inputs if uc else ())}
    for n in names:
        v = "2026-01-01" if any(n.endswith(d) or n == d for d in DATES) else \
            ("1" if "int" in repr(hints.get(n, ""))[:60] and "str" not in repr(hints.get(n, ""))[:30] else "X")
        path = path.replace("{" + n + "}", "2026" if n == "fiscal_year" else v)
    return path


@pytest.fixture
def world(gl):
    setup_policy(classes=("VEH",), start="2026-01-01")
    return gl


@pytest.mark.parametrize("uc", CASES, ids=lambda u: u.name)
def test_a_caller_holding_everything_but_this_permission_is_refused(world, uc):
    who = person("everyone@uk.example", [p for p in ALL if p != uc.permission])
    path = fill(uc.path, uc.path_params, uc)
    status, body = api(uc.method, path, who, body_for(uc))
    assert status == 403, (uc.name, status, body)


@pytest.mark.parametrize("uc", [u for u in CASES if u.permission in use_cases.MACHINE], ids=lambda u: u.name)
@pytest.mark.parametrize("role", [r for r in ROLES if r != "system"])
def test_a_machine_permission_is_refused_to_every_person_role_and_to_a_wildcard(world, uc, role):
    path = fill(uc.path, uc.path_params, uc)
    for who in (person(f"{role}@x.example", ROLES[role]), person("wild@x.example", ["fa:*:*", "fa:*", "*"])):
        status, body = api(uc.method, path, who, body_for(uc))
        assert status == 403, (uc.name, role, status, body)


def test_a_missing_id_is_404_for_an_authorised_caller_never_403_or_500(world):
    routes = [("GET", "/assets/NOPE", ACCOUNTANT, None), ("GET", "/asset-transactions/NOPE", ACCOUNTANT, None),
              ("POST", "/asset-transactions/NOPE/approve", APPROVER, {}),
              ("POST", "/asset-transactions/NOPE/reject", APPROVER, {"reason": "r"}),
              ("POST", "/asset-transactions/NOPE/reverse", APPROVER, {"reason": "r"}),
              ("GET", "/depreciation-runs/NOPE", ACCOUNTANT, None), ("POST", "/depreciation-runs/NOPE/approve", APPROVER, {}),
              ("GET", "/my/assets/NOPE", CUSTODIAN, None), ("GET", "/manage/asset-counts/NOWHERE/2026-01-01", ACCOUNTANT, None),
              ("GET", "/migration/odoo/NOPE", OWNER, None), ("POST", "/migration/odoo/NOPE/sign", OWNER, {}),
              ("GET", "/ca-computations/UK01/2026-04-01/2027-03-31", TAX, None),
              ("POST", "/assets/NOPE/retirements", ACCOUNTANT, {"retirement_date": "2026-01-01", "percent": "100"}),
              ("POST", "/assets/NOPE/sub-assets", ACCOUNTANT, {"request_id": "r", "description": "d"}),
              ("POST", "/assets/NOPE/deactivate", ACCOUNTANT, {"reason": "r"})]
    for method, path, who, body in routes:
        status, got = api(method, path, who, body)
        assert status == 404, (method, path, status, got)


def test_a_custodian_sees_only_assets_in_their_own_location_another_sites_is_not_found(world):
    mine = create_asset("mine", description="Mine", location_id="SITE-A", custodian_email="asset_custodian@uk.example")
    other = create_asset("other", description="Other site", location_id="SITE-B", custodian_email="pat@uk.example")
    for a in (mine, other):
        must(acquire(a["asset_id"], "1000.00", ref=f"pur:{a['request_id'] if 'request_id' in a else a['asset_no']}:1"))
    got = must(api("GET", "/my/assets", CUSTODIAN))["assets"]
    assert [a["asset_id"] for a in got] == [mine["asset_id"]]
    assert api("GET", f"/my/assets/{mine['asset_id']}", CUSTODIAN)[0] == 200
    s, b = api("GET", f"/my/assets/{other['asset_id']}", CUSTODIAN)
    assert s == 404, (s, b)                                                  # not found, not forbidden
    assert api("GET", f"/assets/{other['asset_id']}", CUSTODIAN)[0] == 403   # the custodian has no fa:asset:read at all
    nobody = person("nobody@uk.example", ["fa:asset:read_own"])
    assert must(api("GET", "/my/assets", nobody))["assets"] == []            # no email match: nothing, never everything


def test_the_tax_preparer_cannot_post_a_book_transaction_and_the_accountant_cannot_approve_the_computation(world):
    a = create_asset("x")
    must(acquire(a["asset_id"], "1000.00", ref="pur:X-1:1"))
    assert api("POST", f"/assets/{a['asset_id']}/retirements", TAX, {"retirement_date": "2026-11-19", "percent": "100"})[0] == 403
    assert api("POST", "/asset-transactions/X/approve", TAX, {})[0] == 403
    assert api("POST", "/ca-computations/UK01/2026-04-01/2027-03-31/approve", ACCOUNTANT, {})[0] == 403


def test_settings_write_cannot_change_a_control_and_a_controller_of_assets_cannot_hold_controls(world):
    assert api("POST", "/settings/controls", APPROVER, {"setting_key": "capitalisation_threshold", "value": "10"})[0] == 403
    both = person("both@uk.example", ROLES["owner"] + ROLES["asset_accountant"])
    s, b = api("POST", "/settings/controls", both, {"setting_key": "capitalisation_threshold", "value": "10"})
    assert s == 403 and "never held by whoever it controls" in str(b)


def test_purchasing_hands_over_and_gl_reads_but_neither_can_do_more(world):
    assert api("POST", "/asset-counts", PURCHASING, {"location_id": "L", "count_date": "2026-01-01", "custodian_email": "a@b.c"})[0] == 403
    assert api("GET", "/depreciation-status", GL_CLIENT, {"company_code": "UK01", "period_id": "2026-10"})[0] in (200, 404, 409)
    assert api("POST", "/depreciation-runs", GL_CLIENT, {"company_code": "UK01", "period_id": "2026-10"})[0] == 403
    assert api("POST", "/migration/odoo/load", ACCOUNTANT, {
        "odoo_db": "x", "cutover_date": "2026-01-01", "company_code": "UK01", "odoo_flavour": "enterprise", "asset_numbers": "keep",
        "draft_assets": "recreate", "conventions": "keep_asset", "opening_pools": "entered", "analytic": "drop"})[0] == 403


@pytest.mark.parametrize("who", [person("asset_accountant@uk.example", ROLES["asset_accountant"] + ROLES["finance_approver"], sub="login-2"),
                                 person("other@uk.example", ROLES["asset_accountant"] + ROLES["finance_approver"],
                                        sub="asset_accountant@uk.example")])
def test_self_approval_under_a_second_identity_is_refused_for_runs_transactions_and_reversals(world, who):
    a = create_asset("van")
    must(acquire(a["asset_id"], "12000.00", ref="pur:V-1:1"))
    with on_day("2026-10-31T09:00:00+00:00"):
        run = must(api("POST", "/depreciation-runs", ACCOUNTANT, {"company_code": "UK01", "period_id": "2026-10"}))
        s, b = api("POST", f"/depreciation-runs/{run['run_id']}/approve", who)
        assert s in (403, 409) and ("second" in str(b).lower()), (s, b)
        must(api("POST", f"/depreciation-runs/{run['run_id']}/approve", APPROVER))
    with on_day("2026-11-02T09:00:00+00:00"):
        s, b = api("POST", f"/depreciation-runs/{run['run_id']}/reverse", who, {"reason": "oops"})    # the starter's other login
        assert s in (403, 409) and "second" in str(b).lower(), (s, b)
        assert api("POST", f"/depreciation-runs/{run['run_id']}/reverse", APPROVER, {"reason": "oops"})[0] == 200