@app-vitals/agent-admin 1.13.0 → 1.14.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/openapi.json +787 -0
- package/openapi.platform.json +787 -0
- package/package.json +2 -2
- package/src/admin-boot.ts +21 -1
- package/src/admin-spec.ts +19 -0
- package/src/fixtures/github-enterprise-cassette.json +65 -0
- package/src/github-app-api.ts +111 -0
- package/src/github-enterprise-api.integration.test.ts +264 -0
- package/src/github-enterprise-api.smoke.test.ts +595 -0
- package/src/github-enterprise-api.ts +750 -0
- package/src/github-enterprise-client.integration.test.ts +1 -0
- package/src/github-enterprise-client.ts +10 -1
- package/src/github-enterprise-isolation.unit.test.ts +84 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-enterprise-api.smoke.test.ts
|
|
3
|
+
* Smoke tests for the /github-enterprise admin routes via app.request(): every
|
|
4
|
+
* route, auth, the private-App refusal and error mapping. In-memory app
|
|
5
|
+
* service and a cassette-replaying fetchFn; no DB, no network.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { parseAdminApiKeys } from "./api-auth.ts";
|
|
12
|
+
import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
|
|
13
|
+
import type {
|
|
14
|
+
GitHubEnterpriseAppMetadata,
|
|
15
|
+
GitHubEnterpriseAppRole,
|
|
16
|
+
SaveGitHubEnterpriseAppInput,
|
|
17
|
+
} from "./github-enterprise-apps.ts";
|
|
18
|
+
|
|
19
|
+
interface CassetteEntry {
|
|
20
|
+
status: number;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body: unknown;
|
|
23
|
+
}
|
|
24
|
+
const cassette: Record<string, CassetteEntry> = JSON.parse(
|
|
25
|
+
readFileSync(
|
|
26
|
+
new URL("./fixtures/github-enterprise-cassette.json", import.meta.url)
|
|
27
|
+
.pathname,
|
|
28
|
+
"utf-8",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const { privateKey: PEM } = generateKeyPairSync("rsa", {
|
|
33
|
+
modulusLength: 2048,
|
|
34
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
35
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ADMIN = { Authorization: "Bearer tok-admin" };
|
|
39
|
+
const JSON_ADMIN = { ...ADMIN, "Content-Type": "application/json" };
|
|
40
|
+
|
|
41
|
+
function fakeAppService() {
|
|
42
|
+
const rows = new Map<string, GitHubEnterpriseAppMetadata & { pem: string }>();
|
|
43
|
+
const now = new Date("2026-11-09T12:00:00Z");
|
|
44
|
+
return {
|
|
45
|
+
rows,
|
|
46
|
+
async list() {
|
|
47
|
+
return [...rows.values()].map(({ pem: _p, ...m }) => m);
|
|
48
|
+
},
|
|
49
|
+
async get(role: GitHubEnterpriseAppRole) {
|
|
50
|
+
const r = rows.get(role);
|
|
51
|
+
if (!r) return null;
|
|
52
|
+
const { pem: _p, ...m } = r;
|
|
53
|
+
return m;
|
|
54
|
+
},
|
|
55
|
+
async save(input: SaveGitHubEnterpriseAppInput) {
|
|
56
|
+
const row = {
|
|
57
|
+
role: input.role,
|
|
58
|
+
appId: input.appId,
|
|
59
|
+
slug: input.slug,
|
|
60
|
+
enterpriseSlug:
|
|
61
|
+
input.role === "installer" ? (input.enterpriseSlug ?? null) : null,
|
|
62
|
+
keySet: true,
|
|
63
|
+
lastValidationStatus: null,
|
|
64
|
+
lastValidatedAt: null,
|
|
65
|
+
lastValidationError: null,
|
|
66
|
+
createdAt: now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
pem: input.privateKey,
|
|
69
|
+
};
|
|
70
|
+
rows.set(input.role, row);
|
|
71
|
+
const { pem: _p, ...m } = row;
|
|
72
|
+
return m;
|
|
73
|
+
},
|
|
74
|
+
async delete(role: GitHubEnterpriseAppRole) {
|
|
75
|
+
rows.delete(role);
|
|
76
|
+
},
|
|
77
|
+
async getPrivateKey(role: GitHubEnterpriseAppRole) {
|
|
78
|
+
return rows.get(role)?.pem ?? null;
|
|
79
|
+
},
|
|
80
|
+
async recordValidation(
|
|
81
|
+
role: GitHubEnterpriseAppRole,
|
|
82
|
+
r: { status: string; error?: string | null },
|
|
83
|
+
) {
|
|
84
|
+
const row = rows.get(role);
|
|
85
|
+
if (!row) return;
|
|
86
|
+
row.lastValidationStatus = r.status;
|
|
87
|
+
row.lastValidatedAt = now;
|
|
88
|
+
row.lastValidationError = r.error ?? null;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function replayFetch(routes: Record<string, string>) {
|
|
94
|
+
const calls: string[] = [];
|
|
95
|
+
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
96
|
+
const u = new URL(input.toString());
|
|
97
|
+
const base = `${init?.method ?? "GET"} ${u.pathname}`;
|
|
98
|
+
calls.push(base);
|
|
99
|
+
const key = routes[base];
|
|
100
|
+
if (!key) throw new Error(`no route for ${base}`);
|
|
101
|
+
const entry = cassette[key] as CassetteEntry;
|
|
102
|
+
return new Response(JSON.stringify(entry.body), {
|
|
103
|
+
status: entry.status,
|
|
104
|
+
headers: { "Content-Type": "application/json", ...(entry.headers ?? {}) },
|
|
105
|
+
});
|
|
106
|
+
}) as typeof fetch;
|
|
107
|
+
return { fetchFn, calls };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ENT = "GET /enterprises/acme/installation";
|
|
111
|
+
const MINT = "POST /app/installations/4242/access_tokens";
|
|
112
|
+
const ORGS = "GET /enterprises/acme/apps/installable_organizations";
|
|
113
|
+
const INSTS = "GET /enterprises/acme/apps/organizations/org-1/installations";
|
|
114
|
+
const INSTALL = "POST /enterprises/acme/apps/organizations/org-1/installations";
|
|
115
|
+
|
|
116
|
+
function build(
|
|
117
|
+
opts: {
|
|
118
|
+
routes?: Record<string, string>;
|
|
119
|
+
agents?: Record<string, Record<string, string> | null>;
|
|
120
|
+
} = {},
|
|
121
|
+
) {
|
|
122
|
+
const appService = fakeAppService();
|
|
123
|
+
const { fetchFn, calls } = replayFetch({
|
|
124
|
+
[ENT]: "enterprise_installation_success",
|
|
125
|
+
[MINT]: "access_token_success",
|
|
126
|
+
...opts.routes,
|
|
127
|
+
});
|
|
128
|
+
const agents = opts.agents ?? {
|
|
129
|
+
"agent-1": { GH_APP_SLUG: "example-agent-app" },
|
|
130
|
+
"agent-noslug": { OTHER: "x" },
|
|
131
|
+
};
|
|
132
|
+
const app = createGithubEnterpriseApp({
|
|
133
|
+
appService,
|
|
134
|
+
agentService: {
|
|
135
|
+
getSummary: async (id: string) =>
|
|
136
|
+
id in agents
|
|
137
|
+
? ({
|
|
138
|
+
id,
|
|
139
|
+
name: id,
|
|
140
|
+
selfHosted: false,
|
|
141
|
+
specialty: "specialist",
|
|
142
|
+
} as never)
|
|
143
|
+
: null,
|
|
144
|
+
},
|
|
145
|
+
agentEnvService: {
|
|
146
|
+
getByAgentId: async (id: string) =>
|
|
147
|
+
agents[id] ? ({ env: agents[id] } as never) : null,
|
|
148
|
+
},
|
|
149
|
+
sessionSecret: "test-secret-32chars-for-ghe-api!!",
|
|
150
|
+
adminApiKeys: parseAdminApiKeys(
|
|
151
|
+
"root:tok-admin:*,ro:tok-read:platform.read,bot:tok-agent:agent-1",
|
|
152
|
+
),
|
|
153
|
+
agentTokenService: { validate: async () => null },
|
|
154
|
+
fetchFn,
|
|
155
|
+
apiBase: "https://ghe.test",
|
|
156
|
+
});
|
|
157
|
+
return { app, appService, calls };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const installerBody = {
|
|
161
|
+
appId: "12345",
|
|
162
|
+
slug: "example-installer",
|
|
163
|
+
enterpriseSlug: "acme",
|
|
164
|
+
privateKey: PEM,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
async function seed(h: ReturnType<typeof build>) {
|
|
168
|
+
await h.appService.save({ role: "installer", ...installerBody });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("auth", () => {
|
|
172
|
+
const cases: Array<[string, string, string]> = [
|
|
173
|
+
["GET", "/github-enterprise/apps", ""],
|
|
174
|
+
["PUT", "/github-enterprise/apps/installer", JSON.stringify(installerBody)],
|
|
175
|
+
["DELETE", "/github-enterprise/apps/installer", ""],
|
|
176
|
+
["POST", "/github-enterprise/apps/installer/validate", ""],
|
|
177
|
+
["GET", "/github-enterprise/orgs", ""],
|
|
178
|
+
[
|
|
179
|
+
"POST",
|
|
180
|
+
"/github-enterprise/orgs/org-1/install",
|
|
181
|
+
JSON.stringify({ agentId: "agent-1" }),
|
|
182
|
+
],
|
|
183
|
+
];
|
|
184
|
+
it("rejects missing and unknown credentials with 401", async () => {
|
|
185
|
+
const { app } = build();
|
|
186
|
+
for (const [method, path, body] of cases) {
|
|
187
|
+
const init = { method, ...(body ? { body } : {}) };
|
|
188
|
+
expect((await app.request(path, init)).status).toBe(401);
|
|
189
|
+
const bad = await app.request(path, {
|
|
190
|
+
...init,
|
|
191
|
+
headers: { Authorization: "Bearer nope" },
|
|
192
|
+
});
|
|
193
|
+
expect(bad.status).toBe(401);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
it("rejects agent-scoped and platform.read callers with 403", async () => {
|
|
197
|
+
const { app, calls } = build();
|
|
198
|
+
for (const tok of ["tok-agent", "tok-read"]) {
|
|
199
|
+
for (const [method, path, body] of cases) {
|
|
200
|
+
const res = await app.request(path, {
|
|
201
|
+
method,
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization: `Bearer ${tok}`,
|
|
204
|
+
"Content-Type": "application/json",
|
|
205
|
+
},
|
|
206
|
+
...(body ? { body } : {}),
|
|
207
|
+
});
|
|
208
|
+
expect(res.status).toBe(403);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
expect(calls).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("unconfigured deployment", () => {
|
|
216
|
+
it("GET /apps reports unconfigured with no side effects", async () => {
|
|
217
|
+
const { app, appService, calls } = build();
|
|
218
|
+
const res = await app.request("/github-enterprise/apps", {
|
|
219
|
+
headers: ADMIN,
|
|
220
|
+
});
|
|
221
|
+
expect(res.status).toBe(200);
|
|
222
|
+
expect(await res.json()).toEqual({
|
|
223
|
+
configured: false,
|
|
224
|
+
installer: null,
|
|
225
|
+
lookup: null,
|
|
226
|
+
});
|
|
227
|
+
expect(appService.rows.size).toBe(0);
|
|
228
|
+
expect(calls).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
it("GET /orgs returns an empty unconfigured result without calling GitHub", async () => {
|
|
231
|
+
const { app, calls } = build();
|
|
232
|
+
const res = await app.request("/github-enterprise/orgs", {
|
|
233
|
+
headers: ADMIN,
|
|
234
|
+
});
|
|
235
|
+
expect(res.status).toBe(200);
|
|
236
|
+
expect(await res.json()).toEqual({ configured: false, orgs: [] });
|
|
237
|
+
expect(calls).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
it("validate and install refuse with 4xx and never call GitHub", async () => {
|
|
240
|
+
const { app, calls } = build();
|
|
241
|
+
const v = await app.request("/github-enterprise/apps/installer/validate", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: ADMIN,
|
|
244
|
+
});
|
|
245
|
+
expect(v.status).toBe(404);
|
|
246
|
+
const i = await app.request("/github-enterprise/orgs/org-1/install", {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: JSON_ADMIN,
|
|
249
|
+
body: JSON.stringify({ agentId: "agent-1" }),
|
|
250
|
+
});
|
|
251
|
+
expect(i.status).toBe(409);
|
|
252
|
+
expect(calls).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
it("DELETE of an absent role is a no-op 204", async () => {
|
|
255
|
+
const { app } = build();
|
|
256
|
+
const res = await app.request("/github-enterprise/apps/lookup", {
|
|
257
|
+
method: "DELETE",
|
|
258
|
+
headers: ADMIN,
|
|
259
|
+
});
|
|
260
|
+
expect(res.status).toBe(204);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("PUT/GET/DELETE per role", () => {
|
|
265
|
+
it("stores and returns metadata with keySet, never the key", async () => {
|
|
266
|
+
const h = build();
|
|
267
|
+
const put = await h.app.request("/github-enterprise/apps/installer", {
|
|
268
|
+
method: "PUT",
|
|
269
|
+
headers: JSON_ADMIN,
|
|
270
|
+
body: JSON.stringify(installerBody),
|
|
271
|
+
});
|
|
272
|
+
expect(put.status).toBe(200);
|
|
273
|
+
const text = await put.text();
|
|
274
|
+
expect(text).not.toContain("PRIVATE KEY");
|
|
275
|
+
expect(JSON.parse(text)).toMatchObject({
|
|
276
|
+
role: "installer",
|
|
277
|
+
appId: "12345",
|
|
278
|
+
slug: "example-installer",
|
|
279
|
+
enterpriseSlug: "acme",
|
|
280
|
+
keySet: true,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const status = await h.app.request("/github-enterprise/apps", {
|
|
284
|
+
headers: ADMIN,
|
|
285
|
+
});
|
|
286
|
+
const st = await status.text();
|
|
287
|
+
expect(st).not.toContain("PRIVATE KEY");
|
|
288
|
+
const parsed = JSON.parse(st);
|
|
289
|
+
expect(parsed.configured).toBe(true);
|
|
290
|
+
expect(parsed.installer.keySet).toBe(true);
|
|
291
|
+
expect(parsed.lookup).toBeNull();
|
|
292
|
+
|
|
293
|
+
const del = await h.app.request("/github-enterprise/apps/installer", {
|
|
294
|
+
method: "DELETE",
|
|
295
|
+
headers: ADMIN,
|
|
296
|
+
});
|
|
297
|
+
expect(del.status).toBe(204);
|
|
298
|
+
expect(h.appService.rows.size).toBe(0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("rejects bad input with 4xx and never echoes the key", async () => {
|
|
302
|
+
const h = build();
|
|
303
|
+
const put = (role: string, body: unknown) =>
|
|
304
|
+
h.app.request(`/github-enterprise/apps/${role}`, {
|
|
305
|
+
method: "PUT",
|
|
306
|
+
headers: JSON_ADMIN,
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
});
|
|
309
|
+
expect((await put("bogus", installerBody)).status).toBe(400);
|
|
310
|
+
expect(
|
|
311
|
+
(await put("installer", { ...installerBody, enterpriseSlug: undefined }))
|
|
312
|
+
.status,
|
|
313
|
+
).toBe(422);
|
|
314
|
+
const bad = await put("installer", {
|
|
315
|
+
...installerBody,
|
|
316
|
+
privateKey: "not-a-pem-SECRETVALUE",
|
|
317
|
+
});
|
|
318
|
+
expect(bad.status).toBe(422);
|
|
319
|
+
expect(await bad.text()).not.toContain("SECRETVALUE");
|
|
320
|
+
expect((await put("installer", { appId: "1" })).status).toBe(400);
|
|
321
|
+
expect(h.appService.rows.size).toBe(0);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("POST validate", () => {
|
|
326
|
+
it("installer: valid when enterprise_organization_installations is granted", async () => {
|
|
327
|
+
const h = build();
|
|
328
|
+
await seed(h);
|
|
329
|
+
const res = await h.app.request(
|
|
330
|
+
"/github-enterprise/apps/installer/validate",
|
|
331
|
+
{ method: "POST", headers: ADMIN },
|
|
332
|
+
);
|
|
333
|
+
expect(res.status).toBe(200);
|
|
334
|
+
const text = await res.text();
|
|
335
|
+
expect(text).not.toContain("ghs_cassette");
|
|
336
|
+
expect(JSON.parse(text)).toMatchObject({
|
|
337
|
+
role: "installer",
|
|
338
|
+
ok: true,
|
|
339
|
+
status: "valid",
|
|
340
|
+
requiredPermission: "enterprise_organization_installations",
|
|
341
|
+
error: null,
|
|
342
|
+
});
|
|
343
|
+
expect(h.appService.rows.get("installer")?.lastValidationStatus).toBe(
|
|
344
|
+
"valid",
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("installer: reports a missing permission and persists it", async () => {
|
|
349
|
+
const h = build({
|
|
350
|
+
routes: { [MINT]: "access_token_no_installer_permission" },
|
|
351
|
+
});
|
|
352
|
+
await seed(h);
|
|
353
|
+
const res = await h.app.request(
|
|
354
|
+
"/github-enterprise/apps/installer/validate",
|
|
355
|
+
{ method: "POST", headers: ADMIN },
|
|
356
|
+
);
|
|
357
|
+
expect(res.status).toBe(200);
|
|
358
|
+
expect(await res.json()).toMatchObject({
|
|
359
|
+
ok: false,
|
|
360
|
+
status: "missing_permission",
|
|
361
|
+
});
|
|
362
|
+
expect(h.appService.rows.get("installer")?.lastValidationStatus).toBe(
|
|
363
|
+
"missing_permission",
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("installer: GitHub errors are recorded as non-leaky failures", async () => {
|
|
368
|
+
const h = build({ routes: { [ENT]: "enterprise_installation_403" } });
|
|
369
|
+
await seed(h);
|
|
370
|
+
const res = await h.app.request(
|
|
371
|
+
"/github-enterprise/apps/installer/validate",
|
|
372
|
+
{ method: "POST", headers: ADMIN },
|
|
373
|
+
);
|
|
374
|
+
const text = await res.text();
|
|
375
|
+
expect(res.status).toBe(200);
|
|
376
|
+
expect(JSON.parse(text)).toMatchObject({ ok: false, status: "error" });
|
|
377
|
+
expect(text).not.toContain("PRIVATE KEY");
|
|
378
|
+
expect(text).not.toContain("Resource not accessible");
|
|
379
|
+
expect(h.appService.rows.get("installer")?.lastValidationStatus).toBe(
|
|
380
|
+
"error",
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("lookup: checks metadata read via GET /app", async () => {
|
|
385
|
+
const h = build({ routes: { "GET /app": "app_get_success" } });
|
|
386
|
+
await h.appService.save({
|
|
387
|
+
role: "lookup",
|
|
388
|
+
appId: "555",
|
|
389
|
+
slug: "example-lookup-app",
|
|
390
|
+
privateKey: PEM,
|
|
391
|
+
});
|
|
392
|
+
const res = await h.app.request("/github-enterprise/apps/lookup/validate", {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: ADMIN,
|
|
395
|
+
});
|
|
396
|
+
expect(await res.json()).toMatchObject({ ok: true, status: "valid" });
|
|
397
|
+
|
|
398
|
+
const h2 = build({ routes: { "GET /app": "app_get_no_metadata" } });
|
|
399
|
+
await h2.appService.save({
|
|
400
|
+
role: "lookup",
|
|
401
|
+
appId: "555",
|
|
402
|
+
slug: "example-lookup-app",
|
|
403
|
+
privateKey: PEM,
|
|
404
|
+
});
|
|
405
|
+
const res2 = await h2.app.request(
|
|
406
|
+
"/github-enterprise/apps/lookup/validate",
|
|
407
|
+
{ method: "POST", headers: ADMIN },
|
|
408
|
+
);
|
|
409
|
+
expect(await res2.json()).toMatchObject({
|
|
410
|
+
ok: false,
|
|
411
|
+
status: "missing_permission",
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe("GET orgs", () => {
|
|
417
|
+
it("lists installable orgs with lookup and agent App installation flags", async () => {
|
|
418
|
+
const h = build({
|
|
419
|
+
routes: {
|
|
420
|
+
[ORGS]: "installable_orgs_success",
|
|
421
|
+
[INSTS]: "org_installations_success",
|
|
422
|
+
"GET /enterprises/acme/apps/organizations/org-2/installations":
|
|
423
|
+
"installable_orgs_empty",
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
await seed(h);
|
|
427
|
+
await h.appService.save({
|
|
428
|
+
role: "lookup",
|
|
429
|
+
appId: "555",
|
|
430
|
+
slug: "example-lookup-app",
|
|
431
|
+
privateKey: PEM,
|
|
432
|
+
});
|
|
433
|
+
const res = await h.app.request("/github-enterprise/orgs?agentId=agent-1", {
|
|
434
|
+
headers: ADMIN,
|
|
435
|
+
});
|
|
436
|
+
expect(res.status).toBe(200);
|
|
437
|
+
const body = (await res.json()) as {
|
|
438
|
+
configured: boolean;
|
|
439
|
+
agentAppSlug: string | null;
|
|
440
|
+
orgs: Array<{
|
|
441
|
+
login: string;
|
|
442
|
+
lookupAppInstalled: boolean | null;
|
|
443
|
+
agentAppInstalled: boolean | null;
|
|
444
|
+
}>;
|
|
445
|
+
};
|
|
446
|
+
expect(body.configured).toBe(true);
|
|
447
|
+
expect(body.agentAppSlug).toBe("example-agent-app");
|
|
448
|
+
expect(body.orgs.map((o) => o.login)).toEqual(["org-1", "org-2"]);
|
|
449
|
+
expect(body.orgs[1]).toMatchObject({
|
|
450
|
+
lookupAppInstalled: false,
|
|
451
|
+
agentAppInstalled: false,
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("without agentId or lookup app the flags are null", async () => {
|
|
456
|
+
const h = build({
|
|
457
|
+
routes: {
|
|
458
|
+
[ORGS]: "installable_orgs_success",
|
|
459
|
+
[INSTS]: "installable_orgs_empty",
|
|
460
|
+
"GET /enterprises/acme/apps/organizations/org-2/installations":
|
|
461
|
+
"installable_orgs_empty",
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
await seed(h);
|
|
465
|
+
const res = await h.app.request("/github-enterprise/orgs", {
|
|
466
|
+
headers: ADMIN,
|
|
467
|
+
});
|
|
468
|
+
const body = (await res.json()) as { orgs: Array<Record<string, unknown>> };
|
|
469
|
+
expect(body.orgs[0]).toMatchObject({
|
|
470
|
+
lookupAppInstalled: null,
|
|
471
|
+
agentAppInstalled: null,
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("404 for an unknown agentId", async () => {
|
|
476
|
+
const h = build({ routes: { [ORGS]: "installable_orgs_empty" } });
|
|
477
|
+
await seed(h);
|
|
478
|
+
const res = await h.app.request("/github-enterprise/orgs?agentId=nope", {
|
|
479
|
+
headers: ADMIN,
|
|
480
|
+
});
|
|
481
|
+
expect(res.status).toBe(404);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("POST install", () => {
|
|
486
|
+
const post = (h: ReturnType<typeof build>, body: unknown, org = "org-1") =>
|
|
487
|
+
h.app.request(`/github-enterprise/orgs/${org}/install`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers: JSON_ADMIN,
|
|
490
|
+
body: JSON.stringify(body),
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("resolves the client id from the agent's slug and installs", async () => {
|
|
494
|
+
const h = build({
|
|
495
|
+
routes: {
|
|
496
|
+
"GET /apps/example-agent-app": "public_app_success",
|
|
497
|
+
[INSTALL]: "install_created",
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
await seed(h);
|
|
501
|
+
const res = await post(h, { agentId: "agent-1" });
|
|
502
|
+
expect(res.status).toBe(200);
|
|
503
|
+
expect(await res.json()).toMatchObject({
|
|
504
|
+
created: true,
|
|
505
|
+
org: "org-1",
|
|
506
|
+
installation: { id: 601 },
|
|
507
|
+
});
|
|
508
|
+
expect(h.calls).toContain(INSTALL);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("refuses a private App (public lookup 404) with a clear 422", async () => {
|
|
512
|
+
const h = build({
|
|
513
|
+
routes: { "GET /apps/example-agent-app": "public_app_private_404" },
|
|
514
|
+
});
|
|
515
|
+
await seed(h);
|
|
516
|
+
const res = await post(h, { agentId: "agent-1" });
|
|
517
|
+
expect(res.status).toBe(422);
|
|
518
|
+
expect(((await res.json()) as { error: string }).error).toMatch(/private/i);
|
|
519
|
+
expect(h.calls).not.toContain(INSTALL);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("refuses an App with no client id", async () => {
|
|
523
|
+
const h = build({
|
|
524
|
+
routes: { "GET /apps/example-agent-app": "public_app_no_client_id" },
|
|
525
|
+
});
|
|
526
|
+
await seed(h);
|
|
527
|
+
expect((await post(h, { agentId: "agent-1" })).status).toBe(422);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("refuses an agent with no GH_APP_SLUG, and an unknown agent", async () => {
|
|
531
|
+
const h = build();
|
|
532
|
+
await seed(h);
|
|
533
|
+
const res = await post(h, { agentId: "agent-noslug" });
|
|
534
|
+
expect(res.status).toBe(422);
|
|
535
|
+
expect(((await res.json()) as { error: string }).error).toMatch(
|
|
536
|
+
/GH_APP_SLUG/,
|
|
537
|
+
);
|
|
538
|
+
expect((await post(h, { agentId: "ghost" })).status).toBe(404);
|
|
539
|
+
expect(h.calls).toEqual([]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("rejects an invalid stored slug before any outbound call", async () => {
|
|
543
|
+
const h = build({ agents: { a: { GH_APP_SLUG: "../evil" } } });
|
|
544
|
+
await seed(h);
|
|
545
|
+
expect((await post(h, { agentId: "a" })).status).toBe(422);
|
|
546
|
+
expect(h.calls).toEqual([]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("validates the body and the org", async () => {
|
|
550
|
+
const h = build();
|
|
551
|
+
await seed(h);
|
|
552
|
+
expect((await post(h, {})).status).toBe(400);
|
|
553
|
+
expect((await post(h, { agentId: "agent-1" }, "bad_org!")).status).toBe(
|
|
554
|
+
400,
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const MAPPED: Array<[string, number, RegExp]> = [
|
|
559
|
+
["install_403", 424, /permission/i],
|
|
560
|
+
["install_404", 404, /could not find/i],
|
|
561
|
+
["install_422", 422, /rejected/i],
|
|
562
|
+
["install_429", 429, /rate/i],
|
|
563
|
+
["install_500", 502, /unavailable|error/i],
|
|
564
|
+
];
|
|
565
|
+
for (const [key, status, msg] of MAPPED) {
|
|
566
|
+
it(`maps GitHub ${key} to ${status} without leaking`, async () => {
|
|
567
|
+
const h = build({
|
|
568
|
+
routes: {
|
|
569
|
+
"GET /apps/example-agent-app": "public_app_success",
|
|
570
|
+
[INSTALL]: key,
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
await seed(h);
|
|
574
|
+
const res = await post(h, { agentId: "agent-1" });
|
|
575
|
+
expect(res.status).toBe(status);
|
|
576
|
+
const text = await res.text();
|
|
577
|
+
expect(JSON.parse(text).error).toMatch(msg);
|
|
578
|
+
expect(text).not.toContain("PRIVATE KEY");
|
|
579
|
+
expect(text).not.toContain("ghs_cassette");
|
|
580
|
+
expect(text).not.toContain("Resource not accessible");
|
|
581
|
+
expect(text).not.toContain("documentation_url");
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
it("secondary rate limit 403 maps to 429", async () => {
|
|
586
|
+
const h = build({
|
|
587
|
+
routes: {
|
|
588
|
+
"GET /apps/example-agent-app": "public_app_success",
|
|
589
|
+
[INSTALL]: "install_403_secondary_rate_limit",
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
await seed(h);
|
|
593
|
+
expect((await post(h, { agentId: "agent-1" })).status).toBe(429);
|
|
594
|
+
});
|
|
595
|
+
});
|