@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@anthropic-ai/sdk": "0.135.0",
|
|
45
|
-
"@app-vitals/agent-lib": "1.
|
|
45
|
+
"@app-vitals/agent-lib": "1.14.0",
|
|
46
46
|
"@hono/zod-openapi": "^1.0.0",
|
|
47
47
|
"@prisma/adapter-pg": "^7.10.0",
|
|
48
48
|
"@prisma/client": "^7.10.0",
|
package/src/admin-boot.ts
CHANGED
|
@@ -99,6 +99,8 @@ import { CronRunNowService } from "./cron-run-now.ts";
|
|
|
99
99
|
import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
|
|
100
100
|
import { CronRunTranscriptService } from "./cron-run-transcripts.ts";
|
|
101
101
|
import { isDevAuthAllowed } from "./dev-auth-guard.ts";
|
|
102
|
+
import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
|
|
103
|
+
import { GitHubEnterpriseAppService } from "./github-enterprise-apps.ts";
|
|
102
104
|
import { HttpGithubAppProvisioningClient } from "./github-app-provisioning-client.ts";
|
|
103
105
|
import { HttpGoogleAuthClient } from "./google-auth-client.ts";
|
|
104
106
|
import { HttpChatClient } from "./http-chat-client.ts";
|
|
@@ -113,7 +115,7 @@ import { parseSelfServeConfig } from "./self-serve-config.ts";
|
|
|
113
115
|
import { HttpSlackProvisioningClient } from "./slack-provisioning-client.ts";
|
|
114
116
|
import { SpecialtyTypes } from "./specialty-types.ts";
|
|
115
117
|
import { runStartupPolicyReconcile } from "./startup-policy-reconcile.ts";
|
|
116
|
-
import { makeTokenCrypto } from "./token-crypto.ts";
|
|
118
|
+
import { makeStrictTokenCrypto, makeTokenCrypto } from "./token-crypto.ts";
|
|
117
119
|
import { parseTranscriptConfig } from "./transcript-config.ts";
|
|
118
120
|
import {
|
|
119
121
|
resolveTrialExpirySweepIntervalMs,
|
|
@@ -530,6 +532,24 @@ export async function startAdmin(
|
|
|
530
532
|
}),
|
|
531
533
|
);
|
|
532
534
|
|
|
535
|
+
// 3b'. GitHub Enterprise Apps API — /github-enterprise/* — admin-only,
|
|
536
|
+
// optional (GHE-1.3). With no rows configured it reports unconfigured
|
|
537
|
+
// and does nothing else. Strict crypto: no plain-text key storage.
|
|
538
|
+
root.route(
|
|
539
|
+
"/",
|
|
540
|
+
createGithubEnterpriseApp({
|
|
541
|
+
appService: new GitHubEnterpriseAppService(
|
|
542
|
+
prisma,
|
|
543
|
+
makeStrictTokenCrypto(),
|
|
544
|
+
),
|
|
545
|
+
agentService,
|
|
546
|
+
agentEnvService,
|
|
547
|
+
sessionSecret,
|
|
548
|
+
adminApiKeys,
|
|
549
|
+
agentTokenService,
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
|
|
533
553
|
// 3c. Extension API routes — after every platform API, before the admin UI
|
|
534
554
|
// (same no-shadowing rule as the admin CRUD API above).
|
|
535
555
|
mountRouteContributions(root, resolvedExtensions.apiRoutes);
|
package/src/admin-spec.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
parseAdminApiKeys,
|
|
25
25
|
} from "./agents-api.ts";
|
|
26
26
|
import { createAgentRuntimeApp } from "./api.ts";
|
|
27
|
+
import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
|
|
27
28
|
|
|
28
29
|
/** The admin OpenAPI document shape the spec builders and generators share. */
|
|
29
30
|
export interface AdminOpenApiDocument {
|
|
@@ -172,6 +173,23 @@ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// GHE-1.3: the /github-enterprise routes live in their own app (mounted at
|
|
177
|
+
// the root in admin-boot.ts); fold their paths and schemas in.
|
|
178
|
+
const gheSpec = createGithubEnterpriseApp({
|
|
179
|
+
appService: {} as never,
|
|
180
|
+
agentService: {} as never,
|
|
181
|
+
agentEnvService: {} as never,
|
|
182
|
+
sessionSecret: SESSION_SECRET,
|
|
183
|
+
adminApiKeys,
|
|
184
|
+
agentTokenService: { validate: async () => null },
|
|
185
|
+
}).getOpenAPI31Document({
|
|
186
|
+
openapi: "3.1.0",
|
|
187
|
+
info: { title: "github-enterprise (internal)", version: "0.1.0" },
|
|
188
|
+
});
|
|
189
|
+
for (const [path, def] of Object.entries(gheSpec.paths ?? {})) {
|
|
190
|
+
mergedPaths[path] = def as Record<string, unknown>;
|
|
191
|
+
}
|
|
192
|
+
|
|
175
193
|
return {
|
|
176
194
|
...adminSpec,
|
|
177
195
|
paths: mergedPaths,
|
|
@@ -180,6 +198,7 @@ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
|
|
|
180
198
|
schemas: {
|
|
181
199
|
...(adminSpec.components?.schemas ?? {}),
|
|
182
200
|
...(runtimeSpec.components?.schemas ?? {}),
|
|
201
|
+
...(gheSpec.components?.schemas ?? {}),
|
|
183
202
|
},
|
|
184
203
|
},
|
|
185
204
|
};
|
|
@@ -606,5 +606,70 @@
|
|
|
606
606
|
"body": {
|
|
607
607
|
"message": "Service Unavailable"
|
|
608
608
|
}
|
|
609
|
+
},
|
|
610
|
+
"public_app_success": {
|
|
611
|
+
"status": 200,
|
|
612
|
+
"body": {
|
|
613
|
+
"id": 777,
|
|
614
|
+
"slug": "example-agent-app",
|
|
615
|
+
"client_id": "Iv1.targetapp0001",
|
|
616
|
+
"name": "Example Agent App",
|
|
617
|
+
"permissions": {
|
|
618
|
+
"contents": "read",
|
|
619
|
+
"metadata": "read"
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
"public_app_private_404": {
|
|
624
|
+
"status": 404,
|
|
625
|
+
"body": {
|
|
626
|
+
"message": "Not Found",
|
|
627
|
+
"documentation_url": "https://docs.github.com/rest/apps/apps#get-an-app"
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
"public_app_no_client_id": {
|
|
631
|
+
"status": 200,
|
|
632
|
+
"body": {
|
|
633
|
+
"id": 777,
|
|
634
|
+
"slug": "example-agent-app"
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
"app_get_success": {
|
|
638
|
+
"status": 200,
|
|
639
|
+
"body": {
|
|
640
|
+
"id": 555,
|
|
641
|
+
"slug": "example-lookup-app",
|
|
642
|
+
"permissions": {
|
|
643
|
+
"metadata": "read"
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
"app_get_no_metadata": {
|
|
648
|
+
"status": 200,
|
|
649
|
+
"body": {
|
|
650
|
+
"id": 555,
|
|
651
|
+
"slug": "example-lookup-app",
|
|
652
|
+
"permissions": {
|
|
653
|
+
"issues": "read"
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
"app_get_401": {
|
|
658
|
+
"status": 401,
|
|
659
|
+
"body": {
|
|
660
|
+
"message": "A JSON web token could not be decoded",
|
|
661
|
+
"documentation_url": "https://docs.github.com/rest"
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
"access_token_no_installer_permission": {
|
|
665
|
+
"status": 201,
|
|
666
|
+
"body": {
|
|
667
|
+
"token": "ghs_cassetteInstallationToken0000000000",
|
|
668
|
+
"expires_at": "2026-11-09T13:00:00Z",
|
|
669
|
+
"permissions": {
|
|
670
|
+
"metadata": "read"
|
|
671
|
+
},
|
|
672
|
+
"repository_selection": "all"
|
|
673
|
+
}
|
|
609
674
|
}
|
|
610
675
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-app-api.ts
|
|
3
|
+
* Two small GitHub App reads that sit beside GithubEnterpriseClient:
|
|
4
|
+
*
|
|
5
|
+
* - fetchPublicApp(slug): GET /apps/{slug}, unauthenticated. Returns the App's
|
|
6
|
+
* client id. A private App is 404 here (no credentials are sent).
|
|
7
|
+
* - fetchAuthenticatedApp(): GET /app with an app JWT. Returns the App's
|
|
8
|
+
* declared permissions; used to validate the 'lookup' role.
|
|
9
|
+
*
|
|
10
|
+
* Errors are GithubEnterpriseError (message redacted of the key); the key
|
|
11
|
+
* never appears in a result or an error.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
GithubEnterpriseError,
|
|
16
|
+
mapGithubEnterpriseError,
|
|
17
|
+
} from "./github-enterprise-client.ts";
|
|
18
|
+
import { signGithubAppJwt } from "./github-app-jwt.ts";
|
|
19
|
+
|
|
20
|
+
interface FetchOpts {
|
|
21
|
+
apiBase?: string;
|
|
22
|
+
fetchFn?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PublicApp {
|
|
26
|
+
slug: string;
|
|
27
|
+
clientId: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getJson(
|
|
31
|
+
url: string,
|
|
32
|
+
headers: Record<string, string>,
|
|
33
|
+
context: string,
|
|
34
|
+
fetchFn: typeof fetch,
|
|
35
|
+
secrets: readonly string[],
|
|
36
|
+
): Promise<unknown> {
|
|
37
|
+
let resp: Response;
|
|
38
|
+
try {
|
|
39
|
+
resp = await fetchFn(url, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers: {
|
|
42
|
+
Accept: "application/vnd.github+json",
|
|
43
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
44
|
+
...headers,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
throw new GithubEnterpriseError("network", `${context}: network error`);
|
|
49
|
+
}
|
|
50
|
+
let data: unknown;
|
|
51
|
+
try {
|
|
52
|
+
data = await resp.json();
|
|
53
|
+
} catch {
|
|
54
|
+
data = undefined;
|
|
55
|
+
}
|
|
56
|
+
if (!resp.ok) {
|
|
57
|
+
throw mapGithubEnterpriseError(
|
|
58
|
+
resp.status,
|
|
59
|
+
resp.headers,
|
|
60
|
+
data,
|
|
61
|
+
context,
|
|
62
|
+
secrets,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** GET /apps/{slug} (public). `slug` must already be validated. */
|
|
69
|
+
export async function fetchPublicApp(
|
|
70
|
+
slug: string,
|
|
71
|
+
opts: FetchOpts = {},
|
|
72
|
+
): Promise<PublicApp> {
|
|
73
|
+
const data = (await getJson(
|
|
74
|
+
`${opts.apiBase ?? "https://api.github.com"}/apps/${encodeURIComponent(slug)}`,
|
|
75
|
+
{},
|
|
76
|
+
"GitHub public App lookup",
|
|
77
|
+
opts.fetchFn ?? fetch,
|
|
78
|
+
[],
|
|
79
|
+
)) as { slug?: unknown; client_id?: unknown } | undefined;
|
|
80
|
+
return {
|
|
81
|
+
slug: typeof data?.slug === "string" ? data.slug : slug,
|
|
82
|
+
clientId:
|
|
83
|
+
typeof data?.client_id === "string" && data.client_id
|
|
84
|
+
? data.client_id
|
|
85
|
+
: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** GET /app with an app JWT: the App's own declared permissions. */
|
|
90
|
+
export async function fetchAuthenticatedApp(
|
|
91
|
+
opts: FetchOpts & { appId: string; privateKey: string; now?: () => Date },
|
|
92
|
+
): Promise<{ permissions: Record<string, string> }> {
|
|
93
|
+
const jwt = signGithubAppJwt({
|
|
94
|
+
appId: opts.appId,
|
|
95
|
+
privateKey: opts.privateKey,
|
|
96
|
+
now: opts.now?.(),
|
|
97
|
+
});
|
|
98
|
+
const data = (await getJson(
|
|
99
|
+
`${opts.apiBase ?? "https://api.github.com"}/app`,
|
|
100
|
+
{ Authorization: `Bearer ${jwt}` },
|
|
101
|
+
"GitHub App lookup",
|
|
102
|
+
opts.fetchFn ?? fetch,
|
|
103
|
+
[jwt, opts.privateKey],
|
|
104
|
+
)) as { permissions?: Record<string, string> } | undefined;
|
|
105
|
+
return {
|
|
106
|
+
permissions:
|
|
107
|
+
data?.permissions && typeof data.permissions === "object"
|
|
108
|
+
? data.permissions
|
|
109
|
+
: {},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-enterprise-api.integration.test.ts
|
|
3
|
+
* Integration tests for the /github-enterprise routes against a real
|
|
4
|
+
* PostgreSQL DB and the GHE-1.2 cassettes (replayed through an injected
|
|
5
|
+
* fetchFn): the validate flow end to end, plus the isolation guarantee that
|
|
6
|
+
* no agent-facing path can return a stored key.
|
|
7
|
+
*
|
|
8
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
12
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
15
|
+
import { AgentEnvService } from "./agent-envs.ts";
|
|
16
|
+
import { AgentPluginService } from "./agent-plugins.ts";
|
|
17
|
+
import { AgentService } from "./agents.ts";
|
|
18
|
+
import { createAgentRuntimeApp } from "./api.ts";
|
|
19
|
+
import { parseAdminApiKeys } from "./api-auth.ts";
|
|
20
|
+
import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
|
|
21
|
+
import { GitHubEnterpriseAppService } from "./github-enterprise-apps.ts";
|
|
22
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
23
|
+
import { identityCrypto, makeStrictTokenCrypto } from "./token-crypto.ts";
|
|
24
|
+
|
|
25
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
26
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
27
|
+
|
|
28
|
+
const cassette: Record<
|
|
29
|
+
string,
|
|
30
|
+
{ status: number; headers?: Record<string, string>; body: unknown }
|
|
31
|
+
> = JSON.parse(
|
|
32
|
+
readFileSync(
|
|
33
|
+
new URL("./fixtures/github-enterprise-cassette.json", import.meta.url)
|
|
34
|
+
.pathname,
|
|
35
|
+
"utf-8",
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const { privateKey: PEM } = generateKeyPairSync("rsa", {
|
|
40
|
+
modulusLength: 2048,
|
|
41
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
42
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
43
|
+
});
|
|
44
|
+
const PEM_BODY = PEM.split("\n")[1] as string;
|
|
45
|
+
const ENC_KEY = "c".repeat(64);
|
|
46
|
+
const ADMIN = { Authorization: "Bearer tok-admin" };
|
|
47
|
+
|
|
48
|
+
function replay(routes: Record<string, string>): typeof fetch {
|
|
49
|
+
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
50
|
+
const u = new URL(input.toString());
|
|
51
|
+
const key = routes[`${init?.method ?? "GET"} ${u.pathname}`];
|
|
52
|
+
if (!key) throw new Error(`no route for ${init?.method} ${u.pathname}`);
|
|
53
|
+
const e = cassette[key];
|
|
54
|
+
return new Response(JSON.stringify(e?.body), {
|
|
55
|
+
status: e?.status,
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
});
|
|
58
|
+
}) as typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describeOrSkip("github-enterprise API (integration)", () => {
|
|
62
|
+
let prisma: PrismaClient;
|
|
63
|
+
let appService: GitHubEnterpriseAppService;
|
|
64
|
+
|
|
65
|
+
const build = (routes: Record<string, string> = {}) =>
|
|
66
|
+
createGithubEnterpriseApp({
|
|
67
|
+
appService,
|
|
68
|
+
agentService: new AgentService(prisma),
|
|
69
|
+
agentEnvService: new AgentEnvService(prisma, identityCrypto),
|
|
70
|
+
sessionSecret: "test-secret-32chars-for-ghe-api!!",
|
|
71
|
+
adminApiKeys: parseAdminApiKeys("root:tok-admin:*"),
|
|
72
|
+
agentTokenService: { validate: async () => null },
|
|
73
|
+
fetchFn: replay({
|
|
74
|
+
"GET /enterprises/acme/installation": "enterprise_installation_success",
|
|
75
|
+
"POST /app/installations/4242/access_tokens": "access_token_success",
|
|
76
|
+
...routes,
|
|
77
|
+
}),
|
|
78
|
+
apiBase: "https://ghe.test",
|
|
79
|
+
now: () => new Date("2026-11-09T12:00:00Z"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const put = (app: ReturnType<typeof build>) =>
|
|
83
|
+
app.request("/github-enterprise/apps/installer", {
|
|
84
|
+
method: "PUT",
|
|
85
|
+
headers: { ...ADMIN, "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
appId: "12345",
|
|
88
|
+
slug: "example-installer",
|
|
89
|
+
enterpriseSlug: "acme",
|
|
90
|
+
privateKey: PEM,
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeEach(async () => {
|
|
95
|
+
prisma = createAdminPrismaClient(TEST_DB as string);
|
|
96
|
+
await prisma.gitHubEnterpriseApp.deleteMany();
|
|
97
|
+
await prisma.agentEnv.deleteMany();
|
|
98
|
+
await prisma.agentToken.deleteMany();
|
|
99
|
+
await prisma.agentCronJob.deleteMany();
|
|
100
|
+
await prisma.agentTool.deleteMany();
|
|
101
|
+
await prisma.agent.deleteMany();
|
|
102
|
+
appService = new GitHubEnterpriseAppService(
|
|
103
|
+
prisma,
|
|
104
|
+
makeStrictTokenCrypto(ENC_KEY),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(async () => {
|
|
109
|
+
await prisma.$disconnect();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("unconfigured: status is empty and nothing is written", async () => {
|
|
113
|
+
const app = build();
|
|
114
|
+
const res = await app.request("/github-enterprise/apps", {
|
|
115
|
+
headers: ADMIN,
|
|
116
|
+
});
|
|
117
|
+
expect(await res.json()).toEqual({
|
|
118
|
+
configured: false,
|
|
119
|
+
installer: null,
|
|
120
|
+
lookup: null,
|
|
121
|
+
});
|
|
122
|
+
expect(await prisma.gitHubEnterpriseApp.count()).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("PUT then validate persists the result; the key is encrypted and never returned", async () => {
|
|
126
|
+
const app = build();
|
|
127
|
+
const saved = await put(app);
|
|
128
|
+
expect(saved.status).toBe(200);
|
|
129
|
+
expect(await saved.text()).not.toContain(PEM_BODY);
|
|
130
|
+
|
|
131
|
+
const res = await app.request(
|
|
132
|
+
"/github-enterprise/apps/installer/validate",
|
|
133
|
+
{
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: ADMIN,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
expect(text).not.toContain(PEM_BODY);
|
|
141
|
+
expect(text).not.toContain("ghs_cassette");
|
|
142
|
+
expect(JSON.parse(text)).toMatchObject({ ok: true, status: "valid" });
|
|
143
|
+
|
|
144
|
+
const row = await prisma.gitHubEnterpriseApp.findUnique({
|
|
145
|
+
where: { role: "installer" },
|
|
146
|
+
});
|
|
147
|
+
expect(row?.lastValidationStatus).toBe("valid");
|
|
148
|
+
expect(row?.lastValidatedAt).toBeInstanceOf(Date);
|
|
149
|
+
expect(row?.privateKeyEncrypted).not.toContain(PEM_BODY);
|
|
150
|
+
|
|
151
|
+
const status = await app.request("/github-enterprise/apps", {
|
|
152
|
+
headers: ADMIN,
|
|
153
|
+
});
|
|
154
|
+
const st = await status.text();
|
|
155
|
+
expect(st).not.toContain(PEM_BODY);
|
|
156
|
+
expect(st).not.toContain(row?.privateKeyEncrypted as string);
|
|
157
|
+
expect(JSON.parse(st).installer.lastValidationStatus).toBe("valid");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("validate against a cassette 403 records an error status", async () => {
|
|
161
|
+
const app = build({
|
|
162
|
+
"GET /enterprises/acme/installation": "enterprise_installation_403",
|
|
163
|
+
});
|
|
164
|
+
await put(app);
|
|
165
|
+
const res = await app.request(
|
|
166
|
+
"/github-enterprise/apps/installer/validate",
|
|
167
|
+
{
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: ADMIN,
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
expect(await res.json()).toMatchObject({ ok: false, status: "error" });
|
|
173
|
+
const row = await prisma.gitHubEnterpriseApp.findUnique({
|
|
174
|
+
where: { role: "installer" },
|
|
175
|
+
});
|
|
176
|
+
expect(row?.lastValidationStatus).toBe("error");
|
|
177
|
+
expect(row?.lastValidationError).not.toContain("Resource not accessible");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("PUT without SHIPWRIGHT_ENCRYPTION_KEY is refused with 422 and stores nothing", async () => {
|
|
181
|
+
appService = new GitHubEnterpriseAppService(
|
|
182
|
+
prisma,
|
|
183
|
+
makeStrictTokenCrypto(undefined),
|
|
184
|
+
);
|
|
185
|
+
const res = await put(build());
|
|
186
|
+
expect(res.status).toBe(422);
|
|
187
|
+
expect(await prisma.gitHubEnterpriseApp.count()).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("install resolves the agent's slug from its stored env against the cassettes", async () => {
|
|
191
|
+
const agent = await prisma.agent.create({ data: { name: "Ghe Agent" } });
|
|
192
|
+
await new AgentEnvService(prisma, identityCrypto).upsert(agent.id, {
|
|
193
|
+
GH_APP_SLUG: "example-agent-app",
|
|
194
|
+
});
|
|
195
|
+
const app = build({
|
|
196
|
+
"GET /apps/example-agent-app": "public_app_success",
|
|
197
|
+
"POST /enterprises/acme/apps/organizations/org-1/installations":
|
|
198
|
+
"install_created",
|
|
199
|
+
});
|
|
200
|
+
await put(app);
|
|
201
|
+
const res = await app.request("/github-enterprise/orgs/org-1/install", {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { ...ADMIN, "Content-Type": "application/json" },
|
|
204
|
+
body: JSON.stringify({ agentId: agent.id }),
|
|
205
|
+
});
|
|
206
|
+
expect(res.status).toBe(200);
|
|
207
|
+
expect(await res.json()).toMatchObject({ created: true, org: "org-1" });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("isolation: no agent-facing path returns the stored key", async () => {
|
|
211
|
+
const agent = await prisma.agent.create({ data: { name: "Iso Agent" } });
|
|
212
|
+
const envService = new AgentEnvService(prisma, identityCrypto);
|
|
213
|
+
await envService.upsert(agent.id, { GH_APP_SLUG: "example-agent-app" });
|
|
214
|
+
await put(build());
|
|
215
|
+
const row = await prisma.gitHubEnterpriseApp.findUnique({
|
|
216
|
+
where: { role: "installer" },
|
|
217
|
+
});
|
|
218
|
+
const needles = [
|
|
219
|
+
PEM_BODY,
|
|
220
|
+
"PRIVATE KEY",
|
|
221
|
+
row?.privateKeyEncrypted as string,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const surfaces: unknown[] = [
|
|
225
|
+
await envService.getConfigBundle(agent.id),
|
|
226
|
+
await envService.getByAgentId(agent.id),
|
|
227
|
+
];
|
|
228
|
+
const runtime = createAgentRuntimeApp({
|
|
229
|
+
agentEnvService: envService,
|
|
230
|
+
agentCronJobService: { list: async () => [] },
|
|
231
|
+
agentService: new AgentService(prisma),
|
|
232
|
+
agentPluginService: new AgentPluginService(prisma),
|
|
233
|
+
adminApiKeys: parseAdminApiKeys(`agent-key:agent-tok:${agent.id}`),
|
|
234
|
+
agentTokenService: { validate: async () => null },
|
|
235
|
+
sessionSecret: "test-secret-32chars-for-ghe-api!!",
|
|
236
|
+
});
|
|
237
|
+
for (const path of [`/${agent.id}/config`, `/${agent.id}/crons`]) {
|
|
238
|
+
const res = await runtime.request(path, {
|
|
239
|
+
headers: { Authorization: "Bearer agent-tok" },
|
|
240
|
+
});
|
|
241
|
+
expect(res.status).toBe(200);
|
|
242
|
+
surfaces.push(await res.text());
|
|
243
|
+
}
|
|
244
|
+
for (const surface of surfaces) {
|
|
245
|
+
const text =
|
|
246
|
+
typeof surface === "string" ? surface : JSON.stringify(surface);
|
|
247
|
+
for (const needle of needles) expect(text).not.toContain(needle);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The agent-bearer key cannot reach the key-bearing admin routes either.
|
|
251
|
+
const app = createGithubEnterpriseApp({
|
|
252
|
+
appService,
|
|
253
|
+
agentService: new AgentService(prisma),
|
|
254
|
+
agentEnvService: envService,
|
|
255
|
+
sessionSecret: "test-secret-32chars-for-ghe-api!!",
|
|
256
|
+
adminApiKeys: parseAdminApiKeys(`agent-key:agent-tok:${agent.id}`),
|
|
257
|
+
agentTokenService: { validate: async () => null },
|
|
258
|
+
});
|
|
259
|
+
const denied = await app.request("/github-enterprise/apps", {
|
|
260
|
+
headers: { Authorization: "Bearer agent-tok" },
|
|
261
|
+
});
|
|
262
|
+
expect(denied.status).toBe(403);
|
|
263
|
+
});
|
|
264
|
+
});
|