@app-vitals/agent-runtime 0.0.1 → 0.1.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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent/src/runtime/setup-github-auth.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests the GitHub auth initialization logic extracted from entrypoint.ts.
|
|
5
|
+
* Uses full dependency injection — no real GitHub API calls, no real git/gh processes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
afterEach,
|
|
10
|
+
beforeEach,
|
|
11
|
+
describe,
|
|
12
|
+
expect,
|
|
13
|
+
mock,
|
|
14
|
+
spyOn,
|
|
15
|
+
test,
|
|
16
|
+
} from "bun:test";
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdtempSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { createAgentReposRef } from "./agent-repos-ref.ts";
|
|
27
|
+
import { writeTokenFiles } from "./gh-token-files.ts";
|
|
28
|
+
import type { BotIdentity } from "./github-app-auth.ts";
|
|
29
|
+
import {
|
|
30
|
+
createGitHubAuthActiveRef,
|
|
31
|
+
createGitHubAuthStartGuard,
|
|
32
|
+
createGitHubInstallationsManagerRef,
|
|
33
|
+
reconcileGitHubInstallations,
|
|
34
|
+
startGitHubAuthIfPossible,
|
|
35
|
+
} from "./github-auth-startup.ts";
|
|
36
|
+
import { GitHubInstallationsManager } from "./github-installations.ts";
|
|
37
|
+
import {
|
|
38
|
+
type GitHubAuthDeps,
|
|
39
|
+
type GitHubInstallationsDeps,
|
|
40
|
+
setupGitHubAuth,
|
|
41
|
+
} from "./setup-github-auth.ts";
|
|
42
|
+
|
|
43
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function makeTokenManager(token = "ghs_test_token") {
|
|
46
|
+
const getToken = mock(async () => token);
|
|
47
|
+
const startBackgroundRefresh = mock(
|
|
48
|
+
(_onRefresh: (t: string) => Promise<void>) => {},
|
|
49
|
+
);
|
|
50
|
+
return { getToken, startBackgroundRefresh };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeSpawnSync(status = 0) {
|
|
54
|
+
return mock(
|
|
55
|
+
(
|
|
56
|
+
_cmd: string,
|
|
57
|
+
_args: string[],
|
|
58
|
+
_opts: {
|
|
59
|
+
stdio: string;
|
|
60
|
+
env: Record<string, string | undefined>;
|
|
61
|
+
},
|
|
62
|
+
) => ({ status }),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeBotIdentity(
|
|
67
|
+
identity: BotIdentity = {
|
|
68
|
+
slug: "keanu-hifriends",
|
|
69
|
+
name: "Keanu HiFriends",
|
|
70
|
+
userId: 987654,
|
|
71
|
+
},
|
|
72
|
+
) {
|
|
73
|
+
return mock(async () => identity);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function neverCalledBotIdentity() {
|
|
77
|
+
return mock(async (): Promise<BotIdentity> => {
|
|
78
|
+
throw new Error("should not be called");
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const TEST_TOKEN_PATH = "/tmp/test-vitals-agent-gh-token";
|
|
83
|
+
const TEST_HELPER_PATH = "/tmp/test-bin/git-credential-vitals.sh";
|
|
84
|
+
|
|
85
|
+
function makeWriteToken() {
|
|
86
|
+
return mock((_token: string) => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function neverCalledWriteToken() {
|
|
90
|
+
return mock((_token: string) => {
|
|
91
|
+
throw new Error("should not be called");
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Tests: App path ──────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe("setupGitHubAuth — App path", () => {
|
|
98
|
+
test("writes token to file, registers credential helper via git config, configures bot identity, starts background refresh", async () => {
|
|
99
|
+
const { getToken, startBackgroundRefresh } =
|
|
100
|
+
makeTokenManager("ghs_app_token");
|
|
101
|
+
const spawnSync = makeSpawnSync();
|
|
102
|
+
const writeToken = makeWriteToken();
|
|
103
|
+
const getBotIdentity = makeBotIdentity({
|
|
104
|
+
slug: "keanu-hifriends",
|
|
105
|
+
name: "Keanu HiFriends",
|
|
106
|
+
userId: 987654,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const deps: GitHubAuthDeps = {
|
|
110
|
+
env: {
|
|
111
|
+
GH_APP_ID: "123",
|
|
112
|
+
GH_APP_INSTALLATION_ID: "456",
|
|
113
|
+
GH_APP_PRIVATE_KEY: "fake-private-key",
|
|
114
|
+
},
|
|
115
|
+
createTokenManager: mock(() => ({ getToken, startBackgroundRefresh })),
|
|
116
|
+
getBotIdentity,
|
|
117
|
+
spawnSync,
|
|
118
|
+
writeToken,
|
|
119
|
+
tokenPath: TEST_TOKEN_PATH,
|
|
120
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await setupGitHubAuth(deps);
|
|
124
|
+
|
|
125
|
+
expect(deps.createTokenManager).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(getToken).toHaveBeenCalledTimes(1);
|
|
127
|
+
|
|
128
|
+
// App path must NOT mutate env.GH_TOKEN — the credential helper reads from
|
|
129
|
+
// GH_TOKEN_FILE on disk, not from a process-env var that doesn't propagate
|
|
130
|
+
// to subprocesses through the kernel-level env snapshot.
|
|
131
|
+
expect(deps.env.GH_TOKEN).toBeUndefined();
|
|
132
|
+
expect(deps.env.GH_TOKEN_FILE).toBe(TEST_TOKEN_PATH);
|
|
133
|
+
|
|
134
|
+
// Token written to disk via the injected writeToken
|
|
135
|
+
expect(writeToken).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(writeToken).toHaveBeenCalledWith("ghs_app_token");
|
|
137
|
+
|
|
138
|
+
expect(getBotIdentity).toHaveBeenCalledTimes(1);
|
|
139
|
+
|
|
140
|
+
// Credential helper registered via git config (no `gh auth setup-git`)
|
|
141
|
+
const expectedEnv = expect.objectContaining({
|
|
142
|
+
GH_TOKEN_FILE: TEST_TOKEN_PATH,
|
|
143
|
+
});
|
|
144
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
145
|
+
"git",
|
|
146
|
+
[
|
|
147
|
+
"config",
|
|
148
|
+
"--global",
|
|
149
|
+
"credential.https://github.com.helper",
|
|
150
|
+
`!${TEST_HELPER_PATH}`,
|
|
151
|
+
],
|
|
152
|
+
{ stdio: "inherit", env: expectedEnv },
|
|
153
|
+
);
|
|
154
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
155
|
+
"git",
|
|
156
|
+
[
|
|
157
|
+
"config",
|
|
158
|
+
"--global",
|
|
159
|
+
"credential.https://github.com.useHttpPath",
|
|
160
|
+
"true",
|
|
161
|
+
],
|
|
162
|
+
{ stdio: "inherit", env: expectedEnv },
|
|
163
|
+
);
|
|
164
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
165
|
+
"git",
|
|
166
|
+
["config", "--global", "user.name", "keanu-hifriends[bot]"],
|
|
167
|
+
{ stdio: "inherit", env: expectedEnv },
|
|
168
|
+
);
|
|
169
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
170
|
+
"git",
|
|
171
|
+
[
|
|
172
|
+
"config",
|
|
173
|
+
"--global",
|
|
174
|
+
"user.email",
|
|
175
|
+
"987654+keanu-hifriends[bot]@users.noreply.github.com",
|
|
176
|
+
],
|
|
177
|
+
{ stdio: "inherit", env: expectedEnv },
|
|
178
|
+
);
|
|
179
|
+
// safe.directory always set unconditionally
|
|
180
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
181
|
+
"git",
|
|
182
|
+
["config", "--global", "safe.directory", "*"],
|
|
183
|
+
{ stdio: "inherit", env: expect.any(Object) },
|
|
184
|
+
);
|
|
185
|
+
// No `gh auth setup-git` invocation on the App path anymore
|
|
186
|
+
for (const call of spawnSync.mock.calls) {
|
|
187
|
+
expect(call[0]).not.toBe("gh");
|
|
188
|
+
}
|
|
189
|
+
expect(spawnSync).toHaveBeenCalledTimes(5);
|
|
190
|
+
expect(startBackgroundRefresh).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("logs an error when git config credential helper registration exits non-zero", async () => {
|
|
194
|
+
const { getToken, startBackgroundRefresh } = makeTokenManager();
|
|
195
|
+
const spawnSync = makeSpawnSync(1);
|
|
196
|
+
const errorSpy = mock((..._args: unknown[]) => {});
|
|
197
|
+
|
|
198
|
+
await setupGitHubAuth({
|
|
199
|
+
env: {
|
|
200
|
+
GH_APP_ID: "123",
|
|
201
|
+
GH_APP_INSTALLATION_ID: "456",
|
|
202
|
+
GH_APP_PRIVATE_KEY: "fake-private-key",
|
|
203
|
+
},
|
|
204
|
+
createTokenManager: mock(() => ({ getToken, startBackgroundRefresh })),
|
|
205
|
+
getBotIdentity: makeBotIdentity(),
|
|
206
|
+
spawnSync,
|
|
207
|
+
writeToken: makeWriteToken(),
|
|
208
|
+
tokenPath: TEST_TOKEN_PATH,
|
|
209
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
210
|
+
logger: { error: errorSpy },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
214
|
+
// call[0] = safe.directory failure (fires first, unconditionally)
|
|
215
|
+
const safeDirectoryCall = errorSpy.mock.calls[0]?.[0] as string;
|
|
216
|
+
expect(safeDirectoryCall).toContain("safe.directory");
|
|
217
|
+
expect(safeDirectoryCall).toContain("status 1");
|
|
218
|
+
// call[1] = credential helper failure (the actual subject of this test)
|
|
219
|
+
const credHelperCall = errorSpy.mock.calls[1]?.[0] as string;
|
|
220
|
+
expect(credHelperCall).toContain("credential");
|
|
221
|
+
expect(credHelperCall).toContain("status 1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("background refresh callback rewrites the token file only — no env mutation, no further spawnSync calls", async () => {
|
|
225
|
+
let capturedCallback: ((t: string) => Promise<void>) | null = null;
|
|
226
|
+
const getToken = mock(async () => "ghs_first_token");
|
|
227
|
+
const startBackgroundRefresh = mock((cb: (t: string) => Promise<void>) => {
|
|
228
|
+
capturedCallback = cb;
|
|
229
|
+
});
|
|
230
|
+
const spawnSync = makeSpawnSync();
|
|
231
|
+
const writeToken = makeWriteToken();
|
|
232
|
+
|
|
233
|
+
const deps: GitHubAuthDeps = {
|
|
234
|
+
env: {
|
|
235
|
+
GH_APP_ID: "123",
|
|
236
|
+
GH_APP_INSTALLATION_ID: "456",
|
|
237
|
+
GH_APP_PRIVATE_KEY: "fake-private-key",
|
|
238
|
+
},
|
|
239
|
+
createTokenManager: mock(() => ({ getToken, startBackgroundRefresh })),
|
|
240
|
+
getBotIdentity: makeBotIdentity(),
|
|
241
|
+
spawnSync,
|
|
242
|
+
writeToken,
|
|
243
|
+
tokenPath: TEST_TOKEN_PATH,
|
|
244
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
await setupGitHubAuth(deps);
|
|
248
|
+
const writeCallsBeforeRefresh = writeToken.mock.calls.length;
|
|
249
|
+
const spawnCallsBeforeRefresh = spawnSync.mock.calls.length;
|
|
250
|
+
|
|
251
|
+
expect(capturedCallback).not.toBeNull();
|
|
252
|
+
// biome-ignore lint/style/noNonNullAssertion: asserted above
|
|
253
|
+
await capturedCallback!("ghs_refreshed_token");
|
|
254
|
+
|
|
255
|
+
// refresh writes the new token to the file...
|
|
256
|
+
expect(writeToken.mock.calls.length).toBe(writeCallsBeforeRefresh + 1);
|
|
257
|
+
expect(writeToken).toHaveBeenLastCalledWith("ghs_refreshed_token");
|
|
258
|
+
// ...and does NOT mutate env.GH_TOKEN or spawn anything
|
|
259
|
+
expect(deps.env.GH_TOKEN).toBeUndefined();
|
|
260
|
+
expect(spawnSync.mock.calls.length).toBe(spawnCallsBeforeRefresh);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ─── Tests: PAT path ─────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
describe("setupGitHubAuth — PAT path", () => {
|
|
267
|
+
test("calls spawnSync with gh auth setup-git when only GH_TOKEN is set", async () => {
|
|
268
|
+
const spawnSync = makeSpawnSync();
|
|
269
|
+
const createTokenManager = mock(() => {
|
|
270
|
+
throw new Error("should not be called");
|
|
271
|
+
});
|
|
272
|
+
const getBotIdentity = neverCalledBotIdentity();
|
|
273
|
+
const writeToken = neverCalledWriteToken();
|
|
274
|
+
|
|
275
|
+
const deps: GitHubAuthDeps = {
|
|
276
|
+
env: { GH_TOKEN: "ghp_test_pat" },
|
|
277
|
+
createTokenManager,
|
|
278
|
+
getBotIdentity,
|
|
279
|
+
spawnSync,
|
|
280
|
+
writeToken,
|
|
281
|
+
tokenPath: TEST_TOKEN_PATH,
|
|
282
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
await setupGitHubAuth(deps);
|
|
286
|
+
|
|
287
|
+
expect(spawnSync).toHaveBeenCalledTimes(2);
|
|
288
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
289
|
+
"git",
|
|
290
|
+
["config", "--global", "safe.directory", "*"],
|
|
291
|
+
{ stdio: "inherit", env: expect.any(Object) },
|
|
292
|
+
);
|
|
293
|
+
expect(spawnSync).toHaveBeenCalledWith("gh", ["auth", "setup-git"], {
|
|
294
|
+
stdio: "inherit",
|
|
295
|
+
env: expect.objectContaining({ GH_TOKEN: "ghp_test_pat" }),
|
|
296
|
+
});
|
|
297
|
+
expect(createTokenManager).not.toHaveBeenCalled();
|
|
298
|
+
expect(getBotIdentity).not.toHaveBeenCalled();
|
|
299
|
+
expect(writeToken).not.toHaveBeenCalled();
|
|
300
|
+
// PAT path doesn't touch GH_TOKEN_FILE — it relies on gh's helper
|
|
301
|
+
expect(deps.env.GH_TOKEN_FILE).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ─── Tests: Skip path ────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe("setupGitHubAuth — no auth configured", () => {
|
|
308
|
+
test("skips all GitHub setup when neither GH_TOKEN nor GH_APP_* vars are set", async () => {
|
|
309
|
+
const spawnSync = makeSpawnSync();
|
|
310
|
+
const createTokenManager = mock(() => {
|
|
311
|
+
throw new Error("should not be called");
|
|
312
|
+
});
|
|
313
|
+
const getBotIdentity = neverCalledBotIdentity();
|
|
314
|
+
const writeToken = neverCalledWriteToken();
|
|
315
|
+
|
|
316
|
+
const deps: GitHubAuthDeps = {
|
|
317
|
+
env: {},
|
|
318
|
+
createTokenManager,
|
|
319
|
+
getBotIdentity,
|
|
320
|
+
spawnSync,
|
|
321
|
+
writeToken,
|
|
322
|
+
tokenPath: TEST_TOKEN_PATH,
|
|
323
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
await setupGitHubAuth(deps);
|
|
327
|
+
|
|
328
|
+
// safe.directory is set even when no auth is configured
|
|
329
|
+
expect(spawnSync).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
331
|
+
"git",
|
|
332
|
+
["config", "--global", "safe.directory", "*"],
|
|
333
|
+
{ stdio: "inherit", env: expect.any(Object) },
|
|
334
|
+
);
|
|
335
|
+
expect(createTokenManager).not.toHaveBeenCalled();
|
|
336
|
+
expect(getBotIdentity).not.toHaveBeenCalled();
|
|
337
|
+
expect(writeToken).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("skips when only some GH_APP_* vars are present (incomplete)", async () => {
|
|
341
|
+
const spawnSync = makeSpawnSync();
|
|
342
|
+
const createTokenManager = mock(() => {
|
|
343
|
+
throw new Error("should not be called");
|
|
344
|
+
});
|
|
345
|
+
const getBotIdentity = neverCalledBotIdentity();
|
|
346
|
+
const writeToken = neverCalledWriteToken();
|
|
347
|
+
|
|
348
|
+
const deps: GitHubAuthDeps = {
|
|
349
|
+
env: { GH_APP_ID: "123" },
|
|
350
|
+
createTokenManager,
|
|
351
|
+
getBotIdentity,
|
|
352
|
+
spawnSync,
|
|
353
|
+
writeToken,
|
|
354
|
+
tokenPath: TEST_TOKEN_PATH,
|
|
355
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
await setupGitHubAuth(deps);
|
|
359
|
+
|
|
360
|
+
// safe.directory is set even with incomplete GH_APP_* vars
|
|
361
|
+
expect(spawnSync).toHaveBeenCalledTimes(1);
|
|
362
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
363
|
+
"git",
|
|
364
|
+
["config", "--global", "safe.directory", "*"],
|
|
365
|
+
{ stdio: "inherit", env: expect.any(Object) },
|
|
366
|
+
);
|
|
367
|
+
expect(createTokenManager).not.toHaveBeenCalled();
|
|
368
|
+
expect(getBotIdentity).not.toHaveBeenCalled();
|
|
369
|
+
expect(writeToken).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ─── Tests: installations manager (MGI-2.4) ─────────────────────────────────
|
|
374
|
+
|
|
375
|
+
type RawInstall = { id: number; owner: string };
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Real GitHubInstallationsManager with injected auth/fetch/clock and a fake
|
|
379
|
+
* setIntervalFn, plus real token files in a temp agent home.
|
|
380
|
+
*/
|
|
381
|
+
function makeInstallationsHarness(
|
|
382
|
+
opts: {
|
|
383
|
+
installs?: RawInstall[];
|
|
384
|
+
pin?: number;
|
|
385
|
+
scopeOwners?: string[] | null;
|
|
386
|
+
discoveryStatus?: number;
|
|
387
|
+
} = {},
|
|
388
|
+
) {
|
|
389
|
+
const home = mkdtempSync(join(tmpdir(), "mgi-24-"));
|
|
390
|
+
let installs = opts.installs ?? [];
|
|
391
|
+
let discoveryStatus = opts.discoveryStatus ?? 200;
|
|
392
|
+
let scopeOwners = opts.scopeOwners === undefined ? null : opts.scopeOwners;
|
|
393
|
+
const intervals: { fn: () => void; cleared: boolean }[] = [];
|
|
394
|
+
const created: GitHubInstallationsManager[] = [];
|
|
395
|
+
const activated: unknown[] = [];
|
|
396
|
+
let nowYear = 2029;
|
|
397
|
+
const failMintIds = new Set<number>();
|
|
398
|
+
|
|
399
|
+
const auth = async (p?: { type: string; installationId?: number }) => {
|
|
400
|
+
if (p?.type === "app")
|
|
401
|
+
return { token: "jwt", expiresAt: "", type: "app", tokenType: "app" };
|
|
402
|
+
if (p?.installationId !== undefined && failMintIds.has(p.installationId))
|
|
403
|
+
throw new Error("mint failed");
|
|
404
|
+
return {
|
|
405
|
+
token: `ghs_inst_${p?.installationId}${nowYear > 2029 ? `_r${nowYear}` : ""}`,
|
|
406
|
+
expiresAt: new Date(Date.UTC(nowYear + 1, 0, 1)).toISOString(),
|
|
407
|
+
type: "token",
|
|
408
|
+
tokenType: "installation",
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
const fetchFn = (async () =>
|
|
412
|
+
new Response(
|
|
413
|
+
JSON.stringify(
|
|
414
|
+
installs.map((i) => ({
|
|
415
|
+
id: i.id,
|
|
416
|
+
account: { login: i.owner },
|
|
417
|
+
suspended_at: null,
|
|
418
|
+
})),
|
|
419
|
+
),
|
|
420
|
+
{ status: discoveryStatus },
|
|
421
|
+
)) as unknown as typeof fetch;
|
|
422
|
+
const setIntervalFn = ((fn: () => void) => {
|
|
423
|
+
const rec = { fn, cleared: false };
|
|
424
|
+
intervals.push(rec);
|
|
425
|
+
return rec as unknown as ReturnType<typeof setInterval>;
|
|
426
|
+
}) as unknown as typeof setInterval;
|
|
427
|
+
const clearIntervalFn = ((h: { cleared: boolean }) => {
|
|
428
|
+
h.cleared = true;
|
|
429
|
+
}) as unknown as typeof clearInterval;
|
|
430
|
+
|
|
431
|
+
const installations: GitHubInstallationsDeps = {
|
|
432
|
+
createManager: (onTokensChanged) => {
|
|
433
|
+
const m = new GitHubInstallationsManager({
|
|
434
|
+
auth,
|
|
435
|
+
fetchFn,
|
|
436
|
+
clock: { now: () => new Date(Date.UTC(nowYear, 0, 1)) },
|
|
437
|
+
setIntervalFn,
|
|
438
|
+
clearIntervalFn,
|
|
439
|
+
pinnedId: opts.pin ?? null,
|
|
440
|
+
onEvent: (e) => {
|
|
441
|
+
if (e.type === "minted") onTokensChanged();
|
|
442
|
+
},
|
|
443
|
+
onChange: () => onTokensChanged(),
|
|
444
|
+
});
|
|
445
|
+
created.push(m);
|
|
446
|
+
return m;
|
|
447
|
+
},
|
|
448
|
+
getScopeOwners: () => scopeOwners,
|
|
449
|
+
writeTokenFiles: (input) => writeTokenFiles(home, input),
|
|
450
|
+
onActivated: (m) => activated.push(m),
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const env: Record<string, string | undefined> = {
|
|
454
|
+
GH_APP_ID: "123",
|
|
455
|
+
GH_APP_PRIVATE_KEY: "fake-private-key",
|
|
456
|
+
};
|
|
457
|
+
if (opts.pin !== undefined) env.GH_APP_INSTALLATION_ID = String(opts.pin);
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
home,
|
|
461
|
+
env,
|
|
462
|
+
installations,
|
|
463
|
+
intervals,
|
|
464
|
+
created,
|
|
465
|
+
activated,
|
|
466
|
+
tokenPath: join(home, "gh-token"),
|
|
467
|
+
/** Expires every cached token and makes the given installations fail to re-mint. */
|
|
468
|
+
expireTokens: (failIds: number[] = []) => {
|
|
469
|
+
nowYear += 2;
|
|
470
|
+
failMintIds.clear();
|
|
471
|
+
for (const id of failIds) failMintIds.add(id);
|
|
472
|
+
},
|
|
473
|
+
setInstalls: (i: RawInstall[]) => {
|
|
474
|
+
installs = i;
|
|
475
|
+
},
|
|
476
|
+
setScopeOwners: (o: string[] | null) => {
|
|
477
|
+
scopeOwners = o;
|
|
478
|
+
},
|
|
479
|
+
setDiscoveryStatus: (s: number) => {
|
|
480
|
+
discoveryStatus = s;
|
|
481
|
+
},
|
|
482
|
+
cleanup: () => rmSync(home, { recursive: true, force: true }),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Lets fire-and-forget token-file writes settle. */
|
|
487
|
+
async function flush(): Promise<void> {
|
|
488
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
describe("setupGitHubAuth — installations manager", () => {
|
|
492
|
+
let logSpy: ReturnType<typeof spyOn>;
|
|
493
|
+
const harnesses: { cleanup: () => void }[] = [];
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
496
|
+
});
|
|
497
|
+
afterEach(() => {
|
|
498
|
+
logSpy.mockRestore();
|
|
499
|
+
for (const h of harnesses.splice(0)) h.cleanup();
|
|
500
|
+
});
|
|
501
|
+
function harness(opts: Parameters<typeof makeInstallationsHarness>[0]) {
|
|
502
|
+
const h = makeInstallationsHarness(opts);
|
|
503
|
+
harnesses.push(h);
|
|
504
|
+
return h;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
test("pinned-only (unsynced scope) produces the same spawnSync calls and token file as the legacy pinned path", async () => {
|
|
508
|
+
// Legacy pinned path (no installations deps) — today's behavior.
|
|
509
|
+
const legacy = harness({ pin: 456 });
|
|
510
|
+
const legacySpawn = makeSpawnSync();
|
|
511
|
+
const legacyTm = makeTokenManager("ghs_inst_456");
|
|
512
|
+
const legacyActivated = await setupGitHubAuth({
|
|
513
|
+
env: { ...legacy.env },
|
|
514
|
+
createTokenManager: mock(() => legacyTm),
|
|
515
|
+
getBotIdentity: makeBotIdentity(),
|
|
516
|
+
spawnSync: legacySpawn,
|
|
517
|
+
writeToken: (t) => writeTokenFiles(legacy.home, { defaultToken: t }),
|
|
518
|
+
tokenPath: legacy.tokenPath,
|
|
519
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const h = harness({
|
|
523
|
+
pin: 456,
|
|
524
|
+
installs: [
|
|
525
|
+
{ id: 456, owner: "acme" },
|
|
526
|
+
{ id: 789, owner: "other" },
|
|
527
|
+
],
|
|
528
|
+
scopeOwners: null,
|
|
529
|
+
});
|
|
530
|
+
const spawnSync = makeSpawnSync();
|
|
531
|
+
const createTokenManager = mock(() => makeTokenManager());
|
|
532
|
+
const activated = await setupGitHubAuth({
|
|
533
|
+
env: h.env,
|
|
534
|
+
createTokenManager,
|
|
535
|
+
getBotIdentity: makeBotIdentity(),
|
|
536
|
+
spawnSync,
|
|
537
|
+
writeToken: neverCalledWriteToken(),
|
|
538
|
+
tokenPath: h.tokenPath,
|
|
539
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
540
|
+
installations: h.installations,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
expect(legacyActivated).toBe(true);
|
|
544
|
+
expect(activated).toBe(true);
|
|
545
|
+
const norm = (calls: unknown[][], tokenPath: string) =>
|
|
546
|
+
JSON.stringify(calls).replaceAll(tokenPath, "<TOKEN_PATH>");
|
|
547
|
+
expect(norm(spawnSync.mock.calls, h.tokenPath)).toBe(
|
|
548
|
+
norm(legacySpawn.mock.calls, legacy.tokenPath),
|
|
549
|
+
);
|
|
550
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe(
|
|
551
|
+
readFileSync(legacy.tokenPath, "utf8"),
|
|
552
|
+
);
|
|
553
|
+
expect(readdirSync(join(h.home, "gh-token.d"))).toEqual(["acme"]);
|
|
554
|
+
expect(h.env.GH_TOKEN_FILE).toBe(h.tokenPath);
|
|
555
|
+
expect(createTokenManager).not.toHaveBeenCalled();
|
|
556
|
+
expect(h.intervals).toHaveLength(1);
|
|
557
|
+
expect(h.activated).toEqual([h.created[0]]);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("a single in-scope installation writes gh-token.d/<owner>", async () => {
|
|
561
|
+
const h = harness({
|
|
562
|
+
installs: [{ id: 11, owner: "Acme" }],
|
|
563
|
+
scopeOwners: ["acme"],
|
|
564
|
+
});
|
|
565
|
+
await setupGitHubAuth({
|
|
566
|
+
env: h.env,
|
|
567
|
+
createTokenManager: mock(() => makeTokenManager()),
|
|
568
|
+
getBotIdentity: makeBotIdentity(),
|
|
569
|
+
spawnSync: makeSpawnSync(),
|
|
570
|
+
writeToken: neverCalledWriteToken(),
|
|
571
|
+
tokenPath: h.tokenPath,
|
|
572
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
573
|
+
installations: h.installations,
|
|
574
|
+
});
|
|
575
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe("ghs_inst_11");
|
|
576
|
+
expect(readdirSync(join(h.home, "gh-token.d"))).toEqual(["acme"]);
|
|
577
|
+
expect(readFileSync(join(h.home, "gh-token.d", "acme"), "utf8")).toBe(
|
|
578
|
+
"ghs_inst_11",
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("App id + key with two in-scope installations activates and writes per-owner token files", async () => {
|
|
583
|
+
const h = harness({
|
|
584
|
+
installs: [
|
|
585
|
+
{ id: 11, owner: "Acme" },
|
|
586
|
+
{ id: 22, owner: "other" },
|
|
587
|
+
{ id: 33, owner: "out-of-scope" },
|
|
588
|
+
],
|
|
589
|
+
scopeOwners: ["acme", "other"],
|
|
590
|
+
});
|
|
591
|
+
const spawnSync = makeSpawnSync();
|
|
592
|
+
const activated = await setupGitHubAuth({
|
|
593
|
+
env: h.env,
|
|
594
|
+
createTokenManager: mock(() => {
|
|
595
|
+
throw new Error("should not be called");
|
|
596
|
+
}),
|
|
597
|
+
getBotIdentity: makeBotIdentity(),
|
|
598
|
+
spawnSync,
|
|
599
|
+
writeToken: neverCalledWriteToken(),
|
|
600
|
+
tokenPath: h.tokenPath,
|
|
601
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
602
|
+
installations: h.installations,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
expect(activated).toBe(true);
|
|
606
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe("ghs_inst_11");
|
|
607
|
+
expect(readdirSync(join(h.home, "gh-token.d")).sort()).toEqual([
|
|
608
|
+
"acme",
|
|
609
|
+
"other",
|
|
610
|
+
]);
|
|
611
|
+
expect(readFileSync(join(h.home, "gh-token.d", "acme"), "utf8")).toBe(
|
|
612
|
+
"ghs_inst_11",
|
|
613
|
+
);
|
|
614
|
+
expect(readFileSync(join(h.home, "gh-token.d", "other"), "utf8")).toBe(
|
|
615
|
+
"ghs_inst_22",
|
|
616
|
+
);
|
|
617
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
618
|
+
"git",
|
|
619
|
+
[
|
|
620
|
+
"config",
|
|
621
|
+
"--global",
|
|
622
|
+
"credential.https://github.com.helper",
|
|
623
|
+
`!${TEST_HELPER_PATH}`,
|
|
624
|
+
],
|
|
625
|
+
expect.any(Object),
|
|
626
|
+
);
|
|
627
|
+
expect(h.intervals).toHaveLength(1);
|
|
628
|
+
expect(h.activated).toHaveLength(1);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("a transient mint failure for one of two installations keeps its last good file; the other still refreshes", async () => {
|
|
632
|
+
const h = harness({
|
|
633
|
+
installs: [
|
|
634
|
+
{ id: 11, owner: "acme" },
|
|
635
|
+
{ id: 22, owner: "other" },
|
|
636
|
+
],
|
|
637
|
+
scopeOwners: ["acme", "other"],
|
|
638
|
+
});
|
|
639
|
+
await setupGitHubAuth({
|
|
640
|
+
env: h.env,
|
|
641
|
+
createTokenManager: mock(() => makeTokenManager()),
|
|
642
|
+
getBotIdentity: makeBotIdentity(),
|
|
643
|
+
spawnSync: makeSpawnSync(),
|
|
644
|
+
writeToken: neverCalledWriteToken(),
|
|
645
|
+
tokenPath: h.tokenPath,
|
|
646
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
647
|
+
installations: h.installations,
|
|
648
|
+
});
|
|
649
|
+
h.expireTokens([22]);
|
|
650
|
+
await h.intervals[0].fn();
|
|
651
|
+
await flush();
|
|
652
|
+
|
|
653
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe("ghs_inst_11_r2031");
|
|
654
|
+
expect(readFileSync(join(h.home, "gh-token.d", "acme"), "utf8")).toBe(
|
|
655
|
+
"ghs_inst_11_r2031",
|
|
656
|
+
);
|
|
657
|
+
expect(readFileSync(join(h.home, "gh-token.d", "other"), "utf8")).toBe(
|
|
658
|
+
"ghs_inst_22",
|
|
659
|
+
);
|
|
660
|
+
const lines = logSpy.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
661
|
+
// one line per token-file write; a refresh can trigger more than one write
|
|
662
|
+
expect(lines).toContain(
|
|
663
|
+
"[github-auth] mint failed for other — keeping last good token file",
|
|
664
|
+
);
|
|
665
|
+
expect(lines.join("\n")).not.toContain("ghs_inst_");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("when the default installation's mint fails, gh-token keeps its last good value", async () => {
|
|
669
|
+
const h = harness({
|
|
670
|
+
installs: [
|
|
671
|
+
{ id: 11, owner: "acme" },
|
|
672
|
+
{ id: 22, owner: "other" },
|
|
673
|
+
],
|
|
674
|
+
scopeOwners: ["acme", "other"],
|
|
675
|
+
});
|
|
676
|
+
await setupGitHubAuth({
|
|
677
|
+
env: h.env,
|
|
678
|
+
createTokenManager: mock(() => makeTokenManager()),
|
|
679
|
+
getBotIdentity: makeBotIdentity(),
|
|
680
|
+
spawnSync: makeSpawnSync(),
|
|
681
|
+
writeToken: neverCalledWriteToken(),
|
|
682
|
+
tokenPath: h.tokenPath,
|
|
683
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
684
|
+
installations: h.installations,
|
|
685
|
+
});
|
|
686
|
+
h.expireTokens([11]);
|
|
687
|
+
await h.intervals[0].fn();
|
|
688
|
+
await flush();
|
|
689
|
+
|
|
690
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe("ghs_inst_11");
|
|
691
|
+
expect(readFileSync(join(h.home, "gh-token.d", "acme"), "utf8")).toBe(
|
|
692
|
+
"ghs_inst_11",
|
|
693
|
+
);
|
|
694
|
+
expect(readFileSync(join(h.home, "gh-token.d", "other"), "utf8")).toBe(
|
|
695
|
+
"ghs_inst_22_r2031",
|
|
696
|
+
);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("initial boot is unchanged: a failed non-default mint is dropped and the default is written", async () => {
|
|
700
|
+
const h = harness({
|
|
701
|
+
installs: [
|
|
702
|
+
{ id: 11, owner: "acme" },
|
|
703
|
+
{ id: 22, owner: "other" },
|
|
704
|
+
],
|
|
705
|
+
scopeOwners: ["acme", "other"],
|
|
706
|
+
});
|
|
707
|
+
h.expireTokens([22]);
|
|
708
|
+
await setupGitHubAuth({
|
|
709
|
+
env: h.env,
|
|
710
|
+
createTokenManager: mock(() => makeTokenManager()),
|
|
711
|
+
getBotIdentity: makeBotIdentity(),
|
|
712
|
+
spawnSync: makeSpawnSync(),
|
|
713
|
+
writeToken: neverCalledWriteToken(),
|
|
714
|
+
tokenPath: h.tokenPath,
|
|
715
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
716
|
+
installations: h.installations,
|
|
717
|
+
});
|
|
718
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe("ghs_inst_11_r2031");
|
|
719
|
+
expect(readdirSync(join(h.home, "gh-token.d"))).toEqual(["acme"]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("App id + key + PAT with zero installations stays on the PAT path", async () => {
|
|
723
|
+
const h = harness({ installs: [], scopeOwners: ["acme"] });
|
|
724
|
+
h.env.GH_TOKEN = "ghp_test_pat";
|
|
725
|
+
const spawnSync = makeSpawnSync();
|
|
726
|
+
const activated = await setupGitHubAuth({
|
|
727
|
+
env: h.env,
|
|
728
|
+
createTokenManager: mock(() => {
|
|
729
|
+
throw new Error("should not be called");
|
|
730
|
+
}),
|
|
731
|
+
getBotIdentity: neverCalledBotIdentity(),
|
|
732
|
+
spawnSync,
|
|
733
|
+
writeToken: neverCalledWriteToken(),
|
|
734
|
+
tokenPath: h.tokenPath,
|
|
735
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
736
|
+
installations: h.installations,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
expect(activated).toBe(false);
|
|
740
|
+
expect(spawnSync).toHaveBeenCalledWith("gh", ["auth", "setup-git"], {
|
|
741
|
+
stdio: "inherit",
|
|
742
|
+
env: expect.objectContaining({ GH_TOKEN: "ghp_test_pat" }),
|
|
743
|
+
});
|
|
744
|
+
expect(spawnSync).toHaveBeenCalledTimes(2);
|
|
745
|
+
expect(h.env.GH_TOKEN_FILE).toBeUndefined();
|
|
746
|
+
expect(existsSync(h.tokenPath)).toBe(false);
|
|
747
|
+
expect(existsSync(join(h.home, "gh-token.d"))).toBe(false);
|
|
748
|
+
expect(h.intervals).toHaveLength(0);
|
|
749
|
+
expect(h.activated).toHaveLength(0);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("App id + key, no PAT, and only out-of-scope installations skips setup without activating", async () => {
|
|
753
|
+
const h = harness({
|
|
754
|
+
installs: [{ id: 11, owner: "elsewhere" }],
|
|
755
|
+
scopeOwners: ["acme"],
|
|
756
|
+
});
|
|
757
|
+
const spawnSync = makeSpawnSync();
|
|
758
|
+
const activated = await setupGitHubAuth({
|
|
759
|
+
env: h.env,
|
|
760
|
+
createTokenManager: mock(() => {
|
|
761
|
+
throw new Error("should not be called");
|
|
762
|
+
}),
|
|
763
|
+
getBotIdentity: neverCalledBotIdentity(),
|
|
764
|
+
spawnSync,
|
|
765
|
+
writeToken: neverCalledWriteToken(),
|
|
766
|
+
tokenPath: h.tokenPath,
|
|
767
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
768
|
+
installations: h.installations,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
expect(activated).toBe(false);
|
|
772
|
+
expect(spawnSync).toHaveBeenCalledTimes(1); // safe.directory only
|
|
773
|
+
expect(h.intervals).toHaveLength(0);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("a pin whose discovery fails falls back to the legacy pinned token manager", async () => {
|
|
777
|
+
const h = harness({ pin: 456, discoveryStatus: 503 });
|
|
778
|
+
const tm = makeTokenManager("ghs_legacy_456");
|
|
779
|
+
const writeToken = makeWriteToken();
|
|
780
|
+
const activated = await setupGitHubAuth({
|
|
781
|
+
env: h.env,
|
|
782
|
+
createTokenManager: mock(() => tm),
|
|
783
|
+
getBotIdentity: makeBotIdentity(),
|
|
784
|
+
spawnSync: makeSpawnSync(),
|
|
785
|
+
writeToken,
|
|
786
|
+
tokenPath: h.tokenPath,
|
|
787
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
788
|
+
installations: h.installations,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
expect(activated).toBe(true);
|
|
792
|
+
expect(writeToken).toHaveBeenCalledWith("ghs_legacy_456");
|
|
793
|
+
expect(tm.startBackgroundRefresh).toHaveBeenCalledTimes(1);
|
|
794
|
+
expect(h.intervals).toHaveLength(0);
|
|
795
|
+
expect(h.activated).toHaveLength(0);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("after N config-sync ticks there is still exactly one refresh interval, and reconcile picks up new installations", async () => {
|
|
799
|
+
const h = harness({ installs: [], scopeOwners: ["acme", "other"] });
|
|
800
|
+
const activeRef = createGitHubAuthActiveRef();
|
|
801
|
+
const guard = createGitHubAuthStartGuard();
|
|
802
|
+
const managerRef = createGitHubInstallationsManagerRef();
|
|
803
|
+
const reposRef = createAgentReposRef();
|
|
804
|
+
h.installations.onActivated = (m) => managerRef.set(m);
|
|
805
|
+
const spawnSync = makeSpawnSync();
|
|
806
|
+
|
|
807
|
+
const tick = async () => {
|
|
808
|
+
reposRef.set(["acme/api", "other/web"]);
|
|
809
|
+
await reconcileGitHubInstallations(managerRef, reposRef);
|
|
810
|
+
return startGitHubAuthIfPossible({
|
|
811
|
+
env: h.env,
|
|
812
|
+
isActive: activeRef.isActive,
|
|
813
|
+
markActive: () => activeRef.setActive(true),
|
|
814
|
+
guard,
|
|
815
|
+
setupGitHubAuth: () =>
|
|
816
|
+
setupGitHubAuth({
|
|
817
|
+
env: h.env,
|
|
818
|
+
createTokenManager: mock(() => {
|
|
819
|
+
throw new Error("should not be called");
|
|
820
|
+
}),
|
|
821
|
+
getBotIdentity: makeBotIdentity(),
|
|
822
|
+
spawnSync,
|
|
823
|
+
writeToken: neverCalledWriteToken(),
|
|
824
|
+
tokenPath: h.tokenPath,
|
|
825
|
+
credentialHelperPath: TEST_HELPER_PATH,
|
|
826
|
+
installations: h.installations,
|
|
827
|
+
}),
|
|
828
|
+
});
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// Zero installations: not active, retried each tick, no interval.
|
|
832
|
+
expect(await tick()).toBe(false);
|
|
833
|
+
expect(await tick()).toBe(false);
|
|
834
|
+
expect(activeRef.isActive()).toBe(false);
|
|
835
|
+
expect(h.intervals).toHaveLength(0);
|
|
836
|
+
|
|
837
|
+
// One installation appears: activates exactly once.
|
|
838
|
+
h.setInstalls([{ id: 11, owner: "acme" }]);
|
|
839
|
+
expect(await tick()).toBe(true);
|
|
840
|
+
for (let i = 0; i < 5; i++) expect(await tick()).toBe(false);
|
|
841
|
+
expect(h.intervals).toHaveLength(1);
|
|
842
|
+
expect(h.intervals[0].cleared).toBe(false);
|
|
843
|
+
expect(readdirSync(join(h.home, "gh-token.d"))).toEqual(["acme"]);
|
|
844
|
+
|
|
845
|
+
// A second in-scope installation is picked up by a later tick's reconcile.
|
|
846
|
+
h.setInstalls([
|
|
847
|
+
{ id: 11, owner: "acme" },
|
|
848
|
+
{ id: 22, owner: "other" },
|
|
849
|
+
]);
|
|
850
|
+
await tick();
|
|
851
|
+
await flush();
|
|
852
|
+
expect(h.intervals).toHaveLength(1);
|
|
853
|
+
expect(readFileSync(h.tokenPath, "utf8")).toBe("ghs_inst_11");
|
|
854
|
+
expect(readFileSync(join(h.home, "gh-token.d", "other"), "utf8")).toBe(
|
|
855
|
+
"ghs_inst_22",
|
|
856
|
+
);
|
|
857
|
+
});
|
|
858
|
+
});
|