"""治理面的 HTTP 面 —— 权限边界,以及「失败必须看起来像失败」。 最重要的一条:点了「移除」之后看到「成功」而人其实还在组织里,是这套 东西最不能出的错。所以驱动失败必须变成 502,而且台账上**不能**留一条 假的回收记录。 """ from __future__ import annotations import uuid import pytest from app.domains.github_org import constants as ghc from app.domains.github_org import driver_access as da from app.domains.github_org import execution as ghe 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, *, remove_ok=True, error="boom") -> None: super().__init__(integration_id="fake", config={}) self._remove_ok = remove_ok self._error = error self.removed: list[str] = [] async def list_members(self, org, *, role=None): return [GithubOrgMember(login="x", state="active", role="member")] async def list_pending_invitations(self, org): return [ GithubOrgInvitation( invitation_id="1", login="x", email=None, role="direct_member", ) ] async def invite_member(self, org, login, *, role="member"): return GithubOrgActionResult(ok=True, invited=True) async def remove_member(self, org, login): if not self._remove_ok: return GithubOrgActionResult( ok=False, error_message=self._error, permission_denied=True, ) self.removed.append(login) return GithubOrgActionResult(ok=True) def _patch_driver(monkeypatch, driver): 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(ghe, "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, *, status="active"): 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="Some One", employment_status=status, ) db.add(p) await db.flush() return p async def _membership(db, org, login, *, person=None): m = await gh.upsert_membership( db, org=org, github_login=login, person_id=str(person.id) if person else None, state=ghc.MEMBERSHIP_STATE_ACTIVE, source="reconcile", actor_kind="integration", trace_id="t", ) await db.commit() return m # ── 权限边界 ───────────────────────────────────────────────────── async def test_an_employee_cannot_read_the_action_items( client, employee_headers, ): r = await client.get("/api/v1/github/action-items", headers=employee_headers) assert r.status_code == 403 async def test_an_auditor_can_read_but_cannot_act( client, auditor_headers, db_session, ): """审计能看治理待办(那是他的本职),但按钮不给他 —— 移除是操作面。""" org = await _org(db_session) m = await _membership(db_session, org, "someone") r = await client.get("/api/v1/github/action-items", headers=auditor_headers) assert r.status_code == 200 r = await client.post( f"/api/v1/github/memberships/{m.id}/remove", json={"reason": "试试"}, headers=auditor_headers, ) assert r.status_code == 403 async def test_a_github_admin_can_read_and_act( client, github_admin_headers, db_session, monkeypatch, ): org = await _org(db_session) m = await _membership(db_session, org, "someone") driver = _FakeDriver() _patch_driver(monkeypatch, driver) r = await client.get( "/api/v1/github/action-items", headers=github_admin_headers, ) assert r.status_code == 200 assert "counts" in r.json()["data"] r = await client.post( f"/api/v1/github/memberships/{m.id}/remove", json={"reason": "离职了还在组织里"}, headers=github_admin_headers, ) assert r.status_code == 200, r.text assert r.json()["data"]["outcome"] == ghe.OUTCOME_REMOVED assert driver.removed == ["someone"] # ── 失败必须看起来像失败 ───────────────────────────────────────── async def test_a_driver_failure_becomes_a_502_and_leaves_no_fake_removal( client, github_admin_headers, db_session, monkeypatch, ): """🔴 这套东西最不能出的错:点了移除、看到成功、人还在组织里。 台账上那条记录正是以后所有人判断「这个人还有没有访问权」的依据。 """ org = await _org(db_session) m = await _membership(db_session, org, "stubborn") _patch_driver(monkeypatch, _FakeDriver(remove_ok=False, error="403 denied")) r = await client.post( f"/api/v1/github/memberships/{m.id}/remove", json={"reason": "离职"}, headers=github_admin_headers, ) assert r.status_code == 502, r.text fresh = await gh.find_membership( db_session, org_id=org.id, github_login="stubborn", ) assert fresh.state == ghc.MEMBERSHIP_STATE_ACTIVE assert fresh.removed_at is None async def test_no_credential_reports_requires_manual_action( client, github_admin_headers, db_session, monkeypatch, ): """凭证没配好时人还在组织里 —— 界面必须说清楚,否则它长得和成功 一模一样。""" org = await _org(db_session) m = await _membership(db_session, org, "someone") _patch_driver(monkeypatch, None) r = await client.post( f"/api/v1/github/memberships/{m.id}/remove", json={"reason": "离职"}, headers=github_admin_headers, ) assert r.status_code == 200, r.text body = r.json()["data"] assert body["outcome"] == ghe.OUTCOME_MANUAL assert body["requires_manual_action"] is True async def test_a_reason_is_required_for_a_manual_removal( client, github_admin_headers, db_session, ): """手工移除是唯一没有上游凭据的写动作 —— 审批有单号、离职有流程, 只有它全靠人的判断。事后有人问「为什么被踢了」时得有答案。""" org = await _org(db_session) m = await _membership(db_session, org, "someone") r = await client.post( f"/api/v1/github/memberships/{m.id}/remove", json={"reason": ""}, headers=github_admin_headers, ) assert r.status_code == 422 # ── 关联 / 确认外部 ────────────────────────────────────────────── async def test_linking_to_a_nonexistent_person_is_refused( client, github_admin_headers, db_session, ): """关联到一个不存在的人会得到一条永远对不上的记录,而它在界面上 长得和正常关联一模一样。""" org = await _org(db_session) m = await _membership(db_session, org, "someone") r = await client.post( f"/api/v1/github/memberships/{m.id}/link", json={"person_id": str(uuid.uuid4())}, headers=github_admin_headers, ) assert r.status_code == 404 async def test_link_then_unlink_round_trips( client, github_admin_headers, db_session, ): org = await _org(db_session) p = await _person(db_session) await db_session.commit() m = await _membership(db_session, org, "someone") r = await client.post( f"/api/v1/github/memberships/{m.id}/link", json={"person_id": str(p.id)}, headers=github_admin_headers, ) assert r.status_code == 200, r.text assert r.json()["data"]["person_id"] == str(p.id) r = await client.post( f"/api/v1/github/memberships/{m.id}/link", json={"person_id": None}, headers=github_admin_headers, ) assert r.status_code == 200 assert r.json()["data"]["person_id"] is None async def test_confirm_external_then_undo( client, github_admin_headers, db_session, ): org = await _org(db_session) m = await _membership(db_session, org, "bytedancer") r = await client.post( f"/api/v1/github/memberships/{m.id}/confirm-external", json={"note": "ByteDance"}, headers=github_admin_headers, ) assert r.status_code == 200, r.text assert r.json()["data"]["external_confirmed_at"] is not None assert r.json()["data"]["external_note"] == "ByteDance" r = await client.delete( f"/api/v1/github/memberships/{m.id}/confirm-external", headers=github_admin_headers, ) assert r.status_code == 200 assert r.json()["data"]["external_confirmed_at"] is None async def test_confirming_a_linked_membership_as_external_is_a_409( client, github_admin_headers, db_session, ): org = await _org(db_session) p = await _person(db_session) await db_session.commit() m = await _membership(db_session, org, "known", person=p) r = await client.post( f"/api/v1/github/memberships/{m.id}/confirm-external", json={}, headers=github_admin_headers, ) assert r.status_code == 409 async def test_a_missing_membership_is_a_404_not_a_500( client, github_admin_headers, ): r = await client.post( f"/api/v1/github/memberships/{uuid.uuid4()}/link", json={"person_id": None}, headers=github_admin_headers, ) assert r.status_code == 404 # ── 摘要预览 ───────────────────────────────────────────────────── async def test_the_digest_preview_answers_would_it_send_and_to_whom( client, github_admin_headers, db_session, ): """「有 dispatch 代码」不等于「有人收到过」。这个端点回答的正是 那个问题 —— 而且**不发**。""" r = await client.post( "/api/v1/github/governance-digest/preview", headers=github_admin_headers, ) assert r.status_code == 200, r.text body = r.json()["data"] # 没有待办 → 不会发,而且原因说得出来。 assert body["would_send"] is False assert body["skipped"] == "no_action_items" # 收件人清单不是空的 —— github_admin fixture 就在里面。 assert body["recipients"] async def test_a_malformed_person_id_is_a_404_not_a_500( client, github_admin_headers, db_session, ): """畸形 id 是**调用方发来的坏输入**,不是服务端故障。 区别不是措辞:5xx 会让调用方(以及重试逻辑、以及看监控的人)以为是我们 坏了并且重试,而这个请求重试一万次也不会好。原来这里直接把字符串喂给 ``uuid.UUID()``,ValueError 没人接,于是变成 500。 """ org = await _org(db_session) m = await _membership(db_session, org, "someone") r = await client.post( f"/api/v1/github/memberships/{m.id}/link", json={"person_id": "not-a-uuid"}, headers=github_admin_headers, ) assert r.status_code == 404, r.text # ── 机器账号策略(HTTP 面)──────────────────────────────────────── async def _seed_bot_label(db): """词表由迁移 586 播种;测试库从 metadata 建表,所以显式种一条。 注意 ``set_patterns`` 自己会 ensure 这个标签(见那里的注释),这个 helper 只是让「读」的用例也有一份词表可查显示名。 """ 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_bot_policy_preview_is_readable_by_an_auditor( client, auditor_headers, db_session, ): """预演是只读的 —— 审计该看得到「有多少机器账号占着待办」。""" org = await _org(db_session) await _membership(db_session, org, "sre-bot") r = await client.get("/api/v1/github/bot-policy", headers=auditor_headers) assert r.status_code == 200, r.text d = r.json()["data"] assert d["handles"] == 1 assert "命名约定" in d["items"][0]["reason"] async def test_only_a_manager_can_change_the_policy( client, auditor_headers, github_admin_headers, ): r = await client.put( "/api/v1/github/bot-policy", json={"patterns": ["*-runner"]}, headers=auditor_headers, ) assert r.status_code == 403 r = await client.put( "/api/v1/github/bot-policy", json={"patterns": ["*-runner"]}, headers=github_admin_headers, ) assert r.status_code == 200, r.text assert r.json()["data"]["patterns"] == ["*-runner"] async def test_an_empty_policy_is_refused_with_422( client, github_admin_headers, ): r = await client.put( "/api/v1/github/bot-policy", json={"patterns": []}, headers=github_admin_headers, ) assert r.status_code == 422 async def test_apply_marks_every_row_of_each_matching_handle( client, github_admin_headers, db_session, ): """一个 bot 在多个组织里,一次应用要覆盖它的所有行。""" a = await _org(db_session, "pingcap") b = await _org(db_session, "tikv") await _membership(db_session, a, "ti-chi-bot") await _membership(db_session, b, "ti-chi-bot") await _membership(db_session, a, "Ehco1996") # 真人,不该被动 r = await client.post( "/api/v1/github/bot-policy/apply", json={}, headers=github_admin_headers, ) assert r.status_code == 200, r.text d = r.json()["data"] assert d["marked_handles"] == ["ti-chi-bot"] assert d["marked_memberships"] == 2 # 真人那条没被碰 from app.domains.github_org import service as gh2 human = await gh2.find_membership( db_session, org_id=a.id, github_login="Ehco1996", ) assert human.external_confirmed_at is None async def test_apply_can_be_limited_to_selected_handles( client, github_admin_headers, db_session, ): """界面上勾选几个就只标那几个 —— 批量不等于全都要。""" org = await _org(db_session) await _membership(db_session, org, "sre-bot") await _membership(db_session, org, "tidb-ci-bot") r = await client.post( "/api/v1/github/bot-policy/apply", json={"handles": ["sre-bot"]}, headers=github_admin_headers, ) assert r.status_code == 200, r.text assert r.json()["data"]["marked_handles"] == ["sre-bot"] from app.domains.github_org import service as gh2 other = await gh2.find_membership( db_session, org_id=org.id, github_login="tidb-ci-bot", ) assert other.external_confirmed_at is None async def test_apply_is_idempotent( client, github_admin_headers, db_session, ): """再点一次不该报错,也不该重复留事件 —— 第二次应该无事可做。""" org = await _org(db_session) await _membership(db_session, org, "sre-bot") r1 = await client.post( "/api/v1/github/bot-policy/apply", json={}, headers=github_admin_headers, ) assert r1.status_code == 200 assert r1.json()["data"]["marked_handles"] == ["sre-bot"] # 换一个幂等键,模拟「人又点了一次」而不是同一次重放 r2 = await client.post( "/api/v1/github/bot-policy/apply", json={"note": "second run"}, headers=github_admin_headers, ) assert r2.status_code == 200, r2.text assert r2.json()["data"]["marked_handles"] == [] async def test_confirm_external_accepts_a_kind( client, github_admin_headers, db_session, ): org = await _org(db_session) m = await _membership(db_session, org, "sre-bot") r = await client.post( f"/api/v1/github/memberships/{m.id}/confirm-external", json={"kind": "bot", "note": "CI 机器人"}, headers=github_admin_headers, ) assert r.status_code == 200, r.text assert r.json()["data"]["external_kind"] == "bot"