"""审计日志查询 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}"