@cldmv/gh-broker 0.8.0 → 0.10.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/README.md +48 -13
- package/config.example.json +86 -3
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -18
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +100 -41
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +5171 -1686
- package/dist/index.mjs.map +4 -4
- package/package.json +14 -3
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
|
@@ -1,388 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { loadConfig, indexTools } from "../src/config.ts";
|
|
5
|
-
|
|
6
|
-
// loadConfig reads a real file off disk, so we materialize each fixture into the
|
|
7
|
-
// repo's gitignored tmp/ and pass the path.
|
|
8
|
-
const DIR = join(process.cwd(), "tmp", "config-tests");
|
|
9
|
-
let counter = 0;
|
|
10
|
-
|
|
11
|
-
/** Write `contents` (string or object) to a unique tmp file and return its path. */
|
|
12
|
-
function writeConfig(contents: unknown): string {
|
|
13
|
-
const path = join(DIR, `cfg-${counter++}.json`);
|
|
14
|
-
writeFileSync(path, typeof contents === "string" ? contents : JSON.stringify(contents));
|
|
15
|
-
return path;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function baseConfig(): Record<string, unknown> {
|
|
19
|
-
return {
|
|
20
|
-
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
21
|
-
upstream: "https://api.githubcopilot.com/mcp/",
|
|
22
|
-
tools: [{ name: "get_me", mode: "read" }],
|
|
23
|
-
owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } }
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
beforeAll(() => {
|
|
28
|
-
mkdirSync(DIR, { recursive: true });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterAll(() => {
|
|
32
|
-
rmSync(DIR, { recursive: true, force: true });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("loadConfig — file/parse errors", () => {
|
|
36
|
-
it("throws when the file cannot be read", () => {
|
|
37
|
-
expect(() => loadConfig(join(DIR, "does-not-exist.json"))).toThrow(/cannot read\/parse/);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("throws on malformed JSON", () => {
|
|
41
|
-
const p = writeConfig("{ not json ");
|
|
42
|
-
expect(() => loadConfig(p)).toThrow(/cannot read\/parse/);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("throws when the root is not an object", () => {
|
|
46
|
-
const p = writeConfig("123");
|
|
47
|
-
expect(() => loadConfig(p)).toThrow(/root must be an object/);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("throws when the root is JSON null", () => {
|
|
51
|
-
const p = writeConfig("null");
|
|
52
|
-
expect(() => loadConfig(p)).toThrow(/root must be an object/);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("loadConfig — listen / upstream", () => {
|
|
57
|
-
it("rejects a non-string listen", () => {
|
|
58
|
-
const c = baseConfig();
|
|
59
|
-
delete c.listen;
|
|
60
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.listen/);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("rejects a listen without the unix: prefix", () => {
|
|
64
|
-
const c = baseConfig();
|
|
65
|
-
c.listen = "tcp:0.0.0.0:9000";
|
|
66
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.listen/);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("rejects a non-http upstream", () => {
|
|
70
|
-
const c = baseConfig();
|
|
71
|
-
c.upstream = "ftp://example.com";
|
|
72
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.upstream/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("rejects a non-string upstream", () => {
|
|
76
|
-
const c = baseConfig();
|
|
77
|
-
c.upstream = 42;
|
|
78
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.upstream/);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("loadConfig — tools", () => {
|
|
83
|
-
it("rejects a missing tools array", () => {
|
|
84
|
-
const c = baseConfig();
|
|
85
|
-
delete c.tools;
|
|
86
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools: must be a non-empty array/);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("rejects an empty tools array", () => {
|
|
90
|
-
const c = baseConfig();
|
|
91
|
-
c.tools = [];
|
|
92
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools: must be a non-empty array/);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("rejects a tool with no name", () => {
|
|
96
|
-
const c = baseConfig();
|
|
97
|
-
c.tools = [{ mode: "read" }];
|
|
98
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools\[0\]\.name: required/);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("rejects a tool with a bad mode", () => {
|
|
102
|
-
const c = baseConfig();
|
|
103
|
-
c.tools = [{ name: "get_me", mode: "admin" }];
|
|
104
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools\[0\]\.mode: must be read\|write/);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("rejects duplicate tool names", () => {
|
|
108
|
-
const c = baseConfig();
|
|
109
|
-
c.tools = [
|
|
110
|
-
{ name: "get_me", mode: "read" },
|
|
111
|
-
{ name: "get_me", mode: "write" }
|
|
112
|
-
];
|
|
113
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/duplicate tool "get_me"/);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("carries the custom flag through, defaulting to false", () => {
|
|
117
|
-
const c = baseConfig();
|
|
118
|
-
c.tools = [
|
|
119
|
-
{ name: "get_me", mode: "read" },
|
|
120
|
-
{ name: "link_issue", mode: "write", custom: true }
|
|
121
|
-
];
|
|
122
|
-
const out = loadConfig(writeConfig(c));
|
|
123
|
-
expect(out.tools).toEqual([
|
|
124
|
-
{ name: "get_me", mode: "read", custom: false, augment: false },
|
|
125
|
-
{ name: "link_issue", mode: "write", custom: true, augment: false }
|
|
126
|
-
]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("carries the augment flag through, defaulting to false", () => {
|
|
130
|
-
const c = baseConfig();
|
|
131
|
-
c.tools = [
|
|
132
|
-
{ name: "get_me", mode: "read" },
|
|
133
|
-
{ name: "pull_request_read", mode: "read", augment: true }
|
|
134
|
-
];
|
|
135
|
-
const out = loadConfig(writeConfig(c));
|
|
136
|
-
expect(out.tools).toEqual([
|
|
137
|
-
{ name: "get_me", mode: "read", custom: false, augment: false },
|
|
138
|
-
{ name: "pull_request_read", mode: "read", custom: false, augment: true }
|
|
139
|
-
]);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("rejects a tool marked both custom and augment", () => {
|
|
143
|
-
const c = baseConfig();
|
|
144
|
-
c.tools = [{ name: "weird", mode: "read", custom: true, augment: true }];
|
|
145
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/custom and augment are mutually exclusive/);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe("loadConfig — owners", () => {
|
|
150
|
-
it("rejects a non-object owners", () => {
|
|
151
|
-
const c = baseConfig();
|
|
152
|
-
c.owners = "cldmv";
|
|
153
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners: must be an object/);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("rejects an owner missing read/write", () => {
|
|
157
|
-
const c = baseConfig();
|
|
158
|
-
c.owners = { cldmv: { read: "cred:x" } };
|
|
159
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners\.cldmv: needs "read" and "write"/);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("rejects a non-numeric installationId", () => {
|
|
163
|
-
const c = baseConfig();
|
|
164
|
-
c.owners = { cldmv: { read: "cred:r", write: "cred:w", installationId: "42" } };
|
|
165
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners\.cldmv\.installationId: must be a number/);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("rejects an empty owners object", () => {
|
|
169
|
-
const c = baseConfig();
|
|
170
|
-
c.owners = {};
|
|
171
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/at least one owner required/);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("lowercases owner keys and carries installationId", () => {
|
|
175
|
-
const c = baseConfig();
|
|
176
|
-
c.owners = { CLDMV: { read: "cred:r", write: "cred:w", installationId: 99 } };
|
|
177
|
-
const out = loadConfig(writeConfig(c));
|
|
178
|
-
expect(out.owners).toEqual({ cldmv: { read: "cred:r", write: "cred:w", installationId: 99 } });
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("omits installationId when absent", () => {
|
|
182
|
-
const out = loadConfig(writeConfig(baseConfig()));
|
|
183
|
-
expect(out.owners.cldmv).toEqual({ read: "cred:cldmv-read", write: "cred:cldmv-write" });
|
|
184
|
-
expect(out.owners.cldmv).not.toHaveProperty("installationId");
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe("loadConfig — defaultOwner", () => {
|
|
189
|
-
it("is undefined when omitted", () => {
|
|
190
|
-
const out = loadConfig(writeConfig(baseConfig()));
|
|
191
|
-
expect(out.defaultOwner).toBeUndefined();
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("lowercases a valid defaultOwner and carries it through", () => {
|
|
195
|
-
const c = baseConfig();
|
|
196
|
-
c.defaultOwner = "CLDMV";
|
|
197
|
-
const out = loadConfig(writeConfig(c));
|
|
198
|
-
expect(out.defaultOwner).toBe("cldmv");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("rejects a non-string / empty defaultOwner", () => {
|
|
202
|
-
const c = baseConfig();
|
|
203
|
-
c.defaultOwner = "";
|
|
204
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.defaultOwner: must be a non-empty string/);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("rejects a defaultOwner that is not a configured owner", () => {
|
|
208
|
-
const c = baseConfig();
|
|
209
|
-
c.defaultOwner = "nobody";
|
|
210
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.defaultOwner "nobody" is not a key of config\.owners/);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe("loadConfig — app", () => {
|
|
215
|
-
it("rejects a non-numeric appId", () => {
|
|
216
|
-
const c = baseConfig();
|
|
217
|
-
c.app = { appId: "1", privateKey: "cred:app" };
|
|
218
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.app\.appId: must be a number/);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("rejects a non-string privateKey", () => {
|
|
222
|
-
const c = baseConfig();
|
|
223
|
-
c.app = { appId: 1, privateKey: 5 };
|
|
224
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.app\.privateKey/);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("carries a valid app config through", () => {
|
|
228
|
-
const c = baseConfig();
|
|
229
|
-
c.app = { appId: 12345, privateKey: "cred:gh-app-key" };
|
|
230
|
-
const out = loadConfig(writeConfig(c));
|
|
231
|
-
expect(out.app).toEqual({ appId: 12345, privateKey: "cred:gh-app-key" });
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("omits app when absent", () => {
|
|
235
|
-
const out = loadConfig(writeConfig(baseConfig()));
|
|
236
|
-
expect(out.app).toBeUndefined();
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe("loadConfig — secrets", () => {
|
|
241
|
-
it("rejects an array (which typeof reports as 'object')", () => {
|
|
242
|
-
const c = baseConfig();
|
|
243
|
-
c.secrets = [];
|
|
244
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets: must be an object/);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("rejects null", () => {
|
|
248
|
-
const c = baseConfig();
|
|
249
|
-
c.secrets = null;
|
|
250
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets: must be an object/);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("rejects an unknown provider", () => {
|
|
254
|
-
const c = baseConfig();
|
|
255
|
-
c.secrets = { provider: "vault" };
|
|
256
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.provider/);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("carries a valid secrets block through", () => {
|
|
260
|
-
const c = baseConfig();
|
|
261
|
-
c.secrets = { provider: "file", path: "/etc/gh-broker/secrets.json" };
|
|
262
|
-
const out = loadConfig(writeConfig(c));
|
|
263
|
-
expect(out.secrets).toEqual({ provider: "file", path: "/etc/gh-broker/secrets.json" });
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("carries an empty secrets block, and a provider without a path, through", () => {
|
|
267
|
-
const c = baseConfig();
|
|
268
|
-
c.secrets = {};
|
|
269
|
-
expect(loadConfig(writeConfig(c)).secrets).toEqual({});
|
|
270
|
-
c.secrets = { provider: "file" };
|
|
271
|
-
expect(loadConfig(writeConfig(c)).secrets).toEqual({ provider: "file" });
|
|
272
|
-
c.secrets = { provider: "systemd-creds" };
|
|
273
|
-
expect(loadConfig(writeConfig(c)).secrets).toEqual({ provider: "systemd-creds" });
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("rejects a path without the file provider (omitted → systemd-creds default)", () => {
|
|
277
|
-
const c = baseConfig();
|
|
278
|
-
c.secrets = { path: "/etc/gh-broker/secrets.json" };
|
|
279
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path is only used by the "file" provider/);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("rejects a path with the systemd-creds provider", () => {
|
|
283
|
-
const c = baseConfig();
|
|
284
|
-
c.secrets = { provider: "systemd-creds", path: "/etc/gh-broker/secrets.json" };
|
|
285
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path is only used by the "file" provider/);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it("rejects an empty path (would be silently ignored by resolveSecretsPath)", () => {
|
|
289
|
-
const c = baseConfig();
|
|
290
|
-
c.secrets = { provider: "file", path: "" };
|
|
291
|
-
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path: must be a non-empty string/);
|
|
292
|
-
});
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
describe("loadConfig — full valid config", () => {
|
|
296
|
-
it("returns the normalized shape", () => {
|
|
297
|
-
const c = baseConfig();
|
|
298
|
-
c.defaultOwner = "cldmv";
|
|
299
|
-
c.app = { appId: 7, privateKey: "cred:app" };
|
|
300
|
-
const out = loadConfig(writeConfig(c));
|
|
301
|
-
expect(out).toEqual({
|
|
302
|
-
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
303
|
-
upstream: "https://api.githubcopilot.com/mcp/",
|
|
304
|
-
tools: [{ name: "get_me", mode: "read", custom: false, augment: false }],
|
|
305
|
-
owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
|
|
306
|
-
app: { appId: 7, privateKey: "cred:app" },
|
|
307
|
-
defaultOwner: "cldmv"
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
describe("indexTools", () => {
|
|
313
|
-
it("builds a name→rule map", () => {
|
|
314
|
-
const rules = [
|
|
315
|
-
{ name: "get_me", mode: "read" as const },
|
|
316
|
-
{ name: "create_pull_request", mode: "write" as const, custom: false }
|
|
317
|
-
];
|
|
318
|
-
const map = indexTools(rules);
|
|
319
|
-
expect(map.size).toBe(2);
|
|
320
|
-
expect(map.get("get_me")).toEqual({ name: "get_me", mode: "read" });
|
|
321
|
-
expect(map.get("create_pull_request")).toEqual({ name: "create_pull_request", mode: "write", custom: false });
|
|
322
|
-
expect(map.get("missing")).toBeUndefined();
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
describe("loadConfig — npm", () => {
|
|
327
|
-
it("is absent unless configured", () => {
|
|
328
|
-
expect(loadConfig(writeConfig(baseConfig())).npm).toBeUndefined();
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it("leaves registry out when it is not set", () => {
|
|
332
|
-
expect(loadConfig(writeConfig({ ...baseConfig(), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } })).npm).toEqual({ token: "cred:npm-token", scopes: ["@cldmv"] });
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("accepts a token ref, scopes, an https registry and token orgs", () => {
|
|
336
|
-
const npm = { token: "cred:npm-token", scopes: ["@cldmv"], registry: "https://registry.npmjs.org", tokenOrgs: ["cldmv"] };
|
|
337
|
-
expect(loadConfig(writeConfig({ ...baseConfig(), npm })).npm).toEqual(npm);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it.each([
|
|
341
|
-
[[], /config.npm: must be an object/],
|
|
342
|
-
[{ scopes: ["@cldmv"] }, /config.npm.token/],
|
|
343
|
-
[{ token: "npm_inline_secret", scopes: ["@cldmv"] }, /config.npm.token/],
|
|
344
|
-
[{ token: "cred:npm-token" }, /config.npm.scopes: must be a non-empty array/],
|
|
345
|
-
[{ token: "cred:npm-token", scopes: [] }, /config.npm.scopes: must be a non-empty array/],
|
|
346
|
-
[{ token: "cred:npm-token", scopes: ["cldmv"] }, /config.npm.scopes\[0\]/],
|
|
347
|
-
[{ token: "cred:npm-token", scopes: ["@CLDMV"] }, /config.npm.scopes\[0\]/],
|
|
348
|
-
[{ token: "cred:npm-token", scopes: ["@cldmv"], registry: "http://registry.npmjs.org" }, /config.npm.registry: must be an https URL/],
|
|
349
|
-
[{ token: "cred:npm-token", scopes: ["@cldmv"], tokenOrgs: "cldmv" }, /config.npm.tokenOrgs/],
|
|
350
|
-
[{ token: "cred:npm-token", scopes: ["@cldmv"], tokenOrgs: ["@cldmv"] }, /config.npm.tokenOrgs/]
|
|
351
|
-
] as Array<[unknown, RegExp]>)("rejects an invalid npm block (%#)", (npm, msg) => {
|
|
352
|
-
expect(() => loadConfig(writeConfig({ ...baseConfig(), npm }))).toThrow(msg);
|
|
353
|
-
});
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
describe("loadConfig — ghcr", () => {
|
|
357
|
-
const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", owners: { cldmv: { username: "Shinrai", credential: "cred:cldmv-packages", images: ["hoot"] } } };
|
|
358
|
-
|
|
359
|
-
it("parses a ghcr block", () => {
|
|
360
|
-
const c = loadConfig(writeConfig({ ...baseConfig(), ghcr }));
|
|
361
|
-
expect(c.ghcr?.owners.cldmv?.credential).toBe("cred:cldmv-packages");
|
|
362
|
-
expect(c.ghcr?.protectReleaseTags).toBe(true);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("omits ghcr when absent and rejects a malformed block", () => {
|
|
366
|
-
expect(loadConfig(writeConfig(baseConfig())).ghcr).toBeUndefined();
|
|
367
|
-
expect(() => loadConfig(writeConfig({ ...baseConfig(), ghcr: { ...ghcr, dropDir: "/etc/x/y" } }))).toThrow(/config.ghcr.dropDir: may not be under \/etc/);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it("ships no owner-specific data in config.example.json, and it loads once an owner is added", () => {
|
|
371
|
-
const raw = JSON.parse(readFileSync(join(process.cwd(), "config.example.json"), "utf8"));
|
|
372
|
-
expect(Object.keys(raw).sort()).toEqual(["listen", "tools", "upstream"]);
|
|
373
|
-
const c = loadConfig(writeConfig({ ...raw, owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } } }));
|
|
374
|
-
expect(c.tools.filter((t) => t.name.startsWith("ghcr_")).map((t) => [t.name, t.mode, t.custom])).toEqual([
|
|
375
|
-
["ghcr_push", "write", true],
|
|
376
|
-
["ghcr_push_status", "read", true],
|
|
377
|
-
["ghcr_tags", "read", true]
|
|
378
|
-
]);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("accepts the documentation-only config.sample.json merged onto the example tools", () => {
|
|
382
|
-
const raw = JSON.parse(readFileSync(join(process.cwd(), "config.example.json"), "utf8"));
|
|
383
|
-
const sample = JSON.parse(readFileSync(join(process.cwd(), "config.sample.json"), "utf8"));
|
|
384
|
-
const c = loadConfig(writeConfig({ ...sample, tools: raw.tools }));
|
|
385
|
-
expect(c.ghcr?.dropDir).toBe("/srv/gh-broker/ghcr-drop");
|
|
386
|
-
expect(c.defaultOwner).toBe("your-org");
|
|
387
|
-
});
|
|
388
|
-
});
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { credentialPlan } from "../deploy/lib/credentials.mjs";
|
|
3
|
-
|
|
4
|
-
type Opts = Parameters<typeof credentialPlan>[0];
|
|
5
|
-
|
|
6
|
-
function plan(over: Partial<Opts> = {}, answers: string[] = []) {
|
|
7
|
-
const logs: string[] = [];
|
|
8
|
-
const promptHidden = vi.fn(async () => answers.shift() ?? "");
|
|
9
|
-
const mint = vi.fn(async () => "npm_MINTED");
|
|
10
|
-
const p = credentialPlan({
|
|
11
|
-
needed: ["cldmv-read", "cldmv-write", "npm-token"],
|
|
12
|
-
optional: new Set(["npm-token"]),
|
|
13
|
-
cliSecrets: {},
|
|
14
|
-
imported: {},
|
|
15
|
-
env: {},
|
|
16
|
-
existingFile: {},
|
|
17
|
-
decrypt: () => undefined,
|
|
18
|
-
mints: () => false,
|
|
19
|
-
mint,
|
|
20
|
-
appKeyName: undefined,
|
|
21
|
-
nonInteractive: false,
|
|
22
|
-
promptHidden,
|
|
23
|
-
log: (m: string) => logs.push(m),
|
|
24
|
-
...over
|
|
25
|
-
});
|
|
26
|
-
return { p, logs, promptHidden, mint };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** A fake systemd-creds store: which .cred files exist, and what was encrypted. */
|
|
30
|
-
function creds(existing: string[] = []) {
|
|
31
|
-
const files = new Set(existing);
|
|
32
|
-
const written: Record<string, string> = {};
|
|
33
|
-
return {
|
|
34
|
-
written,
|
|
35
|
-
io: {
|
|
36
|
-
exists: (n: string) => files.has(n),
|
|
37
|
-
encrypt: (n: string, v: string) => {
|
|
38
|
-
files.add(n);
|
|
39
|
-
written[n] = v;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe("credentialPlan", () => {
|
|
46
|
-
it("refuses a --secret name the config does not use", () => {
|
|
47
|
-
expect(() => plan({ cliSecrets: { "cldmv-raed": "x" } })).toThrow(/--secret names no credential this config uses: cldmv-raed \(it uses: cldmv-read, cldmv-write, npm-token\)/);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("takes an explicit value from --secret, then --secrets-file, then env", () => {
|
|
51
|
-
const env = { GH_BROKER_SECRET_CLDMV_READ: "from-env" };
|
|
52
|
-
expect(plan({ env }).p.explicit("cldmv-read")).toBe("from-env");
|
|
53
|
-
expect(plan({ env, imported: { "cldmv-read": "from-file" } }).p.explicit("cldmv-read")).toBe("from-file");
|
|
54
|
-
expect(plan({ env, imported: { "cldmv-read": "from-file" }, cliSecrets: { "cldmv-read": "from-cli" } }).p.explicit("cldmv-read")).toBe("from-cli");
|
|
55
|
-
expect(plan({ imported: { "cldmv-read": "" }, env: { GH_BROKER_SECRET_CLDMV_READ: "" } }).p.explicit("cldmv-read")).toBeUndefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("resolves explicit → stored file → decrypt → prompt", async () => {
|
|
59
|
-
expect(await plan({ cliSecrets: { "cldmv-read": "cli" }, existingFile: { "cldmv-read": "old" } }).p.resolve("cldmv-read")).toBe("cli");
|
|
60
|
-
expect(await plan({ existingFile: { "cldmv-read": "old" }, decrypt: () => "dec" }).p.resolve("cldmv-read")).toBe("old");
|
|
61
|
-
const d = plan({ decrypt: () => "dec" });
|
|
62
|
-
expect(await d.p.resolve("cldmv-read")).toBe("dec");
|
|
63
|
-
expect(d.logs).toContain(" cldmv-read: migrated from systemd-creds");
|
|
64
|
-
const prompted = plan({}, ["typed"]);
|
|
65
|
-
expect(await prompted.p.resolve("cldmv-read")).toBe("typed");
|
|
66
|
-
expect(prompted.promptHidden).toHaveBeenCalledWith(" cldmv-read (input hidden): ");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("fails a required credential that is missing or left empty", async () => {
|
|
70
|
-
await expect(plan({ nonInteractive: true }).p.resolve("cldmv-read")).rejects.toThrow(/missing credential "cldmv-read" — provide via --secret, --secrets-file or env GH_BROKER_SECRET_CLDMV_READ/);
|
|
71
|
-
await expect(plan({}, [""]).p.resolve("cldmv-read")).rejects.toThrow(/empty value for "cldmv-read"/);
|
|
72
|
-
await expect(plan({ appKeyName: "gh-app-key", needed: ["gh-app-key"] }).p.resolve("gh-app-key")).rejects.toThrow(/GitHub App private key/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("lets an optional credential be skipped, never failing", async () => {
|
|
76
|
-
expect(await plan({ nonInteractive: true }).p.resolve("npm-token")).toBeUndefined();
|
|
77
|
-
const i = plan({}, [""]);
|
|
78
|
-
expect(await i.p.resolve("npm-token")).toBeUndefined();
|
|
79
|
-
expect(i.promptHidden).toHaveBeenCalledWith(expect.stringMatching(/Enter to skip/));
|
|
80
|
-
expect(await plan({}, ["npm_typed"]).p.resolve("npm-token")).toBe("npm_typed");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("creates the npm token when this run mints it, unless one is supplied", async () => {
|
|
84
|
-
const m = plan({ mints: (n) => n === "npm-token" });
|
|
85
|
-
expect(await m.p.resolve("npm-token")).toBe("npm_MINTED");
|
|
86
|
-
expect(m.p.replaces("npm-token")).toBe(true);
|
|
87
|
-
const given = plan({ mints: (n) => n === "npm-token", cliSecrets: { "npm-token": "npm_given" } });
|
|
88
|
-
expect(await given.p.resolve("npm-token")).toBe("npm_given");
|
|
89
|
-
expect(given.mint).not.toHaveBeenCalled();
|
|
90
|
-
await expect(plan({ mints: () => true, nonInteractive: true }).p.resolve("npm-token")).rejects.toThrow(/needs a browser login/);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe("storeSystemd", () => {
|
|
94
|
-
it("keeps existing creds, encrypts new ones, and lists only what is stored", async () => {
|
|
95
|
-
const c = creds(["cldmv-read"]);
|
|
96
|
-
const { p, logs } = plan({}, ["write-typed", ""]);
|
|
97
|
-
const stored = await p.storeSystemd(c.io);
|
|
98
|
-
expect(stored).toEqual(["cldmv-read", "cldmv-write"]);
|
|
99
|
-
expect(c.written).toEqual({ "cldmv-write": "write-typed" });
|
|
100
|
-
expect(logs).toEqual([" cldmv-read: existing systemd-creds kept", " cldmv-write: encrypted (systemd-creds)", " npm-token: not stored — its tools stay hidden until it is (re-run the installer to add it)"]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("rotates on an explicit value or a plaintext entry, and notes a replaced npm token", async () => {
|
|
104
|
-
const c = creds(["cldmv-read", "cldmv-write", "npm-token"]);
|
|
105
|
-
const { p, logs } = plan({ cliSecrets: { "cldmv-read": "new-read" }, existingFile: { "cldmv-write": "file-write" }, mints: (n) => n === "npm-token" });
|
|
106
|
-
expect(await p.storeSystemd(c.io)).toEqual(["cldmv-read", "cldmv-write", "npm-token"]);
|
|
107
|
-
expect(c.written).toEqual({ "cldmv-read": "new-read", "cldmv-write": "file-write", "npm-token": "npm_MINTED" });
|
|
108
|
-
expect(logs).toContain(" cldmv-read: rotated (systemd-creds)");
|
|
109
|
-
expect(logs.some((l) => /previous npm-token is still valid/.test(l))).toBe(true);
|
|
110
|
-
expect(logs.filter((l) => /previous/.test(l))).toHaveLength(1);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("storeFile", () => {
|
|
115
|
-
it("keeps stored values, fills missing ones, rotates on an explicit value, and skips an optional one", async () => {
|
|
116
|
-
const { p, logs } = plan({ needed: ["cldmv-read", "cldmv-write", "cldmv-new", "npm-token"], existingFile: { "cldmv-read": "old-read", "cldmv-write": "old-write" }, cliSecrets: { "cldmv-write": "new-write", "cldmv-new": "fresh" }, nonInteractive: true });
|
|
117
|
-
expect(await p.storeFile()).toEqual({ "cldmv-read": "old-read", "cldmv-write": "new-write", "cldmv-new": "fresh" });
|
|
118
|
-
expect(logs).toEqual([" npm-token: not stored — its tools stay hidden until it is (re-run the installer to add it)"]);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("replaces a stored npm token with a newly created one and says the old one is still live", async () => {
|
|
122
|
-
const { p, logs } = plan({ existingFile: { "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_old" }, mints: (n) => n === "npm-token" });
|
|
123
|
-
expect(await p.storeFile()).toEqual({ "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_MINTED" });
|
|
124
|
-
expect(logs.some((l) => /previous npm-token is still valid/.test(l))).toBe(true);
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|