"""机器账号的策略标记。 这个文件钉住的核心是**为什么它只能是策略、不能是自动判定**: 1. GitHub 的 `type` 字段在本租户里全部报 `User`(七个 bot 账号实测), 所以没有权威信号可用; 2. 只能靠命名约定,而命名约定**会误伤真人** —— `robot-liu` 命中 `*robot*`; 3. 因此写入永远经过人确认,没有定时自动版本。 第 2 条有一条专门的用例。它不是理论上的担心:把一个员工标成机器账号, 后果是他离职时不被回收 —— 和挂错人一样,是一次静默的权限泄漏。 """ from __future__ import annotations import uuid import pytest from app.domains.github_org import bot_policy, report from app.domains.github_org import constants as ghc from app.domains.github_org import service as gh from app.identity.person_models import IdentityPerson pytestmark = pytest.mark.asyncio async def _org(db, slug="pingcap"): o = await gh.create_org(db, slug=slug, display_name=slug) await db.commit() return o async def _m(db, org, login, *, profile=None): m = await gh.upsert_membership( db, org=org, github_login=login, person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source="reconcile", actor_kind="integration", trace_id="t", ) if profile is not None: m.github_profile = profile await db.flush() return m # ── 命名策略 ───────────────────────────────────────────────────── def test_the_production_bot_handles_all_match(): """生产台账里真实存在的那批,策略必须都认出来。""" pats = list(bot_policy.DEFAULT_PATTERNS) for h in ("sre-bot", "TestInfra-Bot", "ti-chi-bot", "ti-srebot", "tidb-ci-bot", "tidbcloud-bot", "pingcap-zendesk-bot"): assert bot_policy.match_reason(h, pats, None) is not None, h def test_real_contributor_handles_do_not_match(): """生产上真实的外部贡献者 handle,不能被误判。""" pats = list(bot_policy.DEFAULT_PATTERNS) for h in ("Ehco1996", "zz-jason", "hawkingrei", "suzaku", "cicada-lewis", "birdstorm", "CalvinNeo", "anotherrachel", "innerr"): assert bot_policy.match_reason(h, pats, None) is None, h def test_a_human_named_robot_is_a_known_false_positive(): """🔴 这条用例是整个「只建议、不自动落库」设计的理由本身。 `robot-liu` 命中 `*robot*`。策略**必须**会误伤 —— 我们没有更好的信号 (GitHub 的 type 字段在本租户全报 User)。所以这里断言的不是「它不匹配」, 而是「它确实匹配,因此必须有人看一眼」。 如果哪天有人给这条加了自动标记的 cron,这个注释就是那次改动要面对的 反例:一个真名叫 robot 的员工被标成机器账号,他离职时不会被回收。 """ assert bot_policy.match_reason( "robot-liu", list(bot_policy.DEFAULT_PATTERNS), None, ) is not None def test_the_profile_name_strengthens_the_reason_but_never_creates_it(): """资料名只能加强理由,不能单独构成理由 —— 一个叫「CI Bot」的人 如果 handle 完全不像机器账号,不该被建议。""" pats = list(bot_policy.DEFAULT_PATTERNS) strong = bot_policy.match_reason("sre-bot", pats, {"name": "SRE bot"}) assert "资料名" in strong assert bot_policy.match_reason("zhang.san", pats, {"name": "CI Bot"}) is None def test_a_real_github_app_bot_type_outranks_the_naming_convention(): """GitHub App 身份确实会报 type=Bot。本租户从没命中过,但留着, 并且它的理由应该压过命名约定 —— 那是权威信号。""" r = bot_policy.match_reason( "dependabot", list(bot_policy.DEFAULT_PATTERNS), {"account_type": "Bot"}, ) assert "GitHub 账号类型就是 Bot" in r # ── 策略读写 ───────────────────────────────────────────────────── async def _seed_bot_label(db): """586 起 bot 策略从标签规则派生,所以要先有 ``account:bot`` 这个标签。""" from app.domains.github_org import labels as lb await lb.upsert_label( db, key="account:bot", category="account", display_zh="机器账号", display_en="Machine account", ) await db.commit() async def test_no_bot_rules_falls_back_instead_of_matching_nothing(db_session): """🔴 一条 bot 规则都没有时退回内置默认。 空策略静默地什么都不匹配,而那看起来和「没有机器账号」一模一样。 586 起这个「空」的含义是「标签规则里没有 account:bot 的 handle_glob」。 """ await _seed_bot_label(db_session) from app.domains.github_org import labels as lb await lb.set_rules(db_session, []) await db_session.commit() assert await bot_policy.get_patterns(db_session) == list( bot_policy.DEFAULT_PATTERNS ) async def test_setting_an_empty_policy_is_refused(db_session): await _seed_bot_label(db_session) with pytest.raises(ValueError): await bot_policy.set_patterns(db_session, [], actor_person_id=None) with pytest.raises(ValueError): await bot_policy.set_patterns(db_session, [" "], actor_person_id=None) async def test_patterns_round_trip_through_the_shared_rule_store(db_session): """🔴 写进去的是**标签规则**,不是第二个存储。 两个存储并存的必然结果是它们不一致 —— 同一个问题两个界面给两个答案。 """ await _seed_bot_label(db_session) await bot_policy.set_patterns( db_session, ["*-runner", " *-bot "], actor_person_id=None, ) await db_session.commit() assert await bot_policy.get_patterns(db_session) == ["*-runner", "*-bot"] # 确认它真的落在标签规则里,而不是自己那个设置项 from app.domains.github_org import labels as lb rules = await lb.get_rules(db_session) bot_globs = { r["value"] for r in rules if r["label"] == "account:bot" and r["match"] == lb.MATCH_HANDLE_GLOB } assert bot_globs == {"*-runner", "*-bot"} async def test_changing_bot_patterns_does_not_drop_other_label_rules(db_session): """🔴 改 bot 策略不能顺手删掉 employer / community 那些规则。""" await _seed_bot_label(db_session) from app.domains.github_org import labels as lb await lb.upsert_label( db_session, key="employer:bytedance", category="employer", display_zh="ByteDance", display_en="ByteDance", ) await lb.set_rules(db_session, [ {"match": "profile_company_contains", "value": "bytedance", "label": "employer:bytedance"}, {"match": "handle_glob", "value": "*-bot", "label": "account:bot"}, ]) await db_session.commit() await bot_policy.set_patterns(db_session, ["*-runner"], actor_person_id=None) await db_session.commit() rules = await lb.get_rules(db_session) assert any(r["label"] == "employer:bytedance" for r in rules), rules assert {r["value"] for r in rules if r["label"] == "account:bot"} == {"*-runner"} # ── 预演 ───────────────────────────────────────────────────────── async def test_preview_aggregates_one_handle_across_orgs(db_session): """一个 bot 通常在多个组织里(ti-chi-bot 在 5 个)。一次确认应该覆盖 它的所有行,而不是让人对同一个账号点五次。""" a = await _org(db_session, "pingcap") b = await _org(db_session, "tikv") await _m(db_session, a, "ti-chi-bot") await _m(db_session, b, "ti-chi-bot") await db_session.commit() pv = await bot_policy.preview(db_session) assert pv["handles"] == 1 assert pv["memberships"] == 2 assert pv["items"][0]["org_slugs"] == ["pingcap", "tikv"] async def test_preview_skips_rows_already_confirmed(db_session): """人做过的判断不能被重新捞出来问一遍。""" org = await _org(db_session) m = await _m(db_session, org, "sre-bot") await db_session.commit() assert (await bot_policy.preview(db_session))["handles"] == 1 await gh.confirm_external( db_session, m, actor_person_id=None, kind=ghc.EXTERNAL_KIND_BOT, ) await db_session.commit() assert (await bot_policy.preview(db_session))["handles"] == 0 async def test_preview_skips_rows_already_linked_to_a_person(db_session): """已经认出是谁的行,不该再被当成机器账号候选。""" org = await _org(db_session) p = IdentityPerson( id=uuid.uuid4(), source_system="test", hris_person_id=f"P{uuid.uuid4().hex[:8]}", work_email=f"u{uuid.uuid4().hex[:8]}@pingcap.com", display_name="Robot Liu", employment_status="active", ) db_session.add(p) await db_session.flush() await gh.upsert_membership( db_session, org=org, github_login="robot-liu", person_id=str(p.id), state=ghc.MEMBERSHIP_STATE_ACTIVE, source="reconcile", actor_kind="integration", trace_id="t", ) await db_session.commit() assert (await bot_policy.preview(db_session))["handles"] == 0 # ── kind 落库与报表分组 ───────────────────────────────────────── async def test_kind_is_recorded_and_split_in_the_report(db_session): """机器账号和「别家公司的人」在报表里必须分开 —— 混成一格, 「已确认外部 N 条」就再也回答不了任何问题。""" org = await _org(db_session) bot = await _m(db_session, org, "sre-bot") human = await _m(db_session, org, "someone-else") await db_session.commit() await gh.confirm_external( db_session, bot, actor_person_id=None, kind=ghc.EXTERNAL_KIND_BOT, ) await gh.confirm_external( db_session, human, actor_person_id=None, kind=ghc.EXTERNAL_KIND_OUTSIDE_HUMAN, ) await db_session.commit() t = (await report.build_report(db_session))["totals"] assert t["confirmed_external"] == 2 assert t["bots"] == 1 assert t["outside_humans"] == 1 async def test_an_unrecognised_kind_lands_as_none_not_stored_verbatim(db_session): """🔴 拼错的 'Bot' 不能原样存下来 —— 那会让按 kind 的分组凭空多出 一类,而那一类在界面上没有任何解释。""" org = await _org(db_session) m = await _m(db_session, org, "sre-bot") await db_session.commit() await gh.confirm_external( db_session, m, actor_person_id=None, kind="Bot", # 大写,不在枚举里 ) await db_session.commit() assert m.external_confirmed_at is not None # 确认本身有效 assert m.external_kind is None # 但 kind 落 None async def test_clearing_external_also_clears_the_kind(db_session): org = await _org(db_session) m = await _m(db_session, org, "sre-bot") await db_session.commit() await gh.confirm_external( db_session, m, actor_person_id=None, kind=ghc.EXTERNAL_KIND_BOT, ) await db_session.commit() await gh.clear_external(db_session, m, actor_person_id=None) await db_session.commit() assert m.external_kind is None async def test_linking_a_person_also_clears_the_kind(db_session): """先标成机器账号、后来发现是个人 —— 那个 kind 必须一起清掉。""" org = await _org(db_session) m = await _m(db_session, org, "robot-liu") await db_session.commit() await gh.confirm_external( db_session, m, actor_person_id=None, kind=ghc.EXTERNAL_KIND_BOT, ) await db_session.commit() p = IdentityPerson( id=uuid.uuid4(), source_system="test", hris_person_id=f"P{uuid.uuid4().hex[:8]}", work_email=f"u{uuid.uuid4().hex[:8]}@pingcap.com", display_name="Robot Liu", employment_status="active", ) db_session.add(p) await db_session.flush() await gh.relink_membership( db_session, m, person_id=str(p.id), actor_person_id=None, ) await db_session.commit() assert m.external_kind is None assert m.external_confirmed_at is None