"""审批通过之后发生什么 —— 四种结局,不是两种。 这一层最容易出错的地方不是「成功路径」,而是把不同的失败**合并**: 把「没配凭证」记成失败会让上线第一周看起来像一连串故障;把「邀请已发出」 和「本来就在里面」合并会让台账宣称一批还没进组的人已经有访问权。 """ from __future__ import annotations import pytest from app.domains.github_org import constants as const from app.domains.github_org import execution from app.domains.github_org import service as svc from app.integrations.driver_base import ( GithubOrgActionResult, GithubOrgDriver, ) pytestmark = pytest.mark.asyncio class _FakeDriver(GithubOrgDriver): """强制 wire 约束的替身:它**继承真基类**,所以基类上任何一个 abstract 方法的签名变化都会在这里炸,而不是让测试悄悄测一个 已经不存在的契约。""" provider = "fake" def __init__(self, *, invite: GithubOrgActionResult | None = None, remove: GithubOrgActionResult | None = None) -> None: super().__init__(integration_id="fake", config={}) self._invite = invite or GithubOrgActionResult(ok=True, invited=True) self._remove = remove or GithubOrgActionResult(ok=True) self.invite_calls: list[tuple[str, str, str]] = [] self.remove_calls: 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"): self.invite_calls.append((org, login, role)) return self._invite async def remove_member(self, org, login): self.remove_calls.append((org, login)) return self._remove async def _org(db, **kw): payload = {"slug": "pingcap-inc", "display_name": "PingCAP Inc"} payload.update(kw) org = await svc.create_org(db, **payload) await db.commit() return org def _patch_driver(monkeypatch, driver: GithubOrgDriver | None): """把 resolve_org_driver 换掉。``None`` = 没配凭证。""" 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(execution, "resolve_org_driver", _fake) # ── 邀请 ───────────────────────────────────────────────────────── async def test_invited_lands_pending_not_active(db_session, monkeypatch): """邀请发出去了 ≠ 他进组了。台账必须停在 pending_invite。""" org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) assert out.outcome == execution.OUTCOME_INVITED assert out.membership.state == const.MEMBERSHIP_STATE_PENDING_INVITE assert out.membership.invitation_expires_at is not None async def test_already_member_lands_active(db_session, monkeypatch): org = await _org(db_session) _patch_driver( monkeypatch, _FakeDriver(invite=GithubOrgActionResult(ok=True, already_done=True)), ) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) assert out.outcome == execution.OUTCOME_ALREADY_MEMBER assert out.membership.state == const.MEMBERSHIP_STATE_ACTIVE assert out.membership.joined_at is not None async def test_no_credential_is_manual_NOT_failure(db_session, monkeypatch): """🔴 没配凭证**不是失败**。 审批确实通过了、访问权确实该给,只是这一步由人完成。把它记成 failed 会让上线第一周看起来像一连串故障 —— 而且更糟的是,台账不落行的话, 离职回收就不知道这个人拿到过访问权。 """ org = await _org(db_session) _patch_driver(monkeypatch, None) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) assert out.outcome == execution.OUTCOME_MANUAL assert out.ok is True, "转人工不该被算成失败" assert out.membership is not None, "转人工也必须落台账" # 落 ``awaiting_manual`` 而不是 ``pending_invite`` —— 后者的意思是 # 「邀请已经发出去了」,而这里一封都没发。复审第 2 轮把这一条改了, # 理由见 test_manual_fallback_uses_its_own_state_not_pending_invite。 assert out.membership.state == const.MEMBERSHIP_STATE_AWAITING_MANUAL async def test_driver_failure_does_not_write_a_ledger_row( db_session, monkeypatch, ): """🔴 没发出去的邀请不是一条成员资格。 写进台账就是在上面留一条谁也不对应的记录,而离职回收会去撤它。 """ org = await _org(db_session) _patch_driver( monkeypatch, _FakeDriver( invite=GithubOrgActionResult( ok=False, not_found=True, error_message="no such user", ), ), ) out = await execution.execute_approved( db_session, org=org, github_login="ghost", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) assert out.outcome == execution.OUTCOME_FAILED assert out.failure_kind == "unknown_handle" assert await svc.find_membership( db_session, org_id=org.id, github_login="ghost", ) is None async def test_failure_kinds_are_distinguished(db_session, monkeypatch): """三种失败的处置完全不同:handle 打错去找申请人,权限不足去修集成, 网络失败等下一次重试。合成一个「失败」等于让运维每次都从头查。""" org = await _org(db_session) cases = [ (GithubOrgActionResult(ok=False, not_found=True), "unknown_handle"), ( GithubOrgActionResult(ok=False, permission_denied=True), "permission_denied", ), (GithubOrgActionResult(ok=False, error_message="boom"), "transient"), ] for result, expected in cases: _patch_driver(monkeypatch, _FakeDriver(invite=result)) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) assert out.failure_kind == expected async def test_execution_is_idempotent_on_redelivery(db_session, monkeypatch): """飞书重投一次是安全的 —— 收敛到同一行台账,不是两行。""" org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) for _ in range(3): await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) from sqlalchemy import func, select from app.domains.github_org.models import GithubOrgMembership n = ( await db_session.execute( select(func.count()).select_from(GithubOrgMembership).where( GithubOrgMembership.org_id == org.id, ) ) ).scalar_one() assert n == 1 async def test_unlinked_person_still_executes(db_session, monkeypatch): """认不出人不阻止执行 —— org owner 已经批了,访问权该给。 台账留 person_id=NULL 让它出现在「认不出是谁」那个数里。""" org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) out = await execution.execute_approved( db_session, org=org, github_login="contractor", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) assert out.ok is True assert out.membership.person_id is None await db_session.commit() assert await svc.count_unlinked(db_session) == 1 # ── 移除 ───────────────────────────────────────────────────────── async def test_revoke_failure_does_not_mark_removed(db_session, monkeypatch): """🔴 驱动失败时绝不标 removed。 那会在台账上留一条**假的回收记录**,而那条记录正是以后所有人判断 「这个人还有没有访问权」的依据。 """ org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) membership = out.membership _patch_driver( monkeypatch, _FakeDriver( remove=GithubOrgActionResult(ok=False, error_message="timeout"), ), ) rev = await execution.revoke_membership( db_session, membership=membership, org=org, reason="offboarding", removal_source=const.REMOVAL_SOURCE_OFFBOARDING, trace_id="t2", ) assert rev.outcome == execution.OUTCOME_FAILED assert membership.state != const.MEMBERSHIP_STATE_REMOVED assert membership.removed_at is None async def test_revoke_already_absent_is_success(db_session, monkeypatch): org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) _patch_driver( monkeypatch, _FakeDriver(remove=GithubOrgActionResult(ok=True, already_done=True)), ) rev = await execution.revoke_membership( db_session, membership=out.membership, org=org, reason="offboarding", removal_source=const.REMOVAL_SOURCE_OFFBOARDING, trace_id="t2", ) assert rev.ok is True assert out.membership.state == const.MEMBERSHIP_STATE_REMOVED async def test_revoke_without_credential_is_manual_not_removed( db_session, monkeypatch, ): """没配凭证时**不能**标 removed —— 那个人还在组织里。""" org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) _patch_driver(monkeypatch, None) rev = await execution.revoke_membership( db_session, membership=out.membership, org=org, reason="offboarding", removal_source=const.REMOVAL_SOURCE_OFFBOARDING, trace_id="t2", ) assert rev.outcome == execution.OUTCOME_MANUAL assert out.membership.state != const.MEMBERSHIP_STATE_REMOVED # ── 事件流 ─────────────────────────────────────────────────────── async def test_every_transition_leaves_an_event(db_session, monkeypatch): """事件流是访问复核唯一的证据来源 —— 状态迁移不能有空洞。""" org = await _org(db_session) _patch_driver(monkeypatch, _FakeDriver()) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t1", ) _patch_driver(monkeypatch, _FakeDriver()) await execution.revoke_membership( db_session, membership=out.membership, org=org, reason="offboarding", removal_source=const.REMOVAL_SOURCE_OFFBOARDING, trace_id="t2", ) await db_session.flush() events = await svc.list_events(db_session, out.membership.id) kinds = [e.event_type for e in events] assert const.EVENT_INVITED in kinds assert const.EVENT_REMOVED in kinds # ── 回归:人工兜底必须落在自己的状态上 ─────────────────────────── async def test_manual_fallback_uses_its_own_state_not_pending_invite( db_session, monkeypatch, ): """🔴 复审第 2 轮抓到的真缺陷(对抗性探针证实过)。 人工兜底的记录**一封邀请都没发过**。落成 ``pending_invite`` 之后: * 邀请对账靠「GitHub 上既不是成员、也没有待接受邀请」+ **时间**判断 一条 pending 是过期还是被拒绝 → 7 天内会把它判成 ``declined``, 而那是**不可重发的终态**。一条本该等人去点的记录就此冻死。 * 成员对账会把「不在 GitHub 上」读成「上游把他移除了」→ 一件待办 被悄悄标成已完成,而那个人其实一直没拿到访问权。 两条都由 ``awaiting_manual`` 这个独立状态解决,而不是靠在两个对账里 各加一个 if —— 那样第三个消费方还会再踩一次。 """ org = await _org(db_session) _patch_driver(monkeypatch, None) out = await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t", ) assert out.outcome == execution.OUTCOME_MANUAL assert out.membership.state == const.MEMBERSHIP_STATE_AWAITING_MANUAL # 没有邀请,就没有到期这回事 —— 设了它,邀请对账会按时间去判一条 # 根本不存在的邀请。 assert out.membership.invitation_expires_at is None async def test_awaiting_manual_still_counts_as_live_access( db_session, monkeypatch, ): """等人工去点的那条同样算「持有或即将持有访问权」。 IT 可能已经手工点过邀请了,而 Helix 还不知道 —— 把它排除在离职回收 和对账之外,就是留了一个后门。 """ assert ( const.MEMBERSHIP_STATE_AWAITING_MANUAL in const.MEMBERSHIP_LIVE_STATES ) org = await _org(db_session) _patch_driver(monkeypatch, None) await execution.execute_approved( db_session, org=org, github_login="octocat", person=None, source=const.SOURCE_APPROVAL, trace_id="t", ) await db_session.commit() assert await svc.count_unlinked(db_session) == 1