@app-vitals/agent-admin 1.12.1 → 1.13.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-enterprise-cassette.json +610 -0
- package/src/github-app-jwt.ts +44 -0
- package/src/github-app-jwt.unit.test.ts +95 -0
- package/src/github-enterprise-client.integration.test.ts +317 -0
- package/src/github-enterprise-client.ts +375 -0
- package/src/github-enterprise-client.unit.test.ts +97 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-enterprise-client.ts
|
|
3
|
+
* GithubEnterpriseClient: manages GitHub App installations on enterprise-owned
|
|
4
|
+
* organizations, authenticated as a "installer" GitHub App that is itself
|
|
5
|
+
* installed on the enterprise.
|
|
6
|
+
*
|
|
7
|
+
* Flow: sign app JWT -> GET /enterprises/{enterprise}/installation (JWT) ->
|
|
8
|
+
* POST /app/installations/{id}/access_tokens (JWT) -> call the
|
|
9
|
+
* /enterprises/{enterprise}/apps/... endpoints with the installation token.
|
|
10
|
+
*
|
|
11
|
+
* Repo add/remove on an installation is Phase 3 and intentionally not here.
|
|
12
|
+
*
|
|
13
|
+
* ── Verified findings (feed GHE-1.5 docs) ─────────────────────────────────
|
|
14
|
+
* Source: github/rest-api-description descriptions/ghec/ghec.json (fetched
|
|
15
|
+
* 2026-11-09) and docs.github.com "permissions required for GitHub Apps".
|
|
16
|
+
* The enterprise endpoints exist ONLY in the ghec (and GHES) descriptions,
|
|
17
|
+
* not in api.github.com.json; they are githubCloudOnly.
|
|
18
|
+
*
|
|
19
|
+
* - Install POST: POST /enterprises/{enterprise}/apps/organizations/{org}/installations
|
|
20
|
+
* body { client_id (required), repository_selection (required: all |
|
|
21
|
+
* selected | none), repositories (string[] of simple repo names, max 50,
|
|
22
|
+
* only for "selected") }. So YES it needs the TARGET App's client_id (not
|
|
23
|
+
* its numeric id). Returns 201 for a new installation and 200 when the app
|
|
24
|
+
* was already installed (suspended -> unsuspended, pending requests
|
|
25
|
+
* approved, pending update applied). Response is an `installation` object.
|
|
26
|
+
* "none" is only for apps that request no repository permissions.
|
|
27
|
+
* - Enterprise installation token: GET /enterprises/{enterprise}/installation
|
|
28
|
+
* with the app JWT ("Get an enterprise installation for the authenticated
|
|
29
|
+
* app") returns the installation whose `id` is then used in the normal
|
|
30
|
+
* POST /app/installations/{installation_id}/access_tokens (JWT) call, which
|
|
31
|
+
* returns { token, expires_at (1h) }. The OpenAPI description documents the
|
|
32
|
+
* two calls separately; the combination is the documented path, but it was
|
|
33
|
+
* not exercised live (no live recording in this task).
|
|
34
|
+
* - Installer app permissions (enterprise-level): `enterprise_organization_installations`
|
|
35
|
+
* (read | write). Docs page: read for GET installable_organizations and GET
|
|
36
|
+
* organizations/{org}/installations, write for the POST install (and DELETE).
|
|
37
|
+
* `enterprise_organization_installation_repositories` (read | write) is for
|
|
38
|
+
* the repositories endpoints, i.e. Phase 3, not needed here.
|
|
39
|
+
* - Caller restriction: all enterprise endpoints "can only be called by a
|
|
40
|
+
* GitHub App installed on the enterprise that owns the organization".
|
|
41
|
+
* - Collaborator-permission lookup (GET /repos/{owner}/{repo}/collaborators/
|
|
42
|
+
* {username}/permission, usable by GitHub Apps): the docs list it under
|
|
43
|
+
* repository permission "Metadata" read, NOT Administration. The returned
|
|
44
|
+
* `permission` reflects the highest grant across repo, team, org and
|
|
45
|
+
* enterprise. Whether Metadata read alone suffices at runtime was not
|
|
46
|
+
* verified live.
|
|
47
|
+
* - Pagination: per_page (max 100, default 30) and page query params.
|
|
48
|
+
* - NOT verified: secondary-rate-limit specifics for these endpoints (the
|
|
49
|
+
* docs page has no rate limit information); the 403/429 classification
|
|
50
|
+
* below follows GitHub's general rate-limit documentation.
|
|
51
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { signGithubAppJwt } from "./github-app-jwt.ts";
|
|
55
|
+
|
|
56
|
+
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export type GithubEnterpriseErrorKind =
|
|
59
|
+
| "unauthorized" // 401
|
|
60
|
+
| "permission" // 403, missing permission
|
|
61
|
+
| "not_found" // 404
|
|
62
|
+
| "validation" // 422
|
|
63
|
+
| "rate_limit" // 429, or 403 primary/secondary rate limit
|
|
64
|
+
| "server" // 5xx
|
|
65
|
+
| "network" // fetch threw
|
|
66
|
+
| "unexpected"; // other statuses, malformed body
|
|
67
|
+
|
|
68
|
+
export class GithubEnterpriseError extends Error {
|
|
69
|
+
readonly kind: GithubEnterpriseErrorKind;
|
|
70
|
+
readonly status?: number;
|
|
71
|
+
readonly retryAfterSeconds?: number;
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
kind: GithubEnterpriseErrorKind,
|
|
75
|
+
message: string,
|
|
76
|
+
opts?: { status?: number; retryAfterSeconds?: number },
|
|
77
|
+
) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "GithubEnterpriseError";
|
|
80
|
+
this.kind = kind;
|
|
81
|
+
this.status = opts?.status;
|
|
82
|
+
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map an HTTP error response to a typed error. `secrets` are redacted from
|
|
88
|
+
* the message so a token or key echoed by a server never propagates.
|
|
89
|
+
*/
|
|
90
|
+
export function mapGithubEnterpriseError(
|
|
91
|
+
status: number,
|
|
92
|
+
headers: Headers,
|
|
93
|
+
body: unknown,
|
|
94
|
+
context: string,
|
|
95
|
+
secrets: readonly string[] = [],
|
|
96
|
+
): GithubEnterpriseError {
|
|
97
|
+
let detail =
|
|
98
|
+
body && typeof body === "object" && typeof (body as { message?: unknown }).message === "string"
|
|
99
|
+
? (body as { message: string }).message
|
|
100
|
+
: "";
|
|
101
|
+
for (const s of secrets) if (s) detail = detail.split(s).join("[redacted]");
|
|
102
|
+
detail = detail.slice(0, 200);
|
|
103
|
+
const message = `${context}: HTTP ${status}${detail ? ` ${detail}` : ""}`;
|
|
104
|
+
|
|
105
|
+
const ra = Number(headers.get("retry-after"));
|
|
106
|
+
const retryAfterSeconds = Number.isFinite(ra) && ra > 0 ? ra : undefined;
|
|
107
|
+
const opts = { status, retryAfterSeconds };
|
|
108
|
+
|
|
109
|
+
if (status === 429) return new GithubEnterpriseError("rate_limit", message, opts);
|
|
110
|
+
if (status === 403) {
|
|
111
|
+
const limited =
|
|
112
|
+
headers.get("retry-after") !== null ||
|
|
113
|
+
headers.get("x-ratelimit-remaining") === "0" ||
|
|
114
|
+
/rate limit/i.test(detail);
|
|
115
|
+
return new GithubEnterpriseError(limited ? "rate_limit" : "permission", message, opts);
|
|
116
|
+
}
|
|
117
|
+
if (status === 401) return new GithubEnterpriseError("unauthorized", message, opts);
|
|
118
|
+
if (status === 404) return new GithubEnterpriseError("not_found", message, opts);
|
|
119
|
+
if (status === 422) return new GithubEnterpriseError("validation", message, opts);
|
|
120
|
+
if (status >= 500) return new GithubEnterpriseError("server", message, opts);
|
|
121
|
+
return new GithubEnterpriseError("unexpected", message, opts);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export type RepositorySelection = "all" | "selected" | "none";
|
|
127
|
+
|
|
128
|
+
export interface InstallableOrganization {
|
|
129
|
+
id: number;
|
|
130
|
+
login: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface AppInstallation {
|
|
134
|
+
id: number;
|
|
135
|
+
appId: number;
|
|
136
|
+
appSlug: string;
|
|
137
|
+
clientId?: string;
|
|
138
|
+
accountLogin: string;
|
|
139
|
+
repositorySelection: RepositorySelection;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface InstallOnOrgInput {
|
|
143
|
+
/** Client ID of the App to install (not the numeric app id). */
|
|
144
|
+
clientId: string;
|
|
145
|
+
repositorySelection: RepositorySelection;
|
|
146
|
+
/** Simple repo names (max 50); required when repositorySelection is "selected". */
|
|
147
|
+
repositories?: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface InstallOnOrgResult {
|
|
151
|
+
installation: AppInstallation;
|
|
152
|
+
/** true when GitHub returned 201; false (200) when already installed. */
|
|
153
|
+
created: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface EnterpriseInstallationToken {
|
|
157
|
+
token: string;
|
|
158
|
+
expiresAt: string;
|
|
159
|
+
installationId: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface GithubEnterpriseClientOptions {
|
|
163
|
+
appId: string;
|
|
164
|
+
enterprise: string;
|
|
165
|
+
privateKey: string;
|
|
166
|
+
apiBase?: string;
|
|
167
|
+
fetchFn?: typeof fetch;
|
|
168
|
+
now?: () => Date;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const PER_PAGE = 100;
|
|
172
|
+
const MAX_PAGES = 100;
|
|
173
|
+
const MAX_REPOSITORIES = 50;
|
|
174
|
+
/** Refresh a cached installation token this long before it expires. */
|
|
175
|
+
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
176
|
+
|
|
177
|
+
// ─── Client ───────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export class GithubEnterpriseClient {
|
|
180
|
+
private readonly appId: string;
|
|
181
|
+
private readonly enterprise: string;
|
|
182
|
+
private readonly privateKey: string;
|
|
183
|
+
private readonly apiBase: string;
|
|
184
|
+
private readonly fetchFn: typeof fetch;
|
|
185
|
+
private readonly now: () => Date;
|
|
186
|
+
private cached?: EnterpriseInstallationToken;
|
|
187
|
+
|
|
188
|
+
constructor(opts: GithubEnterpriseClientOptions) {
|
|
189
|
+
this.appId = opts.appId;
|
|
190
|
+
this.enterprise = opts.enterprise;
|
|
191
|
+
this.privateKey = opts.privateKey;
|
|
192
|
+
this.apiBase = opts.apiBase ?? "https://api.github.com";
|
|
193
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
194
|
+
this.now = opts.now ?? (() => new Date());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Mint (or reuse a still-fresh) enterprise installation token. */
|
|
198
|
+
async getInstallationToken(): Promise<EnterpriseInstallationToken> {
|
|
199
|
+
if (
|
|
200
|
+
this.cached &&
|
|
201
|
+
Date.parse(this.cached.expiresAt) - this.now().getTime() > TOKEN_REFRESH_SKEW_MS
|
|
202
|
+
) {
|
|
203
|
+
return this.cached;
|
|
204
|
+
}
|
|
205
|
+
const jwt = signGithubAppJwt({
|
|
206
|
+
appId: this.appId,
|
|
207
|
+
privateKey: this.privateKey,
|
|
208
|
+
now: this.now(),
|
|
209
|
+
});
|
|
210
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
211
|
+
const inst = await this.request<{ id?: number }>(
|
|
212
|
+
"GET",
|
|
213
|
+
`/enterprises/${ent}/installation`,
|
|
214
|
+
jwt,
|
|
215
|
+
"GitHub enterprise installation lookup",
|
|
216
|
+
);
|
|
217
|
+
if (typeof inst.data?.id !== "number") {
|
|
218
|
+
throw new GithubEnterpriseError(
|
|
219
|
+
"unexpected",
|
|
220
|
+
"GitHub enterprise installation lookup: response missing id",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const minted = await this.request<{ token?: string; expires_at?: string }>(
|
|
224
|
+
"POST",
|
|
225
|
+
`/app/installations/${inst.data.id}/access_tokens`,
|
|
226
|
+
jwt,
|
|
227
|
+
"GitHub installation access token",
|
|
228
|
+
);
|
|
229
|
+
if (!minted.data?.token || !minted.data.expires_at) {
|
|
230
|
+
throw new GithubEnterpriseError(
|
|
231
|
+
"unexpected",
|
|
232
|
+
"GitHub installation access token: response missing token or expires_at",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
this.cached = {
|
|
236
|
+
token: minted.data.token,
|
|
237
|
+
expiresAt: minted.data.expires_at,
|
|
238
|
+
installationId: inst.data.id,
|
|
239
|
+
};
|
|
240
|
+
return this.cached;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** List all organizations the installer App can install Apps on (paginated). */
|
|
244
|
+
async listInstallableOrganizations(): Promise<InstallableOrganization[]> {
|
|
245
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
246
|
+
const rows = await this.paginate<{ id: number; login: string }>(
|
|
247
|
+
`/enterprises/${ent}/apps/installable_organizations`,
|
|
248
|
+
"GitHub installable organizations",
|
|
249
|
+
);
|
|
250
|
+
return rows.map((r) => ({ id: r.id, login: r.login }));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** List all App installations on an enterprise-owned org (paginated). */
|
|
254
|
+
async listOrgInstallations(org: string): Promise<AppInstallation[]> {
|
|
255
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
256
|
+
const rows = await this.paginate<RawInstallation>(
|
|
257
|
+
`/enterprises/${ent}/apps/organizations/${encodeURIComponent(org)}/installations`,
|
|
258
|
+
"GitHub org installations",
|
|
259
|
+
);
|
|
260
|
+
return rows.map(toInstallation);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Install an App on an enterprise-owned org. */
|
|
264
|
+
async installOnOrg(org: string, input: InstallOnOrgInput): Promise<InstallOnOrgResult> {
|
|
265
|
+
if (!input.clientId) {
|
|
266
|
+
throw new GithubEnterpriseError("validation", "installOnOrg: clientId is required");
|
|
267
|
+
}
|
|
268
|
+
const repos = input.repositories ?? [];
|
|
269
|
+
if (input.repositorySelection === "selected" && (repos.length === 0 || repos.length > MAX_REPOSITORIES)) {
|
|
270
|
+
throw new GithubEnterpriseError(
|
|
271
|
+
"validation",
|
|
272
|
+
`installOnOrg: repositories must list 1-${MAX_REPOSITORIES} names when repositorySelection is "selected"`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const body: Record<string, unknown> = {
|
|
276
|
+
client_id: input.clientId,
|
|
277
|
+
repository_selection: input.repositorySelection,
|
|
278
|
+
};
|
|
279
|
+
if (input.repositorySelection === "selected") body.repositories = repos;
|
|
280
|
+
const { token } = await this.getInstallationToken();
|
|
281
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
282
|
+
const res = await this.request<RawInstallation>(
|
|
283
|
+
"POST",
|
|
284
|
+
`/enterprises/${ent}/apps/organizations/${encodeURIComponent(org)}/installations`,
|
|
285
|
+
token,
|
|
286
|
+
"GitHub install App on org",
|
|
287
|
+
body,
|
|
288
|
+
);
|
|
289
|
+
if (typeof res.data?.id !== "number") {
|
|
290
|
+
throw new GithubEnterpriseError("unexpected", "GitHub install App on org: response missing id");
|
|
291
|
+
}
|
|
292
|
+
return { installation: toInstallation(res.data), created: res.status === 201 };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── internals ──────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
private async paginate<T>(path: string, context: string): Promise<T[]> {
|
|
298
|
+
const { token } = await this.getInstallationToken();
|
|
299
|
+
const all: T[] = [];
|
|
300
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
301
|
+
const res = await this.request<T[]>(
|
|
302
|
+
"GET",
|
|
303
|
+
`${path}?per_page=${PER_PAGE}&page=${page}`,
|
|
304
|
+
token,
|
|
305
|
+
context,
|
|
306
|
+
);
|
|
307
|
+
if (!Array.isArray(res.data)) {
|
|
308
|
+
throw new GithubEnterpriseError("unexpected", `${context}: response is not a list`);
|
|
309
|
+
}
|
|
310
|
+
all.push(...res.data);
|
|
311
|
+
const link = res.headers.get("link");
|
|
312
|
+
const hasNext = link !== null ? /rel="next"/.test(link) : res.data.length >= PER_PAGE;
|
|
313
|
+
if (!hasNext) break;
|
|
314
|
+
}
|
|
315
|
+
return all;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async request<T>(
|
|
319
|
+
method: "GET" | "POST",
|
|
320
|
+
path: string,
|
|
321
|
+
bearer: string,
|
|
322
|
+
context: string,
|
|
323
|
+
body?: unknown,
|
|
324
|
+
): Promise<{ status: number; headers: Headers; data: T }> {
|
|
325
|
+
let resp: Response;
|
|
326
|
+
try {
|
|
327
|
+
resp = await this.fetchFn(`${this.apiBase}${path}`, {
|
|
328
|
+
method,
|
|
329
|
+
headers: {
|
|
330
|
+
Accept: "application/vnd.github+json",
|
|
331
|
+
Authorization: `Bearer ${bearer}`,
|
|
332
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
333
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
334
|
+
},
|
|
335
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
336
|
+
});
|
|
337
|
+
} catch {
|
|
338
|
+
// The underlying error is dropped on purpose: it must never echo headers.
|
|
339
|
+
throw new GithubEnterpriseError("network", `${context}: network error`);
|
|
340
|
+
}
|
|
341
|
+
let data: unknown;
|
|
342
|
+
try {
|
|
343
|
+
data = await resp.json();
|
|
344
|
+
} catch {
|
|
345
|
+
data = undefined;
|
|
346
|
+
}
|
|
347
|
+
if (!resp.ok) {
|
|
348
|
+
throw mapGithubEnterpriseError(resp.status, resp.headers, data, context, [
|
|
349
|
+
bearer,
|
|
350
|
+
this.privateKey,
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
return { status: resp.status, headers: resp.headers, data: data as T };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface RawInstallation {
|
|
358
|
+
id: number;
|
|
359
|
+
app_id: number;
|
|
360
|
+
app_slug: string;
|
|
361
|
+
client_id?: string;
|
|
362
|
+
account?: { login?: string } | null;
|
|
363
|
+
repository_selection: RepositorySelection;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function toInstallation(r: RawInstallation): AppInstallation {
|
|
367
|
+
return {
|
|
368
|
+
id: r.id,
|
|
369
|
+
appId: r.app_id,
|
|
370
|
+
appSlug: r.app_slug,
|
|
371
|
+
...(r.client_id ? { clientId: r.client_id } : {}),
|
|
372
|
+
accountLogin: r.account?.login ?? "",
|
|
373
|
+
repositorySelection: r.repository_selection,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-enterprise-client.unit.test.ts
|
|
3
|
+
* Unit tests for GitHub enterprise error mapping and client-side validation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
GithubEnterpriseClient,
|
|
9
|
+
GithubEnterpriseError,
|
|
10
|
+
type GithubEnterpriseErrorKind,
|
|
11
|
+
mapGithubEnterpriseError,
|
|
12
|
+
} from "./github-enterprise-client.ts";
|
|
13
|
+
|
|
14
|
+
const h = (init?: Record<string, string>) => new Headers(init);
|
|
15
|
+
|
|
16
|
+
describe("mapGithubEnterpriseError", () => {
|
|
17
|
+
const cases: Array<
|
|
18
|
+
[number, Record<string, string>, unknown, GithubEnterpriseErrorKind]
|
|
19
|
+
> = [
|
|
20
|
+
[401, {}, { message: "Bad credentials" }, "unauthorized"],
|
|
21
|
+
[
|
|
22
|
+
403,
|
|
23
|
+
{},
|
|
24
|
+
{ message: "Resource not accessible by integration" },
|
|
25
|
+
"permission",
|
|
26
|
+
],
|
|
27
|
+
[404, {}, { message: "Not Found" }, "not_found"],
|
|
28
|
+
[422, {}, { message: "Validation Failed" }, "validation"],
|
|
29
|
+
[429, {}, { message: "API rate limit exceeded" }, "rate_limit"],
|
|
30
|
+
[403, { "retry-after": "60" }, { message: "x" }, "rate_limit"],
|
|
31
|
+
[403, { "x-ratelimit-remaining": "0" }, { message: "x" }, "rate_limit"],
|
|
32
|
+
[
|
|
33
|
+
403,
|
|
34
|
+
{},
|
|
35
|
+
{ message: "You have exceeded a secondary rate limit." },
|
|
36
|
+
"rate_limit",
|
|
37
|
+
],
|
|
38
|
+
[500, {}, { message: "Server Error" }, "server"],
|
|
39
|
+
[503, {}, undefined, "server"],
|
|
40
|
+
[418, {}, undefined, "unexpected"],
|
|
41
|
+
];
|
|
42
|
+
for (const [status, headers, body, kind] of cases) {
|
|
43
|
+
it(`maps ${status} ${JSON.stringify(headers)} to ${kind}`, () => {
|
|
44
|
+
const e = mapGithubEnterpriseError(status, h(headers), body, "ctx");
|
|
45
|
+
expect(e).toBeInstanceOf(GithubEnterpriseError);
|
|
46
|
+
expect(e.kind).toBe(kind);
|
|
47
|
+
expect(e.status).toBe(status);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it("exposes retry-after seconds", () => {
|
|
52
|
+
const e = mapGithubEnterpriseError(
|
|
53
|
+
429,
|
|
54
|
+
h({ "retry-after": "30" }),
|
|
55
|
+
{},
|
|
56
|
+
"ctx",
|
|
57
|
+
);
|
|
58
|
+
expect(e.retryAfterSeconds).toBe(30);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("redacts secrets echoed in the message", () => {
|
|
62
|
+
const e = mapGithubEnterpriseError(
|
|
63
|
+
403,
|
|
64
|
+
h(),
|
|
65
|
+
{ message: "token ghs_secret123 denied" },
|
|
66
|
+
"ctx",
|
|
67
|
+
["ghs_secret123"],
|
|
68
|
+
);
|
|
69
|
+
expect(e.message).not.toContain("ghs_secret123");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("GithubEnterpriseClient validation", () => {
|
|
74
|
+
const neverFetch = (async () => {
|
|
75
|
+
throw new Error("fetch must not be called");
|
|
76
|
+
}) as unknown as typeof fetch;
|
|
77
|
+
const client = new GithubEnterpriseClient({
|
|
78
|
+
appId: "1",
|
|
79
|
+
enterprise: "acme",
|
|
80
|
+
privateKey: "unused",
|
|
81
|
+
fetchFn: neverFetch,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects selected without repositories before any network call", async () => {
|
|
85
|
+
const err = await client
|
|
86
|
+
.installOnOrg("o", { clientId: "Iv1.x", repositorySelection: "selected" })
|
|
87
|
+
.catch((e) => e);
|
|
88
|
+
expect(err.kind).toBe("validation");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects a missing clientId", async () => {
|
|
92
|
+
const err = await client
|
|
93
|
+
.installOnOrg("o", { clientId: "", repositorySelection: "all" })
|
|
94
|
+
.catch((e) => e);
|
|
95
|
+
expect(err.kind).toBe("validation");
|
|
96
|
+
});
|
|
97
|
+
});
|