"""与 GitHub 的双向对账。 最重要的一条是**空列表刹车**:「GitHub 返回了空成员列表」和「这个组织 真的一个人都没有」在 HTTP 层长得一模一样。没有刹车,一次组织改名、一次 权限被撤、一次 API 行为变化,都会让对账把整个组织的成员全部标成已移除 —— 而那张表正是离职回收读的。 """ from __future__ import annotations import uuid import pytest from app.domains.github_org import constants as ghc from app.domains.github_org import reconcile from app.domains.github_org import service as gh from app.identity.person_models import IdentityPerson from app.integrations.driver_base import ( GithubOrgActionResult, GithubOrgDriver, GithubOrgInvitation, GithubOrgMember, ) pytestmark = pytest.mark.asyncio class _FakeDriver(GithubOrgDriver): provider = "fake" def __init__(self, *, members=(), invitations=(), roles=None) -> None: super().__init__(integration_id="fake", config={}) self._members = list(members) self._roles = roles or {} self._invitations = list(invitations) async def list_members(self, org, *, role=None): return [ GithubOrgMember( login=m, state="active", role=self._roles.get(m, "member"), user_id=i + 1, ) for i, m in enumerate(self._members) ] async def list_pending_invitations(self, org): return [ GithubOrgInvitation( invitation_id=str(i), login=login, email=None, role="direct_member", ) for i, login in enumerate(self._invitations, start=1) ] async def invite_member(self, org, login, *, role="member"): return GithubOrgActionResult(ok=True, invited=True) async def remove_member(self, org, login): return GithubOrgActionResult(ok=True) def _patch(monkeypatch, driver): from app.domains.github_org import driver_access as da async def _fake(db, org): if driver is None: return da.OrgDriverResolution( org=org, reason=da.REASON_NOT_CONFIGURED, detail="none", ) return da.OrgDriverResolution(org=org, driver=driver) monkeypatch.setattr(reconcile, "resolve_org_driver", _fake) async def _org(db, slug="pingcap-inc"): org = await gh.create_org(db, slug=slug, display_name=slug) await db.commit() return org async def _person(db, github_id): 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]}@x.test", display_name="Someone", employment_status="active", github_id=github_id, ) db.add(p) await db.flush() return p # ── 方向一:GitHub 有、Helix 没有 ──────────────────────────────── async def test_unknown_github_member_is_imported(db_session, monkeypatch): """有人绕过 Helix 直接被加进了组织。不是错误,但必须被看见 —— 一条 Helix 不知道的成员资格,离职时不会被回收。""" org = await _org(db_session) _patch(monkeypatch, _FakeDriver(members=["octocat"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["imported"] == 1 m = await gh.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m is not None assert m.state == ghc.MEMBERSHIP_STATE_ACTIVE assert m.source == ghc.SOURCE_RECONCILE async def test_imported_member_links_to_a_person_when_the_handle_is_known( db_session, monkeypatch, ): org = await _org(db_session) person = await _person(db_session, "octocat") await db_session.flush() _patch(monkeypatch, _FakeDriver(members=["OctoCat"])) await reconcile.reconcile_org(db_session, org, trace_id="t") m = await gh.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.person_id == str(person.id), "handle 大小写没有归一" async def test_unresolvable_handle_lands_unlinked_not_dropped( db_session, monkeypatch, ): """🔴 认不出是谁**不是**丢弃的理由 —— 那正是离职回收够不着的部分。""" org = await _org(db_session) _patch(monkeypatch, _FakeDriver(members=["some-contractor"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["imported"] == 1 assert out["unlinked"] == 1 m = await gh.find_membership( db_session, org_id=org.id, github_login="some-contractor", ) assert m.person_id is None async def test_person_id_is_backfilled_once_the_handle_is_registered( db_session, monkeypatch, ): """「认不出是谁」这个数自己变小的机制。 上线时一批记录认不出人;员工后来补登记了 GitHub 用户名,下一次对账 就把它们接上 —— 不需要任何人回头处理。 """ org = await _org(db_session) _patch(monkeypatch, _FakeDriver(members=["octocat"])) await reconcile.reconcile_org(db_session, org, trace_id="t") m = await gh.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.person_id is None person = await _person(db_session, "octocat") await db_session.flush() out = await reconcile.reconcile_org(db_session, org, trace_id="t2") assert out["linked"] == 1 assert out["unlinked"] == 0 await db_session.refresh(m) assert m.person_id == str(person.id) async def test_role_drift_is_recorded(db_session, monkeypatch): """member → admin 是一次实质的扩权,台账要跟上并留事件。""" org = await _org(db_session) await gh.upsert_membership( db_session, org=org, github_login="octocat", person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_IMPORT, role=ghc.MEMBERSHIP_ROLE_MEMBER, ) await db_session.flush() _patch( monkeypatch, _FakeDriver(members=["octocat"], roles={"octocat": "admin"}), ) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["role_drift"] == 1 m = await gh.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.role == ghc.MEMBERSHIP_ROLE_ADMIN events = await gh.list_events(db_session, m.id) assert any(e.event_type == ghc.EVENT_ROLE_CHANGED for e in events) # ── 方向二:Helix 说在、GitHub 说不在 ──────────────────────────── async def test_member_removed_on_github_is_marked_removed( db_session, monkeypatch, ): org = await _org(db_session) for login in ("a", "b", "gone"): await gh.upsert_membership( db_session, org=org, github_login=login, person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_IMPORT, ) await db_session.flush() _patch(monkeypatch, _FakeDriver(members=["a", "b"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["removed_upstream"] == 1 m = await gh.find_membership(db_session, org_id=org.id, github_login="gone") assert m.state == ghc.MEMBERSHIP_STATE_REMOVED assert m.removal_source == ghc.REMOVAL_SOURCE_RECONCILE async def test_pending_invite_is_left_to_the_invitation_sweep( db_session, monkeypatch, ): """pending 的推进(接受 / 过期 / 拒绝)要按**时间**判,而一次成员 快照分不出是哪一种 —— 那是 invitation_lifecycle 的地盘。""" org = await _org(db_session) await gh.upsert_membership( db_session, org=org, github_login="waiting", person_id=None, state=ghc.MEMBERSHIP_STATE_PENDING_INVITE, source=ghc.SOURCE_APPROVAL, ) for login in ("a", "b", "c"): await gh.upsert_membership( db_session, org=org, github_login=login, person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_IMPORT, ) await db_session.flush() _patch(monkeypatch, _FakeDriver(members=["a", "b", "c"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["removed_upstream"] == 0 m = await gh.find_membership( db_session, org_id=org.id, github_login="waiting", ) assert m.state == ghc.MEMBERSHIP_STATE_PENDING_INVITE # ── 空列表刹车 ─────────────────────────────────────────────────── async def test_empty_fetch_refuses_to_wipe_the_ledger( db_session, monkeypatch, ): """🔴 「GitHub 说 0 个成员」和「所有人都走了」在 HTTP 层长得一样。 一次组织改名、一次权限被撤,都会让对账把整个组织标成已移除 —— 而那张表正是离职回收读的。 """ org = await _org(db_session) for login in ("a", "b", "c", "d"): await gh.upsert_membership( db_session, org=org, github_login=login, person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_IMPORT, ) await db_session.flush() _patch(monkeypatch, _FakeDriver(members=[])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["skipped"] == "empty_fetch_brake" assert out["removed_upstream"] == 0 for login in ("a", "b", "c", "d"): m = await gh.find_membership( db_session, org_id=org.id, github_login=login, ) assert m.state == ghc.MEMBERSHIP_STATE_ACTIVE # 拒绝的理由必须留在组织行上,而不是只在日志里。 assert org.last_reconcile_error assert "0 members" in org.last_reconcile_error async def test_small_org_can_legitimately_empty_out(db_session, monkeypatch): """刹车阈值取 3 而不是 1:一个只有 1–2 人的组织真的清空是可能的。""" org = await _org(db_session) for login in ("a", "b"): await gh.upsert_membership( db_session, org=org, github_login=login, person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_IMPORT, ) await db_session.flush() _patch(monkeypatch, _FakeDriver(members=[])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out.get("skipped") is None assert out["removed_upstream"] == 2 # ── 存量导入 ───────────────────────────────────────────────────── async def test_import_mode_never_removes(db_session, monkeypatch): """第一次导入时 Helix 什么都不知道 —— 任何「Helix 说在、GitHub 说 不在」的判断都是无意义的。""" org = await _org(db_session) await gh.upsert_membership( db_session, org=org, github_login="stale", person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_MANUAL, ) await db_session.flush() _patch(monkeypatch, _FakeDriver(members=["octocat"])) out = await reconcile.reconcile_org( db_session, org, trace_id="t", apply_removals=False, ) assert out["imported"] == 1 assert out["removed_upstream"] == 0 m = await gh.find_membership(db_session, org_id=org.id, github_login="stale") assert m.state == ghc.MEMBERSHIP_STATE_ACTIVE async def test_import_is_idempotent(db_session, monkeypatch): org = await _org(db_session) _patch(monkeypatch, _FakeDriver(members=["a", "b"])) first = await reconcile.reconcile_org( db_session, org, trace_id="t", apply_removals=False, ) second = await reconcile.reconcile_org( db_session, org, trace_id="t2", apply_removals=False, ) assert first["imported"] == 2 assert second["imported"] == 0 # ── 隔离 ───────────────────────────────────────────────────────── async def test_one_broken_org_does_not_stop_the_others( db_session, monkeypatch, ): await _org(db_session, slug="pingcap-inc") bad = await _org(db_session, slug="tidbcloud") from app.domains.github_org import driver_access as da async def _fake(db, org): if org.slug == "tidbcloud": raise RuntimeError("credential exploded") return da.OrgDriverResolution( org=org, driver=_FakeDriver(members=["octocat"]), ) monkeypatch.setattr(reconcile, "resolve_org_driver", _fake) totals = await reconcile.reconcile_all(db_session, trace_id="t") assert totals["imported"] == 1 assert "tidbcloud" in totals["failed_orgs"] assert bad.last_reconcile_error assert "credential exploded" in bad.last_reconcile_error async def test_no_credential_org_is_skipped_not_failed( db_session, monkeypatch, ): org = await _org(db_session) _patch(monkeypatch, None) totals = await reconcile.reconcile_all(db_session, trace_id="t") assert totals["failed_orgs"] == [] assert totals["skipped"] == {org.slug: "not_configured"} # ── 端点 ───────────────────────────────────────────────────────── async def test_reconcile_endpoint_is_manage_gated( client, auditor_headers, db_session, ): org = await _org(db_session) resp = await client.post( f"/api/v1/github/orgs/{org.id}/reconcile", headers=auditor_headers, ) assert resp.status_code == 403 async def test_import_endpoint_is_manage_gated(client, auditor_headers): resp = await client.post( "/api/v1/github/import-existing", headers=auditor_headers, ) assert resp.status_code == 403 # ── 已离职的人还在组织里 ───────────────────────────────────────── async def _leaver(db, github_id, status="left"): 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]}@x.test", display_name="Gone", employment_status=status, github_id=github_id, ) db.add(p) await db.flush() return p async def test_departed_employee_still_in_org_is_flagged( db_session, monkeypatch, ): """🔴 复审第 1 轮抓到的真缺口(对抗性探针证实过)。 对账把 GitHub 上的事实如实记下来是对的,但一条「已离职 + active」 的记录如果只是安静地躺在台账里,**这套自动化就等于没做**。 两条真实的产生路径,而且都绕开了离职流程:有人在 GitHub 上手工把 离职的人加回去;或者这条成员资格在他离职时 ``person_id`` 还是空的 (认不出是谁),离职扫描根本看不到它。 """ from sqlalchemy import select from app.audit.models import AuditLog org = await _org(db_session) await _leaver(db_session, "ghost") _patch(monkeypatch, _FakeDriver(members=["ghost"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["leavers_still_in_org"] == 1 # 一个数字告诉你「有几个」,逐条审计才告诉你「是谁」。 row = ( await db_session.execute( select(AuditLog).where( AuditLog.action == "github_org.reconcile.leaver_still_member", ) ) ).scalars().first() assert row is not None assert row.status == "failure" assert row.detail["github_login"] == "ghost" assert row.detail["employment_status"] == "left" async def test_leaver_pending_counts_too(db_session, monkeypatch): """待离职同样算 —— 离职回收是 T+0 的,到那天他就不该在里面了。""" org = await _org(db_session) await _leaver(db_session, "leaving", status="leaver_pending") _patch(monkeypatch, _FakeDriver(members=["leaving"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["leavers_still_in_org"] == 1 async def test_exempt_community_org_does_not_count_leavers( db_session, monkeypatch, ): """社区组织的成员本来就允许离职后保留 —— 把它算成缺口只会训练人 忽略这个数。""" org = await gh.create_org( db_session, slug="pingcap", display_name="PingCAP", offboarding_policy=ghc.OFFBOARDING_POLICY_EXEMPT, ) await db_session.commit() await _leaver(db_session, "maintainer") _patch(monkeypatch, _FakeDriver(members=["maintainer"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["leavers_still_in_org"] == 0 async def test_active_employee_is_not_flagged(db_session, monkeypatch): org = await _org(db_session) await _person(db_session, "octocat") _patch(monkeypatch, _FakeDriver(members=["octocat"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["leavers_still_in_org"] == 0 async def test_governance_count_is_exposed_on_the_admin_list( client, github_admin_headers, db_session, monkeypatch, ): """这套自动化的全部意义就是这个数应该是 0 —— 它必须在管理面上, 而不是只在一次 cron 的返回值里。""" org = await _org(db_session) await _leaver(db_session, "ghost") _patch(monkeypatch, _FakeDriver(members=["ghost"])) await reconcile.reconcile_org(db_session, org, trace_id="t") await db_session.commit() resp = await client.get("/api/v1/github/orgs", headers=github_admin_headers) assert resp.status_code == 200 assert resp.json()["meta"]["leavers_still_in_orgs"] == 1 async def test_awaiting_manual_is_not_read_as_removed_upstream( db_session, monkeypatch, ): """🔴 等人工去点的记录本来就不该在 GitHub 上 —— 邀请还没发。 把「不在 GitHub 上」读成「上游把他移除了」,等于把一件待办悄悄标成 已完成,而那个人其实一直没拿到访问权。 """ org = await _org(db_session) for login in ("a", "b", "c"): await gh.upsert_membership( db_session, org=org, github_login=login, person_id=None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source=ghc.SOURCE_IMPORT, ) m = await gh.upsert_membership( db_session, org=org, github_login="waiting-on-it", person_id=None, state=ghc.MEMBERSHIP_STATE_AWAITING_MANUAL, source=ghc.SOURCE_APPROVAL, ) await db_session.flush() _patch(monkeypatch, _FakeDriver(members=["a", "b", "c"])) out = await reconcile.reconcile_org(db_session, org, trace_id="t") assert out["removed_upstream"] == 0 assert m.state == ghc.MEMBERSHIP_STATE_AWAITING_MANUAL