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