2026-06-12 16:26:57 +08:00
|
|
|
|
"""masking 模块单元测试 (T11.2).
|
|
|
|
|
|
|
|
|
|
|
|
覆盖:
|
|
|
|
|
|
- 各 mask 函数的正常路径与边界(None / 空串 / 非字符串 / 非标准长度)
|
|
|
|
|
|
- Order.to_dict 的 mask 模式串联通路
|
|
|
|
|
|
- 默认安全:未知 decrypt_sensitive 值回退 mask 而非明文
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-unit-tests")
|
|
|
|
|
|
|
|
|
|
|
|
from data.orders.models import Order
|
|
|
|
|
|
from data.orders.masking import (
|
|
|
|
|
|
mask_id_card,
|
|
|
|
|
|
mask_name,
|
|
|
|
|
|
mask_phone,
|
|
|
|
|
|
mask_sensitive_dict,
|
2026-06-13 14:49:58 +08:00
|
|
|
|
mask_wechat,
|
2026-06-12 16:26:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------- mask_phone ----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_standard_11_digits():
|
|
|
|
|
|
assert mask_phone("13800001234") == "138****1234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_strip_prefix_and_separator():
|
|
|
|
|
|
assert mask_phone("+86 138-0000-1234") == "138****1234"
|
|
|
|
|
|
assert mask_phone(" 13800001234 ") == "138****1234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_none_returns_none():
|
|
|
|
|
|
assert mask_phone(None) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_empty_returns_empty():
|
|
|
|
|
|
assert mask_phone("") == ""
|
|
|
|
|
|
assert mask_phone(" ") == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_non_string_returns_none():
|
|
|
|
|
|
assert mask_phone(13800001234) is None # type: ignore[arg-type]
|
|
|
|
|
|
assert mask_phone(["13800001234"]) is None # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_short_falls_back_to_partial_mask():
|
|
|
|
|
|
"""7 位临界值:走遮罩(至少 1 个 *),8-10 位类似。"""
|
|
|
|
|
|
out7: str = mask_phone("1234567") or "" # type: ignore[assignment]
|
|
|
|
|
|
assert out7.startswith("123")
|
|
|
|
|
|
assert out7.endswith("4567")
|
|
|
|
|
|
assert "*" in out7
|
|
|
|
|
|
# 6 位及以下 — 长度不足,原样返回
|
|
|
|
|
|
assert mask_phone("123456") == "123456"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_phone_keeps_non_standard_length_prefix_suffix():
|
|
|
|
|
|
"""长度 >= 7 但不是 11 位(如带分机 12 位)也走遮罩 — 至少遮 1 个 *。"""
|
|
|
|
|
|
# 12 位带分机号 — 至少 1 个 *,前 3 后 4 保留
|
|
|
|
|
|
out: str = mask_phone("138000012345") or "" # type: ignore[assignment]
|
|
|
|
|
|
assert out.startswith("138*")
|
|
|
|
|
|
assert out.endswith("2345")
|
|
|
|
|
|
assert "*" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------- mask_id_card ----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_18_digits_keeps_district_and_tail():
|
|
|
|
|
|
assert mask_id_card("430102200501011234") == "430102********1234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_18_with_x_checksum_lowercased_ok():
|
|
|
|
|
|
"""末位 X/x 视为合法的身份证校验位 — 遮罩保留末 4 位(含 X)。"""
|
|
|
|
|
|
assert mask_id_card("43010220050101123X") == "430102********123X"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_15_legacy_format():
|
|
|
|
|
|
assert mask_id_card("430102050101123") == "430102******123"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_none_returns_none():
|
|
|
|
|
|
assert mask_id_card(None) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_empty_returns_empty():
|
|
|
|
|
|
assert mask_id_card("") == ""
|
|
|
|
|
|
assert mask_id_card(" ") == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_non_string_returns_none():
|
|
|
|
|
|
assert mask_id_card(123) is None # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_short_below_threshold_unchanged():
|
|
|
|
|
|
"""< 13 位视为非身份证 — 原样返回,避免误遮短串。"""
|
|
|
|
|
|
assert mask_id_card("4301021234") == "4301021234"
|
|
|
|
|
|
assert mask_id_card("12345") == "12345"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_id_card_non_standard_length_partial_mask():
|
|
|
|
|
|
"""13-14 位非标准长度:前 6 后 4 保留,中段至少 2 个 *。"""
|
|
|
|
|
|
out: str = mask_id_card("4301022005011") or "" # type: ignore[assignment] # 13 位
|
|
|
|
|
|
assert out.startswith("430102")
|
|
|
|
|
|
assert out.endswith("5011") # 末 4 位
|
|
|
|
|
|
assert "*" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------- mask_name ----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_one_char_unchanged():
|
|
|
|
|
|
assert mask_name("张") == "张"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_two_chars_surname_plus_star():
|
|
|
|
|
|
assert mask_name("张三") == "张*"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_three_chars_surname_given_name_partial():
|
|
|
|
|
|
"""3 字姓名 → 姓 + 1 个 * + 名末字(如"张三丰" → "张*丰")。"""
|
|
|
|
|
|
assert mask_name("张三丰") == "张*丰"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_four_chars_collapsed_to_surname_two_stars():
|
|
|
|
|
|
"""4+ 字姓名 → 姓 + 2 个 *(不暴露名长度信息)。"""
|
|
|
|
|
|
assert mask_name("欧阳明月") == "欧**"
|
|
|
|
|
|
assert mask_name("上官婉儿") == "上**"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_none_returns_none():
|
|
|
|
|
|
assert mask_name(None) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_empty_returns_empty():
|
|
|
|
|
|
assert mask_name("") == ""
|
|
|
|
|
|
assert mask_name(" ") == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_english_fully_masked():
|
|
|
|
|
|
assert mask_name("Alice") == "*****"
|
|
|
|
|
|
assert mask_name("Bo") == "**"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_name_mixed_chinese_and_digits_treats_as_non_cjk():
|
|
|
|
|
|
"""含非中日韩字符 → 全遮,不暴露字符数。"""
|
|
|
|
|
|
assert mask_name("张3") == "**"
|
|
|
|
|
|
assert mask_name("John 张") == "******" # 6 字符全遮
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------- mask_sensitive_dict ----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
def test_mask_wechat_keeps_prefix_suffix():
|
|
|
|
|
|
assert mask_wechat("wx-li") == "wx*li"
|
|
|
|
|
|
assert mask_wechat("wechat_user") == "we*******er"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_wechat_none_and_empty_are_safe():
|
|
|
|
|
|
assert mask_wechat(None) is None
|
|
|
|
|
|
assert mask_wechat("") == ""
|
|
|
|
|
|
assert mask_wechat("ab") == "**"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 16:26:57 +08:00
|
|
|
|
def test_mask_sensitive_dict_handles_all_known_fields():
|
|
|
|
|
|
data = {
|
|
|
|
|
|
"customer_phone": "13800001234",
|
|
|
|
|
|
"candidate_id_card": "430102200501011234",
|
|
|
|
|
|
"customer_name": "张三",
|
2026-06-13 14:49:58 +08:00
|
|
|
|
"customer_wechat": "wx-li",
|
2026-06-12 16:26:57 +08:00
|
|
|
|
"candidate_name": "李四光",
|
|
|
|
|
|
"customer_phone_hash": "abc",
|
|
|
|
|
|
"amount_cents": 1000,
|
|
|
|
|
|
}
|
|
|
|
|
|
out = mask_sensitive_dict(data)
|
|
|
|
|
|
assert out["customer_phone"] == "138****1234"
|
|
|
|
|
|
assert out["candidate_id_card"] == "430102********1234"
|
|
|
|
|
|
assert out["customer_name"] == "张*"
|
2026-06-13 14:49:58 +08:00
|
|
|
|
assert out["customer_wechat"] == "wx*li"
|
2026-06-12 16:26:57 +08:00
|
|
|
|
assert out["candidate_name"] == "李*光" # 3 字姓名 → 姓 + * + 名末字
|
|
|
|
|
|
assert out["customer_phone_hash"] == "abc"
|
|
|
|
|
|
assert out["amount_cents"] == 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_sensitive_dict_keeps_none_fields_none():
|
|
|
|
|
|
data = {"customer_phone": None, "candidate_id_card": None}
|
|
|
|
|
|
out = mask_sensitive_dict(data)
|
|
|
|
|
|
assert out["customer_phone"] is None
|
|
|
|
|
|
assert out["candidate_id_card"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_sensitive_dict_does_not_mutate_input():
|
|
|
|
|
|
data = {"customer_phone": "13800001234"}
|
|
|
|
|
|
out = mask_sensitive_dict(data)
|
|
|
|
|
|
assert data["customer_phone"] == "13800001234" # 原字典未变
|
|
|
|
|
|
assert out["customer_phone"] == "138****1234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mask_sensitive_dict_non_dict_passthrough():
|
|
|
|
|
|
assert mask_sensitive_dict("not a dict") == "not a dict" # type: ignore[arg-type]
|
|
|
|
|
|
assert mask_sensitive_dict(None) is None # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------- Order.to_dict mask 集成 ----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_order() -> Order:
|
|
|
|
|
|
return Order(
|
|
|
|
|
|
id="GKO-20260612-MASK",
|
|
|
|
|
|
source="web",
|
|
|
|
|
|
service_version="basic",
|
|
|
|
|
|
customer_name="张三",
|
|
|
|
|
|
customer_phone="13800001234",
|
|
|
|
|
|
candidate_name="李四光",
|
|
|
|
|
|
candidate_id_card="430102200501011234",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_order_to_dict_default_is_mask():
|
|
|
|
|
|
"""不传参 = mask 模式 — 默认安全。"""
|
|
|
|
|
|
order = _make_order()
|
|
|
|
|
|
d = order.to_dict()
|
|
|
|
|
|
assert d["customer_phone"] == "138****1234"
|
|
|
|
|
|
assert d["candidate_id_card"] == "430102********1234"
|
|
|
|
|
|
assert d["customer_name"] == "张*"
|
|
|
|
|
|
assert d["candidate_name"] == "李*光" # 3 字姓名规则
|
|
|
|
|
|
# hash 字段保留
|
|
|
|
|
|
assert "customer_phone_hash" in d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_order_to_dict_mask_explicit_string():
|
|
|
|
|
|
order = _make_order()
|
|
|
|
|
|
d = order.to_dict(decrypt_sensitive="mask")
|
|
|
|
|
|
assert d["customer_phone"] == "138****1234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_order_to_dict_unknown_string_falls_back_to_mask():
|
|
|
|
|
|
"""默认安全:未知策略值回退 mask,而非明文。"""
|
|
|
|
|
|
order = _make_order()
|
|
|
|
|
|
d = order.to_dict(decrypt_sensitive="plaintext-oops") # type: ignore[arg-type]
|
|
|
|
|
|
assert d["customer_phone"] == "138****1234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_order_to_dict_true_returns_plaintext():
|
|
|
|
|
|
order = _make_order()
|
|
|
|
|
|
d = order.to_dict(decrypt_sensitive=True)
|
|
|
|
|
|
assert d["customer_phone"] == "13800001234"
|
|
|
|
|
|
assert d["candidate_id_card"] == "430102200501011234"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_order_to_dict_false_strips_sensitive():
|
|
|
|
|
|
order = _make_order()
|
|
|
|
|
|
d = order.to_dict(decrypt_sensitive=False)
|
|
|
|
|
|
assert "customer_phone" not in d
|
|
|
|
|
|
assert "candidate_id_card" not in d
|
|
|
|
|
|
assert "customer_phone_hash" in d
|