@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,234 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { buildCatalog } from "../src/catalog.ts";
|
|
3
|
-
|
|
4
|
-
function makeConfig(tools: any[], owners: Record<string, unknown> = { cldmv: { read: "cred:r", write: "cred:w" } }): any {
|
|
5
|
-
return { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools, owners };
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
describe("buildCatalog", () => {
|
|
9
|
-
let secrets: { resolve: ReturnType<typeof vi.fn> };
|
|
10
|
-
let upstream: { listToolsFor: ReturnType<typeof vi.fn> };
|
|
11
|
-
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
secrets = { resolve: vi.fn(() => "tok"), has: vi.fn(() => true) };
|
|
15
|
-
upstream = { listToolsFor: vi.fn() };
|
|
16
|
-
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
vi.restoreAllMocks();
|
|
21
|
-
delete process.env.GH_BROKER_CATALOG_TIMEOUT_MS;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("uses upstream schemas, emits local schemas for custom verbs, and skips unexposed proxied tools", async () => {
|
|
25
|
-
const upstreamTool = { name: "get_me", description: "real", inputSchema: { type: "object", properties: {} } };
|
|
26
|
-
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
27
|
-
const config = makeConfig([
|
|
28
|
-
{ name: "get_me", mode: "read" },
|
|
29
|
-
{ name: "not_exposed", mode: "read" },
|
|
30
|
-
{ name: "link_issue", mode: "write", custom: true },
|
|
31
|
-
{ name: "resolve_review_thread", mode: "write", custom: true },
|
|
32
|
-
{ name: "reply_review_thread", mode: "write", custom: true },
|
|
33
|
-
{ name: "mystery_custom", mode: "read", custom: true }
|
|
34
|
-
]);
|
|
35
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
36
|
-
|
|
37
|
-
expect(secrets.resolve).toHaveBeenCalledWith("cred:r");
|
|
38
|
-
const names = catalog.map((t) => t.name);
|
|
39
|
-
// get_me is served from upstream; not_exposed is dropped (fetched but absent);
|
|
40
|
-
// the two wired custom verbs get local schemas; link_issue is DEFERRED (its
|
|
41
|
-
// catalog branch is commented out) and mystery_custom matches no branch — both
|
|
42
|
-
// are silently skipped even when allowlisted.
|
|
43
|
-
expect(names).toEqual(["get_me", "resolve_review_thread", "reply_review_thread"]);
|
|
44
|
-
expect(names).not.toContain("link_issue");
|
|
45
|
-
expect(catalog[0]).toBe(upstreamTool);
|
|
46
|
-
expect(catalog.find((t) => t.name === "resolve_review_thread")?.inputSchema).toMatchObject({ type: "object" });
|
|
47
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/allowlisted tool "not_exposed" is NOT exposed/));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("advertises report_unlabeled with its local schema", async () => {
|
|
51
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
52
|
-
const config = makeConfig([{ name: "report_unlabeled", mode: "read", custom: true }]);
|
|
53
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
54
|
-
expect(catalog).toHaveLength(1);
|
|
55
|
-
expect(catalog[0]).toMatchObject({ name: "report_unlabeled" });
|
|
56
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("advertises report_security_alerts with its local schema", async () => {
|
|
60
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
61
|
-
const config = makeConfig([{ name: "report_security_alerts", mode: "read", custom: true }]);
|
|
62
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
63
|
-
expect(catalog).toHaveLength(1);
|
|
64
|
-
expect(catalog[0]).toMatchObject({ name: "report_security_alerts" });
|
|
65
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("advertises report_release_prs with its local schema", async () => {
|
|
69
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
70
|
-
const config = makeConfig([{ name: "report_release_prs", mode: "read", custom: true }]);
|
|
71
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
72
|
-
expect(catalog).toHaveLength(1);
|
|
73
|
-
expect(catalog[0]).toMatchObject({ name: "report_release_prs" });
|
|
74
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("does not expose npm_scaffold without an npm block, or when its token is not stored", async () => {
|
|
78
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
79
|
-
const rules = [{ name: "npm_scaffold", mode: "write", custom: true }];
|
|
80
|
-
expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
|
|
81
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: the npm integration is not configured/));
|
|
82
|
-
secrets.has.mockReturnValue(false);
|
|
83
|
-
const noToken = { ...makeConfig(rules), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } };
|
|
84
|
-
expect(await buildCatalog(noToken, secrets as any, upstream as any)).toEqual([]);
|
|
85
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: its credential "npm-token" is not stored/));
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("advertises npm_scaffold with its local schema", async () => {
|
|
89
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
90
|
-
const catalog = await buildCatalog({ ...makeConfig([{ name: "npm_scaffold", mode: "write", custom: true }]), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } }, secrets as any, upstream as any);
|
|
91
|
-
expect(catalog).toHaveLength(1);
|
|
92
|
-
expect(catalog[0]).toMatchObject({ name: "npm_scaffold" });
|
|
93
|
-
expect(catalog[0]?.description).toMatch(/one-time password/);
|
|
94
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "package", "otp"] });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("advertises issue_labels with its local schema", async () => {
|
|
98
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
99
|
-
const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
|
|
100
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
101
|
-
expect(catalog).toHaveLength(1);
|
|
102
|
-
expect(catalog[0]).toMatchObject({ name: "issue_labels" });
|
|
103
|
-
expect(catalog[0]?.description).toMatch(/swap/);
|
|
104
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "issue_number"] });
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("warns on issue_write that labels replaces the set, pointing at issue_labels when it is allowlisted", async () => {
|
|
108
|
-
const upstreamTool = { name: "issue_write", description: "Create or update an issue.", inputSchema: { type: "object", properties: { labels: {} } } };
|
|
109
|
-
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
110
|
-
const withVerb = await buildCatalog(
|
|
111
|
-
makeConfig([{ name: "issue_write", mode: "write" }, { name: "issue_labels", mode: "write", custom: true }]),
|
|
112
|
-
secrets as any,
|
|
113
|
-
upstream as any
|
|
114
|
-
);
|
|
115
|
-
const iw = withVerb.find((t) => t.name === "issue_write")!;
|
|
116
|
-
expect(iw.description).toMatch(/^Create or update an issue\. WARNING \(gh-broker\): "labels" REPLACES the issue's whole label set/);
|
|
117
|
-
expect(iw.description).toMatch(/use issue_labels instead/);
|
|
118
|
-
expect(iw.inputSchema).toBe(upstreamTool.inputSchema);
|
|
119
|
-
expect(upstreamTool.description).toBe("Create or update an issue."); // the fetched tool is not mutated
|
|
120
|
-
|
|
121
|
-
const without = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
122
|
-
expect(without[0]?.description).toMatch(/REPLACES/);
|
|
123
|
-
expect(without[0]?.description).not.toMatch(/issue_labels/);
|
|
124
|
-
expect(without[0]?.description).toMatch(/pass the full set/);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("warns on issue_write even when the upstream tool carries no description", async () => {
|
|
128
|
-
upstream.listToolsFor.mockResolvedValue([{ name: "issue_write", inputSchema: { type: "object" } }]);
|
|
129
|
-
const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
130
|
-
expect(catalog[0]?.description).toMatch(/^WARNING \(gh-broker\): "labels" REPLACES/);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
|
|
134
|
-
upstream.listToolsFor.mockRejectedValue(new Error("down"));
|
|
135
|
-
const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
136
|
-
expect(catalog[0]?.description).toMatch(/^\(write\) proxied to the GitHub MCP WARNING/);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("advertises report_ci_status with its local schema", async () => {
|
|
140
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
141
|
-
const config = makeConfig([{ name: "report_ci_status", mode: "read", custom: true }]);
|
|
142
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
143
|
-
expect(catalog).toHaveLength(1);
|
|
144
|
-
expect(catalog[0]).toMatchObject({ name: "report_ci_status" });
|
|
145
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo"] });
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("layers ciVerbosity/logLines onto pull_request_read's real upstream schema without dropping its own params", async () => {
|
|
149
|
-
const upstreamTool = {
|
|
150
|
-
name: "pull_request_read",
|
|
151
|
-
description: "Get information on a specific pull request.",
|
|
152
|
-
inputSchema: { type: "object", required: ["owner", "repo", "pullNumber", "method"], properties: { method: { type: "string" }, pullNumber: { type: "number" } } }
|
|
153
|
-
};
|
|
154
|
-
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
155
|
-
const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
|
|
156
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
157
|
-
expect(catalog).toHaveLength(1);
|
|
158
|
-
const tool = catalog[0]!;
|
|
159
|
-
expect(tool.description).toContain("gh-broker extension");
|
|
160
|
-
expect((tool.inputSchema as any).required).toEqual(["owner", "repo", "pullNumber", "method"]);
|
|
161
|
-
expect((tool.inputSchema as any).properties.pullNumber).toEqual({ type: "number" });
|
|
162
|
-
expect((tool.inputSchema as any).properties.ciVerbosity).toMatchObject({ type: "number", enum: [0, 1, 2, 3] });
|
|
163
|
-
expect((tool.inputSchema as any).properties.logLines).toMatchObject({ type: "number" });
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("still adds ciVerbosity/logLines to pull_request_read when the upstream fetch fails entirely", async () => {
|
|
167
|
-
upstream.listToolsFor.mockRejectedValue(new Error("network down"));
|
|
168
|
-
const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
|
|
169
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
170
|
-
expect(catalog).toHaveLength(1);
|
|
171
|
-
expect((catalog[0]!.inputSchema as any).properties).toEqual({
|
|
172
|
-
ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: expect.any(String) },
|
|
173
|
-
logLines: { type: "number", description: expect.any(String) }
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("falls back to minimal schemas when the upstream fetch fails", async () => {
|
|
178
|
-
upstream.listToolsFor.mockRejectedValue(new Error("401 bad token"));
|
|
179
|
-
const config = makeConfig([
|
|
180
|
-
{ name: "get_me", mode: "read" },
|
|
181
|
-
{ name: "resolve_review_thread", mode: "write", custom: true }
|
|
182
|
-
]);
|
|
183
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
184
|
-
|
|
185
|
-
const getMe = catalog.find((t) => t.name === "get_me");
|
|
186
|
-
expect(getMe).toEqual({ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } });
|
|
187
|
-
expect(catalog.map((t) => t.name)).toContain("resolve_review_thread");
|
|
188
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/could not fetch upstream tool schemas/));
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("skips the upstream fetch entirely when there are no owners", async () => {
|
|
192
|
-
const config = makeConfig([{ name: "get_me", mode: "read" }], {});
|
|
193
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
194
|
-
expect(upstream.listToolsFor).not.toHaveBeenCalled();
|
|
195
|
-
expect(catalog).toEqual([{ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } }]);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("times out a slow upstream and advertises minimal schemas", async () => {
|
|
199
|
-
process.env.GH_BROKER_CATALOG_TIMEOUT_MS = "15";
|
|
200
|
-
upstream.listToolsFor.mockReturnValue(new Promise(() => {})); // never resolves
|
|
201
|
-
const config = makeConfig([{ name: "get_me", mode: "read" }]);
|
|
202
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
203
|
-
expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
|
|
204
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("advertises ghcr_push / ghcr_push_status / ghcr_tags with their local schemas", async () => {
|
|
208
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
209
|
-
const ghcrTools = [
|
|
210
|
-
{ name: "ghcr_push", mode: "write", custom: true },
|
|
211
|
-
{ name: "ghcr_push_status", mode: "read", custom: true },
|
|
212
|
-
{ name: "ghcr_tags", mode: "read", custom: true }
|
|
213
|
-
];
|
|
214
|
-
const ghcr = { owners: { cldmv: { credential: "cred:cldmv-packages" }, other: { credential: "cred:other-packages" } } };
|
|
215
|
-
const config = { ...makeConfig(ghcrTools), ghcr };
|
|
216
|
-
// Exposed as soon as any one GHCR owner's credential is stored.
|
|
217
|
-
secrets.has.mockImplementation((ref: string) => ref === "cred:other-packages");
|
|
218
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
219
|
-
expect(catalog.map((t) => t.name)).toEqual(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
|
|
220
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ required: ["owner", "image", "tag", "archive"], additionalProperties: false });
|
|
221
|
-
expect(catalog[0]?.description).toMatch(/background job/);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("does not expose the GHCR tools without config.ghcr, or while no GHCR credential is stored", async () => {
|
|
225
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
226
|
-
const rules = [{ name: "ghcr_push", mode: "write", custom: true }];
|
|
227
|
-
expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
|
|
228
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"ghcr_push" is not exposed: GHCR pushes are not configured/));
|
|
229
|
-
secrets.has.mockReturnValue(false);
|
|
230
|
-
const config = { ...makeConfig(rules), ghcr: { owners: { cldmv: { credential: "cred:cldmv-packages" } } } };
|
|
231
|
-
expect(await buildCatalog(config, secrets as any, upstream as any)).toEqual([]);
|
|
232
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/no GHCR credential is stored \("cldmv-packages"; re-run the installer/));
|
|
233
|
-
});
|
|
234
|
-
});
|
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { mergeConfig, layerConfig, overlayFromFlags, readOperatorConfig, buildConfig, secretsBlock, DEFAULT_GHCR_DROP_DIR } from "../deploy/lib/config-merge.mjs";
|
|
3
|
-
|
|
4
|
-
const example = {
|
|
5
|
-
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
6
|
-
upstream: "https://api.githubcopilot.com/mcp/x/all",
|
|
7
|
-
tools: [
|
|
8
|
-
{ name: "get_me", mode: "read" },
|
|
9
|
-
{ name: "list_issues", mode: "read" }
|
|
10
|
-
],
|
|
11
|
-
owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } },
|
|
12
|
-
defaultOwner: "acme"
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
describe("mergeConfig", () => {
|
|
16
|
-
it("uses the example as-is on first install (no current)", () => {
|
|
17
|
-
expect(mergeConfig(example, null)).toEqual(example);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("takes tools from the example so allowlist removals/tightenings apply", () => {
|
|
21
|
-
const current = { ...example, tools: [{ name: "get_me", mode: "read" }, { name: "removed_tool", mode: "write" }] };
|
|
22
|
-
const merged = mergeConfig(example, current);
|
|
23
|
-
expect(merged.tools).toEqual(example.tools);
|
|
24
|
-
expect(merged.tools.find((t: { name: string }) => t.name === "removed_tool")).toBeUndefined();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("preserves current owner data and adds example-only owners on upgrade", () => {
|
|
28
|
-
const current = { ...example, owners: { acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 123 } } };
|
|
29
|
-
const example2 = {
|
|
30
|
-
...example,
|
|
31
|
-
owners: {
|
|
32
|
-
acme: { read: "cred:acme-read", write: "cred:acme-write" },
|
|
33
|
-
beta: { read: "cred:beta-read", write: "cred:beta-write" }
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
const merged = mergeConfig(example2, current);
|
|
37
|
-
expect(merged.owners.acme.installationId).toBe(123); // current wins
|
|
38
|
-
expect(merged.owners.beta).toBeDefined(); // new example owner appears
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("lets current win for listen/upstream/defaultOwner/app/secrets", () => {
|
|
42
|
-
const current = { ...example, listen: "unix:/tmp/custom.sock", app: { appId: 1, privateKey: "cred:k" } };
|
|
43
|
-
const merged = mergeConfig(example, current);
|
|
44
|
-
expect(merged.listen).toBe("unix:/tmp/custom.sock");
|
|
45
|
-
expect(merged.app).toEqual({ appId: 1, privateKey: "cred:k" });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("keeps the current npm block on upgrade (the example has none)", () => {
|
|
49
|
-
const npm = { token: "cred:npm-token", scopes: ["@cldmv"] };
|
|
50
|
-
expect(mergeConfig(example, { ...example, npm }).npm).toEqual(npm);
|
|
51
|
-
expect(mergeConfig(example, { ...example }).npm).toBeUndefined();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("keeps the example's scalars when current omits them, and tolerates configs with no owners", () => {
|
|
55
|
-
const merged = mergeConfig({ tools: [], listen: "unix:/a" }, { tools: [] });
|
|
56
|
-
expect(merged.listen).toBe("unix:/a");
|
|
57
|
-
expect(merged.owners).toEqual({});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("does not mutate its inputs", () => {
|
|
61
|
-
const ex = structuredClone(example);
|
|
62
|
-
mergeConfig(ex, { ...example, listen: "unix:/z" });
|
|
63
|
-
expect(ex).toEqual(example);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("rejects an array as example or current (typeof reports 'object')", () => {
|
|
67
|
-
expect(() => mergeConfig([], null)).toThrow(/example config must be an object/);
|
|
68
|
-
expect(() => mergeConfig(example, [])).toThrow(/current config must be an object/);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("ghcr", () => {
|
|
72
|
-
const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", owners: { cldmv: { username: "u", credential: "cred:p", images: ["hoot"] } } };
|
|
73
|
-
|
|
74
|
-
it("keeps the deployed ghcr block (operator data); the example ships none", () => {
|
|
75
|
-
expect(mergeConfig(example, { ...example, ghcr }).ghcr).toEqual(ghcr);
|
|
76
|
-
expect(mergeConfig(example, { ...example }).ghcr).toBeUndefined();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("lets the deployed ghcr win over one the example carries", () => {
|
|
80
|
-
const edited = { ...ghcr, dropDir: "/srv/other/drop" };
|
|
81
|
-
expect(mergeConfig({ ...example, ghcr }, { ...example, ghcr: edited }).ghcr).toEqual(edited);
|
|
82
|
-
expect(mergeConfig({ ...example, ghcr }, example).ghcr).toEqual(ghcr);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const shipped = {
|
|
88
|
-
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
89
|
-
upstream: "https://api.githubcopilot.com/mcp/x/all",
|
|
90
|
-
tools: [{ name: "get_me", mode: "read" }]
|
|
91
|
-
};
|
|
92
|
-
const noOpts = {};
|
|
93
|
-
const ask = async () => {
|
|
94
|
-
throw new Error("unexpected prompt");
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
describe("layerConfig", () => {
|
|
98
|
-
it("replaces scalars and merges owners per owner and per field", () => {
|
|
99
|
-
const base = { listen: "unix:/a", upstream: "https://a", owners: { acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 7 } } };
|
|
100
|
-
const out = layerConfig(base, { listen: "unix:/b", defaultOwner: "acme", owners: { acme: { write: "cred:w2" }, beta: { read: "cred:r", write: "cred:w" } } });
|
|
101
|
-
expect(out.listen).toBe("unix:/b");
|
|
102
|
-
expect(out.upstream).toBe("https://a");
|
|
103
|
-
expect(out.defaultOwner).toBe("acme");
|
|
104
|
-
expect(out.owners.acme).toEqual({ read: "cred:acme-read", write: "cred:w2", installationId: 7 });
|
|
105
|
-
expect(out.owners.beta).toEqual({ read: "cred:r", write: "cred:w" });
|
|
106
|
-
expect(base.owners.acme.write).toBe("cred:acme-write"); // input untouched
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("merges app/secrets/npm per field and creates them when absent", () => {
|
|
110
|
-
const out = layerConfig({ npm: { token: "cred:t", scopes: ["@a"] } }, { npm: { registry: "https://r" }, secrets: { path: "/p" }, app: { appId: 1, privateKey: "cred:k" } });
|
|
111
|
-
expect(out.npm).toEqual({ token: "cred:t", scopes: ["@a"], registry: "https://r" });
|
|
112
|
-
expect(out.secrets).toEqual({ path: "/p" });
|
|
113
|
-
expect(out.app).toEqual({ appId: 1, privateKey: "cred:k" });
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("merges ghcr scalars and ghcr owners per owner", () => {
|
|
117
|
-
const base = { owners: {}, ghcr: { dropDir: "/srv/a/b", owners: { acme: { username: "u", credential: "cred:acme-packages", images: ["x"] } } } };
|
|
118
|
-
const out = layerConfig(base, { ghcr: { protectReleaseTags: false, owners: { acme: { images: ["y"] }, beta: { username: "b", credential: "cred:beta-packages", images: ["z"] } } } });
|
|
119
|
-
expect(out.ghcr.dropDir).toBe("/srv/a/b");
|
|
120
|
-
expect(out.ghcr.protectReleaseTags).toBe(false);
|
|
121
|
-
expect(out.ghcr.owners.acme).toEqual({ username: "u", credential: "cred:acme-packages", images: ["y"] });
|
|
122
|
-
expect(Object.keys(out.ghcr.owners)).toEqual(["acme", "beta"]);
|
|
123
|
-
expect(layerConfig({}, { ghcr: { dropDir: "/srv/a/b" } }).ghcr).toEqual({ dropDir: "/srv/a/b" });
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("leaves a base without overlay data alone", () => {
|
|
127
|
-
expect(layerConfig({ listen: "unix:/a" }, {})).toEqual({ listen: "unix:/a" });
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe("overlayFromFlags", () => {
|
|
132
|
-
it("is empty with no flags", () => {
|
|
133
|
-
expect(overlayFromFlags(noOpts)).toEqual({});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("maps every flag to its config field", () => {
|
|
137
|
-
const o = overlayFromFlags({
|
|
138
|
-
listen: "unix:/x.sock",
|
|
139
|
-
upstream: "https://u",
|
|
140
|
-
defaultOwner: "acme",
|
|
141
|
-
owners: ["acme", "beta"],
|
|
142
|
-
installationIds: { acme: 42, gamma: 9 },
|
|
143
|
-
appId: 5,
|
|
144
|
-
appPrivateKey: "my-key",
|
|
145
|
-
secretsPath: "/s.json",
|
|
146
|
-
ghcrOwner: "acme",
|
|
147
|
-
ghcrUsername: "me",
|
|
148
|
-
ghcrImages: ["app", "app-*"],
|
|
149
|
-
ghcrDropDir: "/srv/x/drop",
|
|
150
|
-
ghcrMaxArchiveBytes: 1024,
|
|
151
|
-
protectReleaseTags: false
|
|
152
|
-
});
|
|
153
|
-
expect(o).toEqual({
|
|
154
|
-
listen: "unix:/x.sock",
|
|
155
|
-
upstream: "https://u",
|
|
156
|
-
defaultOwner: "acme",
|
|
157
|
-
owners: {
|
|
158
|
-
acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 42 },
|
|
159
|
-
beta: { read: "cred:beta-read", write: "cred:beta-write" },
|
|
160
|
-
gamma: { installationId: 9 }
|
|
161
|
-
},
|
|
162
|
-
app: { appId: 5, privateKey: "cred:my-key" },
|
|
163
|
-
secrets: { path: "/s.json" },
|
|
164
|
-
ghcr: {
|
|
165
|
-
dropDir: "/srv/x/drop",
|
|
166
|
-
maxArchiveBytes: 1024,
|
|
167
|
-
protectReleaseTags: false,
|
|
168
|
-
owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["app", "app-*"] } }
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("derives the App key name when only --app-id is given and handles --protect-release-tags", () => {
|
|
174
|
-
expect(overlayFromFlags({ appId: 5 }).app).toEqual({ appId: 5, privateKey: "cred:gh-app-key" });
|
|
175
|
-
expect(overlayFromFlags({ protectReleaseTags: true }).ghcr).toEqual({ protectReleaseTags: true });
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("readOperatorConfig", () => {
|
|
180
|
-
const read = (text: string) => () => text;
|
|
181
|
-
|
|
182
|
-
it("returns a valid operator config", () => {
|
|
183
|
-
const cfg = { listen: "unix:/a", owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } } };
|
|
184
|
-
expect(readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toEqual(cfg);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("reports an unreadable file and bad JSON", () => {
|
|
188
|
-
expect(() =>
|
|
189
|
-
readOperatorConfig("/missing.json", () => {
|
|
190
|
-
throw new Error("ENOENT: no such file");
|
|
191
|
-
})
|
|
192
|
-
).toThrow(/--config: cannot read\/parse \/missing.json: ENOENT/);
|
|
193
|
-
expect(() => readOperatorConfig("/c.json", read("{ nope"))).toThrow(/--config: cannot read\/parse/);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("refuses a non-object root, tools, and unknown keys", () => {
|
|
197
|
-
expect(() => readOperatorConfig("/c.json", read("[]"))).toThrow(/must be a JSON object/);
|
|
198
|
-
expect(() => readOperatorConfig("/c.json", read("null"))).toThrow(/must be a JSON object/);
|
|
199
|
-
expect(() => readOperatorConfig("/c.json", read('{"tools":[]}'))).toThrow(/`tools` is the shipped allowlist/);
|
|
200
|
-
expect(() => readOperatorConfig("/c.json", read('{"lisen":"x","owners":{}}'))).toThrow(/unknown key\(s\) lisen/);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it.each([
|
|
204
|
-
["a classic PAT", { owners: { acme: { read: "ghp_" + "a".repeat(36), write: "cred:w" } } }, "owners.acme.read"],
|
|
205
|
-
["a fine-grained PAT", { ghcr: { owners: { a: { images: ["github_pat_" + "A1".repeat(20)] } } } }, "ghcr.owners.a.images[0]"],
|
|
206
|
-
["an npm token", { npm: { token: "npm_" + "b".repeat(36), scopes: [] } }, "npm.token"],
|
|
207
|
-
["a token as a list entry", { npm: { scopes: ["ok", "ghs_" + "c".repeat(30)] } }, "npm.scopes[1]"]
|
|
208
|
-
])("refuses %s", (_name, cfg, where) => {
|
|
209
|
-
expect(() => readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toThrow(new RegExp(`--config: ${where.replace(/[[\]]/g, "\\$&")} looks like a token`));
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("accepts cred refs, numbers and booleans alongside strings", () => {
|
|
213
|
-
const cfg = { app: { appId: 1, privateKey: "cred:gh-app-key" }, ghcr: { protectReleaseTags: true } };
|
|
214
|
-
expect(readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toEqual(cfg);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
describe("buildConfig", () => {
|
|
219
|
-
const acme = { read: "cred:acme-read", write: "cred:acme-write" };
|
|
220
|
-
const build = (o: Record<string, unknown>) => buildConfig({ example: shipped, current: null, file: undefined, opts: noOpts, nonInteractive: true, ask, ...o } as never);
|
|
221
|
-
|
|
222
|
-
it("a first install takes owners from flags, defaults defaultOwner and sets ghcr.dropDir", async () => {
|
|
223
|
-
const { config, notes } = await build({
|
|
224
|
-
opts: { owners: ["acme"], ghcrOwner: "acme", ghcrUsername: "me", ghcrImages: ["app"] }
|
|
225
|
-
});
|
|
226
|
-
expect(config.tools).toEqual(shipped.tools);
|
|
227
|
-
expect(config.owners).toEqual({ acme });
|
|
228
|
-
expect(config.defaultOwner).toBe("acme");
|
|
229
|
-
expect(config.ghcr).toEqual({ dropDir: DEFAULT_GHCR_DROP_DIR, owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["app"] } } });
|
|
230
|
-
expect(notes).toHaveLength(2);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("does not pick a defaultOwner with several owners, or when one is set", async () => {
|
|
234
|
-
expect((await build({ opts: { owners: ["acme", "beta"] } })).config.defaultOwner).toBeUndefined();
|
|
235
|
-
expect((await build({ opts: { owners: ["acme", "beta"], defaultOwner: "beta" } })).config.defaultOwner).toBe("beta");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("layers example < deployed < --config < flags", async () => {
|
|
239
|
-
const current = { ...shipped, listen: "unix:/deployed", upstream: "https://deployed", owners: { acme }, defaultOwner: "acme" };
|
|
240
|
-
const file = { listen: "unix:/file", upstream: "https://file" };
|
|
241
|
-
const flags = { listen: "unix:/flag" };
|
|
242
|
-
const { config } = await build({ current, file, opts: flags });
|
|
243
|
-
expect(config.listen).toBe("unix:/flag"); // flag beats file
|
|
244
|
-
expect(config.upstream).toBe("https://file"); // file beats deployed
|
|
245
|
-
const deployedOnly = await build({ current });
|
|
246
|
-
expect(deployedOnly.config.listen).toBe("unix:/deployed"); // deployed beats example
|
|
247
|
-
expect(deployedOnly.config.upstream).toBe("https://deployed");
|
|
248
|
-
expect((await build({ current: { tools: [], owners: { acme } } })).config.listen).toBe(shipped.listen); // example fills gaps
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("keeps the deployed owners, ghcr and tools on an upgrade with no flags", async () => {
|
|
252
|
-
const ghcr = { dropDir: "/srv/old/drop", protectReleaseTags: false, maxArchiveBytes: 5, owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["x"] } } };
|
|
253
|
-
const current = { ...shipped, tools: [{ name: "gone", mode: "write" }], owners: { acme: { ...acme, installationId: 3 } }, ghcr };
|
|
254
|
-
const { config, notes } = await build({ current });
|
|
255
|
-
expect(config.owners).toEqual({ acme: { ...acme, installationId: 3 } });
|
|
256
|
-
expect(config.ghcr).toEqual(ghcr);
|
|
257
|
-
expect(config.tools).toEqual(shipped.tools); // allowlist from the example
|
|
258
|
-
expect(config.defaultOwner).toBeUndefined();
|
|
259
|
-
expect(notes).toEqual([]);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("adds an owner on top of a deployed one without losing it", async () => {
|
|
263
|
-
const { config } = await build({ current: { ...shipped, owners: { acme: { ...acme, installationId: 3 } } }, opts: { owners: ["acme", "beta"] } });
|
|
264
|
-
expect(config.owners.acme.installationId).toBe(3);
|
|
265
|
-
expect(config.owners.beta).toEqual({ read: "cred:beta-read", write: "cred:beta-write" });
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("takes owners, ghcr and defaultOwner from a --config file", async () => {
|
|
269
|
-
const file = { owners: { acme }, defaultOwner: "acme", ghcr: { dropDir: "/srv/f/drop", owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["a"] } } } };
|
|
270
|
-
const { config } = await build({ file });
|
|
271
|
-
expect(config.owners).toEqual({ acme });
|
|
272
|
-
expect(config.ghcr.dropDir).toBe("/srv/f/drop");
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("fails non-interactively when no owner exists anywhere, naming --owner and --config", async () => {
|
|
276
|
-
await expect(build({})).rejects.toThrow(/--owner <org>.*--config/);
|
|
277
|
-
await expect(build({ current: { ...shipped } })).rejects.toThrow(/no GitHub owner configured/);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("asks for the owner on an interactive first install", async () => {
|
|
281
|
-
const asked: string[] = [];
|
|
282
|
-
const { config } = await build({
|
|
283
|
-
nonInteractive: false,
|
|
284
|
-
ask: async (q: string) => {
|
|
285
|
-
asked.push(q);
|
|
286
|
-
return " Acme-Corp \n";
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
expect(asked).toHaveLength(1);
|
|
290
|
-
expect(config.owners).toEqual({ "acme-corp": { read: "cred:acme-corp-read", write: "cred:acme-corp-write" } });
|
|
291
|
-
expect(config.defaultOwner).toBe("acme-corp");
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("rejects an invalid owner typed at the prompt", async () => {
|
|
295
|
-
await expect(build({ nonInteractive: false, ask: async () => "not an org!" })).rejects.toThrow(/not a valid GitHub owner name/);
|
|
296
|
-
await expect(build({ nonInteractive: false, ask: async () => "" })).rejects.toThrow(/not a valid GitHub owner name/);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("does not prompt when owners are already configured", async () => {
|
|
300
|
-
const { config } = await build({ nonInteractive: false, current: { ...shipped, owners: { acme } } });
|
|
301
|
-
expect(Object.keys(config.owners)).toEqual(["acme"]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("refuses an owner that has no credentials (e.g. --installation-id for an unknown owner)", async () => {
|
|
305
|
-
await expect(build({ opts: { owners: ["acme"], installationIds: { gamma: 9 } } })).rejects.toThrow(/owner "gamma" has no credentials.*--owner gamma/);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it("keeps an explicit ghcr.dropDir instead of the default", async () => {
|
|
309
|
-
const { config, notes } = await build({ opts: { owners: ["acme"], ghcrOwner: "acme", ghcrUsername: "me", ghcrImages: ["a"], ghcrDropDir: "/srv/mine/drop" } });
|
|
310
|
-
expect(config.ghcr.dropDir).toBe("/srv/mine/drop");
|
|
311
|
-
expect(notes.some((n: string) => n.includes("dropDir"))).toBe(false);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
describe("secretsBlock", () => {
|
|
316
|
-
it("returns just the provider, keeping a path only for the file provider", () => {
|
|
317
|
-
expect(secretsBlock("systemd-creds", undefined)).toEqual({ provider: "systemd-creds" });
|
|
318
|
-
expect(secretsBlock("file", { path: "/s.json" })).toEqual({ provider: "file", path: "/s.json" });
|
|
319
|
-
expect(secretsBlock("file", {})).toEqual({ provider: "file" });
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("refuses a path with the systemd-creds provider", () => {
|
|
323
|
-
expect(() => secretsBlock("systemd-creds", { path: "/s.json" })).toThrow(/only used by the "file" provider/);
|
|
324
|
-
});
|
|
325
|
-
});
|