@app-vitals/agent-admin 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/fixtures/github-lookup-cassette.json +499 -0
- package/src/github-lookup-client.integration.test.ts +313 -0
- package/src/github-lookup-client.ts +308 -0
- package/src/github-lookup-client.unit.test.ts +93 -0
- package/src/user-repo-access-resolver.integration.test.ts +156 -0
- package/src/user-repo-access-resolver.ts +257 -0
- package/src/user-repo-access-resolver.unit.test.ts +287 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/user-repo-access-resolver.integration.test.ts
|
|
3
|
+
* UserRepoAccessResolver driven through the real GithubLookupClient replaying
|
|
4
|
+
* the GHE-2.2 cassettes (injected fetchFn; no global overrides).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { GithubLookupClient } from "./github-lookup-client.ts";
|
|
11
|
+
import { UserRepoAccessResolver } from "./user-repo-access-resolver.ts";
|
|
12
|
+
|
|
13
|
+
interface CassetteEntry {
|
|
14
|
+
status: number;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
body: unknown;
|
|
17
|
+
}
|
|
18
|
+
const cassette: Record<string, CassetteEntry> = JSON.parse(
|
|
19
|
+
readFileSync(
|
|
20
|
+
new URL("./fixtures/github-lookup-cassette.json", import.meta.url).pathname,
|
|
21
|
+
"utf-8",
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
25
|
+
modulusLength: 2048,
|
|
26
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
27
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const NOW = new Date("2026-11-09T12:00:00Z");
|
|
31
|
+
const BASE: Record<string, string> = {
|
|
32
|
+
"GET /orgs/acme-eng/installation": "org_installation_success",
|
|
33
|
+
"POST /app/installations/7001/access_tokens": "lookup_token_success",
|
|
34
|
+
"GET /installation/repositories": "repos_success",
|
|
35
|
+
"GET /users/octocat": "user_success",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function setup(routes: Record<string, string>) {
|
|
39
|
+
const all = { ...BASE, ...routes };
|
|
40
|
+
const calls: string[] = [];
|
|
41
|
+
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
42
|
+
const u = new URL(input.toString());
|
|
43
|
+
const key = `${init?.method ?? "GET"} ${u.pathname}`;
|
|
44
|
+
calls.push(key);
|
|
45
|
+
const name = all[key];
|
|
46
|
+
if (!name) throw new Error(`no route for ${key}`);
|
|
47
|
+
const e = cassette[name];
|
|
48
|
+
if (!e) throw new Error(`cassette key not found: ${name}`);
|
|
49
|
+
return new Response(
|
|
50
|
+
e.status === 204 || e.status === 302 ? null : JSON.stringify(e.body),
|
|
51
|
+
{
|
|
52
|
+
status: e.status,
|
|
53
|
+
headers: { "Content-Type": "application/json", ...(e.headers ?? {}) },
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}) as typeof fetch;
|
|
57
|
+
const lookup = new GithubLookupClient({
|
|
58
|
+
appId: "555",
|
|
59
|
+
privateKey,
|
|
60
|
+
apiBase: "https://ghe.test",
|
|
61
|
+
fetchFn,
|
|
62
|
+
now: () => NOW,
|
|
63
|
+
});
|
|
64
|
+
const resolver = new UserRepoAccessResolver({
|
|
65
|
+
installer: {
|
|
66
|
+
listInstallableOrganizations: async () => [{ id: 11, login: "acme-eng" }],
|
|
67
|
+
},
|
|
68
|
+
lookup,
|
|
69
|
+
now: () => NOW,
|
|
70
|
+
concurrency: 1,
|
|
71
|
+
});
|
|
72
|
+
return { resolver, calls };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const API = "GET /repos/acme-eng/api/collaborators/octocat/permission";
|
|
76
|
+
const WEB = "GET /repos/acme-eng/web/collaborators/octocat/permission";
|
|
77
|
+
|
|
78
|
+
describe("UserRepoAccessResolver against GHE-2.2 cassettes", () => {
|
|
79
|
+
it("complete run", async () => {
|
|
80
|
+
const { resolver } = setup({
|
|
81
|
+
[API]: "permission_maintain",
|
|
82
|
+
[WEB]: "permission_triage",
|
|
83
|
+
});
|
|
84
|
+
const res = await resolver.resolve("octocat");
|
|
85
|
+
expect(res.complete).toBe(true);
|
|
86
|
+
expect(res.outcome).toBe("ok");
|
|
87
|
+
expect(res.unresolved).toBe(0);
|
|
88
|
+
expect(res.orgs).toEqual([
|
|
89
|
+
{
|
|
90
|
+
org: "acme-eng",
|
|
91
|
+
status: "ok",
|
|
92
|
+
repos: [
|
|
93
|
+
{ fullName: "acme-eng/api", level: "maintain", canPush: true },
|
|
94
|
+
{ fullName: "acme-eng/web", level: "triage", canPush: false },
|
|
95
|
+
],
|
|
96
|
+
unresolved: 0,
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("partial run: 429 mid-org keeps resolved and stops further calls", async () => {
|
|
102
|
+
const { resolver, calls } = setup({
|
|
103
|
+
[API]: "permission_write",
|
|
104
|
+
[WEB]: "permission_429",
|
|
105
|
+
});
|
|
106
|
+
const res = await resolver.resolve("octocat");
|
|
107
|
+
expect(res.complete).toBe(false);
|
|
108
|
+
expect(res.orgs[0].status).toBe("partial");
|
|
109
|
+
expect(res.orgs[0].repos).toEqual([
|
|
110
|
+
{ fullName: "acme-eng/api", level: "write", canPush: true },
|
|
111
|
+
]);
|
|
112
|
+
expect(res.orgs[0].unresolved).toBe(1);
|
|
113
|
+
expect(res.unresolved).toBe(1);
|
|
114
|
+
expect(calls.filter((c) => c.includes("/permission"))).toEqual([API, WEB]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rate-limited run: 429 before anything resolved", async () => {
|
|
118
|
+
const { resolver } = setup({
|
|
119
|
+
[API]: "permission_429",
|
|
120
|
+
[WEB]: "permission_read",
|
|
121
|
+
});
|
|
122
|
+
const res = await resolver.resolve("octocat");
|
|
123
|
+
expect(res.orgs[0].status).toBe("rate_limited");
|
|
124
|
+
expect(res.orgs[0].repos).toEqual([]);
|
|
125
|
+
expect(res.orgs[0].unresolved).toBe(2);
|
|
126
|
+
expect(res.complete).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("missing-user run", async () => {
|
|
130
|
+
const { resolver, calls } = setup({
|
|
131
|
+
"GET /users/octocat": "user_nonexistent",
|
|
132
|
+
});
|
|
133
|
+
const res = await resolver.resolve("octocat");
|
|
134
|
+
expect(res.outcome).toBe("user_not_found");
|
|
135
|
+
expect(res.orgs).toEqual([]);
|
|
136
|
+
expect(calls.some((c) => c.includes("/permission"))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("org without the lookup app", async () => {
|
|
140
|
+
const { resolver } = setup({
|
|
141
|
+
"GET /orgs/acme-eng/installation": "org_installation_404",
|
|
142
|
+
});
|
|
143
|
+
const res = await resolver.resolve("octocat");
|
|
144
|
+
expect(res.orgs[0].status).toBe("lookup_app_not_installed");
|
|
145
|
+
expect(res.complete).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("never leaks the installation token", async () => {
|
|
149
|
+
const { resolver } = setup({
|
|
150
|
+
[API]: "permission_503",
|
|
151
|
+
[WEB]: "permission_503",
|
|
152
|
+
});
|
|
153
|
+
const res = await resolver.resolve("octocat");
|
|
154
|
+
expect(JSON.stringify(res)).not.toContain("ghs_lookup");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/user-repo-access-resolver.ts
|
|
3
|
+
* UserRepoAccessResolver: the single place that answers "what can GitHub user
|
|
4
|
+
* X do on which repos" through the lookup App (GHE-2.2 client) across the orgs
|
|
5
|
+
* the installer App (GHE-1.1) can install on.
|
|
6
|
+
*
|
|
7
|
+
* Completeness contract (reused by later phases):
|
|
8
|
+
* - Unknown is never none. A repo that could not be checked is absent from
|
|
9
|
+
* `repos` and counted in `unresolved`. Only a level GitHub actually returned
|
|
10
|
+
* (including an explicit "none") appears in `repos`.
|
|
11
|
+
* - `complete` is true only when the user exists, was verified, and every org
|
|
12
|
+
* is "ok".
|
|
13
|
+
*
|
|
14
|
+
* 403/429 mapping rule:
|
|
15
|
+
* - Org level (getOrgToken / listing repos): 404 on the installation ->
|
|
16
|
+
* lookup_app_not_installed; rate limit (429, or 403 rate limit) ->
|
|
17
|
+
* rate_limited; anything else (incl. a plain 403) -> error.
|
|
18
|
+
* - Mid-org (per-repo permission call): a rate limit or 403 stops the org's
|
|
19
|
+
* remaining calls (in-flight ones may finish and are kept). With at least
|
|
20
|
+
* one repo resolved the org is "partial"; with none resolved a rate limit is
|
|
21
|
+
* "rate_limited" and a plain 403 is "error".
|
|
22
|
+
* - Any other per-repo failure (404, 5xx, network, unknown role) leaves that
|
|
23
|
+
* repo unresolved without stopping the org; the org is "partial" if some repo
|
|
24
|
+
* resolved, else "error".
|
|
25
|
+
*
|
|
26
|
+
* Ports are injected (no DB, no global state); time and concurrency too.
|
|
27
|
+
* Outputs carry no tokens and error messages are never copied into results.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
GithubEnterpriseError,
|
|
32
|
+
type InstallableOrganization,
|
|
33
|
+
} from "./github-enterprise-client.ts";
|
|
34
|
+
import type {
|
|
35
|
+
LookupRepository,
|
|
36
|
+
LookupToken,
|
|
37
|
+
LookupUser,
|
|
38
|
+
RepoRole,
|
|
39
|
+
} from "./github-lookup-client.ts";
|
|
40
|
+
|
|
41
|
+
export type OrgAccessStatus =
|
|
42
|
+
| "ok"
|
|
43
|
+
| "lookup_app_not_installed"
|
|
44
|
+
| "rate_limited"
|
|
45
|
+
| "partial"
|
|
46
|
+
| "error";
|
|
47
|
+
|
|
48
|
+
export interface ResolvedRepoAccess {
|
|
49
|
+
fullName: string;
|
|
50
|
+
level: RepoRole;
|
|
51
|
+
canPush: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OrgAccessResult {
|
|
55
|
+
org: string;
|
|
56
|
+
status: OrgAccessStatus;
|
|
57
|
+
/** Only repos whose level was actually resolved. */
|
|
58
|
+
repos: ResolvedRepoAccess[];
|
|
59
|
+
/** Repos that could not be checked. Never reported as "none". */
|
|
60
|
+
unresolved: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UserRepoAccessResult {
|
|
64
|
+
username: string;
|
|
65
|
+
outcome: "ok" | "user_not_found";
|
|
66
|
+
/** False when no org could verify the user exists (e.g. rate limited). */
|
|
67
|
+
userVerified: boolean;
|
|
68
|
+
orgs: OrgAccessResult[];
|
|
69
|
+
/** Sum of per-org unresolved counts. */
|
|
70
|
+
unresolved: number;
|
|
71
|
+
/** True only when the user is verified and every org is "ok". */
|
|
72
|
+
complete: boolean;
|
|
73
|
+
checkedAt: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Installer-side port: GithubEnterpriseClient satisfies this. */
|
|
77
|
+
export interface InstallerPort {
|
|
78
|
+
listInstallableOrganizations(): Promise<InstallableOrganization[]>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lookup-side port: GithubLookupClient satisfies this. */
|
|
82
|
+
export interface LookupPort {
|
|
83
|
+
getOrgToken(org: string): Promise<LookupToken>;
|
|
84
|
+
getUser(org: string, login: string): Promise<LookupUser>;
|
|
85
|
+
listInstallationRepositories(org: string): Promise<LookupRepository[]>;
|
|
86
|
+
getCollaboratorPermission(
|
|
87
|
+
owner: string,
|
|
88
|
+
repo: string,
|
|
89
|
+
username: string,
|
|
90
|
+
): Promise<RepoRole>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface UserRepoAccessResolverOptions {
|
|
94
|
+
installer: InstallerPort;
|
|
95
|
+
lookup: LookupPort;
|
|
96
|
+
now?: () => Date;
|
|
97
|
+
/** Max concurrent per-repo permission calls (default 5). */
|
|
98
|
+
concurrency?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const DEFAULT_CONCURRENCY = 5;
|
|
102
|
+
|
|
103
|
+
const PUSH_ROLES: ReadonlySet<RepoRole> = new Set([
|
|
104
|
+
"admin",
|
|
105
|
+
"maintain",
|
|
106
|
+
"write",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/** admin, maintain and write can push; triage, read and none cannot. */
|
|
110
|
+
export function canPushForRole(role: RepoRole): boolean {
|
|
111
|
+
return PUSH_ROLES.has(role);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function kindOf(e: unknown): string | undefined {
|
|
115
|
+
return e instanceof GithubEnterpriseError ? e.kind : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class UserRepoAccessResolver {
|
|
119
|
+
private readonly installer: InstallerPort;
|
|
120
|
+
private readonly lookup: LookupPort;
|
|
121
|
+
private readonly now: () => Date;
|
|
122
|
+
private readonly concurrency: number;
|
|
123
|
+
|
|
124
|
+
constructor(opts: UserRepoAccessResolverOptions) {
|
|
125
|
+
this.installer = opts.installer;
|
|
126
|
+
this.lookup = opts.lookup;
|
|
127
|
+
this.now = opts.now ?? (() => new Date());
|
|
128
|
+
this.concurrency = Math.max(
|
|
129
|
+
1,
|
|
130
|
+
Math.floor(opts.concurrency ?? DEFAULT_CONCURRENCY),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async resolve(username: string): Promise<UserRepoAccessResult> {
|
|
135
|
+
const candidates = (
|
|
136
|
+
await this.installer.listInstallableOrganizations()
|
|
137
|
+
).map((o) => o.login);
|
|
138
|
+
const verdict = await this.verifyUser(candidates, username);
|
|
139
|
+
if (verdict === "not_found") {
|
|
140
|
+
return this.result(username, "user_not_found", true, []);
|
|
141
|
+
}
|
|
142
|
+
const orgs: OrgAccessResult[] = [];
|
|
143
|
+
for (const org of candidates) {
|
|
144
|
+
orgs.push(await this.resolveOrg(org, username));
|
|
145
|
+
}
|
|
146
|
+
return this.result(username, "ok", verdict === "verified", orgs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One getUser check up front, on the first org that can mint a token. */
|
|
150
|
+
private async verifyUser(
|
|
151
|
+
orgs: string[],
|
|
152
|
+
username: string,
|
|
153
|
+
): Promise<"verified" | "not_found" | "unverified"> {
|
|
154
|
+
for (const org of orgs) {
|
|
155
|
+
try {
|
|
156
|
+
await this.lookup.getOrgToken(org);
|
|
157
|
+
} catch {
|
|
158
|
+
continue; // Not installed / rate limited / other: try the next org.
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await this.lookup.getUser(org, username);
|
|
162
|
+
return "verified";
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// The token minted, so a 404 here means "no such user".
|
|
165
|
+
if (kindOf(e) === "not_found") return "not_found";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return "unverified";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async resolveOrg(
|
|
172
|
+
org: string,
|
|
173
|
+
username: string,
|
|
174
|
+
): Promise<OrgAccessResult> {
|
|
175
|
+
let repos: LookupRepository[];
|
|
176
|
+
try {
|
|
177
|
+
await this.lookup.getOrgToken(org);
|
|
178
|
+
repos = await this.lookup.listInstallationRepositories(org);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return orgLevelFailure(org, e);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const resolved = new Map<string, ResolvedRepoAccess>();
|
|
184
|
+
let stop: "rate_limit" | "permission" | undefined;
|
|
185
|
+
let next = 0;
|
|
186
|
+
|
|
187
|
+
const worker = async () => {
|
|
188
|
+
while (!stop && next < repos.length) {
|
|
189
|
+
const repo = repos[next++];
|
|
190
|
+
try {
|
|
191
|
+
const level = await this.lookup.getCollaboratorPermission(
|
|
192
|
+
org,
|
|
193
|
+
repo.name,
|
|
194
|
+
username,
|
|
195
|
+
);
|
|
196
|
+
resolved.set(repo.fullName, {
|
|
197
|
+
fullName: repo.fullName,
|
|
198
|
+
level,
|
|
199
|
+
canPush: canPushForRole(level),
|
|
200
|
+
});
|
|
201
|
+
} catch (e) {
|
|
202
|
+
const kind = kindOf(e);
|
|
203
|
+
if (kind === "rate_limit" || kind === "permission") {
|
|
204
|
+
stop = stop === "rate_limit" ? stop : kind;
|
|
205
|
+
}
|
|
206
|
+
// Other failures: repo stays unresolved, org keeps going.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
await Promise.all(
|
|
211
|
+
Array.from({ length: Math.min(this.concurrency, repos.length) }, worker),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Preserve listing order regardless of completion order.
|
|
215
|
+
const list = repos
|
|
216
|
+
.map((r) => resolved.get(r.fullName))
|
|
217
|
+
.filter((r): r is ResolvedRepoAccess => r !== undefined);
|
|
218
|
+
const unresolved = repos.length - list.length;
|
|
219
|
+
let status: OrgAccessStatus;
|
|
220
|
+
if (unresolved === 0) status = "ok";
|
|
221
|
+
else if (list.length > 0) status = "partial";
|
|
222
|
+
else if (stop === "rate_limit") status = "rate_limited";
|
|
223
|
+
else status = "error";
|
|
224
|
+
return { org, status, repos: list, unresolved };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private result(
|
|
228
|
+
username: string,
|
|
229
|
+
outcome: "ok" | "user_not_found",
|
|
230
|
+
userVerified: boolean,
|
|
231
|
+
orgs: OrgAccessResult[],
|
|
232
|
+
): UserRepoAccessResult {
|
|
233
|
+
return {
|
|
234
|
+
username,
|
|
235
|
+
outcome,
|
|
236
|
+
userVerified,
|
|
237
|
+
orgs,
|
|
238
|
+
unresolved: orgs.reduce((n, o) => n + o.unresolved, 0),
|
|
239
|
+
complete:
|
|
240
|
+
outcome === "ok" &&
|
|
241
|
+
userVerified &&
|
|
242
|
+
orgs.every((o) => o.status === "ok"),
|
|
243
|
+
checkedAt: this.now().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function orgLevelFailure(org: string, e: unknown): OrgAccessResult {
|
|
249
|
+
const kind = kindOf(e);
|
|
250
|
+
const status: OrgAccessStatus =
|
|
251
|
+
kind === "not_found"
|
|
252
|
+
? "lookup_app_not_installed"
|
|
253
|
+
: kind === "rate_limit"
|
|
254
|
+
? "rate_limited"
|
|
255
|
+
: "error";
|
|
256
|
+
return { org, status, repos: [], unresolved: 0 };
|
|
257
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/user-repo-access-resolver.unit.test.ts
|
|
3
|
+
* Unit tests for UserRepoAccessResolver with in-memory ports (no network, no DB).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import { GithubEnterpriseError } from "./github-enterprise-client.ts";
|
|
8
|
+
import type { RepoRole } from "./github-lookup-client.ts";
|
|
9
|
+
import {
|
|
10
|
+
canPushForRole,
|
|
11
|
+
UserRepoAccessResolver,
|
|
12
|
+
type LookupPort,
|
|
13
|
+
} from "./user-repo-access-resolver.ts";
|
|
14
|
+
|
|
15
|
+
const NOW = new Date("2026-11-09T12:00:00Z");
|
|
16
|
+
const err = (kind: ConstructorParameters<typeof GithubEnterpriseError>[0]) =>
|
|
17
|
+
new GithubEnterpriseError(kind, `boom ${kind} ghs_secret`);
|
|
18
|
+
|
|
19
|
+
function repo(org: string, name: string) {
|
|
20
|
+
return {
|
|
21
|
+
id: 1,
|
|
22
|
+
name,
|
|
23
|
+
fullName: `${org}/${name}`,
|
|
24
|
+
private: false,
|
|
25
|
+
archived: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FakeOpts {
|
|
30
|
+
repos?: Record<string, string[]>;
|
|
31
|
+
perm?: (owner: string, repo: string) => RepoRole | Error | Promise<RepoRole>;
|
|
32
|
+
token?: Record<string, Error>;
|
|
33
|
+
user?: Error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fake(o: FakeOpts = {}) {
|
|
37
|
+
const repos = o.repos ?? { acme: ["a", "b", "c"] };
|
|
38
|
+
const calls: string[] = [];
|
|
39
|
+
const lookup: LookupPort = {
|
|
40
|
+
async getOrgToken(org) {
|
|
41
|
+
const e = o.token?.[org];
|
|
42
|
+
if (e) throw e;
|
|
43
|
+
return { token: "ghs_secret", expiresAt: "x", installationId: 1 };
|
|
44
|
+
},
|
|
45
|
+
async getUser(_org, login) {
|
|
46
|
+
if (o.user) throw o.user;
|
|
47
|
+
return { id: 1, login, type: "User" };
|
|
48
|
+
},
|
|
49
|
+
async listInstallationRepositories(org) {
|
|
50
|
+
return (repos[org] ?? []).map((n) => repo(org, n));
|
|
51
|
+
},
|
|
52
|
+
async getCollaboratorPermission(owner, r) {
|
|
53
|
+
calls.push(`${owner}/${r}`);
|
|
54
|
+
const v = await (o.perm ?? (() => "write" as RepoRole))(owner, r);
|
|
55
|
+
if (v instanceof Error) throw v;
|
|
56
|
+
return v;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const installer = {
|
|
60
|
+
async listInstallableOrganizations() {
|
|
61
|
+
return Object.keys(repos).map((login, id) => ({ id, login }));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const resolver = new UserRepoAccessResolver({
|
|
65
|
+
installer,
|
|
66
|
+
lookup,
|
|
67
|
+
now: () => NOW,
|
|
68
|
+
concurrency: 1,
|
|
69
|
+
});
|
|
70
|
+
return { resolver, calls };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("canPushForRole", () => {
|
|
74
|
+
const cases: Array<[RepoRole, boolean]> = [
|
|
75
|
+
["admin", true],
|
|
76
|
+
["maintain", true],
|
|
77
|
+
["write", true],
|
|
78
|
+
["triage", false],
|
|
79
|
+
["read", false],
|
|
80
|
+
["none", false],
|
|
81
|
+
];
|
|
82
|
+
for (const [role, expected] of cases) {
|
|
83
|
+
it(`${role} -> ${expected}`, () =>
|
|
84
|
+
expect(canPushForRole(role)).toBe(expected));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("unknown is never none", () => {
|
|
89
|
+
it("a repo that errors is absent from resolved and counted unresolved", async () => {
|
|
90
|
+
const { resolver } = fake({
|
|
91
|
+
perm: (_o, r) => (r === "b" ? err("server") : "read"),
|
|
92
|
+
});
|
|
93
|
+
const res = await resolver.resolve("octocat");
|
|
94
|
+
const org = res.orgs[0];
|
|
95
|
+
expect(org.repos.map((r) => r.fullName)).toEqual(["acme/a", "acme/c"]);
|
|
96
|
+
expect(org.repos.some((r) => r.level === "none")).toBe(false);
|
|
97
|
+
expect(org.unresolved).toBe(1);
|
|
98
|
+
expect(org.status).toBe("partial");
|
|
99
|
+
expect(res.complete).toBe(false);
|
|
100
|
+
expect(res.unresolved).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("an unknown role (custom role error) is unresolved, not none", async () => {
|
|
104
|
+
const { resolver } = fake({
|
|
105
|
+
perm: (_o, r) => (r === "a" ? err("unexpected") : "write"),
|
|
106
|
+
});
|
|
107
|
+
const res = await resolver.resolve("octocat");
|
|
108
|
+
expect(res.orgs[0].repos.map((r) => r.fullName)).toEqual([
|
|
109
|
+
"acme/b",
|
|
110
|
+
"acme/c",
|
|
111
|
+
]);
|
|
112
|
+
expect(res.orgs[0].unresolved).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("an explicit none from GitHub is a resolved none (known, not unknown)", async () => {
|
|
116
|
+
const { resolver } = fake({ perm: () => "none" });
|
|
117
|
+
const res = await resolver.resolve("octocat");
|
|
118
|
+
expect(
|
|
119
|
+
res.orgs[0].repos.every((r) => r.level === "none" && !r.canPush),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
expect(res.complete).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("all repos failing individually yields error, not an empty ok list", async () => {
|
|
125
|
+
const { resolver } = fake({ perm: () => err("server") });
|
|
126
|
+
const res = await resolver.resolve("octocat");
|
|
127
|
+
expect(res.orgs[0].status).toBe("error");
|
|
128
|
+
expect(res.orgs[0].unresolved).toBe(3);
|
|
129
|
+
expect(res.complete).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("403/429 mapping rule", () => {
|
|
134
|
+
it("mid-org 429 with some resolved -> partial, stops further calls", async () => {
|
|
135
|
+
const { resolver, calls } = fake({
|
|
136
|
+
perm: (_o, r) => (r === "b" ? err("rate_limit") : "admin"),
|
|
137
|
+
});
|
|
138
|
+
const res = await resolver.resolve("octocat");
|
|
139
|
+
expect(res.orgs[0].status).toBe("partial");
|
|
140
|
+
expect(res.orgs[0].repos.map((r) => r.fullName)).toEqual(["acme/a"]);
|
|
141
|
+
expect(res.orgs[0].unresolved).toBe(2);
|
|
142
|
+
expect(calls).toEqual(["acme/a", "acme/b"]);
|
|
143
|
+
expect(res.complete).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("mid-org 403 with some resolved -> partial", async () => {
|
|
147
|
+
const { resolver } = fake({
|
|
148
|
+
perm: (_o, r) => (r === "c" ? err("permission") : "read"),
|
|
149
|
+
});
|
|
150
|
+
const res = await resolver.resolve("octocat");
|
|
151
|
+
expect(res.orgs[0].status).toBe("partial");
|
|
152
|
+
expect(res.orgs[0].repos).toHaveLength(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("429 with nothing resolved -> rate_limited; 403 with nothing resolved -> error", async () => {
|
|
156
|
+
const a = await fake({ perm: () => err("rate_limit") }).resolver.resolve(
|
|
157
|
+
"o",
|
|
158
|
+
);
|
|
159
|
+
expect(a.orgs[0].status).toBe("rate_limited");
|
|
160
|
+
expect(a.orgs[0].unresolved).toBe(3);
|
|
161
|
+
const b = await fake({ perm: () => err("permission") }).resolver.resolve(
|
|
162
|
+
"o",
|
|
163
|
+
);
|
|
164
|
+
expect(b.orgs[0].status).toBe("error");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("token mint: 404 -> lookup_app_not_installed, 429 -> rate_limited, 403 -> error", async () => {
|
|
168
|
+
const r404 = await fake({
|
|
169
|
+
token: { acme: err("not_found") },
|
|
170
|
+
}).resolver.resolve("o");
|
|
171
|
+
expect(r404.orgs[0].status).toBe("lookup_app_not_installed");
|
|
172
|
+
const r429 = await fake({
|
|
173
|
+
token: { acme: err("rate_limit") },
|
|
174
|
+
}).resolver.resolve("o");
|
|
175
|
+
expect(r429.orgs[0].status).toBe("rate_limited");
|
|
176
|
+
const r403 = await fake({
|
|
177
|
+
token: { acme: err("permission") },
|
|
178
|
+
}).resolver.resolve("o");
|
|
179
|
+
expect(r403.orgs[0].status).toBe("error");
|
|
180
|
+
expect(r403.complete).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("other orgs continue after one org is rate limited", async () => {
|
|
184
|
+
const { resolver } = fake({
|
|
185
|
+
repos: { acme: ["a"], beta: ["x"] },
|
|
186
|
+
perm: (o) => (o === "acme" ? err("rate_limit") : "write"),
|
|
187
|
+
});
|
|
188
|
+
const res = await resolver.resolve("octocat");
|
|
189
|
+
expect(res.orgs.map((o) => o.status)).toEqual(["rate_limited", "ok"]);
|
|
190
|
+
expect(res.complete).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("user check and bounds", () => {
|
|
195
|
+
it("nonexistent user short-circuits with user_not_found and no repo calls", async () => {
|
|
196
|
+
const { resolver, calls } = fake({ user: err("not_found") });
|
|
197
|
+
const res = await resolver.resolve("ghost");
|
|
198
|
+
expect(res.outcome).toBe("user_not_found");
|
|
199
|
+
expect(res.orgs).toEqual([]);
|
|
200
|
+
expect(res.complete).toBe(false);
|
|
201
|
+
expect(calls).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("skips orgs without the lookup app when verifying the user", async () => {
|
|
205
|
+
const { resolver } = fake({
|
|
206
|
+
repos: { acme: ["a"], beta: ["x"] },
|
|
207
|
+
token: { acme: err("not_found") },
|
|
208
|
+
});
|
|
209
|
+
const res = await resolver.resolve("octocat");
|
|
210
|
+
expect(res.outcome).toBe("ok");
|
|
211
|
+
expect(res.userVerified).toBe(true);
|
|
212
|
+
expect(res.orgs.map((o) => o.status)).toEqual([
|
|
213
|
+
"lookup_app_not_installed",
|
|
214
|
+
"ok",
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("a rate-limited user check proceeds unverified and is incomplete", async () => {
|
|
219
|
+
const { resolver } = fake({ user: err("rate_limit") });
|
|
220
|
+
const res = await resolver.resolve("octocat");
|
|
221
|
+
expect(res.userVerified).toBe(false);
|
|
222
|
+
expect(res.complete).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("concurrency never exceeds the bound", async () => {
|
|
226
|
+
let inflight = 0;
|
|
227
|
+
let max = 0;
|
|
228
|
+
const names = Array.from({ length: 12 }, (_, i) => `r${i}`);
|
|
229
|
+
const base = fake({
|
|
230
|
+
repos: { acme: names },
|
|
231
|
+
perm: async (): Promise<RepoRole> => {
|
|
232
|
+
inflight++;
|
|
233
|
+
max = Math.max(max, inflight);
|
|
234
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
235
|
+
inflight--;
|
|
236
|
+
return "write";
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const resolver = new UserRepoAccessResolver({
|
|
240
|
+
installer: {
|
|
241
|
+
listInstallableOrganizations: async () => [{ id: 1, login: "acme" }],
|
|
242
|
+
},
|
|
243
|
+
lookup: (base.resolver as unknown as { lookup: LookupPort }).lookup,
|
|
244
|
+
now: () => NOW,
|
|
245
|
+
concurrency: 3,
|
|
246
|
+
});
|
|
247
|
+
const res = await resolver.resolve("octocat");
|
|
248
|
+
expect(res.complete).toBe(true);
|
|
249
|
+
expect(res.orgs[0].repos).toHaveLength(12);
|
|
250
|
+
expect(max).toBe(3);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("defaults concurrency to 5", async () => {
|
|
254
|
+
let inflight = 0;
|
|
255
|
+
let max = 0;
|
|
256
|
+
const base = fake({
|
|
257
|
+
repos: { acme: Array.from({ length: 20 }, (_, i) => `r${i}`) },
|
|
258
|
+
perm: async (): Promise<RepoRole> => {
|
|
259
|
+
inflight++;
|
|
260
|
+
max = Math.max(max, inflight);
|
|
261
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
262
|
+
inflight--;
|
|
263
|
+
return "read";
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const resolver = new UserRepoAccessResolver({
|
|
267
|
+
installer: {
|
|
268
|
+
listInstallableOrganizations: async () => [{ id: 1, login: "acme" }],
|
|
269
|
+
},
|
|
270
|
+
lookup: (base.resolver as unknown as { lookup: LookupPort }).lookup,
|
|
271
|
+
now: () => NOW,
|
|
272
|
+
});
|
|
273
|
+
await resolver.resolve("octocat");
|
|
274
|
+
expect(max).toBe(5);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("never leaks tokens into output", async () => {
|
|
278
|
+
const { resolver } = fake({ perm: () => err("server") });
|
|
279
|
+
const res = await resolver.resolve("octocat");
|
|
280
|
+
expect(JSON.stringify(res)).not.toContain("ghs_");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("stamps checkedAt from the injected clock", async () => {
|
|
284
|
+
const res = await fake().resolver.resolve("octocat");
|
|
285
|
+
expect(res.checkedAt).toBe(NOW.toISOString());
|
|
286
|
+
});
|
|
287
|
+
});
|