@cldmv/gh-broker 0.8.0 → 0.9.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.
Files changed (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,338 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { reportSecurityAlerts, REPORT_SECURITY_ALERTS_SCHEMA } from "../src/verbs/report_security_alerts.ts";
3
-
4
- type Handler = (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>;
5
-
6
- function octokitWith(handler: Handler): any {
7
- return { request: vi.fn(handler) };
8
- }
9
-
10
- const status = (code: number, message = "x") => Object.assign(new Error(message), { status: code });
11
-
12
- const dependabot = (n: number, over: Record<string, unknown> = {}) => ({
13
- number: n,
14
- html_url: `https://github.com/CLDMV/r/security/dependabot/${n}`,
15
- created_at: "2026-10-02T20:10:30Z",
16
- dependency: { package: { ecosystem: "npm", name: "hono" }, manifest_path: "package-lock.json", scope: "runtime" },
17
- security_advisory: { ghsa_id: "GHSA-aaaa", cve_id: "CVE-2026-1", summary: "bad thing", severity: "medium" },
18
- security_vulnerability: { severity: "high", vulnerable_version_range: "< 4.13.7", first_patched_version: { identifier: "4.13.7" } },
19
- ...over
20
- });
21
-
22
- const codeql = (n: number, over: Record<string, unknown> = {}) => ({
23
- number: n,
24
- html_url: `https://github.com/CLDMV/r/security/code-scanning/${n}`,
25
- created_at: "2026-10-01T00:00:00Z",
26
- rule: { id: "js/xss", severity: "error", security_severity_level: "critical", description: "XSS" },
27
- tool: { name: "CodeQL" },
28
- most_recent_instance: { location: { path: "src/a.ts", start_line: 12 } },
29
- ...over
30
- });
31
-
32
- const secret = (n: number, over: Record<string, unknown> = {}) => ({
33
- number: n,
34
- html_url: `https://github.com/CLDMV/r/security/secret-scanning/${n}`,
35
- created_at: "2026-09-30T00:00:00Z",
36
- secret_type: "github_personal_access_token",
37
- secret_type_display_name: "GitHub Personal Access Token",
38
- validity: "active",
39
- secret: "ghp_SHOULD_NEVER_BE_RETURNED",
40
- ...over
41
- });
42
-
43
- describe("reportSecurityAlerts — org-level reads", () => {
44
- it("reads the three org endpoints with state=open and normalizes each alert type", async () => {
45
- const octokit = octokitWith(async (route, params) => {
46
- expect(params).toMatchObject({ owner: "cldmv", state: "open" });
47
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
48
- if (route === "GET /orgs/{owner}/code-scanning/alerts") return { data: [codeql(2, { repository: { full_name: "CLDMV/b" } })], headers: {} };
49
- if (route === "GET /orgs/{owner}/secret-scanning/alerts") return { data: [secret(3, { repository: { full_name: "CLDMV/c" } })], headers: {} };
50
- throw new Error(`unexpected route ${route}`);
51
- });
52
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
53
- expect(out.scope).toEqual({ dependabot: "org", codeql: "org", secrets: "org" });
54
- expect(out.scanned).toEqual({});
55
- expect(octokit.request).toHaveBeenCalledTimes(3);
56
- expect(out.alerts).toEqual([
57
- {
58
- repo: "CLDMV/b",
59
- type: "codeql",
60
- number: 2,
61
- severity: "critical",
62
- summary: "XSS",
63
- url: "https://github.com/CLDMV/r/security/code-scanning/2",
64
- createdAt: "2026-10-01T00:00:00Z",
65
- rule: "js/xss",
66
- tool: "CodeQL",
67
- location: "src/a.ts:12"
68
- },
69
- {
70
- repo: "CLDMV/a",
71
- type: "dependabot",
72
- number: 1,
73
- severity: "high",
74
- summary: "bad thing",
75
- url: "https://github.com/CLDMV/r/security/dependabot/1",
76
- createdAt: "2026-10-02T20:10:30Z",
77
- package: "hono",
78
- ecosystem: "npm",
79
- manifest: "package-lock.json",
80
- scope: "runtime",
81
- ghsa: "GHSA-aaaa",
82
- cve: "CVE-2026-1",
83
- vulnerableRange: "< 4.13.7",
84
- patchedVersion: "4.13.7"
85
- },
86
- {
87
- repo: "CLDMV/c",
88
- type: "secrets",
89
- number: 3,
90
- severity: "open",
91
- summary: "GitHub Personal Access Token",
92
- url: "https://github.com/CLDMV/r/security/secret-scanning/3",
93
- createdAt: "2026-09-30T00:00:00Z",
94
- secretType: "github_personal_access_token",
95
- validity: "active"
96
- }
97
- ]);
98
- expect(out.counts).toEqual({ dependabot: { high: 1 }, codeql: { critical: 1 }, secrets: { open: 1 } });
99
- expect(out.truncated).toBeUndefined();
100
- });
101
-
102
- it("never returns the secret value", async () => {
103
- const octokit = octokitWith(async (route) => ({ data: route.includes("secret-scanning") ? [secret(1, { repository: { full_name: "CLDMV/c" } })] : [], headers: {} }));
104
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["secrets"] });
105
- expect(JSON.stringify(out)).not.toContain("ghp_SHOULD_NEVER_BE_RETURNED");
106
- expect(out.alerts[0]).not.toHaveProperty("secret");
107
- });
108
-
109
- it("only reads the requested types", async () => {
110
- const octokit = octokitWith(async () => ({ data: [], headers: {} }));
111
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"] });
112
- expect(octokit.request).toHaveBeenCalledTimes(1);
113
- expect(octokit.request.mock.calls[0]![0]).toBe("GET /orgs/{owner}/dependabot/alerts");
114
- expect(out.scope).toEqual({ dependabot: "org" });
115
- expect(out.counts).toEqual({ dependabot: {} });
116
- });
117
-
118
- it("treats an empty types array as all three", async () => {
119
- const octokit = octokitWith(async () => ({ data: [], headers: {} }));
120
- await reportSecurityAlerts(octokit, { owner: "cldmv", types: [] });
121
- expect(octokit.request).toHaveBeenCalledTimes(3);
122
- });
123
-
124
- it("follows the Dependabot cursor across pages in org mode", async () => {
125
- const next = '<https://api.github.com/x?after=CUR>; rel="next"';
126
- const octokit = octokitWith(async (route, params) => {
127
- if (route !== "GET /orgs/{owner}/dependabot/alerts") return { data: [], headers: {} };
128
- if (!params.after) return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: { link: next } };
129
- return { data: [dependabot(2, { repository: { full_name: "CLDMV/a" } })], headers: {} };
130
- });
131
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"] });
132
- expect(out.alerts.map((a) => a.number)).toEqual([1, 2]);
133
- });
134
-
135
- it("pages the code-scanning org endpoint with page numbers", async () => {
136
- const full = Array.from({ length: 100 }, (_, i) => codeql(i + 1, { repository: { full_name: "CLDMV/a" } }));
137
- const octokit = octokitWith(async (_route, params) => ({ data: params.page === 1 ? full : [codeql(101, { repository: { full_name: "CLDMV/a" } })], headers: {} }));
138
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["codeql"] });
139
- expect(out.alerts).toHaveLength(101);
140
- });
141
-
142
- it("sorts by severity (critical first), then repo, then number", async () => {
143
- const octokit = octokitWith(async (route) => {
144
- if (route !== "GET /orgs/{owner}/dependabot/alerts") return { data: [], headers: {} };
145
- const sev = (s: string) => ({ security_vulnerability: { severity: s } });
146
- return {
147
- data: [
148
- dependabot(5, { repository: { full_name: "CLDMV/z" }, ...sev("low") }),
149
- dependabot(9, { repository: { full_name: "CLDMV/b" }, ...sev("high") }),
150
- dependabot(2, { repository: { full_name: "CLDMV/b" }, ...sev("high") }),
151
- dependabot(1, { repository: { full_name: "CLDMV/a" }, ...sev("critical") }),
152
- dependabot(3, { repository: { full_name: "CLDMV/a" }, ...sev("weird") })
153
- ],
154
- headers: {}
155
- };
156
- });
157
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"] });
158
- expect(out.alerts.map((a) => `${a.repo}#${a.number}`)).toEqual(["CLDMV/a#1", "CLDMV/b#2", "CLDMV/b#9", "CLDMV/z#5", "CLDMV/a#3"]);
159
- });
160
-
161
- it("falls back to unknown severity and omits absent optional fields", async () => {
162
- const bare = { number: 7, html_url: "u", created_at: "t", repository: { full_name: "CLDMV/a" } };
163
- const octokit = octokitWith(async (route) => {
164
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [bare], headers: {} };
165
- if (route === "GET /orgs/{owner}/code-scanning/alerts") return { data: [{ ...bare, rule: {}, most_recent_instance: { location: { path: "x.ts" } } }], headers: {} };
166
- return { data: [{ ...bare }], headers: {} };
167
- });
168
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
169
- const dep = out.alerts.find((a) => a.type === "dependabot")!;
170
- expect(dep).toEqual({ repo: "CLDMV/a", type: "dependabot", number: 7, severity: "unknown", summary: "", url: "u", createdAt: "t" });
171
- expect(out.alerts.find((a) => a.type === "codeql")).toMatchObject({ severity: "unknown", location: "x.ts" });
172
- expect(out.alerts.find((a) => a.type === "secrets")).toMatchObject({ severity: "open", summary: "" });
173
- });
174
-
175
- it("leaves the location off a code scanning alert whose instance has no path", async () => {
176
- const bare = { number: 7, html_url: "u", created_at: "t", repository: { full_name: "CLDMV/a" } };
177
- const octokit = octokitWith(async (route) => ({
178
- data: route === "GET /orgs/{owner}/code-scanning/alerts" ? [{ ...bare, most_recent_instance: { location: {} } }, { ...bare, number: 8 }] : [],
179
- headers: {}
180
- }));
181
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
182
- for (const a of out.alerts.filter((x) => x.type === "codeql")) expect(a).not.toHaveProperty("location");
183
- });
184
-
185
- it("flags max-alerts when a type fills its alertLimit", async () => {
186
- const octokit = octokitWith(async (route) => ({ data: route.includes("dependabot") ? [dependabot(1, { repository: { full_name: "CLDMV/a" } }), dependabot(2, { repository: { full_name: "CLDMV/a" } })] : [], headers: {} }));
187
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", alertLimit: 2 });
188
- expect(out.truncated).toBe(true);
189
- expect(out.truncatedReason).toBe("max-alerts");
190
- expect(out.alerts).toHaveLength(2);
191
- });
192
- });
193
-
194
- describe("reportSecurityAlerts — per-repo fallback", () => {
195
- const orgDenied = (route: string) => route.startsWith("GET /orgs/{owner}/") && !route.endsWith("/repos");
196
-
197
- it("falls back to a per-repo sweep for a type whose org endpoint is 403/404, keeping the types that worked", async () => {
198
- const octokit = octokitWith(async (route, params) => {
199
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
200
- if (route === "GET /orgs/{owner}/code-scanning/alerts") throw status(403, "Resource not accessible by personal access token");
201
- if (route === "GET /orgs/{owner}/secret-scanning/alerts") throw status(404, "Not Found");
202
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b", archived: true }, { name: "c" }], headers: {} };
203
- if (route === "GET /repos/{owner}/{repo}/code-scanning/alerts") return { data: params.repo === "a" ? [codeql(4)] : [], headers: {} };
204
- if (route === "GET /repos/{owner}/{repo}/secret-scanning/alerts") return { data: params.repo === "c" ? [secret(5)] : [], headers: {} };
205
- throw new Error(`unexpected route ${route}`);
206
- });
207
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
208
- expect(out.scope).toEqual({ dependabot: "org", codeql: "per-repo", secrets: "per-repo" });
209
- expect(out.scanned).toEqual({ repos: 2 });
210
- expect(out.alerts.map((a) => `${a.type}:${a.repo}#${a.number}`).sort()).toEqual(["codeql:cldmv/a#4", "dependabot:CLDMV/a#1", "secrets:cldmv/c#5"]);
211
- expect(out.disabled).toBeUndefined();
212
- });
213
-
214
- it("counts repos where a type is switched off instead of failing", async () => {
215
- const octokit = octokitWith(async (route, params) => {
216
- if (orgDenied(route)) throw status(404);
217
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }, { name: "c" }], headers: {} };
218
- if (route === "GET /repos/{owner}/{repo}/dependabot/alerts") return { data: params.repo === "a" ? [dependabot(1)] : [], headers: {} };
219
- if (route === "GET /repos/{owner}/{repo}/code-scanning/alerts") throw status(403, "Code Security must be enabled for this repository to use code scanning.");
220
- if (route === "GET /repos/{owner}/{repo}/secret-scanning/alerts") {
221
- if (params.repo === "b") throw status(404, "Secret scanning is disabled on this repository.");
222
- return { data: [], headers: {} };
223
- }
224
- throw new Error(`unexpected route ${route}`);
225
- });
226
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
227
- expect(out.disabled).toEqual({ codeql: 3, secrets: 1 });
228
- expect(out.alerts).toHaveLength(1);
229
- expect(out.counts).toEqual({ dependabot: { high: 1 }, codeql: {}, secrets: {} });
230
- expect(out.truncated).toBeUndefined();
231
- });
232
-
233
- it("goes straight to per-repo (no org calls) when a repo is named", async () => {
234
- const octokit = octokitWith(async (route, params) => {
235
- expect(params).toMatchObject({ owner: "cldmv", repo: "gh-broker", state: "open" });
236
- if (route === "GET /repos/{owner}/{repo}/dependabot/alerts") return { data: [dependabot(8)], headers: {} };
237
- return { data: [], headers: {} };
238
- });
239
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "gh-broker" });
240
- expect(out.scanned).toEqual({ repos: 1 });
241
- expect(out.scope).toEqual({ dependabot: "per-repo", codeql: "per-repo", secrets: "per-repo" });
242
- expect(out.alerts[0]).toMatchObject({ repo: "cldmv/gh-broker", number: 8 });
243
- const routes = octokit.request.mock.calls.map((c: any[]) => c[0]);
244
- expect(routes.some((r: string) => r.startsWith("GET /orgs/"))).toBe(false);
245
- });
246
-
247
- it("flags max-repos when the owner has more repos than maxRepos, and sweeps only maxRepos", async () => {
248
- const octokit = octokitWith(async (route, params) => {
249
- if (orgDenied(route)) throw status(404);
250
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }, { name: "c" }], headers: {} };
251
- expect(["a", "b"]).toContain(params.repo);
252
- return { data: [], headers: {} };
253
- });
254
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"], maxRepos: 2 });
255
- expect(out.scanned).toEqual({ repos: 2 });
256
- expect(out.truncated).toBe(true);
257
- expect(out.truncatedReason).toBe("max-repos");
258
- });
259
-
260
- it("does not flag max-repos when the owner has exactly maxRepos repos", async () => {
261
- const octokit = octokitWith(async (route) => {
262
- if (orgDenied(route)) throw status(404);
263
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }], headers: {} };
264
- return { data: [], headers: {} };
265
- });
266
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"], maxRepos: 2 });
267
- expect(out.truncated).toBeUndefined();
268
- });
269
-
270
- it("flags max-alerts when one repo fills its per-type alertLimit", async () => {
271
- const octokit = octokitWith(async () => ({ data: [dependabot(1), dependabot(2)], headers: {} }));
272
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r", types: ["dependabot"], alertLimit: 2 });
273
- expect(out.truncatedReason).toBe("max-alerts");
274
- });
275
- });
276
-
277
- describe("reportSecurityAlerts — limits and errors", () => {
278
- it("returns partial results flagged max-api-calls when the call budget runs out", async () => {
279
- const octokit = octokitWith(async (route) => {
280
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
281
- return { data: [], headers: {} };
282
- });
283
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", maxApiCalls: 1 });
284
- expect(out.truncated).toBe(true);
285
- expect(out.truncatedReason).toBe("max-api-calls");
286
- expect(out.alerts).toHaveLength(1);
287
- // types never reached are not reported as having zero alerts
288
- expect(out.counts).toEqual({ dependabot: { high: 1 } });
289
- expect(out.scope).toEqual({ dependabot: "org" });
290
- });
291
-
292
- it("returns partial results with retryAfterSeconds when rate limited mid-scan", async () => {
293
- const octokit = octokitWith(async (route) => {
294
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
295
- throw Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "retry-after": "30" } } });
296
- });
297
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
298
- expect(out.truncated).toBe(true);
299
- expect(out.truncatedReason).toBe("rate-limited");
300
- expect(out.retryAfterSeconds).toBe(30);
301
- expect(out.alerts).toHaveLength(1);
302
- });
303
-
304
- it("omits retryAfterSeconds when GitHub sent none", async () => {
305
- const octokit = octokitWith(async () => { throw status(429, "Too Many Requests"); });
306
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
307
- expect(out.truncatedReason).toBe("rate-limited");
308
- expect(out).not.toHaveProperty("retryAfterSeconds");
309
- });
310
-
311
- it("stops proactively at the rate-limit floor", async () => {
312
- const octokit = octokitWith(async () => ({ data: [dependabot(1)], headers: { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "50" } }));
313
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
314
- expect(out.truncatedReason).toBe("rate-limited");
315
- });
316
-
317
- it("propagates an unexpected error from an org endpoint", async () => {
318
- const octokit = octokitWith(async () => { throw status(500, "server exploded"); });
319
- await expect(reportSecurityAlerts(octokit, { owner: "cldmv" })).rejects.toThrow("server exploded");
320
- });
321
-
322
- it("propagates an unexpected error from a per-repo endpoint", async () => {
323
- const octokit = octokitWith(async () => { throw status(500, "server exploded"); });
324
- await expect(reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r" })).rejects.toThrow("server exploded");
325
- });
326
-
327
- it("clamps the caps to their hard maximums", async () => {
328
- const octokit = octokitWith(async () => ({ data: [], headers: {} }));
329
- await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r", types: ["codeql"], alertLimit: 99999, maxApiCalls: 99999, maxRepos: 99999 });
330
- expect(octokit.request).toHaveBeenCalledTimes(1);
331
- });
332
-
333
- it("advertises a valid schema", () => {
334
- expect(REPORT_SECURITY_ALERTS_SCHEMA.required).toEqual(["owner"]);
335
- expect(REPORT_SECURITY_ALERTS_SCHEMA.additionalProperties).toBe(false);
336
- expect(REPORT_SECURITY_ALERTS_SCHEMA.properties.types.items.enum).toEqual(["dependabot", "codeql", "secrets"]);
337
- });
338
- });
@@ -1,200 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { connect, type Socket } from "node:net";
3
- import { mkdirSync, rmSync, statSync, writeFileSync, readFileSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
6
- import { startServer } from "../src/server.ts";
7
-
8
- // Real node:fs, except readFileSync is a spy so the /etc/group lookup can be forced to fail.
9
- vi.mock("node:fs", async (importOriginal) => {
10
- const actual = await importOriginal<typeof import("node:fs")>();
11
- return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
12
- });
13
-
14
- // Real node:net, except createServer remembers the server-side sockets so a test can inject events on them.
15
- const seen = vi.hoisted(() => ({ sockets: [] as any[] }));
16
- vi.mock("node:net", async (importOriginal) => {
17
- const actual = await importOriginal<typeof import("node:net")>();
18
- return {
19
- ...actual,
20
- createServer: (listener: (s: any) => void) =>
21
- actual.createServer((s) => {
22
- seen.sockets.push(s);
23
- listener(s);
24
- })
25
- };
26
- });
27
-
28
- const root = join(process.cwd(), "tmp", "tests", "server");
29
- const fakeRouter: any = { call: vi.fn(async (name: string, args: unknown) => ({ content: [{ type: "text", text: `${name}:${JSON.stringify(args)}` }] })) };
30
- const catalog: any[] = [{ name: "get_me", description: "me", inputSchema: { type: "object" } }];
31
- const sockets: Socket[] = [];
32
-
33
- function sockPath(name: string): string {
34
- mkdirSync(root, { recursive: true });
35
- rmSync(join(root, name), { force: true }); // a socket left behind by an earlier run
36
- return join(root, name);
37
- }
38
-
39
- /** Open a client connection and return a request/response helper over newline-delimited JSON-RPC. */
40
- async function client(path: string) {
41
- const sock: Socket = await new Promise((resolve, reject) => {
42
- const s = connect(path, () => resolve(s));
43
- s.once("error", reject);
44
- });
45
- sockets.push(sock);
46
- let buf = "";
47
- const waiting = new Map<number, (m: any) => void>();
48
- sock.on("data", (d) => {
49
- buf += d.toString("utf8");
50
- let nl;
51
- while ((nl = buf.indexOf("\n")) >= 0) {
52
- const msg = JSON.parse(buf.slice(0, nl));
53
- buf = buf.slice(nl + 1);
54
- waiting.get(msg.id)?.(msg);
55
- }
56
- });
57
- let id = 0;
58
- return {
59
- sock,
60
- request: (method: string, params: unknown) =>
61
- new Promise<any>((resolve) => {
62
- const i = ++id;
63
- waiting.set(i, resolve);
64
- sock.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n");
65
- })
66
- };
67
- }
68
-
69
- afterEach(() => {
70
- vi.restoreAllMocks();
71
- for (const s of sockets.splice(0)) s.destroy();
72
- delete process.env.GH_BROKER_SOCKET_GROUP;
73
- vi.mocked(readFileSync).mockClear();
74
- });
75
-
76
- describe("startServer", () => {
77
- it("throws synchronously for a non-unix listen scheme", () => {
78
- expect(() => startServer({ listen: "tcp:0.0.0.0:9000" } as any, fakeRouter, catalog)).toThrow(/unsupported listen/);
79
- });
80
-
81
- it("rejects when the socket cannot be bound", async () => {
82
- const config: any = { listen: "unix:/no/such/dir/gh-broker.sock" };
83
- await expect(startServer(config, fakeRouter, catalog)).rejects.toBeTruthy();
84
- });
85
-
86
- it("serves tools/list and tools/call over the socket, then drops the connection cleanly", async () => {
87
- const path = sockPath("serve.sock");
88
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
89
- expect(statSync(path).mode & 0o777).toBe(0o660);
90
-
91
- const c = await client(path);
92
- const init = await c.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } });
93
- expect(init.result.serverInfo.name).toBe("gh-broker");
94
- const listed = await c.request("tools/list", {});
95
- expect(listed.result.tools).toEqual(catalog);
96
- const called = await c.request("tools/call", { name: "get_me", arguments: { a: 1 } });
97
- expect(called.result.content[0].text).toBe('get_me:{"a":1}');
98
- // arguments omitted → router receives {}
99
- const noArgs = await c.request("tools/call", { name: "get_me" });
100
- expect(noArgs.result.content[0].text).toBe("get_me:{}");
101
-
102
- c.sock.destroy();
103
- await new Promise((r) => setTimeout(r, 50));
104
- });
105
-
106
- it("replaces a stale socket file left by a previous run", async () => {
107
- const path = sockPath("stale.sock");
108
- writeFileSync(path, "");
109
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
110
- const c = await client(path);
111
- expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
112
- });
113
-
114
- it("applies the configured socket group when it resolves", async () => {
115
- const path = sockPath("group.sock");
116
- // the process's own primary group always resolves and is chown-able by the owner
117
- const gid = process.getgid!();
118
- const groupLine = `testgrp:x:${gid}:\n`;
119
- vi.mocked(readFileSync).mockImplementationOnce(((p: any, ...rest: any[]) => (p === "/etc/group" ? groupLine : undefined)) as any);
120
- process.env.GH_BROKER_SOCKET_GROUP = "testgrp";
121
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
122
- expect(statSync(path).gid).toBe(gid);
123
- });
124
-
125
- it("still listens when the group is unknown or /etc/group is unreadable", async () => {
126
- process.env.GH_BROKER_SOCKET_GROUP = "no-such-group-xyz";
127
- await startServer({ listen: `unix:${sockPath("nogroup.sock")}` } as any, fakeRouter, catalog);
128
-
129
- vi.mocked(readFileSync).mockImplementationOnce((() => {
130
- throw new Error("EACCES");
131
- }) as any);
132
- await startServer({ listen: `unix:${sockPath("unreadable.sock")}` } as any, fakeRouter, catalog);
133
- });
134
-
135
- it("tolerates a failed chown (group the process cannot assign)", async () => {
136
- const path = sockPath("chownfail.sock");
137
- // gid 0 (root) is not assignable by an unprivileged owner → chownSync throws, which is swallowed
138
- vi.mocked(readFileSync).mockImplementationOnce((() => "rootish:x:0:\n") as any);
139
- process.env.GH_BROKER_SOCKET_GROUP = "rootish";
140
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
141
- const c = await client(path);
142
- expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
143
- });
144
-
145
- it("survives garbage from a client without taking the server down", async () => {
146
- const path = sockPath("garbage.sock");
147
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
148
- const bad = await client(path);
149
- bad.sock.write("not json\n");
150
- await new Promise((r) => setTimeout(r, 50));
151
- bad.sock.destroy();
152
- const good = await client(path);
153
- expect((await good.request("tools/list", {})).result.tools).toEqual(catalog);
154
- });
155
-
156
- it("ignores an error on a client socket", async () => {
157
- const path = sockPath("hangup.sock");
158
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
159
- const c = await client(path);
160
- await c.request("tools/list", {});
161
- const serverSide = seen.sockets.at(-1);
162
- expect(() => serverSide.emit("error", new Error("ECONNRESET"))).not.toThrow();
163
- expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
164
- });
165
-
166
- it("tolerates a failing MCP close when the client goes away", async () => {
167
- const path = sockPath("closefail.sock");
168
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
169
- const close = vi.spyOn(McpServer.prototype, "close").mockRejectedValue(new Error("close failed"));
170
- const c = await client(path);
171
- await c.request("tools/list", {});
172
- c.sock.destroy();
173
- await vi.waitFor(() => expect(close).toHaveBeenCalled());
174
- });
175
-
176
- it("logs and drops a connection whose MCP transport fails to start", async () => {
177
- const path = sockPath("connectfail.sock");
178
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
179
- vi.spyOn(McpServer.prototype, "connect").mockRejectedValue(new Error("transport broke"));
180
- const err = vi.spyOn(console, "error").mockImplementation(() => {});
181
- const c = await client(path);
182
- await new Promise<void>((resolve) => c.sock.once("close", () => resolve()));
183
- expect(err).toHaveBeenCalledWith("gh-broker: connection error: transport broke");
184
- });
185
-
186
- it("chowns with uid -1 when process.getuid is unavailable", async () => {
187
- const orig = process.getuid;
188
- const path = sockPath("nouid.sock");
189
- const gid = process.getgid!();
190
- vi.mocked(readFileSync).mockImplementationOnce(((p: any) => (p === "/etc/group" ? `testgrp:x:${gid}:\n` : undefined)) as any);
191
- process.env.GH_BROKER_SOCKET_GROUP = "testgrp";
192
- (process as any).getuid = undefined;
193
- try {
194
- await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
195
- } finally {
196
- process.getuid = orig;
197
- }
198
- expect(statSync(path).mode & 0o777).toBe(0o660);
199
- });
200
- });