@app-vitals/agent-admin 1.12.0 → 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/openapi.platform.json +3 -3
- package/package.json +2 -2
- package/src/admin-spec.unit.test.ts +21 -0
- package/src/agents-api.ts +1 -1
- 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
- package/src/openapi-schemas.ts +2 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-app-jwt.ts
|
|
3
|
+
* RS256 signer for GitHub App JWTs (node:crypto only, no dependencies).
|
|
4
|
+
* https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
|
|
5
|
+
*
|
|
6
|
+
* Errors never include the private key material.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/** Backdate iat to absorb clock drift (GitHub's recommendation). */
|
|
12
|
+
const IAT_BACKDATE_SECONDS = 60;
|
|
13
|
+
/** exp - now. GitHub rejects a JWT whose exp is more than 10 minutes out. */
|
|
14
|
+
const EXP_SECONDS = 540;
|
|
15
|
+
|
|
16
|
+
function b64url(input: string | Buffer): string {
|
|
17
|
+
return Buffer.from(input).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function signGithubAppJwt(opts: {
|
|
21
|
+
appId: string;
|
|
22
|
+
privateKey: string;
|
|
23
|
+
now?: Date;
|
|
24
|
+
}): string {
|
|
25
|
+
if (!opts.appId) throw new Error("GitHub App JWT: appId is required");
|
|
26
|
+
const nowS = Math.floor((opts.now ?? new Date()).getTime() / 1000);
|
|
27
|
+
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
28
|
+
const payload = b64url(
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
iss: opts.appId,
|
|
31
|
+
iat: nowS - IAT_BACKDATE_SECONDS,
|
|
32
|
+
exp: nowS + EXP_SECONDS,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
const signingInput = `${header}.${payload}`;
|
|
36
|
+
try {
|
|
37
|
+
const key = createPrivateKey(opts.privateKey);
|
|
38
|
+
const sig = sign("RSA-SHA256", Buffer.from(signingInput), key);
|
|
39
|
+
return `${signingInput}.${b64url(sig)}`;
|
|
40
|
+
} catch {
|
|
41
|
+
// Deliberately drop the underlying error: it can carry key details.
|
|
42
|
+
throw new Error("GitHub App JWT: invalid or unusable RSA private key");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-app-jwt.unit.test.ts
|
|
3
|
+
* Unit tests for the RS256 GitHub App JWT signer. The RSA key is generated
|
|
4
|
+
* in-test; no network, no fixtures.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { createPublicKey, generateKeyPairSync, verify } from "node:crypto";
|
|
9
|
+
import { signGithubAppJwt } from "./github-app-jwt.ts";
|
|
10
|
+
|
|
11
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
12
|
+
modulusLength: 2048,
|
|
13
|
+
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
|
14
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
15
|
+
});
|
|
16
|
+
const pkcs8 = generateKeyPairSync("rsa", {
|
|
17
|
+
modulusLength: 2048,
|
|
18
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
19
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const NOW = new Date("2026-11-09T12:00:00Z");
|
|
23
|
+
const NOW_S = Math.floor(NOW.getTime() / 1000);
|
|
24
|
+
|
|
25
|
+
function decode(part: string): Record<string, unknown> {
|
|
26
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf-8"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("signGithubAppJwt", () => {
|
|
30
|
+
const jwt = signGithubAppJwt({ appId: "12345", privateKey, now: NOW });
|
|
31
|
+
const [h, p, s] = jwt.split(".") as [string, string, string];
|
|
32
|
+
|
|
33
|
+
it("has three base64url segments with an RS256 header", () => {
|
|
34
|
+
expect(jwt.split(".")).toHaveLength(3);
|
|
35
|
+
expect(jwt).not.toMatch(/[+/=]/);
|
|
36
|
+
expect(decode(h)).toEqual({ alg: "RS256", typ: "JWT" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("sets iss to the app id, iat backdated 60s and exp within GitHub's 10 minute cap", () => {
|
|
40
|
+
const claims = decode(p) as { iss: string; iat: number; exp: number };
|
|
41
|
+
expect(claims.iss).toBe("12345");
|
|
42
|
+
expect(claims.iat).toBe(NOW_S - 60);
|
|
43
|
+
expect(claims.exp).toBe(NOW_S + 540);
|
|
44
|
+
expect(claims.exp - NOW_S).toBeLessThanOrEqual(600);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("verifies against the matching public key", () => {
|
|
48
|
+
expect(
|
|
49
|
+
verify(
|
|
50
|
+
"RSA-SHA256",
|
|
51
|
+
Buffer.from(`${h}.${p}`),
|
|
52
|
+
createPublicKey(publicKey),
|
|
53
|
+
Buffer.from(s, "base64url"),
|
|
54
|
+
),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("fails verification against a different key", () => {
|
|
59
|
+
expect(
|
|
60
|
+
verify(
|
|
61
|
+
"RSA-SHA256",
|
|
62
|
+
Buffer.from(`${h}.${p}`),
|
|
63
|
+
createPublicKey(pkcs8.publicKey),
|
|
64
|
+
Buffer.from(s, "base64url"),
|
|
65
|
+
),
|
|
66
|
+
).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("accepts PKCS#8 keys", () => {
|
|
70
|
+
const j = signGithubAppJwt({
|
|
71
|
+
appId: "1",
|
|
72
|
+
privateKey: pkcs8.privateKey,
|
|
73
|
+
now: NOW,
|
|
74
|
+
});
|
|
75
|
+
expect(j.split(".")).toHaveLength(3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("throws a key-free error for an invalid key", () => {
|
|
79
|
+
const bad = "-----BEGIN RSA PRIVATE KEY-----\nSECRETGARBAGE\n-----END RSA PRIVATE KEY-----";
|
|
80
|
+
let msg = "";
|
|
81
|
+
try {
|
|
82
|
+
signGithubAppJwt({ appId: "1", privateKey: bad, now: NOW });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
msg = (e as Error).message;
|
|
85
|
+
}
|
|
86
|
+
expect(msg).toContain("private key");
|
|
87
|
+
expect(msg).not.toContain("SECRETGARBAGE");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects an empty app id", () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
signGithubAppJwt({ appId: "", privateKey, now: NOW }),
|
|
93
|
+
).toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-enterprise-client.integration.test.ts
|
|
3
|
+
* Integration tests for GithubEnterpriseClient against hand-authored cassettes
|
|
4
|
+
* (fixtures/github-enterprise-cassette.json, derived from GitHub's ghec REST
|
|
5
|
+
* OpenAPI description; nothing was recorded live).
|
|
6
|
+
*
|
|
7
|
+
* Replays through an INJECTED fetchFn: no global.fetch override, no
|
|
8
|
+
* mock.module(). The RSA key is generated in-test.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
GithubEnterpriseClient,
|
|
16
|
+
type GithubEnterpriseErrorKind,
|
|
17
|
+
} from "./github-enterprise-client.ts";
|
|
18
|
+
|
|
19
|
+
interface CassetteEntry {
|
|
20
|
+
status: number;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cassette: Record<string, CassetteEntry> = JSON.parse(
|
|
26
|
+
readFileSync(
|
|
27
|
+
new URL("./fixtures/github-enterprise-cassette.json", import.meta.url).pathname,
|
|
28
|
+
"utf-8",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
33
|
+
modulusLength: 2048,
|
|
34
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
35
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const NOW = new Date("2026-11-09T12:00:00Z");
|
|
39
|
+
const API = "https://ghe.test";
|
|
40
|
+
const ENT = "GET /enterprises/acme/installation";
|
|
41
|
+
const MINT = "POST /app/installations/4242/access_tokens";
|
|
42
|
+
const ORGS = "GET /enterprises/acme/apps/installable_organizations";
|
|
43
|
+
const INSTS = "GET /enterprises/acme/apps/organizations/acme-eng/installations";
|
|
44
|
+
const INSTALL = "POST /enterprises/acme/apps/organizations/acme-eng/installations";
|
|
45
|
+
|
|
46
|
+
interface Recorded {
|
|
47
|
+
method: string;
|
|
48
|
+
path: string;
|
|
49
|
+
headers: Headers;
|
|
50
|
+
body?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** routes: "METHOD /path[?page=N]" or "METHOD /path" -> cassette key. */
|
|
54
|
+
function replay(routes: Record<string, string>) {
|
|
55
|
+
const calls: Recorded[] = [];
|
|
56
|
+
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
57
|
+
const u = new URL(input.toString());
|
|
58
|
+
const method = init?.method ?? "GET";
|
|
59
|
+
const page = u.searchParams.get("page");
|
|
60
|
+
const base = `${method} ${u.pathname}`;
|
|
61
|
+
calls.push({
|
|
62
|
+
method,
|
|
63
|
+
path: `${u.pathname}${u.search}`,
|
|
64
|
+
headers: new Headers(init?.headers),
|
|
65
|
+
body: typeof init?.body === "string" ? init.body : undefined,
|
|
66
|
+
});
|
|
67
|
+
const key = (page && routes[`${base}?page=${page}`]) || routes[base];
|
|
68
|
+
if (!key) throw new Error(`no route for ${base} page=${page}`);
|
|
69
|
+
const entry = cassette[key];
|
|
70
|
+
if (!entry) throw new Error(`cassette key not found: ${key}`);
|
|
71
|
+
return new Response(JSON.stringify(entry.body), {
|
|
72
|
+
status: entry.status,
|
|
73
|
+
headers: { "Content-Type": "application/json", ...(entry.headers ?? {}) },
|
|
74
|
+
});
|
|
75
|
+
}) as typeof fetch;
|
|
76
|
+
return { fetchFn, calls };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const OK = { [ENT]: "enterprise_installation_success", [MINT]: "access_token_success" };
|
|
80
|
+
|
|
81
|
+
function makeClient(routes: Record<string, string>) {
|
|
82
|
+
const { fetchFn, calls } = replay({ ...OK, ...routes });
|
|
83
|
+
const client = new GithubEnterpriseClient({
|
|
84
|
+
appId: "12345",
|
|
85
|
+
enterprise: "acme",
|
|
86
|
+
privateKey,
|
|
87
|
+
apiBase: API,
|
|
88
|
+
fetchFn,
|
|
89
|
+
now: () => NOW,
|
|
90
|
+
});
|
|
91
|
+
return { client, calls };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ERRORS: Array<[string, GithubEnterpriseErrorKind, number]> = [
|
|
95
|
+
["403", "permission", 403],
|
|
96
|
+
["403_secondary_rate_limit", "rate_limit", 403],
|
|
97
|
+
["404", "not_found", 404],
|
|
98
|
+
["422", "validation", 422],
|
|
99
|
+
["429", "rate_limit", 429],
|
|
100
|
+
["500", "server", 500],
|
|
101
|
+
["503", "server", 503],
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
async function errOf(p: Promise<unknown>) {
|
|
105
|
+
try {
|
|
106
|
+
await p;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return e as Error & { kind: GithubEnterpriseErrorKind; status?: number };
|
|
109
|
+
}
|
|
110
|
+
throw new Error("expected rejection");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expectNoSecrets(e: Error) {
|
|
114
|
+
expect(e.message).not.toContain("PRIVATE KEY");
|
|
115
|
+
expect(e.message).not.toContain("ghs_cassette");
|
|
116
|
+
expect(e.message).not.toContain("Bearer");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("getInstallationToken", () => {
|
|
120
|
+
it("looks up the enterprise installation with a JWT then mints a token", async () => {
|
|
121
|
+
const { client, calls } = makeClient({});
|
|
122
|
+
const t = await client.getInstallationToken();
|
|
123
|
+
expect(t).toEqual({
|
|
124
|
+
token: "ghs_cassetteInstallationToken0000000000",
|
|
125
|
+
expiresAt: "2026-11-09T13:00:00Z",
|
|
126
|
+
installationId: 4242,
|
|
127
|
+
});
|
|
128
|
+
expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ENT, MINT]);
|
|
129
|
+
for (const c of calls) {
|
|
130
|
+
expect(c.headers.get("authorization")).toMatch(/^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
|
|
131
|
+
expect(c.headers.get("accept")).toBe("application/vnd.github+json");
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("caches the token until near expiry", async () => {
|
|
136
|
+
const { client, calls } = makeClient({});
|
|
137
|
+
await client.getInstallationToken();
|
|
138
|
+
await client.getInstallationToken();
|
|
139
|
+
expect(calls).toHaveLength(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
143
|
+
it(`lookup ${suffix} -> ${kind}`, async () => {
|
|
144
|
+
const { client } = makeClient({ [ENT]: `enterprise_installation_${suffix}` });
|
|
145
|
+
const e = await errOf(client.getInstallationToken());
|
|
146
|
+
expect(e.kind).toBe(kind);
|
|
147
|
+
expect(e.status).toBe(status);
|
|
148
|
+
expectNoSecrets(e);
|
|
149
|
+
});
|
|
150
|
+
it(`mint ${suffix} -> ${kind}`, async () => {
|
|
151
|
+
const { client } = makeClient({ [MINT]: `access_token_${suffix}` });
|
|
152
|
+
const e = await errOf(client.getInstallationToken());
|
|
153
|
+
expect(e.kind).toBe(kind);
|
|
154
|
+
expect(e.status).toBe(status);
|
|
155
|
+
expectNoSecrets(e);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it("maps a thrown fetch to a network error without echoing the cause", async () => {
|
|
160
|
+
const client = new GithubEnterpriseClient({
|
|
161
|
+
appId: "1",
|
|
162
|
+
enterprise: "acme",
|
|
163
|
+
privateKey,
|
|
164
|
+
apiBase: API,
|
|
165
|
+
fetchFn: (async () => {
|
|
166
|
+
throw new Error("connect ECONNREFUSED Bearer abc");
|
|
167
|
+
}) as unknown as typeof fetch,
|
|
168
|
+
});
|
|
169
|
+
const e = await errOf(client.getInstallationToken());
|
|
170
|
+
expect(e.kind).toBe("network");
|
|
171
|
+
expect(e.message).not.toContain("abc");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("listInstallableOrganizations", () => {
|
|
176
|
+
it("returns orgs using the installation token", async () => {
|
|
177
|
+
const { client, calls } = makeClient({ [ORGS]: "installable_orgs_success" });
|
|
178
|
+
expect(await client.listInstallableOrganizations()).toEqual([
|
|
179
|
+
{ id: 1, login: "org-1" },
|
|
180
|
+
{ id: 2, login: "org-2" },
|
|
181
|
+
]);
|
|
182
|
+
const last = calls[calls.length - 1] as Recorded;
|
|
183
|
+
expect(last.path).toBe(
|
|
184
|
+
"/enterprises/acme/apps/installable_organizations?per_page=100&page=1",
|
|
185
|
+
);
|
|
186
|
+
expect(last.headers.get("authorization")).toBe(
|
|
187
|
+
"Bearer ghs_cassetteInstallationToken0000000000",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("follows Link rel=next across pages", async () => {
|
|
192
|
+
const { client, calls } = makeClient({
|
|
193
|
+
[`${ORGS}?page=1`]: "installable_orgs_page_1",
|
|
194
|
+
[`${ORGS}?page=2`]: "installable_orgs_page_2",
|
|
195
|
+
});
|
|
196
|
+
const orgs = await client.listInstallableOrganizations();
|
|
197
|
+
expect(orgs.map((o) => o.login)).toEqual(["org-1", "org-2", "org-3"]);
|
|
198
|
+
expect(calls.filter((c) => c.path.includes("installable_organizations"))).toHaveLength(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns an empty list", async () => {
|
|
202
|
+
const { client } = makeClient({ [ORGS]: "installable_orgs_empty" });
|
|
203
|
+
expect(await client.listInstallableOrganizations()).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
207
|
+
it(`${suffix} -> ${kind}`, async () => {
|
|
208
|
+
const { client } = makeClient({ [ORGS]: `installable_orgs_${suffix}` });
|
|
209
|
+
const e = await errOf(client.listInstallableOrganizations());
|
|
210
|
+
expect(e.kind).toBe(kind);
|
|
211
|
+
expect(e.status).toBe(status);
|
|
212
|
+
expectNoSecrets(e);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("listOrgInstallations", () => {
|
|
218
|
+
it("maps installations", async () => {
|
|
219
|
+
const { client } = makeClient({ [INSTS]: "org_installations_success" });
|
|
220
|
+
const list = await client.listOrgInstallations("acme-eng");
|
|
221
|
+
expect(list).toHaveLength(2);
|
|
222
|
+
expect(list[0]).toEqual({
|
|
223
|
+
id: 501,
|
|
224
|
+
appId: 777,
|
|
225
|
+
appSlug: "target-app",
|
|
226
|
+
clientId: "Iv1.targetapp0001",
|
|
227
|
+
accountLogin: "acme-eng",
|
|
228
|
+
repositorySelection: "all",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("paginates", async () => {
|
|
233
|
+
const { client } = makeClient({
|
|
234
|
+
[`${INSTS}?page=1`]: "org_installations_page_1",
|
|
235
|
+
[`${INSTS}?page=2`]: "org_installations_page_2",
|
|
236
|
+
});
|
|
237
|
+
const list = await client.listOrgInstallations("acme-eng");
|
|
238
|
+
expect(list.map((i) => i.id)).toEqual([501, 502]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
242
|
+
it(`${suffix} -> ${kind}`, async () => {
|
|
243
|
+
const { client } = makeClient({ [INSTS]: `org_installations_${suffix}` });
|
|
244
|
+
const e = await errOf(client.listOrgInstallations("acme-eng"));
|
|
245
|
+
expect(e.kind).toBe(kind);
|
|
246
|
+
expect(e.status).toBe(status);
|
|
247
|
+
expectNoSecrets(e);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("installOnOrg", () => {
|
|
253
|
+
it("posts client_id and repository_selection; 201 means created", async () => {
|
|
254
|
+
const { client, calls } = makeClient({ [INSTALL]: "install_created" });
|
|
255
|
+
const r = await client.installOnOrg("acme-eng", {
|
|
256
|
+
clientId: "Iv1.targetapp0001",
|
|
257
|
+
repositorySelection: "all",
|
|
258
|
+
});
|
|
259
|
+
expect(r.created).toBe(true);
|
|
260
|
+
expect(r.installation.id).toBe(601);
|
|
261
|
+
const last = calls[calls.length - 1] as Recorded;
|
|
262
|
+
expect(last.method).toBe("POST");
|
|
263
|
+
expect(JSON.parse(last.body ?? "")).toEqual({
|
|
264
|
+
client_id: "Iv1.targetapp0001",
|
|
265
|
+
repository_selection: "all",
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("200 means already installed", async () => {
|
|
270
|
+
const { client } = makeClient({ [INSTALL]: "install_already_installed" });
|
|
271
|
+
const r = await client.installOnOrg("acme-eng", {
|
|
272
|
+
clientId: "Iv1.targetapp0001",
|
|
273
|
+
repositorySelection: "all",
|
|
274
|
+
});
|
|
275
|
+
expect(r.created).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("sends repositories for selected", async () => {
|
|
279
|
+
const { client, calls } = makeClient({ [INSTALL]: "install_selected_created" });
|
|
280
|
+
const r = await client.installOnOrg("acme-eng", {
|
|
281
|
+
clientId: "Iv1.targetapp0001",
|
|
282
|
+
repositorySelection: "selected",
|
|
283
|
+
repositories: ["hello-world"],
|
|
284
|
+
});
|
|
285
|
+
expect(r.installation.repositorySelection).toBe("selected");
|
|
286
|
+
expect(JSON.parse((calls[calls.length - 1] as Recorded).body ?? "")).toEqual({
|
|
287
|
+
client_id: "Iv1.targetapp0001",
|
|
288
|
+
repository_selection: "selected",
|
|
289
|
+
repositories: ["hello-world"],
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("sends none without repositories", async () => {
|
|
294
|
+
const { client, calls } = makeClient({ [INSTALL]: "install_created" });
|
|
295
|
+
await client.installOnOrg("acme-eng", {
|
|
296
|
+
clientId: "Iv1.x",
|
|
297
|
+
repositorySelection: "none",
|
|
298
|
+
repositories: ["ignored"],
|
|
299
|
+
});
|
|
300
|
+
expect(JSON.parse((calls[calls.length - 1] as Recorded).body ?? "")).toEqual({
|
|
301
|
+
client_id: "Iv1.x",
|
|
302
|
+
repository_selection: "none",
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
307
|
+
it(`${suffix} -> ${kind}`, async () => {
|
|
308
|
+
const { client } = makeClient({ [INSTALL]: `install_${suffix}` });
|
|
309
|
+
const e = await errOf(
|
|
310
|
+
client.installOnOrg("acme-eng", { clientId: "Iv1.x", repositorySelection: "all" }),
|
|
311
|
+
);
|
|
312
|
+
expect(e.kind).toBe(kind);
|
|
313
|
+
expect(e.status).toBe(status);
|
|
314
|
+
expectNoSecrets(e);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
});
|