blob: 18817ef1d035d072f1c8c7be6f670fc4c6bf193c [file]
"""A fake GitHub GraphQL endpoint, good enough to drive a whole scan.
It answers the five documents the scanner sends (org meta, members, teams, team
members, repositories) out of plain Python lists, honouring first/after
pagination and the alias batching the team loader relies on. It also counts
requests, so tests can check that the cache really did save work.
FakeGitHub is typed against ghclient.QueryClient, which is what OrgScanner
takes, so the fake and the real client are checked against the same interface.
"""
from __future__ import annotations
import time
import typing
from orgscanner import ghclient
from orgscanner.ghtypes import GraphQLData
class FakeMember(typing.TypedDict, total=False):
"""One member of the fake organization."""
login: str
id: int
name: str
two_factor: bool
role: str
class FakeTeam(typing.TypedDict, total=False):
"""One team of the fake organization."""
slug: str
name: str
id: int
privacy: str
members: list[str]
class FakeRepository(typing.TypedDict, total=False):
"""One repository of the fake organization."""
name: str
id: int
visibility: str
url: str
archived: bool
updated_at: str
class FakeGitHub:
"""Stand-in for orgscanner.ghclient.GraphQLClient."""
def __init__(
self,
members: list[FakeMember] | None = None,
teams: list[FakeTeam] | None = None,
repositories: list[FakeRepository] | None = None,
org_id: int = 47359,
):
self.members: list[FakeMember] = members or []
self.teams: list[FakeTeam] = teams or []
self.repositories: list[FakeRepository] = repositories or []
self.org_id = org_id
self.requests = 0
self.points_spent = 0
self.retries = 0
self.rate_limit = ghclient.RateLimit(
limit=5000, remaining=4990, used=10, reset_at=time.time() + 3600, updated_at=time.time()
)
self.labels: list[str] = []
self.closed = False
# -- the plumbing the scanner expects ----------------------------------
async def session(self) -> object:
return None
async def close(self) -> None:
self.closed = True
def request_count(self, kind: str) -> int:
"""How many requests carried a label starting with kind."""
return len([label for label in self.labels if label.startswith(kind)])
# -- the endpoint -------------------------------------------------------
async def query(
self,
document: str,
variables: typing.Mapping[str, typing.Any] | None = None,
*,
label: str = "query",
page_size: ghclient.AdaptivePageSize | None = None,
page_size_variable: str = "size",
) -> GraphQLData:
query_variables: dict[str, typing.Any] = dict(variables or {})
self.requests += 1
self.points_spent += 1
self.labels.append(label)
if page_size is not None:
page_size.success()
if "OrgMeta" in document:
return self._meta()
if "OrgMembers" in document:
return self._members(query_variables)
if "OrgTeams" in document:
return self._team_list(query_variables)
if "TeamMembers" in document:
return self._team_members(query_variables)
if "OrgRepositories" in document:
return self._repositories(query_variables)
raise AssertionError(f"FakeGitHub got an unexpected document: {document[:80]}")
# -- responses ---------------------------------------------------------
def _rate_limit(self) -> GraphQLData:
return {"limit": 5000, "cost": 1, "remaining": self.rate_limit.remaining, "used": 10, "resetAt": None}
def _meta(self) -> GraphQLData:
return {
"organization": {
"login": "apache",
"databaseId": self.org_id,
"membersWithRole": {"totalCount": len(self.members)},
"teams": {"totalCount": len(self.teams)},
"repositories": {"totalCount": len(self.repositories)},
},
"rateLimit": self._rate_limit(),
}
@staticmethod
def _page(items: typing.Sequence[typing.Any], variables: typing.Mapping[str, typing.Any]) -> tuple[
list[typing.Any], GraphQLData
]:
"""Slice items per first/after, and return the slice with a pageInfo.
The cursors are just offsets into the list, which is all the scanner
needs: it passes back whatever endCursor it was handed.
"""
size = int(variables.get("size") or 100)
start = int(variables.get("after") or 0)
chunk = list(items[start : start + size])
end = start + len(chunk)
has_next = end < len(items)
return chunk, {"hasNextPage": has_next, "endCursor": str(end) if has_next else None}
def _members(self, variables: typing.Mapping[str, typing.Any]) -> GraphQLData:
chunk, page = self._page(self.members, variables)
return {
"organization": {
"membersWithRole": {
"totalCount": len(self.members),
"pageInfo": page,
"edges": [
{
"role": member.get("role", "MEMBER"),
"hasTwoFactorEnabled": member.get("two_factor", True),
"node": {
"login": member["login"],
"databaseId": member.get("id"),
"name": member.get("name"),
},
}
for member in chunk
],
}
},
"rateLimit": self._rate_limit(),
}
def _team_list(self, variables: typing.Mapping[str, typing.Any]) -> GraphQLData:
chunk, page = self._page(self.teams, variables)
return {
"organization": {
"teams": {
"totalCount": len(self.teams),
"pageInfo": page,
"nodes": [
{
"slug": team["slug"],
"name": team.get("name", team["slug"]),
"databaseId": team.get("id"),
"privacy": team.get("privacy", "SECRET"),
"members": {"totalCount": len(team.get("members", []))},
}
for team in chunk
],
}
},
"rateLimit": self._rate_limit(),
}
def _team_members(self, variables: typing.Mapping[str, typing.Any]) -> GraphQLData:
by_slug = {team["slug"]: team for team in self.teams}
organization: GraphQLData = {}
index = 0
while f"s{index}" in variables:
slug = variables[f"s{index}"]
team = by_slug.get(slug)
if team is None:
organization[f"t{index}"] = None
index += 1
continue
members = list(team.get("members", []))
chunk, page = self._page(members, {"size": variables.get("size"), "after": variables.get(f"c{index}")})
organization[f"t{index}"] = {
"slug": slug,
"members": {
"totalCount": len(members),
"pageInfo": page,
"nodes": [{"login": login} for login in chunk],
},
}
index += 1
assert index, "TeamMembers document carried no team variables"
return {"organization": organization, "rateLimit": self._rate_limit()}
def _repositories(self, variables: typing.Mapping[str, typing.Any]) -> GraphQLData:
# GitHub hands these back ordered by updatedAt descending.
ordered = sorted(self.repositories, key=lambda repo: repo.get("updated_at", ""), reverse=True)
chunk, page = self._page(ordered, variables)
return {
"organization": {
"repositories": {
"totalCount": len(ordered),
"pageInfo": page,
"nodes": [
{
"name": repo["name"],
"databaseId": repo.get("id"),
"visibility": repo.get("visibility", "PUBLIC"),
"url": repo.get("url", "https://github.com/apache/%s" % repo["name"]),
"isArchived": repo.get("archived", False),
"updatedAt": repo.get("updated_at", "2026-01-01T00:00:00Z"),
}
for repo in chunk
],
}
},
"rateLimit": self._rate_limit(),
}