"""GitHub 组织驱动的 wire 级测试。 respx 打的是**真实的 GitHub 响应形状**,不是我们希望它长的样子: ``PUT /orgs/{org}/memberships/{login}`` 返回 ``{"state": "pending"|"active"}``, ``DELETE`` 返回 204,不存在的 handle 返回 404,二级限流返回带 ``Retry-After`` 的 403。测试钉的是驱动如何**解读**这些形状,因为那正是会悄悄出错的地方 —— 把 pending 读成已加入、把 404 读成已回收、把权限不足当成限流去重试。 """ from __future__ import annotations import httpx import pytest import respx from app.integrations.drivers.github_org import GithubOrgApiDriver _PAT_CONFIG = {"auth_mode": "pat", "token": "ghp_" + "f" * 30} _ORG = "pingcap-inc" _API = "https://api.github.com" def _driver(config: dict | None = None) -> GithubOrgApiDriver: return GithubOrgApiDriver( integration_id="gh-org-test", config=config or dict(_PAT_CONFIG), ) # ── 元数据 ─────────────────────────────────────────────────────── def test_metadata_is_its_own_kind() -> None: """与 SCIM 席位驱动是两个 kind —— 合成一个就必须在语义上撒谎。""" meta = GithubOrgApiDriver.metadata() assert meta.kind == "github_org" assert meta.provider == "github" secret_keys = {f.key for f in meta.fields if f.is_secret} assert secret_keys == {"private_key", "token"} def test_registry_resolves_both_github_drivers_independently() -> None: import app.integrations.drivers # noqa: F401 (触发注册) from app.integrations.registry import get_driver_class assert get_driver_class("github_org", "github") is GithubOrgApiDriver other = get_driver_class("software_provisioning", "github") assert other is not None and other is not GithubOrgApiDriver # ── 邀请 ───────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_invite_pending_is_not_already_done() -> None: """``state=pending`` 是「邀请发出去了」,不是「他进组了」。 这两件事被合并,就等于对外宣称一批还没进组的人已经有访问权。 """ respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response( 200, json={"state": "pending", "role": "member", "user": {"id": 583231, "login": "octocat"}}, ), ) res = await _driver().invite_member(_ORG, "octocat") assert res.ok is True assert res.invited is True assert res.already_done is False assert res.external_ref == "583231" @pytest.mark.asyncio @respx.mock async def test_invite_active_is_idempotent_hit() -> None: """已经是成员 → 幂等命中,不是一次新加入。 这正是「飞书重投一次回调」需要的性质。 """ respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response( 200, json={"state": "active", "role": "member", "user": {"id": 1, "login": "octocat"}}, ), ) res = await _driver().invite_member(_ORG, "octocat") assert res.ok is True assert res.already_done is True assert res.invited is False @pytest.mark.asyncio @respx.mock async def test_invite_unknown_handle_is_not_found_not_a_generic_failure() -> None: """handle 不存在是一个**可行动**的答案:去找申请人改,不是重试。""" respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/nope").mock( return_value=httpx.Response(404, json={"message": "Not Found"}), ) res = await _driver().invite_member(_ORG, "nope") assert res.ok is False assert res.not_found is True assert res.permission_denied is False @pytest.mark.asyncio @respx.mock async def test_invite_403_is_permission_denied_not_not_found() -> None: """凭证不是这个组织的 owner —— 配置问题,不是关于这个人的事实。""" respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response(403, json={"message": "Forbidden"}), ) res = await _driver().invite_member(_ORG, "octocat") assert res.ok is False assert res.permission_denied is True assert res.not_found is False @pytest.mark.asyncio @respx.mock async def test_invite_admin_role_is_translated() -> None: """Helix 说 admin,membership API 也收 admin;但两套词表要显式转换。""" route = respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response(200, json={"state": "pending"}), ) await _driver().invite_member(_ORG, "octocat", role="admin") import json as _json assert _json.loads(route.calls[0].request.content)["role"] == "admin" # ── 移除 ───────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_remove_204_is_success() -> None: respx.mock.delete(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response(204), ) res = await _driver().remove_member(_ORG, "octocat") assert res.ok is True assert res.already_done is False @pytest.mark.asyncio @respx.mock async def test_remove_404_is_idempotent_success() -> None: """已经不在里面了 —— 离职流程每个 tick 重扫时需要这个性质。""" respx.mock.delete(f"{_API}/orgs/{_ORG}/memberships/ghost").mock( return_value=httpx.Response(404, json={"message": "Not Found"}), ) res = await _driver().remove_member(_ORG, "ghost") assert res.ok is True assert res.already_done is True @pytest.mark.asyncio @respx.mock async def test_remove_transport_failure_is_never_read_as_already_gone() -> None: """🔴 平台级驱动规则在这个域里最要命的一条。 一次连接失败没有告诉我们任何关于世界的事。把它读成「已经不在组织里」 然后写成「已回收」,就是在离职台账上留一条假的成功记录 —— 而那条记录 正是以后所有人用来判断「这个人还有没有访问权」的依据。 """ respx.mock.delete(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( side_effect=httpx.ConnectError("connection reset"), ) res = await _driver().remove_member(_ORG, "octocat") assert res.ok is False, "传输失败绝不能报成功" assert res.already_done is False assert "ConnectError" in (res.error_message or "") @pytest.mark.asyncio @respx.mock async def test_remove_403_is_permission_denied() -> None: respx.mock.delete(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response(403, json={"message": "Forbidden"}), ) res = await _driver().remove_member(_ORG, "octocat") assert res.ok is False assert res.permission_denied is True # ── 读 ─────────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_list_members_follows_link_pagination() -> None: page2 = f"{_API}/orgs/{_ORG}/members?page=2" respx.mock.get(url__startswith=f"{_API}/orgs/{_ORG}/members").mock( side_effect=[ httpx.Response( 200, json=[{"login": "a", "id": 1}], headers={"Link": f'<{page2}>; rel="next"'}, ), httpx.Response(200, json=[{"login": "b", "id": 2}]), # role=admin 那一轮 httpx.Response(200, json=[{"login": "b", "id": 2}]), ], ) members = await _driver().list_members(_ORG) assert [m.login for m in members] == ["a", "b"] assert {m.login: m.role for m in members} == {"a": "member", "b": "admin"} @pytest.mark.asyncio @respx.mock async def test_list_pending_invitations_keeps_email_only_rows() -> None: """GitHub 允许按邮箱邀请 —— 那种行没有 handle,不能因此丢掉。""" respx.mock.get(url__startswith=f"{_API}/orgs/{_ORG}/invitations").mock( return_value=httpx.Response( 200, json=[ {"id": 1, "login": "octocat", "email": None, "role": "direct_member", "created_at": "2026-08-20T00:00:00Z"}, {"id": 2, "login": None, "email": "someone@example.com", "role": "direct_member", "created_at": "2026-08-21T00:00:00Z"}, ], ), ) invites = await _driver().list_pending_invitations(_ORG) assert [i.invitation_id for i in invites] == ["1", "2"] assert invites[1].login is None assert invites[1].email == "someone@example.com" @pytest.mark.asyncio @respx.mock async def test_get_user_404_is_none_but_500_raises() -> None: """「不存在」返回 None,「问不到」抛 —— 两者绝不能是同一个值。""" respx.mock.get(f"{_API}/users/ghost").mock( return_value=httpx.Response(404, json={"message": "Not Found"}), ) assert await _driver().get_user("ghost") is None respx.mock.get(f"{_API}/users/flaky").mock( return_value=httpx.Response(500, text="boom"), ) with pytest.raises(Exception, match="user lookup failed"): await _driver().get_user("flaky") # ── 重试语义 ───────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_secondary_rate_limit_403_is_retried() -> None: """带 Retry-After 的 403 是限流,值得重试。""" respx.mock.get(f"{_API}/user").mock( side_effect=[ httpx.Response(403, headers={"Retry-After": "0"}, text="slow down"), httpx.Response(200, json={"login": "helix-bot"}), ], ) res = await _driver().test_connection() assert res.ok is True assert "helix-bot" in res.message @pytest.mark.asyncio @respx.mock async def test_plain_403_is_not_retried() -> None: """不带限流信号的 403 是权限不足 —— 重试只是让每次调用白等十几秒。""" route = respx.mock.get(f"{_API}/user").mock( return_value=httpx.Response(403, json={"message": "Forbidden"}), ) res = await _driver().test_connection() assert res.ok is False assert route.call_count == 1, "权限不足被当成限流重试了" # ── 脱敏 ───────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_error_snippets_never_leak_the_token() -> None: """错误片段会落进管理员看得见的字段。""" token = _PAT_CONFIG["token"] respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/octocat").mock( return_value=httpx.Response(400, text=f"bad request with {token}"), ) res = await _driver().invite_member(_ORG, "octocat") assert res.ok is False assert token not in (res.error_message or "") assert "***" in (res.error_message or "") def test_private_key_is_redacted_whole_not_just_the_body() -> None: """留着 BEGIN/END 头尾等于告诉读日志的人「这里曾经有把私钥」。""" from app.integrations.drivers.github_org import _redact pem = ( "-----BEGIN RSA PRIVATE KEY-----\n" "MIIEowIBAAKCAQEA1234567890\n" "-----END RSA PRIVATE KEY-----" ) out = _redact(f"failed with key {pem} attached") assert "BEGIN RSA PRIVATE KEY" not in out assert "MIIEow" not in out assert "PRIVATE KEY REDACTED" in out # ── 路径转义 ───────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_path_segments_are_escaped() -> None: """handle 曾经来自自助申请的表单。一个带 ``../`` 的值拼进路径, 就是一次指向别的端点的请求。""" # 匹配请求真正上线的 ``raw_path``,而不是把一个 URL 模式交给 respx。 # respx 比对的是**归一化**后的 URL,而归一化恰恰是这条测试要排除的东西: # 在那里 ``%2F`` 与 ``/`` 相等,于是一个已经不转义的驱动照样能匹配上模式 —— # 测试通过,而性质已经没了。这条用例此前正是因此既瞎且红。 # 同一处修法见 tests/test_github_org/test_security.py。 route = respx.mock.put(url__regex=r".*").mock( return_value=httpx.Response(200, json={"state": "pending"}), ) await _driver().invite_member(_ORG, "../admin") assert route.call_count == 1 sent = route.calls[0].request assert sent.url.raw_path == f"/orgs/{_ORG}/memberships/..%2Fadmin".encode(), ( f"handle 未转义就上线了:{sent.url.raw_path!r} —— 这里一个裸 '/' " "就能让请求走出 /memberships/ 这一段" ) # ── GitHub App 鉴权 ────────────────────────────────────────────── def _app_config() -> dict: """一把真实的、临时生成的 RSA 私钥 —— 不 mock 签名。 用假字符串当私钥会让 JWT 签名这一步被跳过,而那一步正是 App 模式里 最容易配错的地方(PEM 格式、算法、iat 回拨)。 """ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa key = rsa.generate_private_key(public_exponent=65537, key_size=2048) pem = key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ).decode() return {"auth_mode": "github_app", "app_id": "123456", "private_key": pem} @pytest.mark.asyncio @respx.mock async def test_app_mode_exchanges_jwt_for_an_installation_token() -> None: respx.mock.get(f"{_API}/orgs/{_ORG}/installation").mock( return_value=httpx.Response(200, json={"id": 42}), ) token_route = respx.mock.post( f"{_API}/app/installations/42/access_tokens", ).mock( return_value=httpx.Response( 201, json={"token": "ghs_installation", "expires_at": "2099-01-01T00:00:00Z"}, ), ) put_route = respx.mock.put( f"{_API}/orgs/{_ORG}/memberships/octocat", ).mock(return_value=httpx.Response(200, json={"state": "pending"})) driver = _driver(_app_config()) res = await driver.invite_member(_ORG, "octocat") assert res.ok is True and res.invited is True assert token_route.call_count == 1 assert ( put_route.calls[0].request.headers["authorization"] == "Bearer ghs_installation" ) @pytest.mark.asyncio @respx.mock async def test_app_installation_token_is_cached_per_org() -> None: """一次离职扫描会对同一个组织连发好几个调用,不该每次都换 token。""" respx.mock.get(f"{_API}/orgs/{_ORG}/installation").mock( return_value=httpx.Response(200, json={"id": 42}), ) token_route = respx.mock.post( f"{_API}/app/installations/42/access_tokens", ).mock( return_value=httpx.Response( 201, json={"token": "ghs_x", "expires_at": "2099-01-01T00:00:00Z"}, ), ) respx.mock.delete(url__startswith=f"{_API}/orgs/{_ORG}/memberships/").mock( return_value=httpx.Response(204), ) driver = _driver(_app_config()) await driver.remove_member(_ORG, "a") await driver.remove_member(_ORG, "b") assert token_route.call_count == 1 @pytest.mark.asyncio @respx.mock async def test_app_not_installed_on_org_says_so() -> None: """App 私钥完全正确、却没装在这个组织上 —— 一个必须说清楚的状态。""" respx.mock.get(f"{_API}/orgs/{_ORG}/installation").mock( return_value=httpx.Response(404, json={"message": "Not Found"}), ) res = await _driver(_app_config()).invite_member(_ORG, "octocat") assert res.ok is False assert "not installed" in (res.error_message or "") @pytest.mark.asyncio @respx.mock async def test_test_connection_without_probe_org_says_what_it_did_not_check() -> None: """凭证有效 ≠ 它对某个具体组织有权限。绿灯必须说清楚它验了什么。""" respx.mock.get(f"{_API}/app").mock( return_value=httpx.Response(200, json={"slug": "helix-github-org"}), ) res = await _driver(_app_config()).test_connection() assert res.ok is True assert "只验证了凭证" in res.message assert res.detail["probe_org"] is None @pytest.mark.asyncio @respx.mock async def test_test_connection_with_probe_org_checks_the_org() -> None: cfg = {**_app_config(), "probe_org": _ORG} respx.mock.get(f"{_API}/app").mock( return_value=httpx.Response(200, json={"slug": "helix-github-org"}), ) respx.mock.get(f"{_API}/orgs/{_ORG}/installation").mock( return_value=httpx.Response(200, json={"id": 42}), ) respx.mock.post(f"{_API}/app/installations/42/access_tokens").mock( return_value=httpx.Response( 201, json={"token": "ghs_x", "expires_at": "2099-01-01T00:00:00Z"}, ), ) respx.mock.get(url__startswith=f"{_API}/orgs/{_ORG}/members").mock( return_value=httpx.Response(200, json=[{"login": "owner1", "id": 9}]), ) res = await _driver(cfg).test_connection() assert res.ok is True assert _ORG in res.message assert res.detail["owner_count"] == 1 # ── 坐席用量 ───────────────────────────────────────────────────── # # 用户明确要求:坐席不足时要通知加坐席,带可直接操作的 URL。而这里最容易 # 出的错不是读不到数,是**读到一个不该相信的数**:free 计划报 # seats=92 / filled=203(按字面算余量 -111),enterprise 报的是整个 # enterprise 的池子。两种都不是我们的上限,显示出来就是永久假警报。 @pytest.mark.asyncio @respx.mock async def test_seat_usage_on_a_capped_plan_is_trusted() -> None: respx.mock.get(f"{_API}/orgs/{_ORG}").mock( return_value=httpx.Response( 200, json={ "login": _ORG, "plan": {"name": "business", "filled_seats": 260, "seats": 263}, }, ), ) usage = await _driver().get_seat_usage(_ORG) assert usage.has_cap is True assert usage.remaining == 3 @pytest.mark.asyncio @respx.mock async def test_seat_usage_on_a_free_plan_reports_no_cap_not_a_fake_number( ) -> None: """🔴 生产实测的形态:tikv 是 free 计划,报 seats=92 / filled=203。 如果按字面算,余量是 -111 —— 一个永久亮着的假警报。它必须回答 「这个组织没有我们能管的坐席上限」,而不是一个负数。 """ respx.mock.get(f"{_API}/orgs/{_ORG}").mock( return_value=httpx.Response( 200, json={ "login": _ORG, "plan": {"name": "free", "filled_seats": 203, "seats": 92}, }, ), ) usage = await _driver().get_seat_usage(_ORG) assert usage.has_cap is False assert usage.remaining is None @pytest.mark.asyncio @respx.mock async def test_seat_usage_on_an_enterprise_plan_reports_no_cap() -> None: """enterprise 报的是整个 enterprise 的池子,不是这个组织的上限。""" respx.mock.get(f"{_API}/orgs/{_ORG}").mock( return_value=httpx.Response( 200, json={ "login": _ORG, "plan": {"name": "enterprise", "filled_seats": 10, "seats": 12}, }, ), ) usage = await _driver().get_seat_usage(_ORG) assert usage.has_cap is False @pytest.mark.asyncio @respx.mock async def test_a_missing_plan_block_does_not_invent_numbers() -> None: """没有 members:write 时 GitHub 直接不返回 plan 块。那是「读不到」, 不是「0 个坐席」。""" respx.mock.get(f"{_API}/orgs/{_ORG}").mock( return_value=httpx.Response(200, json={"login": _ORG}), ) usage = await _driver().get_seat_usage(_ORG) assert usage.plan is None assert usage.filled_seats is None assert usage.remaining is None # ── 坐席用满时的邀请失败 ───────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_a_seat_limit_rejection_is_labelled_as_such() -> None: """坐席用满和权限不足都是 403。分不开的话,管理员会去查 App 权限, 而真正要做的是加坐席。""" respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/newbie").mock( return_value=httpx.Response( 403, text=( '{"message":"You have exceeded your licensed seat count. ' 'Please add more seats."}' ), ), ) res = await _driver().invite_member(_ORG, "newbie") assert res.ok is False assert res.seat_limit is True @pytest.mark.asyncio @respx.mock async def test_a_plain_permission_denial_is_not_labelled_a_seat_limit() -> None: respx.mock.put(f"{_API}/orgs/{_ORG}/memberships/newbie").mock( return_value=httpx.Response( 403, text='{"message":"Resource not accessible by integration"}', ), ) res = await _driver().invite_member(_ORG, "newbie") assert res.ok is False assert res.seat_limit is False assert res.permission_denied is True # ── 个人资料 ───────────────────────────────────────────────────── # # 这几条钉住的是本次改动的前提:handle 字符串本身没有识别信号(实测 217 个 # 认不出的 handle 里 0 命中),真信号在个人资料里。 @pytest.mark.asyncio @respx.mock async def test_a_user_profile_carries_the_fields_matching_actually_needs( ) -> None: respx.mock.get(f"{_API}/users/anotherrachel").mock( return_value=httpx.Response( 200, json={ "login": "anotherrachel", "id": 123, "name": "Yajing Wang", "company": "@pingcap", "email": "yajing.wang@pingcap.com", "bio": "infra", "location": "Beijing", "blog": "https://x.test", "type": "User", }, ), ) p = await _driver().get_user_profile("anotherrachel") assert p is not None assert p.name == "Yajing Wang" assert p.email == "yajing.wang@pingcap.com" assert p.company == "@pingcap" assert p.account_type == "User" assert p.has_signal is True @pytest.mark.asyncio @respx.mock async def test_blank_and_whitespace_profile_fields_become_none() -> None: """GitHub 见过返回空串和纯空白的。「company 是空串」和「没填 company」 在界面上应该长得一样,否则人会以为拉到了什么。""" respx.mock.get(f"{_API}/users/ghost").mock( return_value=httpx.Response( 200, json={ "login": "ghost", "id": 1, "name": "", "company": " ", "email": None, "bio": "", "type": "User", }, ), ) p = await _driver().get_user_profile("ghost") assert p is not None assert p.name is None assert p.company is None assert p.bio is None assert p.has_signal is False @pytest.mark.asyncio @respx.mock async def test_a_404_profile_is_none_but_a_500_raises() -> None: """🔴 「我们没问到」绝不能长得像「这个人不存在」。 后者会让一条记录被永久标成「账号已注销」,而真实原因可能只是限流。 """ respx.mock.get(f"{_API}/users/nobody").mock( return_value=httpx.Response(404, json={"message": "Not Found"}), ) assert await _driver().get_user_profile("nobody") is None respx.mock.get(f"{_API}/users/flaky").mock( return_value=httpx.Response(500, text="upstream boom"), ) with pytest.raises(Exception): # noqa: B017, PT011 await _driver().get_user_profile("flaky") @pytest.mark.asyncio @respx.mock async def test_a_profile_lookup_error_does_not_leak_the_credential() -> None: token = "ghp_" + "b" * 36 respx.mock.get(f"{_API}/users/leaky").mock( return_value=httpx.Response( 500, text=f"boom; Authorization: Bearer {token}", ), ) driver = _driver({"auth_mode": "pat", "token": token}) with pytest.raises(Exception) as exc: # noqa: B017, PT011 await driver.get_user_profile("leaky") assert token not in str(exc.value) @pytest.mark.asyncio @respx.mock async def test_app_mode_reads_a_public_profile_with_an_installation_token() -> None: """🔴 App 模式下 ``/users/{login}`` 必须用 installation token,不是 App JWT。 生产实测(2026-09-01):同一个 ``GET /users/octocat``,App JWT 返回 **401 Bad credentials**,installation token 返回 **200**。App JWT 只对 ``/app*`` 有效。 这个 bug 的形状特别恶劣:错误信息是「Bad credentials」,看起来像私钥 过期或者配错了 —— 而私钥完全正确,同一把凭证同一时刻还在成功跑离职 对账。代价是整条「靠 GitHub 公开资料认出这个 handle 是谁」的链路在 生产上**从来没有工作过**:217 个认不出的 handle,缓存的画像 0 份, 于是 9 条标签规则里 ``profile_company_contains`` 那几条永远命中 0 个, 而界面上看起来只是「规则还不够好」。 """ respx.mock.get(f"{_API}/app/installations").mock( return_value=httpx.Response(200, json=[{"id": 77}]), ) token_route = respx.mock.post( f"{_API}/app/installations/77/access_tokens", ).mock( return_value=httpx.Response( 201, json={"token": "ghs_public_read", "expires_at": "2099-01-01T00:00:00Z"}, ), ) profile_route = respx.mock.get(f"{_API}/users/Mini256").mock( return_value=httpx.Response( 200, json={ "login": "Mini256", "id": 5086433, "name": "Mini256", "company": "@pingcap", "email": None, "type": "User", }, ), ) p = await _driver(_app_config()).get_user_profile("Mini256") assert p is not None assert p.company == "@pingcap" assert token_route.call_count == 1 # 这一行是整个测试的要点 —— 断言的是**发出去的那个头**,不是返回值。 # 只断言 `p.company` 的测试在 mock 下会照常绿,因为 respx 不校验鉴权。 sent = profile_route.calls[0].request.headers["authorization"] assert sent == "Bearer ghs_public_read", sent assert not sent.startswith("Bearer eyJ"), "App JWT 打 /users/ 是 401" @pytest.mark.asyncio @respx.mock async def test_the_any_installation_token_is_cached_across_profile_lookups() -> None: """一次「认人」要连拉两百多个画像,不该每个都换一次 token。""" respx.mock.get(f"{_API}/app/installations").mock( return_value=httpx.Response(200, json=[{"id": 77}]), ) token_route = respx.mock.post( f"{_API}/app/installations/77/access_tokens", ).mock( return_value=httpx.Response( 201, json={"token": "ghs_y", "expires_at": "2099-01-01T00:00:00Z"}, ), ) respx.mock.get(url__startswith=f"{_API}/users/").mock( return_value=httpx.Response(200, json={"login": "x", "id": 1}), ) driver = _driver(_app_config()) await driver.get_user_profile("a") await driver.get_user_profile("b") await driver.get_user_profile("c") assert token_route.call_count == 1 @pytest.mark.asyncio @respx.mock async def test_an_app_installed_nowhere_says_so_instead_of_401() -> None: """App 私钥对、但一个组织都没装 —— 说清楚,别让它变成 Bad credentials。""" respx.mock.get(f"{_API}/app/installations").mock( return_value=httpx.Response(200, json=[]), ) with pytest.raises(Exception) as exc: # noqa: B017, PT011 await _driver(_app_config()).get_user_profile("octocat") assert "not installed anywhere" in str(exc.value) @pytest.mark.asyncio @respx.mock async def test_credential_verification_still_uses_the_app_jwt() -> None: """反向锚:``test_connection`` 那条路径**必须**继续用 App JWT。 修 ``/users/`` 最省事的写法是把 ``org=None`` 一律改成 installation token —— 那会让「只验证凭证本身」这条路径去要一个 installation, 而它存在的意义正是「私钥有效性与任何组织无关」。 """ app_route = respx.mock.get(f"{_API}/app").mock( return_value=httpx.Response(200, json={"slug": "helix-github-org"}), ) res = await _driver(_app_config()).test_connection() assert res.ok is True assert app_route.calls[0].request.headers["authorization"].startswith( "Bearer eyJ", ) @pytest.mark.asyncio @respx.mock async def test_app_mode_resolves_a_login_with_an_installation_token_too() -> None: """``get_user`` 是第二个调 ``/users/{login}`` 的地方,同一个坑。 这两处是独立的调用点 —— 只修一处、只测一处,另一处会带着同样的 401 继续躺着。它把 handle 解析成 user_id,而 user_id 是「这个人改名了还是 换了个人」唯一分得开的依据。 """ respx.mock.get(f"{_API}/app/installations").mock( return_value=httpx.Response(200, json=[{"id": 77}]), ) respx.mock.post(f"{_API}/app/installations/77/access_tokens").mock( return_value=httpx.Response( 201, json={"token": "ghs_z", "expires_at": "2099-01-01T00:00:00Z"}, ), ) route = respx.mock.get(f"{_API}/users/zz-jason").mock( return_value=httpx.Response(200, json={"login": "zz-jason", "id": 123}), ) m = await _driver(_app_config()).get_user("zz-jason") assert m is not None and m.user_id == 123 assert route.calls[0].request.headers["authorization"] == "Bearer ghs_z" @pytest.mark.asyncio @respx.mock async def test_connection_probe_says_when_public_profiles_are_unreadable() -> None: """绿灯必须说得出它验了什么。 2026-09-01 的事故里,``/app`` 200 + 组织成员可读 + 每日对账 ok,而 ``/users/{login}`` 全部 401 —— 连接测试从头到尾绿。这个断言钉住的是 「消息里必须出现公开资料这条结论」,而不是「整体判红」:公开资料只 影响认人,挡不住邀请与回收。 """ respx.mock.get(f"{_API}/app").mock( return_value=httpx.Response(200, json={"slug": "helix-github-org"}), ) respx.mock.get(f"{_API}/orgs/{_ORG}/installation").mock( return_value=httpx.Response(200, json={"id": 42}), ) respx.mock.post(f"{_API}/app/installations/42/access_tokens").mock( return_value=httpx.Response( 201, json={"token": "ghs_a", "expires_at": "2099-01-01T00:00:00Z"}, ), ) respx.mock.get(url__startswith=f"{_API}/orgs/{_ORG}/members").mock( return_value=httpx.Response(200, json=[{"login": "owner1"}]), ) respx.mock.get(f"{_API}/app/installations").mock( return_value=httpx.Response(200, json=[{"id": 42}]), ) # 正是那个事故:公开资料端点 401。 respx.mock.get(f"{_API}/users/octocat").mock( return_value=httpx.Response(401, json={"message": "Bad credentials"}), ) cfg = {**_app_config(), "probe_org": _ORG} res = await _driver(cfg).test_connection() assert res.ok is True, "公开资料读不了不该挡住邀请与回收" assert "公开资料读不了" in res.message assert res.detail["profile_readable"] is False @pytest.mark.asyncio @respx.mock async def test_connection_probe_confirms_public_profiles_when_they_work() -> None: """反向锚:能读的时候要如实说能读,否则这句话变成永久的警告噪音。""" respx.mock.get(f"{_API}/app").mock( return_value=httpx.Response(200, json={"slug": "helix-github-org"}), ) respx.mock.get(f"{_API}/orgs/{_ORG}/installation").mock( return_value=httpx.Response(200, json={"id": 42}), ) respx.mock.post(f"{_API}/app/installations/42/access_tokens").mock( return_value=httpx.Response( 201, json={"token": "ghs_a", "expires_at": "2099-01-01T00:00:00Z"}, ), ) respx.mock.get(url__startswith=f"{_API}/orgs/{_ORG}/members").mock( return_value=httpx.Response(200, json=[{"login": "owner1"}]), ) respx.mock.get(f"{_API}/app/installations").mock( return_value=httpx.Response(200, json=[{"id": 42}]), ) respx.mock.get(f"{_API}/users/octocat").mock( return_value=httpx.Response(200, json={"login": "octocat", "id": 1}), ) cfg = {**_app_config(), "probe_org": _ORG} res = await _driver(cfg).test_connection() assert res.ok is True assert "公开资料可读" in res.message assert res.detail["profile_readable"] is True