"""Display-boundary PII masking on the identity-sync read surfaces. 终轮安全扫描 P2: vendor snapshot raw / run-item payloads / drift values are admin- AND PAT-reachable; clear-text mobiles for the whole population must never cross the wire. Masking happens at serialization — storage keeps real values so the drift comparator stays correct. """ from __future__ import annotations import pytest from app.domains.identity_sync.service import ( _cap_json_for_storage, mask_sensitive_display, ) def test_mask_sensitive_display_masks_phone_shaped_keys(): payload = { "fullName": "张三", "email": "zhang.san@pingcap.com", "mobile": "13812345678", "phoneNumbers": [{"value": "13812345678", "type": "work"}], "nested": {"cellPhone": "+8613812345678"}, "certificateNo": "110101199001011234", } out = mask_sensitive_display(payload) assert out["mobile"] == "138****5678" assert out["nested"]["cellPhone"] == "+86 138****5678" # ID-number-shaped key gets head/tail masking. assert "1990" not in out["certificateNo"] # Non-sensitive fields untouched; original dict not mutated. assert out["fullName"] == "张三" assert out["email"] == "zhang.san@pingcap.com" assert payload["mobile"] == "13812345678" def test_mask_sensitive_display_handles_scalars_and_lists(): assert mask_sensitive_display(None) is None assert mask_sensitive_display("13812345678") == "13812345678" # no key context out = mask_sensitive_display([{"mobile": "13812345678"}]) assert out[0]["mobile"] == "138****5678" def test_cap_json_for_storage_truncates_pathological_rows(): small = {"a": "b"} assert _cap_json_for_storage(small) == small huge = {"blob": "x" * (40 * 1024)} out = _cap_json_for_storage(huge) assert out["_truncated"] is True assert out["_size"] > 32 * 1024 assert len(out["_preview"]) <= 2048 assert _cap_json_for_storage(None) is None @pytest.mark.asyncio async def test_run_items_endpoint_masks_request_payload( client, admin_headers, db_session, ): import uuid as _uuid from datetime import UTC, datetime from app.domains.identity_sync.models import ( IdentitySyncRun, IdentitySyncRunItem, IdentitySyncTarget, ) db = db_session tgt = IdentitySyncTarget( id=_uuid.uuid4(), name="Mask T", slug="mask-t", kind="huilianyi", enabled=False, on_offboard="deactivate", mapping_config={"rules": []}, filter_config={ "include_offboarded": False, "departments": [], "include_subtree": True, "countries": [], "employment_statuses": [], "job_title_includes": [], "email_suffixes": [], "person_ids": [], "mode": "and", }, schedule_config={"trigger": "manual", "cron_expr": None, "timezone": "UTC"}, ) db.add(tgt) await db.flush() run = IdentitySyncRun( id=_uuid.uuid4(), target_id=tgt.id, started_at=datetime.now(UTC), status="ok", trigger_source="manual:test", ) db.add(run) await db.flush() db.add(IdentitySyncRunItem( id=_uuid.uuid4(), run_id=run.id, target_id=tgt.id, item_type="person", subject_display="mask@pingcap.com", outcome="updated", request_payload={"email": "mask@pingcap.com", "mobile": "13812345678"}, )) await db.commit() resp = await client.get( f"/api/v1/identity-sync/runs/{run.id}/items", headers=admin_headers, ) assert resp.status_code == 200, resp.text rows = resp.json()["data"] assert rows, "expected the seeded ledger row" payload = rows[0]["request_payload"] assert payload["mobile"] == "138****5678" assert payload["email"] == "mask@pingcap.com"