"""离职时把人移出 GitHub 组织 —— 这是整个需求真正要修的那一半。 在这之前,平台对 GitHub **完全没有概念**:一个离开公司的人对 5 个组织里 私有仓库的访问权,靠的是有没有人记得手工去踢。 这个文件钉四件事: 1. **扫描**产出 blocker,而且**预览与实际一致**(少一行,运维在按下按钮 之前看到的就是假的)。 2. **豁免的组织不入队** —— 社区组织的成员离职后可能仍是合法 maintainer。 3. **驱动失败绝不标已回收** —— 那会在台账上留一条假的回收记录。 4. **没配凭证时抛出并带上原因** —— 静默返回换来的是一个没有任何解释的 critical 事件。 """ from __future__ import annotations import uuid from datetime import UTC, date, datetime import pytest from app.domains.github_org import constants as ghc from app.domains.github_org import execution as gh_exec from app.domains.github_org import service as gh from app.domains.offboarding.models import ( PROC_BLOCKED, RES_GITHUB_ORG_MEMBERSHIP, RESO_PENDING, RESO_REVOKED_AUTO, OffboardingBlocker, OffboardingProcess, ) from app.domains.offboarding.service import OffboardingService from app.identity.person_models import IdentityPerson from app.integrations.driver_base import GithubOrgActionResult, GithubOrgDriver pytestmark = pytest.mark.asyncio class _FakeDriver(GithubOrgDriver): provider = "fake" def __init__(self, *, remove: GithubOrgActionResult | None = None) -> None: super().__init__(integration_id="fake", config={}) self._remove = remove or GithubOrgActionResult(ok=True) self.removed: list[tuple[str, str]] = [] async def list_members(self, org, *, role=None): return [] async def list_pending_invitations(self, org): return [] async def invite_member(self, org, login, *, role="member"): return GithubOrgActionResult(ok=True, invited=True) async def remove_member(self, org, login): self.removed.append((org, login)) return self._remove 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="no credential in this test", ) return da.OrgDriverResolution(org=org, driver=driver) monkeypatch.setattr(gh_exec, "resolve_org_driver", _fake) async def _person(db, status="leaver_pending") -> IdentityPerson: 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="Leaver", employment_status=status, ) db.add(p) await db.flush() return p async def _proc(db, person) -> OffboardingProcess: proc = OffboardingProcess( id=uuid.uuid4(), person_id=person.id, leaver_date=date.today(), status=PROC_BLOCKED, created_at=datetime(2026, 4, 13, tzinfo=UTC), ) db.add(proc) await db.flush() return proc async def _membership(db, person, *, slug="pingcap-inc", policy="remove", state=ghc.MEMBERSHIP_STATE_ACTIVE): org = await gh.create_org( db, slug=slug, display_name=slug, offboarding_policy=policy, ) await db.flush() m = await gh.upsert_membership( db, org=org, github_login=f"h{uuid.uuid4().hex[:6]}", person_id=str(person.id), state=state, source=ghc.SOURCE_APPROVAL, ) await db.flush() return org, m # ── 扫描 ───────────────────────────────────────────────────────── async def test_active_membership_produces_a_blocker(db_session): person = await _person(db_session) org, m = await _membership(db_session, person) proc = await _proc(db_session, person) await OffboardingService(db_session)._rescan( proc, trace_id="t", actor_user_id=None, ) await db_session.flush() from sqlalchemy import select rows = ( await db_session.execute( select(OffboardingBlocker).where( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resource_type == RES_GITHUB_ORG_MEMBERSHIP, ) ) ).scalars().all() assert len(rows) == 1 assert str(rows[0].resource_id) == m.id assert org.slug in rows[0].label async def test_pending_invite_also_produces_a_blocker(db_session): """待接受的邀请随时可能被点接受 —— 排除它就是留了一个后门。""" person = await _person(db_session) await _membership( db_session, person, state=ghc.MEMBERSHIP_STATE_PENDING_INVITE, ) proc = await _proc(db_session, person) await OffboardingService(db_session)._rescan( proc, trace_id="t", actor_user_id=None, ) await db_session.flush() from sqlalchemy import select rows = ( await db_session.execute( select(OffboardingBlocker).where( OffboardingBlocker.resource_type == RES_GITHUB_ORG_MEMBERSHIP, ) ) ).scalars().all() assert len(rows) == 1 async def test_exempt_org_produces_no_blocker(db_session): """🔴 社区组织:离职的人可能仍是合法的 maintainer。 豁免是 org owner 明确做出的决定,离职流程必须尊重它。 """ person = await _person(db_session) await _membership(db_session, person, slug="pingcap", policy="exempt") proc = await _proc(db_session, person) await OffboardingService(db_session)._rescan( proc, trace_id="t", actor_user_id=None, ) await db_session.flush() from sqlalchemy import select rows = ( await db_session.execute( select(OffboardingBlocker).where( OffboardingBlocker.resource_type == RES_GITHUB_ORG_MEMBERSHIP, ) ) ).scalars().all() assert rows == [] async def test_removed_membership_produces_no_blocker(db_session): person = await _person(db_session) org, m = await _membership(db_session, person) await gh.mark_removed( db_session, m, reason="earlier", removal_source=ghc.REMOVAL_SOURCE_ADMIN, ) await db_session.flush() proc = await _proc(db_session, person) await OffboardingService(db_session)._rescan( proc, trace_id="t", actor_user_id=None, ) await db_session.flush() from sqlalchemy import select rows = ( await db_session.execute( select(OffboardingBlocker).where( OffboardingBlocker.resource_type == RES_GITHUB_ORG_MEMBERSHIP, ) ) ).scalars().all() assert rows == [] async def test_preview_matches_the_real_scan(db_session): """🔴 预览少一行,运维在按下按钮之前看到的就是假的。 这一页存在的全部理由,就是让人看到会发生什么。 """ person = await _person(db_session, status="active") await _membership(db_session, person, slug="pingcap-inc") await _membership(db_session, person, slug="tidbcloud") # 豁免的那个两边都不该出现。 await _membership(db_session, person, slug="pingcap", policy="exempt") await db_session.flush() preview = await OffboardingService(db_session).get_offboarding_preview( person.id, ) gh_rows = [ b for b in preview["blockers"] if b["resource_type"] == RES_GITHUB_ORG_MEMBERSHIP ] assert len(gh_rows) == 2, "预览与扫描不一致" assert all(r["needs_successor"] is False for r in gh_rows) assert all(r["policy"] == "revoke" for r in gh_rows) # ── 执行 ───────────────────────────────────────────────────────── async def _handle(db, proc, person, blocker, monkeypatch, owner=None): """跑一次 ``_handle_one_blocker``。 ``default_new_owner`` 是**非可选**的(函数在分支之前就用它),所以 这里必须传一个真人 —— 平台上层保证它存在(经理链 / 平台兜底)。 对 GitHub 分支它只用来标 ``removed_by``:成员资格是人绑权限,没有 「接班人」这回事。 """ from app.hr_lifecycle.advance_service import ProcessAdvanceService if owner is None: owner = await _person(db, status="active") svc = ProcessAdvanceService(db) return await svc._handle_one_blocker( blocker=blocker, process=proc, person=person, default_new_owner=owner, now=datetime.now(UTC), ) async def _blocker_for(db, proc, membership): b = OffboardingBlocker( process_id=proc.id, resource_type=RES_GITHUB_ORG_MEMBERSHIP, resource_id=uuid.UUID(membership.id), label="GitHub", severity="advisory", resolution=RESO_PENDING, ) db.add(b) await db.flush() return b async def test_revoke_removes_and_marks_resolved(db_session, monkeypatch): person = await _person(db_session) org, m = await _membership(db_session, person) proc = await _proc(db_session, person) blocker = await _blocker_for(db_session, proc, m) driver = _FakeDriver() _patch(monkeypatch, driver) moved, transfer = await _handle( db_session, proc, person, blocker, monkeypatch, ) assert moved is True assert transfer is None, "人绑权限不该合成 transfer 记录" assert driver.removed == [(org.slug, m.github_login)] assert blocker.resolution == RESO_REVOKED_AUTO assert m.state == ghc.MEMBERSHIP_STATE_REMOVED assert m.removal_source == ghc.REMOVAL_SOURCE_OFFBOARDING async def test_driver_failure_raises_and_leaves_ledger_untouched( db_session, monkeypatch, ): """🔴 驱动失败绝不标已回收。 那会在台账上留一条**假的回收记录**,而那条记录正是以后所有人判断 「这个人还有没有访问权」的依据。异常向上抛给 SAVEPOINT,记 last_error 并留 pending,下个 tick 重试。 """ person = await _person(db_session) org, m = await _membership(db_session, person) proc = await _proc(db_session, person) blocker = await _blocker_for(db_session, proc, m) _patch( monkeypatch, _FakeDriver( remove=GithubOrgActionResult(ok=False, error_message="timeout"), ), ) with pytest.raises(RuntimeError, match="GitHub org removal failed"): await _handle(db_session, proc, person, blocker, monkeypatch) assert m.state != ghc.MEMBERSHIP_STATE_REMOVED assert blocker.resolution == RESO_PENDING async def test_no_credential_raises_with_the_real_reason( db_session, monkeypatch, ): """🔴 没配凭证时**抛出并带上原因**。 静默 ``return False, None`` 也会留 pending,但换来的是一个没有任何 解释的 critical 事件 —— IT 看到「force-transfer 本该处理却没处理」, 而真实原因是「凭证还没配」。 """ person = await _person(db_session) org, m = await _membership(db_session, person) proc = await _proc(db_session, person) blocker = await _blocker_for(db_session, proc, m) _patch(monkeypatch, None) with pytest.raises(RuntimeError) as exc: await _handle(db_session, proc, person, blocker, monkeypatch) assert "cannot remove" in str(exc.value) assert m.state != ghc.MEMBERSHIP_STATE_REMOVED async def test_policy_flipped_to_exempt_mid_flight_stops_the_revoke( db_session, monkeypatch, ): """扫描时不豁免、处理时被改成豁免 —— 不撤,并把理由写在 notes 上。""" person = await _person(db_session) org, m = await _membership(db_session, person) proc = await _proc(db_session, person) blocker = await _blocker_for(db_session, proc, m) org.offboarding_policy = ghc.OFFBOARDING_POLICY_EXEMPT await db_session.flush() driver = _FakeDriver() _patch(monkeypatch, driver) moved, _ = await _handle(db_session, proc, person, blocker, monkeypatch) assert moved is True assert driver.removed == [], "豁免的组织被撤了" assert m.state == ghc.MEMBERSHIP_STATE_ACTIVE assert "豁免" in (blocker.notes or "") async def test_membership_relinked_to_someone_else_is_left_alone( db_session, monkeypatch, ): """台账被改指到别人了 —— 不该拿这个离职流程去撤别人的访问权。""" person = await _person(db_session) other = await _person(db_session, status="active") org, m = await _membership(db_session, person) proc = await _proc(db_session, person) blocker = await _blocker_for(db_session, proc, m) m.person_id = str(other.id) await db_session.flush() driver = _FakeDriver() _patch(monkeypatch, driver) moved, _ = await _handle(db_session, proc, person, blocker, monkeypatch) assert moved is False assert driver.removed == [] async def test_github_is_in_the_immediate_revoke_bucket(): """人绑权限必须 T+0 撤,不能吃 24h grace —— 那 24 小时是给 IT 做 **实物**交接的,不是把 RBAC 留到离职之后。""" from app.hr_lifecycle.advance_service import ( IMMEDIATE_REVOKE_RESOURCE_TYPES, SCAN_SCHEDULED_RESOURCE_TYPES, ) assert RES_GITHUB_ORG_MEMBERSHIP in IMMEDIATE_REVOKE_RESOURCE_TYPES assert RES_GITHUB_ORG_MEMBERSHIP not in SCAN_SCHEDULED_RESOURCE_TYPES async def test_severity_is_registered_not_fail_closed_default(): """``_SEVERITY_BY_RESOURCE`` 没登记的类型会 fail-closed 落进 IT 红队列。 有自动 handler 的类型必须显式登记成 advisory。""" from app.domains.offboarding.service import ( SEVERITY_ADVISORY, severity_for_resource, ) assert severity_for_resource(RES_GITHUB_ORG_MEMBERSHIP) == SEVERITY_ADVISORY