"""Regression tests for the Feishu widget alias coverage. Locks in 2026-05-20 fixes for the alias-drift bug class — every time a Feishu admin renames a widget on one of the 4 self-service flows (laptop_replacement, asset_return, computer_borrow, offboarding_buyback), the strict-equality lookups inside the approval service silently drop fields. These tests assert every shipped Feishu widget name resolves to the expected metadata key so the next rename can be detected at PR-time, not in production after the fact. Pairs with the centralised ``WIDGET_ALIASES_*`` constants at the top of ``server/app/approval/service.py`` and the ``WIDGET_NAME_TO_I18N_KEY`` map in ``web/src/utils/approvalWidgetI18n.ts`` — these three locations must move in lockstep. """ from __future__ import annotations import pytest from app.approval.service import ( WIDGET_ALIASES_PAYMENT_INFO, WIDGET_ALIASES_RESIGNATION_DATE, ApprovalService, ) def _form(*pairs: tuple[str, object]) -> list[dict[str, object]]: """Build a Feishu-callback-shaped form list.""" return [{"name": name, "value": value} for name, value in pairs] class _ServiceStub: """Minimal stand-in for ApprovalService — exposes the two staticmethods ``_extract_asset_relevant_fields`` reaches via ``self`` without requiring a real DB session.""" _compact_fields = staticmethod(ApprovalService._compact_fields) _parse_json_if_possible = staticmethod(ApprovalService._parse_json_if_possible) def _extract(template_key: str, form: list[dict[str, object]]) -> dict[str, object]: """Call the metadata extractor without a DB session. ``_extract_asset_relevant_fields`` only consumes its arguments and two staticmethods on ``self``; the unit-of-work session is not touched, so a tiny stub gives the same behaviour as a fully constructed service while keeping the test fast + DB-free. """ fn = ApprovalService.__dict__["_extract_asset_relevant_fields"] return fn(_ServiceStub(), template_key, form) @pytest.mark.parametrize( ("template", "widget", "value", "field", "expected"), ( ("asset_return", "归还日期", "2026-04-15", "return_date", "2026-04-15"), ("asset_return", "Return Date", "2026-04-15", "return_date", "2026-04-15"), ("asset_return", "离职日期", "2026-04-15", "return_date", "2026-04-15"), ("asset_return", "归还地点", "Shanghai", "return_location", "Shanghai"), ("asset_return", "Computer serial number", "SN-001", "device_serial_number", "SN-001"), ("computer_borrow", "Reason for borrowing and computer model/configuration requirements", "客户演示需要", "scenario", "客户演示需要"), ("computer_borrow", "Requirement Scenario", "On-site demo", "scenario", "On-site demo"), ("offboarding_buyback", "Last working day", "2026-04-30", "resignation_date", "2026-04-30"), ("offboarding_buyback", "Resignation Date", "2026-04-30", "resignation_date", "2026-04-30"), ("offboarding_buyback", "Computer serial number", "SN-BUY-001", "device_serial_number", "SN-BUY-001"), ("offboarding_buyback", "Buyback Device Serial Number", "SN-BUY-001", "device_serial_number", "SN-BUY-001"), ("offboarding_buyback", "Computer asset number", "ASSET-001", "asset_tag", "ASSET-001"), ("offboarding_buyback", "Repurchase price", '{"amount":"1000","currency":"CNY"}', "buyback_amount", {"amount": "1000", "currency": "CNY"}), ("offboarding_buyback", "Amount", '{"amount":"1000","currency":"CNY"}', "buyback_amount", {"amount": "1000", "currency": "CNY"}), ("offboarding_buyback", "Base location selection", "Beijing", "base_location", "Beijing"), ("laptop_replacement", "Computer pickup location", "Beijing HQ", "base_location", "Beijing HQ"), ("laptop_replacement", "Base Location", "Shanghai Office", "base_location", "Shanghai Office"), ), ) def test_widget_alias_extracts_expected_field( template: str, widget: str, value: object, field: str, expected: object, ) -> None: assert _extract(template, _form((widget, value)))[field] == expected def test_resignation_date_aliases_cover_canonical_set() -> None: assert set(WIDGET_ALIASES_RESIGNATION_DATE) >= { "Last working day", "Resignation Date", "离职日期", "最后工作日", } @pytest.mark.parametrize( ("widget", "value"), ( ("Whether to purchase the old computer that is currently in use?", "是"), ("Whether to purchase the old computer that is currently in use?", "Yes"), ), ) def test_buyback_requested_accepts_question_mark_variants(widget: str, value: str) -> None: assert _extract("laptop_replacement", _form((widget, value)))["buyback_requested"] == value class TestPaymentInfoAlias: """Payment Information server-side overwrite is a security boundary (see ``_augment_answers_with_asset`` L2585). A missing alias = a bypass that lets a forged bank-info string survive into the approver's review card. Lock the canonical alias set here so any future rename is caught at unit-test time.""" def test_canonical_aliases_present(self): assert "Payment Information" in WIDGET_ALIASES_PAYMENT_INFO assert "Payment information" in WIDGET_ALIASES_PAYMENT_INFO # lowercase i — offboarding_buyback ships this assert "付款信息" in WIDGET_ALIASES_PAYMENT_INFO class TestEmptyFormFallsThrough: """No widget matches — extractor must return an empty dict, not raise. Defensive: protects against future Feishu templates that drop a field entirely (e.g. an A/B test) — we degrade gracefully rather than 500ing the callback.""" @pytest.mark.parametrize( "template_key", ["laptop_replacement", "asset_return", "computer_borrow", "offboarding_buyback"], ) def test_empty_form_returns_empty_dict(self, template_key: str): metadata = _extract(template_key, []) assert metadata == {} class TestPaymentInfoServerOverwrite: """``_augment_answers_with_asset`` Payment Information widget MUST overwrite whatever the client posted with the server-resolved bank info (or the placeholder when no entity matches). 2026-05-20 security fix: the alias set must catch the lowercase ``Payment information`` (offboarding_buyback) variant or the overwrite is bypassable. Regression-locks the highest-stakes change in the diff. If the `WIDGET_ALIASES_PAYMENT_INFO` set drops the lowercase entry, this test fails — preventing a re-introduction of the bypass. """ def _make_widget(self, name: str, widget_id: str = "w1") -> object: from app.approval.schemas import ApprovalTemplateWidget return ApprovalTemplateWidget( id=widget_id, name=name, type="textarea", required=False, options=[], ) def _make_asset(self, **overrides): from datetime import date from app.domains.asset.models import AssetItem defaults = dict( asset_tag="ASSET-1", category="laptop", status="assigned", purchase_cost=None, purchase_date=date(2024, 1, 1), purchase_entity="PINGCAP (US), INC.", ) defaults.update(overrides) return AssetItem(**defaults) def _make_entity(self): from app.domains.asset.models import AssetLegalEntity return AssetLegalEntity( name="PINGCAP (US), INC.", bank_name="TEST BANK", bank_account_name="PingCAP Inc", bank_account="123456", tax_identifier="00-0000000", registered_address="123 Main St", account_type="business", ) @pytest.mark.parametrize( "widget_name", [ "Payment Information", # laptop_replacement legacy "Payment information", # offboarding_buyback (lowercase i) "付款信息", # CN alias " Payment Information ", # whitespace pollution # Round 4 audit #5 (2026-05-20): a Feishu admin who # renames the widget with NBSP / fullwidth / ZWSP cannot # bypass the overwrite. NFKC normalisation collapses # these to the canonical alias before comparing. "Payment Information", # NBSP between words "Payment Information", # THIN SPACE "​Payment Information", # leading ZWSP "Payment Information", # trailing BOM "Payment Information", # fullwidth (NFKC → ASCII) ], ) def test_payment_info_overwrites_client_value_for_every_alias(self, widget_name: str): """Forged client bank-info text must be overwritten by the server-resolved entity text — for every alias including the lowercase ``Payment information`` variant.""" from app.approval.service import ApprovalService widget = self._make_widget(widget_name) forged = "ATTACKER BANK / 999999 / Pay to: attacker@evil.com" asset = self._make_asset() entity = self._make_entity() resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={widget.id: forged}, widgets=[widget], payment_entity=entity, ) # Server-resolved text wins; attacker's value is gone. assert resolved[widget.id] != forged assert "TEST BANK" in resolved[widget.id] assert "attacker" not in resolved[widget.id] def test_payment_info_without_entity_writes_placeholder(self): """No matching entity → placeholder stamped, attacker text still overwritten.""" from app.approval.service import ApprovalService widget = self._make_widget("Payment information") forged = "ATTACKER BANK / 999999" asset = self._make_asset(purchase_entity=None) resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={widget.id: forged}, widgets=[widget], payment_entity=None, ) # Attacker text gone; placeholder is the visible artifact. assert resolved[widget.id] != forged assert "未配置采购主体" in resolved[widget.id] class TestNormaliseWidgetName: """``_normalise_widget_name`` is the trust boundary against Feishu-admin-side Unicode bypass attempts on the Payment Information overwrite. Lock the canonical-form output for every confusable variant we know about.""" def test_nfkc_collapses_fullwidth(self): from app.approval.service import _normalise_widget_name # Fullwidth ASCII letters NFKC-normalise to plain ASCII. assert ( _normalise_widget_name("Payment Information") == "Payment Information" ) def test_nbsp_normalises_to_regular_space(self): from app.approval.service import _normalise_widget_name # NBSP (U+00A0) → regular space under NFKC + whitespace # collapse → matches the canonical alias. assert ( _normalise_widget_name("Payment Information") == "Payment Information" ) def test_zero_width_chars_stripped(self): from app.approval.service import _normalise_widget_name # ZWSP / ZWNJ / ZWJ / BOM survive NFKC but are stripped. assert ( _normalise_widget_name("Payment​Information") == "PaymentInformation" ) def test_whitespace_collapse_and_trim(self): from app.approval.service import _normalise_widget_name assert ( _normalise_widget_name(" Payment Information ") == "Payment Information" ) def test_non_string_input_returns_empty_string(self): from app.approval.service import _normalise_widget_name assert _normalise_widget_name(None) == "" assert _normalise_widget_name(123) == "" # type: ignore[arg-type] class TestAssetPrefillDoesNotMatchOnWidgetId: """``_augment_answers_with_asset`` must NOT use ``widget.id`` in the substring-match heuristics — a Feishu widget UUID containing substrings like ``model`` / ``serial`` / ``code`` would otherwise false-positive-stamp asset metadata onto an unrelated widget. Round 2 audit F6. """ def test_widget_with_uuid_containing_model_substring_not_prefilled(self): from datetime import date from app.approval.schemas import ApprovalTemplateWidget from app.approval.service import ApprovalService from app.domains.asset.models import AssetItem # Widget name is generic ``Reason``; widget id is a fake Feishu # UUID that happens to embed ``model`` — pre-F6 this got # auto-stamped with ``asset.model``, overwriting the empty slot. widget = ApprovalTemplateWidget( id="widget-model-12345", name="Reason", type="textarea", required=False, options=[], ) asset = AssetItem( asset_tag="ASSET-1", category="laptop", status="assigned", model="MacBook Pro 14-inch", serial_number="SN-99", brand="Apple", purchase_date=date(2024, 1, 1), ) resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={}, widgets=[widget], payment_entity=None, ) # No prefill — widget.id is no longer part of the match text. assert widget.id not in resolved or resolved.get(widget.id) in (None, "") def test_widget_with_uuid_containing_serial_substring_not_prefilled(self): from datetime import date from app.approval.schemas import ApprovalTemplateWidget from app.approval.service import ApprovalService from app.domains.asset.models import AssetItem widget = ApprovalTemplateWidget( id="widget-serial-abc", name="Notes", type="textarea", required=False, options=[], ) asset = AssetItem( asset_tag="ASSET-1", category="laptop", status="assigned", serial_number="SN-99", purchase_date=date(2024, 1, 1), ) resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={}, widgets=[widget], payment_entity=None, ) assert widget.id not in resolved or resolved.get(widget.id) in (None, "") def test_widget_with_serial_in_name_still_prefilled(self): """Positive control — the prefill heuristic still works when the substring is in ``widget.name`` (where it belongs).""" from datetime import date from app.approval.schemas import ApprovalTemplateWidget from app.approval.service import ApprovalService from app.domains.asset.models import AssetItem widget = ApprovalTemplateWidget( id="widget-xyz", name="Device Serial Number", type="input", required=True, options=[], ) asset = AssetItem( asset_tag="ASSET-1", category="laptop", status="assigned", serial_number="SN-99", purchase_date=date(2024, 1, 1), ) resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={}, widgets=[widget], payment_entity=None, ) assert resolved.get(widget.id) == "SN-99" class TestBuybackAssetTagAutofill: """Regression for the 2026-05-26 user report — the buyback Feishu card shipped with a blank ``电脑资产编号`` column because the widget was on the ``offboarding_buyback`` denylist (treated as "approver-only, IT fills it post-decision"). The applicant always picks an asset at submit time, so the server already KNOWS the tag — the fix is to let the widget through the filter so ``_augment_answers_with_asset`` can stamp it before the form ships to Feishu. """ def test_chinese_computer_asset_number_passes_filter_and_autofills(self): from datetime import date from app.approval.schemas import ApprovalTemplateWidget from app.approval.service import ApprovalService from app.domains.asset.models import AssetItem # Build a minimal ApprovalService stub — `_filter_applicant_widgets` # only consults `self._approver_only_widget_names()` (a staticmethod) # so an empty-ctor fake is sufficient. widget = ApprovalTemplateWidget( id="widget-asset-cn", name="电脑资产编号", type="input", required=False, options=[], ) filtered = ApprovalService._filter_applicant_widgets( ApprovalService, # type: ignore[arg-type] "offboarding_buyback", [widget], ) assert len(filtered) == 1, "电脑资产编号 must not be denylisted on buyback" asset = AssetItem( asset_tag="MAC-001", category="laptop", status="assigned", purchase_date=date(2024, 1, 1), ) resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={}, widgets=filtered, payment_entity=None, ) assert resolved.get(widget.id) == "MAC-001" def test_english_computer_asset_number_passes_filter_and_autofills(self): from datetime import date from app.approval.schemas import ApprovalTemplateWidget from app.approval.service import ApprovalService from app.domains.asset.models import AssetItem widget = ApprovalTemplateWidget( id="widget-asset-en", name="Computer asset number", type="input", required=False, options=[], ) filtered = ApprovalService._filter_applicant_widgets( ApprovalService, # type: ignore[arg-type] "offboarding_buyback", [widget], ) assert len(filtered) == 1, "Computer asset number must not be denylisted" asset = AssetItem( asset_tag="MAC-002", category="laptop", status="assigned", purchase_date=date(2024, 1, 1), ) resolved = ApprovalService._augment_answers_with_asset( asset=asset, answers={}, widgets=filtered, payment_entity=None, ) assert resolved.get(widget.id) == "MAC-002" class TestParseTemplateStagesApproverShapes: """Regression for the 2026-05-26 user report — offboarding_buyback's post-submit timeline rendered "该环节暂无审批人" on every un-traversed future node because ``_parse_template_stages`` only matched the static-user shape ``{"name": "..."}`` and dropped role / department resolvers (which is what Feishu admins typically pick for Finance / IT-team approval nodes). """ def test_role_name_surfaced_as_approver_label(self): from app.approval.service import ApprovalService definition = { "node_list": [ { "node_id": "node-fin", "name": "Finance confirms payment", "node_type": "AND", "approver_chosen_value": [ {"role_id": "r-fin", "role_name": "财务"}, ], }, ], } stages = ApprovalService._parse_template_stages(definition) assert len(stages) == 1 assert stages[0].approver_names == ["财务"] def test_department_name_surfaced_as_approver_label(self): from app.approval.service import ApprovalService definition = { "node_list": [ { "node_id": "node-dept", "name": "Department review", "approver_chosen_value": [ {"department_id": "d-1", "department_name": "财务部"}, ], }, ], } stages = ApprovalService._parse_template_stages(definition) assert stages[0].approver_names == ["财务部"] def test_static_user_name_still_works(self): """Positive control — the pre-existing ``name`` path is intact.""" from app.approval.service import ApprovalService definition = { "node_list": [ { "node_id": "node-mgr", "name": "Manager review", "approver_chosen_value": [ {"user_id": "u-1", "name": "Alice"}, ], }, ], } stages = ApprovalService._parse_template_stages(definition) assert stages[0].approver_names == ["Alice"]