""" 分享权限策略单元测试 (T7.3) 覆盖: - PermissionPolicy.for_permission() 对 read/comment/edit/admin/未知 - render_report_payload() 字段裁剪 + 姓名脱敏 - 兼容 PERM_ADMIN (alias -> edit) - 路由层 route_short_link_with_report() 在 resolve 失败时不下发 payload 运行: python3 -m pytest data/share/tests/test_permission.py -v # 或 (无 pytest 时) python3 data/share/tests/test_permission.py """ import sys import tempfile import os import uuid from pathlib import Path # 让 data.share 可被 import PROJ = Path(__file__).resolve().parents[3] sys.path.insert(0, str(PROJ)) from data.share.permission import ( # noqa: E402 POLICY_ADMIN_ALIAS, PermissionPolicy, is_known_permission, render_report_payload, supported_permissions, ) from data.share.short_link import ( # noqa: E402 PERM_COMMENT, PERM_EDIT, PERM_READ, STATUS_NOT_FOUND, STATUS_OK, STATUS_PASSWORD_REQUIRED, STATUS_REVOKED, ShortLinkService, route_short_link_with_report, ) # --------------------------------------------------------------------------- # 自定义 runner (兼容无 pytest 环境) # --------------------------------------------------------------------------- _TMP_DBS: list = [] def make_svc() -> ShortLinkService: fd, db = tempfile.mkstemp(prefix=f"perm_test_{uuid.uuid4().hex[:8]}_", suffix=".db") os.close(fd) _TMP_DBS.append(db) return ShortLinkService(db_path=db) def cleanup_tmp_dbs(): for db in _TMP_DBS: try: os.remove(db) except OSError: pass _PASS = 0 _FAIL = 0 _ERRORS: list = [] def _eq(actual, expected, msg=""): global _PASS, _FAIL if actual == expected: _PASS += 1 else: _FAIL += 1 _ERRORS.append(f"FAIL: {msg or 'eq'}: {actual!r} != {expected!r}") def _truthy(v, msg): global _PASS, _FAIL if v: _PASS += 1 else: _FAIL += 1 _ERRORS.append(f"FAIL: {msg}: {v!r}") def _has(d, key, msg): _truthy(key in d, f"{msg}: {key!r} in dict") # --------------------------------------------------------------------------- # PermissionPolicy: 静态策略 # --------------------------------------------------------------------------- def test_policy_supported_permissions(): perms = set(supported_permissions()) # 必须包含 3 级业务 + admin alias 兼容 _eq( {"read", "comment", "edit", "admin"}.issubset(perms), True, "supported_permissions contains read/comment/edit/admin", ) def test_policy_admin_alias(): _eq(POLICY_ADMIN_ALIAS, "edit", "admin aliases to edit") def test_policy_read_caps(): p = PermissionPolicy.for_permission("read") _eq(p.can_view, True, "read can_view") _eq(p.can_comment, False, "read cannot comment") _eq(p.can_edit, False, "read cannot edit") _eq(p.mask_name, True, "read masks name") _truthy(p.visible_fields is not None, "read has explicit visible_fields") def test_policy_comment_caps(): p = PermissionPolicy.for_permission("comment") _eq(p.can_view, True, "comment can_view") _eq(p.can_comment, True, "comment can comment") _eq(p.can_edit, False, "comment cannot edit") _eq(p.mask_name, True, "comment masks name") _truthy(p.visible_fields is not None, "comment has explicit visible_fields") def test_policy_edit_caps(): p = PermissionPolicy.for_permission("edit") _eq(p.can_view, True, "edit can_view") _eq(p.can_comment, True, "edit can comment") _eq(p.can_edit, True, "edit can edit") _eq(p.mask_name, False, "edit does NOT mask name") _truthy(p.visible_fields is not None, "edit uses explicit visible_fields allowlist") def test_policy_admin_alias_to_edit(): p = PermissionPolicy.for_permission("admin") _eq(p.permission, "edit", "admin permission normalized to edit") _eq(p.can_edit, True, "admin can edit (via alias)") _eq(p.mask_name, False, "admin does NOT mask name (via alias)") def test_policy_unknown_permission_falls_back_to_read(): """未知的 permission 必须落到最严格的 read 拒止策略, 不允许越权。""" p = PermissionPolicy.for_permission("superuser") _eq(p.permission, "read", "unknown perm stored as 'read'") _eq(p.can_view, True, "fallback: view OK (for nicer UX)") _eq(p.can_comment, False, "fallback: cannot comment") _eq(p.can_edit, False, "fallback: cannot edit") _eq(p.mask_name, True, "fallback: mask name") _truthy(p.is_restrictive_fallback, "is_restrictive_fallback=True") def test_policy_empty_permission_falls_back(): p = PermissionPolicy.for_permission("") _eq(p.is_restrictive_fallback, True, "empty perm triggers fallback") def test_policy_case_insensitive(): p1 = PermissionPolicy.for_permission("READ") p2 = PermissionPolicy.for_permission("read") _eq(p1.can_edit, p2.can_edit, "case insensitive read parity") _eq(p1.permission, "read", "uppercase normalized") def test_is_known_permission(): _eq(is_known_permission("read"), True, "read is known") _eq(is_known_permission("edit"), True, "edit is known") _eq(is_known_permission("admin"), True, "admin is known") _eq(is_known_permission("superuser"), False, "superuser unknown") _eq(is_known_permission(""), False, "empty unknown") def test_allows_field_for_read(): p = PermissionPolicy.for_permission("read") _eq(p.allows_field("recommendations"), False, "read hides recommendations") _eq(p.allows_field("report_id"), True, "read allows report_id") _eq(p.allows_field("score"), False, "read hides score") def test_allows_field_for_edit(): p = PermissionPolicy.for_permission("edit") _eq(p.allows_field("recommendations"), True, "edit allows recommendations") _eq(p.allows_field("candidate_phone"), False, "edit no longer exposes phone") _eq( p.allows_field("password_hash"), False, "edit: password_hash 不在 allowlist 且仍受内部字段规则保护", ) def test_allows_field_for_no_view(): """can_view=False 时, 即便字段在 visible_fields 内也不应暴露。""" p = PermissionPolicy( permission="nop", can_view=False, can_comment=False, can_edit=False, mask_name=True, visible_fields=frozenset({"report_id"}), ) _eq(p.allows_field("report_id"), False, "no view => no field") def test_can_helper(): p = PermissionPolicy.for_permission("edit") _eq(p.can("view"), True, "can('view')") _eq(p.can("comment"), True, "can('comment')") _eq(p.can("edit"), True, "can('edit')") _eq(p.can("admin"), False, "can('admin') unknown action -> False") # --------------------------------------------------------------------------- # render_report_payload: 字段裁剪 + 姓名脱敏 # --------------------------------------------------------------------------- _SAMPLE_REPORT = { "report_id": "R-2026-001", "title": "578分 湖南 志愿方案", "summary": "冲稳保 45 志愿", "candidate_name": "李明", "customer_name": "李明", "score": 578, "rank": 12345, "year": 2026, "province": "湖南", "recommendations": [{"school": "江西财经大学", "major": "会计学", "prob": 0.35}], "volunteers": [{"group": 1, "school": "江西财经大学", "majors": ["会计学"]}], "candidate_phone": "13800001234", "candidate_id_card": "430102200801011234", "password_hash": "should-not-leak-via-policy", "internal_note": "should-not-leak-via-policy", } def test_render_read_payload(): out = render_report_payload("read", _SAMPLE_REPORT) _eq(out["permission"], "read", "permission echoed") _eq(out["policy"]["can_view"], True, "policy.can_view") _eq(out["policy"]["can_comment"], False, "policy.can_comment") _eq(out["policy"]["mask_name"], True, "policy.mask_name") payload = out["payload"] # 可见 _has(payload, "report_id", "read: report_id visible") # 不可见: title / recommendations / score / phone / hash _eq("title" in payload, False, "read: title hidden") _eq("recommendations" in payload, False, "read: recommendations hidden") _eq("score" in payload, False, "read: score hidden") _eq("candidate_phone" in payload, False, "read: phone hidden") _eq("password_hash" in payload, False, "read: hash hidden") # 姓名可见但必须脱敏 _eq(payload.get("candidate_name"), "李*", "read: candidate_name masked") _eq(payload.get("customer_name"), "李*", "read: customer_name masked") _eq( sorted(out["masked_fields"]), ["candidate_name", "customer_name"], "read: masked_fields reports masked names", ) def test_render_comment_payload(): out = render_report_payload("comment", _SAMPLE_REPORT) payload = out["payload"] # 可见: 报告正文类 _has(payload, "title", "comment: title visible") _has(payload, "summary", "comment: summary visible") _has(payload, "score", "comment: score visible") _has(payload, "rank", "comment: rank visible") _has(payload, "recommendations", "comment: recommendations visible") _has(payload, "volunteers", "comment: volunteers visible") # 不可见: PII / 私密字段 _eq("candidate_phone" in payload, False, "comment: phone hidden") _eq("candidate_id_card" in payload, False, "comment: id_card hidden") _eq("password_hash" in payload, False, "comment: hash hidden") _eq("internal_note" in payload, False, "comment: internal note hidden") # 姓名应被脱敏显示,满足分享页 UI 的“张**”效果 _eq(payload.get("candidate_name"), "李*", "comment: candidate_name masked") _eq(payload.get("customer_name"), "李*", "comment: customer_name masked") _eq( sorted(out["masked_fields"]), ["candidate_name", "customer_name"], "comment: masked_fields reports masked names", ) def test_render_edit_payload(): out = render_report_payload("edit", _SAMPLE_REPORT) payload = out["payload"] _eq(out["policy"]["mask_name"], False, "edit: no mask") _has(payload, "candidate_name", "edit: candidate_name visible") _eq("candidate_phone" in payload, False, "edit: phone hidden") _eq("candidate_id_card" in payload, False, "edit: id_card hidden") _eq("password_hash" in payload, False, "edit: password_hash hidden") _eq("internal_note" in payload, False, "edit: internal_note hidden") _eq(payload["candidate_name"], "李明", "edit: name intact") _eq(out["masked_fields"], [], "edit: no fields masked") def test_render_edit_name_unmasked_in_payload(): """编辑权限下, 报告里所有 *_name 字段应原样输出。""" report = { "candidate_name": "欧阳明月", "customer_name": "欧阳明月", "score": 600, } out = render_report_payload("edit", report) _eq(out["payload"]["candidate_name"], "欧阳明月", "edit: 4-char name intact") _eq(out["payload"]["customer_name"], "欧阳明月", "edit: customer_name intact") def test_render_admin_aliases_to_edit(): """admin 视为 edit, 姓名不脱敏, 但字段仍受显式 allowlist 约束。""" out = render_report_payload("admin", _SAMPLE_REPORT) _eq(out["permission"], "edit", "admin perm normalized to edit in payload") _eq(out["payload"]["candidate_name"], "李明", "admin: name intact") _eq(out["policy"]["can_edit"], True, "admin: can edit") def test_render_mask_name_when_name_field_in_visible(): """分享页公开展示时, 3 字中文名应收敛为“姓 + **”。""" from data.share.permission import _POLICY_TABLE # noqa original = _POLICY_TABLE["read"]["visible_fields"] try: _POLICY_TABLE["read"]["visible_fields"] = set(original) | {"candidate_name"} out = render_report_payload("read", {"candidate_name": "张三丰"}) _eq( out["payload"]["candidate_name"], "张**", "share-name masking collapses 3-char CJK names to surname + **", ) _eq( "candidate_name" in out["masked_fields"], True, "candidate_name reported in masked_fields", ) finally: _POLICY_TABLE["read"]["visible_fields"] = original def test_render_mask_name_non_cjk_collapses_to_constant(): out = render_report_payload("read", {"candidate_name": "Alice"}) _eq( out["payload"]["candidate_name"], "**", "non-cjk share name should not leak length", ) _eq( "candidate_name" in out["masked_fields"], True, "non-cjk masked field still reported", ) def test_render_handles_none_report(): out = render_report_payload("read", None) _eq(out["payload"], {}, "None report -> empty payload (still renders)") _eq(out["masked_fields"], [], "no fields masked on None report") _eq(out["policy"]["can_view"], True, "policy still echoed on None report") def test_render_handles_non_dict_report(): """非 dict 输入应被视作无报告, 不抛错。""" out = render_report_payload("edit", "not a dict") _eq(out["payload"], {}, "string report -> empty payload") def test_render_share_url_injection(): out = render_report_payload( "read", {"report_id": "R-1"}, share_url="https://x/s/ABC" ) _eq( out["payload"].get("share_url"), "https://x/s/ABC", "share_url injected into payload when provided", ) def test_render_visible_fields_echo(): out = render_report_payload("comment", _SAMPLE_REPORT) _truthy(out["visible_fields"] is not None, "comment: visible_fields list echoed") _eq("title" in out["visible_fields"], True, "title in visible_fields") def test_render_edit_visible_fields_none(): """P1-6 修复后: edit 必须使用显式 allowlist, 不再返回 None pass-through. 旧契约 (visible_fields=None) 会让未来新增的敏感字段自动外泄, 这里改为 RED 断言 visible_fields 为非 None 且为显式集合. """ out = render_report_payload("edit", _SAMPLE_REPORT) _truthy( out["visible_fields"] is not None, "edit: visible_fields must be explicit allowlist, not None", ) def test_render_admin_visible_fields_none(): """P1-6: admin (alias->edit) 也必须使用显式 allowlist.""" out = render_report_payload("admin", _SAMPLE_REPORT) _truthy( out["visible_fields"] is not None, "admin: visible_fields must be explicit allowlist, not None", ) # --------------------------------------------------------------------------- # P1-6 回归测试: 新增敏感字段不应自动外泄 # --------------------------------------------------------------------------- def test_policy_edit_uses_explicit_allowlist(): """P1-6: edit.permission 策略必须返回非 None 的显式 allowlist.""" p = PermissionPolicy.for_permission("edit") _truthy( p.visible_fields is not None, "edit: visible_fields must be explicit frozenset, not None pass-through", ) def test_policy_admin_uses_explicit_allowlist(): """P1-6: admin (alias->edit) 同样必须返回非 None allowlist.""" p = PermissionPolicy.for_permission("admin") _truthy( p.visible_fields is not None, "admin: visible_fields must be explicit frozenset, not None pass-through", ) def test_edit_does_not_leak_new_sensitive_field(): """P1-6 核心回归: edit 模式下, payload 里塞入一个全新的敏感字段 (不在任何 allowlist 中) 不应自动出现在对外 payload 中. 这条用例模拟"未来某个 commit 在 report 里新增了 candidate_bank_card / api_token / home_address 等敏感字段, edit/admin 是否会无声地把它外暴". """ report = { "report_id": "R-NEW", "candidate_name": "李明", "candidate_bank_card": "6222020200001234567", # 新增敏感字段 "candidate_home_address": "长沙市岳麓区某街道 88 号", # 新增敏感字段 "api_token": "sk_live_secret_abcdef", # 新增敏感字段 } for perm in ("edit", "admin"): out = render_report_payload(perm, report) payload = out["payload"] _eq( "candidate_bank_card" in payload, False, f"{perm}: 新增敏感字段 candidate_bank_card 不应自动外泄", ) _eq( "candidate_home_address" in payload, False, f"{perm}: 新增敏感字段 candidate_home_address 不应自动外泄", ) _eq( "api_token" in payload, False, f"{perm}: 新增敏感字段 api_token 不应自动外泄", ) def test_edit_allows_field_rejects_unknown_field(): """P1-6: edit 的 allows_field 必须对未知字段返回 False (allowlist 语义).""" p = PermissionPolicy.for_permission("edit") # 已知业务字段仍然允许 _eq(p.allows_field("report_id"), True, "edit: report_id allowed") _eq(p.allows_field("recommendations"), True, "edit: recommendations allowed") # 未来新增的、显式 allowlist 中不存在的字段必须被拒绝 _eq( p.allows_field("candidate_bank_card"), False, "edit: 新增敏感字段 candidate_bank_card 被显式 allowlist 拒绝", ) _eq( p.allows_field("api_token"), False, "edit: 新增敏感字段 api_token 被显式 allowlist 拒绝", ) def test_admin_allows_field_rejects_unknown_field(): """P1-6: admin (alias->edit) 的 allows_field 同样拒绝未知字段.""" p = PermissionPolicy.for_permission("admin") _eq( p.allows_field("candidate_bank_card"), False, "admin: 新增敏感字段 candidate_bank_card 被显式 allowlist 拒绝", ) def test_edit_still_hides_internal_fields(): """P1-6 修复后, edit/admin 的隐式内部敏感字段 (password_hash 等) 仍必须被拦截. 切换为 allowlist 后, 这条由 _ALWAYS_HIDDEN_FIELDS + 显式 allowlist 共同保证: 即便未来有人在 allowlist 里不小心漏掉 password_hash, 也仍被黑名单兜底. """ report = { "report_id": "R-INT", "candidate_name": "李明", "password_hash": "hashed-secret", "internal_note": "ops only", "raw_payload": {"deep": "data"}, } for perm in ("edit", "admin"): out = render_report_payload(perm, report) payload = out["payload"] _eq( "password_hash" in payload, False, f"{perm}: password_hash 仍必须被拦截", ) _eq( "internal_note" in payload, False, f"{perm}: internal_note 仍必须被拦截", ) _eq( "raw_payload" in payload, False, f"{perm}: raw_payload 仍必须被拦截", ) def test_share_edit_scope_excludes_id_card_and_internal_paths(): out = render_report_payload( "edit", { "candidate_name": "张三", "candidate_id_card": "430102200501011234", "customer_phone": "13800138000", "customer_email": "parent@example.com", "customer_wechat": "wx-parent", "audit_report": "/var/lib/gaokao/report.html", "pdf_path": "/var/lib/gaokao/report.pdf", "plan_file": "/var/lib/gaokao/plan.md", "title": "报告标题", }, ) payload = out["payload"] _eq(payload.get("title"), "报告标题", "title still visible") for key in ( "candidate_id_card", "customer_phone", "customer_email", "customer_wechat", "audit_report", "pdf_path", "plan_file", ): _eq(key in payload, False, f"edit must hide {key}") def test_edit_allowlist_superset_of_comment(): """P1-6: edit 的 allowlist 必须是 comment allowlist 的超集, 保证业务字段透传.""" edit_p = PermissionPolicy.for_permission("edit") comment_p = PermissionPolicy.for_permission("comment") _eq( comment_p.visible_fields.issubset(edit_p.visible_fields), True, "edit allowlist 必须是 comment allowlist 的超集", ) # --------------------------------------------------------------------------- # route_short_link_with_report: 端到端 # --------------------------------------------------------------------------- def test_route_with_report_read(): fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-1", owner_id="alice", permission=PERM_READ) report = dict(_SAMPLE_REPORT, report_id="R-1") out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report=report, ) _eq(out["status"], STATUS_OK, "route ok") _has(out, "rendered", "rendered key present") _eq(out["rendered"]["permission"], "read", "rendered permission") _eq(out["url"], "https://gk.example.com/s/" + link.code, "url") _has(out["rendered"]["payload"], "report_id", "report_id in payload") finally: os.remove(db) def test_route_with_report_loader_callback(): fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-9", permission=PERM_COMMENT) loaded = {} def loader(report_id): loaded["report_id"] = report_id return {"report_id": report_id, "title": "from-loader"} out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report_loader=loader, ) _eq(loaded.get("report_id"), "R-9", "loader called with report_id") _eq( out["rendered"]["payload"]["title"], "from-loader", "loader result rendered" ) finally: os.remove(db) def test_route_with_report_loader_exception(): """loader 抛错时, 路由不应整体崩溃, 仍下发的只是空 payload。""" fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-1", permission=PERM_EDIT) def boom(_): raise RuntimeError("storage down") out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report_loader=boom, ) _eq(out["status"], STATUS_OK, "still ok status") # loader 失败时: report_data=None, payload 走"无报告"分支; # 仍保留 share_url 方便前端"复制链接"按钮。 _eq( "report_id" in out["rendered"]["payload"], False, "no report_id when loader fails", ) _eq( "candidate_name" in out["rendered"]["payload"], False, "no name when loader fails", ) _has( out["rendered"]["payload"], "share_url", "share_url still present when loader fails", ) finally: os.remove(db) def test_route_with_report_resolve_failure_no_payload(): """resolve 失败时, 不应下发 rendered (避免泄露元数据)。""" fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: # 不创建 -> not_found out = route_short_link_with_report( "ZZZZZZ", base_url="https://gk.example.com", db_path=db, report={"report_id": "R-1"}, ) _eq(out["status"], STATUS_NOT_FOUND, "not_found") _eq("rendered" in out, False, "no rendered on failure") finally: os.remove(db) def test_route_with_report_password_required_no_payload(): """密码未提供 -> password_required, 不下发 rendered。""" fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-1", password="s3cr3t") out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report={"report_id": "R-1"}, ) _eq(out["status"], STATUS_PASSWORD_REQUIRED, "password_required") _eq("rendered" in out, False, "no rendered when password required") finally: os.remove(db) def test_route_with_report_revoked_no_payload(): """revoked 时, 不应下发 rendered。""" fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-1", owner_id="alice") svc.revoke(link.code, owner_id="alice") out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report={"report_id": "R-1"}, ) _eq(out["status"], STATUS_REVOKED, "revoked") _eq("rendered" in out, False, "no rendered when revoked") finally: os.remove(db) def test_route_with_report_include_url_false(): fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-1", permission=PERM_EDIT) out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report={"report_id": "R-1"}, include_url=False, ) _eq( "share_url" in out["rendered"]["payload"], False, "share_url omitted when include_url=False", ) finally: os.remove(db) def test_route_with_report_report_arg_priority(): """显式 report 参数优先级高于 loader。""" fd, db = tempfile.mkstemp(suffix=".db") os.close(fd) _TMP_DBS.append(db) try: svc = ShortLinkService(db_path=db) link = svc.create(report_id="R-1", permission=PERM_EDIT) def loader(_): return {"title": "from-loader"} out = route_short_link_with_report( link.code, base_url="https://gk.example.com", db_path=db, report={"title": "from-arg"}, report_loader=loader, ) _eq( out["rendered"]["payload"]["title"], "from-arg", "explicit report arg wins over loader", ) finally: os.remove(db) # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- def main(): test_funcs = [ v for k, v in globals().items() if k.startswith("test_") and callable(v) ] for fn in test_funcs: try: fn() except Exception as e: global _FAIL _FAIL += 1 _ERRORS.append(f"ERROR in {fn.__name__}: {type(e).__name__}: {e}") print() print(f"PASS: {_PASS}") print(f"FAIL: {_FAIL}") if _ERRORS: print() for err in _ERRORS: print(f" {err}") cleanup_tmp_dbs() sys.exit(1) print("ALL TESTS PASSED") cleanup_tmp_dbs() sys.exit(0) if __name__ == "__main__": main()