Files

306 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""审计日志查询 API 测试(迭代第 4 轮)。
验证:列表筛选、动作标签与风险分级、统计汇总、单据轨迹、租户隔离、
以及**审计日志不可篡改**(无写接口)。
"""
import pytest
from decimal import Decimal
from model_bakery import baker
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product
from apps.core.audit import log, log_bill_posted, log_force_release
from apps.core.models import AuditLog
from apps.inventory.models import Warehouse
from apps.partner.models import Customer
from apps.sales import services as sales_services
@pytest.fixture
def auth_client(db, user, tenant):
c = APIClient()
refresh = RefreshToken.for_user(user)
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return c
@pytest.fixture
def warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店",
credit_limit=Decimal("99999"))
@pytest.fixture
def product(db, tenant):
return baker.make(Product, tenant=tenant, code="P001", name="可乐",
sale_price=Decimal("10"))
def _make_posted_bill(tenant, warehouse, customer, product):
from apps.inventory import services as inv
inv.inbound(tenant=tenant, warehouse=warehouse, product=product,
quantity=Decimal("100"), unit_cost=Decimal("5"))
bill = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 2, "unit_price": 10}],
)
sales_services.confirm_sales_bill(bill)
return bill
# ============================================================
# 列表
# ============================================================
def test_list_returns_audit_entries(db, auth_client, tenant, warehouse,
customer, product):
bill = _make_posted_bill(tenant, warehouse, customer, product)
resp = auth_client.get("/api/v1/core/audit-logs/")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["count"] >= 1
entry = next(e for e in body["results"] if e["target_id"] == bill.bill_no)
assert entry["action"] == "post"
assert entry["action_label"] == "过账"
assert entry["target_type"] == "SalesBill"
assert entry["detail"]["amount"] == str(bill.total_amount)
def test_list_includes_action_label_and_risk(db, auth_client, tenant,
warehouse, customer, product):
bill = _make_posted_bill(tenant, warehouse, customer, product)
log_force_release(tenant=tenant, kind="credit_limit", target=bill.bill_no,
detail={"note": "测试放行"})
body = auth_client.get("/api/v1/core/audit-logs/").json()
force_entry = next(e for e in body["results"] if e["action"] == "force")
assert force_entry["action_label"] == "管控放行"
assert force_entry["risk"] == "high" # 高风险管理动作
post_entry = next(e for e in body["results"] if e["action"] == "post")
assert post_entry["risk"] == "info"
def test_list_filter_by_target(db, auth_client, tenant, warehouse, customer, product):
b1 = _make_posted_bill(tenant, warehouse, customer, product)
b2 = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 1, "unit_price": 10}],
)
sales_services.confirm_sales_bill(b2)
body = auth_client.get(
f"/api/v1/core/audit-logs/?target_type=SalesBill&target_id={b1.bill_no}"
).json()
assert body["count"] == 1
assert body["results"][0]["target_id"] == b1.bill_no
def test_list_filter_by_action(db, auth_client, tenant, warehouse, customer, product):
bill = _make_posted_bill(tenant, warehouse, customer, product)
log_force_release(tenant=tenant, kind="credit_limit", target="X1", detail={})
body = auth_client.get("/api/v1/core/audit-logs/?action=force").json()
assert body["count"] >= 1
assert all(e["action"] == "force" for e in body["results"])
def test_list_limit_capped(db, auth_client, tenant):
for i in range(20):
log(tenant=tenant, action="post", target_type="T", target_id=f"ID{i}")
body = auth_client.get("/api/v1/core/audit-logs/?limit=99999").json()
assert body["count"] <= 500 # 上限保护
def test_list_ordered_desc(db, auth_client, tenant):
"""最新的在前。
注意:同一秒内创建的记录 created_at 相同,仅按时间排序不稳定,
因此查询用 `-created_at, -id` 双键(见 audit_views)。
"""
for i in range(5):
log(tenant=tenant, action="post", target_type="T", target_id=f"SEQ{i}")
body = auth_client.get("/api/v1/core/audit-logs/").json()
seq_ids = [e["target_id"] for e in body["results"] if e["target_id"].startswith("SEQ")]
assert seq_ids[0] == "SEQ4" # 最新在前
assert seq_ids == ["SEQ4", "SEQ3", "SEQ2", "SEQ1", "SEQ0"]
# ============================================================
# 汇总
# ============================================================
def test_summary_counts_by_action(db, auth_client, tenant, warehouse,
customer, product):
bill = _make_posted_bill(tenant, warehouse, customer, product)
log_force_release(tenant=tenant, kind="credit_limit", target="A", detail={})
log_force_release(tenant=tenant, kind="below_min_price", target="B", detail={})
body = auth_client.get("/api/v1/core/audit-logs/summary/").json()
assert body["total"] >= 3
items = {i["action"]: i for i in body["items"]}
assert items["post"]["count"] >= 1
assert items["force"]["count"] == 2
assert body["high_risk_count"] == 2 # 两次放行都是高风险
def test_summary_days_filter(db, auth_client, tenant):
log(tenant=tenant, action="post", target_type="T", target_id="RECENT")
body = auth_client.get("/api/v1/core/audit-logs/summary/?days=1").json()
assert body["days"] == 1
assert body["total"] >= 1
# ============================================================
# 单据轨迹
# ============================================================
def test_timeline_for_bill(db, auth_client, tenant, warehouse, customer, product):
"""单据轨迹:过账 + 该单的强制放行(放行的 target_type 是管控类型)。"""
bill = _make_posted_bill(tenant, warehouse, customer, product)
# 放行记录挂在管控类型下(credit_limit),但 detail 里带 bill_no
log_force_release(tenant=tenant, kind="credit_limit", target=bill.bill_no,
detail={"bill_no": bill.bill_no, "outstanding": "900"})
# 单据自身的轨迹(含 detail.bill_no 匹配到的放行记录)
body = auth_client.get(
f"/api/v1/core/audit-logs/timeline/?target_type=SalesBill&target_id={bill.bill_no}"
).json()
assert body["target"]["id"] == bill.bill_no
assert body["count"] >= 1
assert any(t["action"] == "post" for t in body["timeline"])
# 按管控类型查:应包含放行记录本身;
# 由于轨迹会双向串联(detail.bill_no 也匹配),过账记录同样会出现——
# 这是有意的:无论从哪个入口查,都能看到这张单的完整动作序列。
body2 = auth_client.get(
f"/api/v1/core/audit-logs/timeline/?target_type=credit_limit&target_id={bill.bill_no}"
).json()
assert body2["count"] >= 1
actions2 = [t["action"] for t in body2["timeline"]]
assert "force" in actions2
def test_timeline_requires_params(db, auth_client):
assert auth_client.get("/api/v1/core/audit-logs/timeline/").status_code == 400
assert auth_client.get(
"/api/v1/core/audit-logs/timeline/?target_type=SalesBill"
).status_code == 400
def test_timeline_includes_user_and_ip(db, auth_client, tenant):
log(tenant=tenant, action="post", target_type="X", target_id="U1",
detail={"who": "test"})
body = auth_client.get(
"/api/v1/core/audit-logs/timeline/?target_type=X&target_id=U1"
).json()
assert body["count"] == 1
assert body["timeline"][0]["user"] in ("系统", None) or isinstance(
body["timeline"][0]["user"], str)
# ============================================================
# 隔离与不可篡改
# ============================================================
def test_tenant_isolation(db, auth_client, tenant, other_tenant):
log(tenant=tenant, action="post", target_type="T", target_id="MINE")
log(tenant=other_tenant, action="post", target_type="T", target_id="THEIRS")
body = auth_client.get("/api/v1/core/audit-logs/?limit=500").json()
ids = [e["target_id"] for e in body["results"]]
assert "MINE" in ids
assert "THEIRS" not in ids
def test_audit_logs_are_read_only(db, auth_client, tenant):
"""审计日志没有写接口:不能 POST/PUT/DELETE(证据链完整)。"""
log(tenant=tenant, action="post", target_type="T", target_id="RO1")
entry = AuditLog.objects.filter(tenant=tenant, target_id="RO1").first()
assert auth_client.post("/api/v1/core/audit-logs/", {}, format="json").status_code in (403, 404, 405)
assert auth_client.delete(f"/api/v1/core/audit-logs/{entry.id}/").status_code in (403, 404, 405)
assert auth_client.put(f"/api/v1/core/audit-logs/{entry.id}/", {},
format="json").status_code in (403, 404, 405)
# 记录仍在
assert AuditLog.objects.filter(pk=entry.pk).exists()
def test_requires_auth(db, client, tenant):
resp = client.get("/api/v1/core/audit-logs/", HTTP_X_TENANT_ID=tenant.code)
assert resp.status_code in (401, 403)
def test_requires_tenant(db, user, tenant):
"""未知租户必须被拒绝。
注意:APIClient.credentials() 设的 header 是**持久**的,单次请求传
HTTP_X_TENANT_ID 不会覆盖它——必须新建 client 才能测出真实行为。
"""
c = APIClient()
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(user).access_token}",
HTTP_X_TENANT_ID="no-such-tenant",
)
resp = c.get("/api/v1/core/audit-logs/")
assert resp.status_code == 400, resp.content
assert "tenant" in str(resp.json())
def test_timeline_merges_related_actions(db, auth_client, tenant, warehouse, product):
"""轨迹要覆盖"这张单的所有动作",包括挂在其他 target_type 下的放行记录。
背景:强制放行的 target_type 是 credit_limit(管控类型),
若轨迹只按 target_type 过滤,用户看到的单据轨迹会缺掉最关键的那次放行。
靠 detail.bill_no 把两者串起来。
"""
from apps.finance.models import Receivable
from apps.inventory import services as inv
limited = baker.make(Customer, tenant=tenant, code="CLIM", name="受限客户",
credit_limit=Decimal("100"))
baker.make(Receivable, tenant=tenant, customer=limited, bill_no="RC-X",
total_amount=Decimal("90"), status="open")
inv.inbound(tenant=tenant, warehouse=warehouse, product=product,
quantity=Decimal("100"), unit_cost=Decimal("2"))
bill = sales_services.create_sales_bill(
tenant=tenant, customer=limited, warehouse=warehouse,
lines=[{"product": product, "quantity": 5, "unit_price": 10}],
)
sales_services.confirm_sales_bill(bill, force=True)
body = auth_client.get(
f"/api/v1/core/audit-logs/timeline/?target_type=SalesBill&target_id={bill.bill_no}"
).json()
actions = [t["action"] for t in body["timeline"]]
assert "post" in actions, f"缺少过账记录:{actions}"
assert "force" in actions, f"缺少放行记录(轨迹未串起同一单据的动作):{actions}"
# 顺序反映**真实执行顺序**:confirm_sales_bill 先做额度校验(可能放行),
# 再做库存扣减、生成应收,最后才写"过账"审计。
# 因此放行在前、过账在后是正确的——测试记录这个事实,避免后人误以为是 bug。
assert actions == ["force", "post"], f"执行顺序变了:{actions}"