"""Regression tests for the laptop_replacement (电脑更换) buyback amount flowing into the Feishu 付款金额 control (2026-06-24). User report: 电脑更换的流程里面回购金额没有传到飞书审批里对应字段上. Root cause: ``付款金额`` was denylisted as approver-only for ``laptop_replacement`` and the policy-quote auto-fill + tamper-guard were scoped to ``offboarding_buyback`` only, so the buyback price of the old computer (是否回购旧电脑 = 是) was never written to the Feishu amount control. Fix mirrors the offboarding_buyback path: * ``付款金额`` removed from the approver-only denylist → flows to the applicant form + serialiser. * ``_enforce_server_buyback_amount`` extended to laptop_replacement. * ``create_request`` drops the amount widget when buyback was NOT chosen, so the common no-buyback path never sees a (statically required) amount field — independent of whether the Feishu template carries a display_condition on it. These tests deliberately give the amount widget NO ``display_condition`` to prove the gating relies on the buyback radio answer, not on the template happening to gate the widget. """ import json from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import AssetEmployeeRequest, AssetItem @pytest.fixture(autouse=True) def _approval_idempotency_headers(request): for fixture_name in ("employee_headers", "admin_headers"): if fixture_name in request.fixturenames: request.getfixturevalue(fixture_name)["Idempotency-Key"] = ( f"test-{request.node.name}" ) _FAKE_FEISHU_CFG = { "app_id": "cli_test", "app_secret": "secret_test", "event_verification_token": "test-feishu-token", } _FAKE_APPROVAL_FLOWS = { "laptop_replacement": "7694D6BE-7FEE-4E49-96B5-A106FC4623DD", "computer_borrow": "8D3A735C-B91C-4EF0-9476-6517EC31B110", "asset_return": "E4ED0075-D2D2-479F-85F7-11374B0CC626", "offboarding_buyback": "3B6AAF34-4762-4A24-AE3A-8E035B7BEDA8", } # laptop_replacement form: the 付款金额 amount widget is statically # required and carries NO display_condition — exactly the case the # server-side gating must handle without relying on the template. _LAPTOP_REPLACEMENT_FORM = [ {"id": "request_type", "name": "申请类型", "type": "radioV2", "required": True}, {"id": "device_sn", "name": "设备序列号", "type": "input", "required": True}, {"id": "reason", "name": "使用电脑说明", "type": "textarea", "required": True}, {"id": "buyback", "name": "是否回购旧电脑", "type": "radioV2", "required": True}, {"id": "payment_amount", "name": "付款金额", "type": "amount", "required": True}, ] @pytest.fixture(autouse=True) def _patch_feishu_config(monkeypatch): async def _fake_cfg(**_kwargs): return dict(_FAKE_FEISHU_CFG) async def _fake_flows(**_kwargs): return dict(_FAKE_APPROVAL_FLOWS) monkeypatch.setattr("app.integrations.compat.get_feishu_config", _fake_cfg) monkeypatch.setattr("app.approval.service.get_feishu_config", _fake_cfg) monkeypatch.setattr( "app.approval.router.get_feishu_config", _fake_cfg, raising=False, ) monkeypatch.setattr( "app.integrations.service.get_approval_flows_map", _fake_flows, ) async def _fake_token(self): return "fake-tenant-token" monkeypatch.setattr( "app.approval.feishu_client.FeishuApprovalClient.get_tenant_access_token", _fake_token, ) def _patches(capture: dict): async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): capture["payload"] = form return {"instance_code": "feishu_lr_bb_001", "instance_status": "PENDING"} return ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock(return_value={ "approval_name": "Laptop Replacement Request", "form": _LAPTOP_REPLACEMENT_FORM, }), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="feishu_user_lr_bb"), ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ) def _make_asset(employee, tag: str) -> AssetItem: return AssetItem( asset_tag=tag, category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", # ≥ 36 months → tier-1 floor = CNY 400 purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) @pytest.mark.asyncio async def test_buyback_amount_injected_when_chosen( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """是否回购 = Yes → the server policy quote (CNY 400) is written to the Feishu 付款金额 control as a plain numeric string, even though the applicant never supplied an amount.""" employee, _ = employee_user asset = _make_asset(employee, "LR-BB-YES") db_session.add(asset) await db_session.commit() cap: dict[str, str] = {} p = _patches(cap) with p[0], p[1], p[2]: response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "laptop_replacement", "asset_id": str(asset.id), "reason": "M5 14 32G", "answers": { "request_type": "retirement_replacement", "device_sn": "SN-LR-BB-YES", "reason": "M5 14 32G", # Real Feishu wire returns the option text "Yes". "buyback": "Yes", }, }, ) assert response.status_code == 200, response.text feishu_form = json.loads(cap["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "payment_amount"] assert len(amount_entries) == 1, feishu_form # Plain numeric string (see _serialize_widget_value), not a dict. assert Decimal(amount_entries[0]["value"]) == Decimal("400.00") request_row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id, ) ) employee_request = request_row.scalar_one() resolved = employee_request.detail["resolved_answers"]["payment_amount"] assert Decimal(resolved["amount"]) == Decimal("400.00") assert resolved["currency"] == "CNY" # Regression (2026-07-04): the first-class ``detail.buyback_amount`` key # (stamped at submit for the 回购汇总 column/summary) must be populated # EVEN THOUGH the 付款金额 amount widget is LAST in the template form # above. A previously-misplaced ``break`` exited the stamping loop after # the first widget (``request_type``), so the amount was never reached and # the money silently dropped out of the by-currency totals. buyback = employee_request.detail.get("buyback_amount") assert buyback is not None, "buyback_amount not stamped when amount widget is not first" assert Decimal(buyback["amount"]) == Decimal("400.00") assert buyback["currency"] == "CNY" @pytest.mark.asyncio async def test_amount_widget_dropped_when_no_buyback( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """是否回购 = No → the (statically required, un-gated) 付款金额 widget is dropped server-side: submit succeeds AND the amount never ships to Feishu. Without the drop this 400s with APPROVAL_FIELD_REQUIRED on the common keep-my-old-laptop path.""" employee, _ = employee_user asset = _make_asset(employee, "LR-BB-NO") db_session.add(asset) await db_session.commit() cap: dict[str, str] = {} p = _patches(cap) with p[0], p[1], p[2]: response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "laptop_replacement", "asset_id": str(asset.id), "reason": "M5 14 32G", "answers": { "request_type": "retirement_replacement", "device_sn": "SN-LR-BB-NO", "reason": "M5 14 32G", "buyback": "No", }, }, ) assert response.status_code == 200, response.text feishu_form = json.loads(cap["payload"]) submitted_ids = {w.get("id") for w in feishu_form} assert "payment_amount" not in submitted_ids, feishu_form assert "buyback" in submitted_ids @pytest.mark.asyncio async def test_buyback_amount_tamper_guard( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """是否回购 = Yes with a FORGED amount → server re-quotes and overwrites before the form ships to Feishu (¥1 buyback defence, same guarantee as offboarding_buyback).""" employee, _ = employee_user asset = _make_asset(employee, "LR-BB-TAMPER") db_session.add(asset) await db_session.commit() cap: dict[str, str] = {} p = _patches(cap) with p[0], p[1], p[2]: response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "laptop_replacement", "asset_id": str(asset.id), "reason": "M5 14 32G", "answers": { "request_type": "retirement_replacement", "device_sn": "SN-LR-BB-TAMPER", "reason": "M5 14 32G", "buyback": "Yes", # The attack — forged buyback price. "payment_amount": {"amount": "1", "currency": "CNY"}, }, }, ) assert response.status_code == 200, response.text feishu_form = json.loads(cap["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "payment_amount"] assert len(amount_entries) == 1, feishu_form assert Decimal(amount_entries[0]["value"]) == Decimal("400.00"), ( "server failed to overwrite tampered laptop_replacement buyback " f"amount; Feishu would have received {amount_entries[0]['value']!r}" ) request_row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id, ) ) employee_request = request_row.scalar_one() submitted = employee_request.detail["submitted_answers"]["payment_amount"] assert submitted == {"amount": "1", "currency": "CNY"}, submitted @pytest.mark.asyncio async def test_buyback_without_asset_id_is_rejected( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """是否回购 = Yes but NO asset_id → 400. Tamper-bypass regression: ``_enforce_server_buyback_amount`` short- circuits when ``asset is None`` (it needs the asset row to compute the canonical quote). Without an asset gate, an affirmative laptop_replacement buyback that omits ``asset_id`` kept the amount widget AND skipped the re-quote, so a forged ¥1 amount would ship to Feishu unmodified — the exact ¥1-laptop threat the buyback enforcement exists to block. The server must reject the request, mirroring OFFBOARDING_BUYBACK_REQUIRES_ASSET. """ cap: dict[str, str] = {} p = _patches(cap) with p[0], p[1], p[2]: response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "laptop_replacement", "reason": "M5 14 32G", "answers": { "request_type": "retirement_replacement", "device_sn": "SN-LR-BB-NOASSET", "reason": "M5 14 32G", "buyback": "Yes", # The attack — forged buyback price with no asset linked. "payment_amount": {"amount": "1", "currency": "CNY"}, }, }, ) assert response.status_code == 400, response.text body = response.json() assert body["errors"][0]["code"] == "LAPTOP_BUYBACK_REQUIRES_ASSET", body # The forged form must never have reached Feishu. assert "payload" not in cap, cap