@zereight/mcp-gitlab 2.1.63 → 2.1.65
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.ko.md +20 -5
- package/README.md +19 -6
- package/README.zh-CN.md +19 -6
- package/build/downloads/proxy.js +14 -7
- package/build/index.js +413 -455
- package/build/schemas.js +2 -2
- package/build/scripts/generate-tool-coverage.js +58 -0
- package/build/scripts/generate-tool-docs.js +6 -1
- package/build/scripts/tool-coverage/coverage.js +339 -0
- package/build/scripts/tool-coverage/coverage.test.js +142 -0
- package/build/test/path-segment-encoding.test.js +73 -17
- package/build/test/sse-session-limits.test.js +273 -0
- package/build/test/test-blank-filters.js +156 -0
- package/build/test/test-download-attachment.js +11 -0
- package/build/test/test-empty-toolsets.js +535 -0
- package/build/test/test-geteffectiveprojectid.js +1 -1
- package/build/test/test-job-artifacts.js +22 -0
- package/build/test/test-list-issues.js +47 -0
- package/build/test/test-list-merge-requests.js +76 -0
- package/build/test/test-list-milestones.js +145 -0
- package/build/test/test-permission-mode.js +103 -12
- package/build/test/test-remote-downloads.js +14 -1
- package/build/test/utils/graphql-query.test.js +72 -0
- package/build/test/utils/safe-redirect-fetch.test.js +463 -0
- package/build/test/utils/tool-args.test.js +89 -1
- package/build/test/utils/url.test.js +128 -0
- package/build/test-note.js +2 -1
- package/build/test-resolve-issue-note.js +4 -3
- package/build/tools/registry.js +16 -2
- package/build/utils/graphql-query.js +31 -9
- package/build/utils/safe-redirect-fetch.js +321 -0
- package/build/utils/tool-args.js +47 -3
- package/build/utils/url.js +102 -0
- package/package.json +2 -1
|
@@ -103,6 +103,42 @@ describe('list_merge_requests', () => {
|
|
|
103
103
|
assert.ok(Array.isArray(mrs), 'Response should be an array');
|
|
104
104
|
assert.strictEqual(mrs.length, 2, 'Should return 2 mock MRs');
|
|
105
105
|
});
|
|
106
|
+
test('omits empty optional filters instead of sending mutually exclusive query parameters', async () => {
|
|
107
|
+
let capturedUrl;
|
|
108
|
+
mockGitLab.addMockHandler('get', '/merge_requests', (req, res) => {
|
|
109
|
+
capturedUrl = req.originalUrl;
|
|
110
|
+
const hasEmptyFilter = Object.values(req.query).some(value => value === '');
|
|
111
|
+
if (hasEmptyFilter) {
|
|
112
|
+
res.status(400).json({ message: 'empty filters are invalid' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.json([]);
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
const mrs = await callListMergeRequests({
|
|
119
|
+
assignee_id: '',
|
|
120
|
+
assignee_username: '',
|
|
121
|
+
author_id: '',
|
|
122
|
+
author_username: '',
|
|
123
|
+
reviewer_id: '',
|
|
124
|
+
reviewer_username: '',
|
|
125
|
+
milestone: ' ',
|
|
126
|
+
search: '\t',
|
|
127
|
+
target_branch: ' ',
|
|
128
|
+
source_branch: '\n',
|
|
129
|
+
labels: [],
|
|
130
|
+
approved_by_usernames: [],
|
|
131
|
+
}, {
|
|
132
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
133
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
134
|
+
});
|
|
135
|
+
assert.deepStrictEqual(mrs, []);
|
|
136
|
+
assert.strictEqual(capturedUrl, '/api/v4/merge_requests');
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
mockGitLab.clearCustomHandlers();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
106
142
|
test('forwards approved_by_usernames in bracket-array form', async () => {
|
|
107
143
|
let capturedUrl;
|
|
108
144
|
mockGitLab.addMockHandler('get', '/merge_requests', (req, res) => {
|
|
@@ -122,4 +158,44 @@ describe('list_merge_requests', () => {
|
|
|
122
158
|
mockGitLab.clearCustomHandlers();
|
|
123
159
|
}
|
|
124
160
|
});
|
|
161
|
+
test('drops blank entries inside array filters', async () => {
|
|
162
|
+
let capturedUrl;
|
|
163
|
+
mockGitLab.addMockHandler('get', '/merge_requests', (req, res) => {
|
|
164
|
+
capturedUrl = req.originalUrl;
|
|
165
|
+
res.json([]);
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
await callListMergeRequests({ labels: ['', ' ', 'bug'], approved_by_usernames: ['alice', ''] }, {
|
|
169
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
170
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
171
|
+
});
|
|
172
|
+
assert.ok(capturedUrl, 'Mock handler should have received a request');
|
|
173
|
+
// Parse the query so the assertion does not depend on parameter order
|
|
174
|
+
const params = new URL(capturedUrl, 'http://localhost').searchParams;
|
|
175
|
+
assert.deepStrictEqual(params.getAll('labels'), ['bug'], 'Blank array entries should be dropped while non-blank ones are kept');
|
|
176
|
+
assert.deepStrictEqual(params.getAll('approved_by_usernames[]'), ['alice'], 'Blank array entries should be dropped while non-blank ones are kept');
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
mockGitLab.clearCustomHandlers();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
test('omits array filters whose entries are all blank', async () => {
|
|
183
|
+
let capturedUrl;
|
|
184
|
+
mockGitLab.addMockHandler('get', '/merge_requests', (req, res) => {
|
|
185
|
+
capturedUrl = req.originalUrl;
|
|
186
|
+
res.json([]);
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
const mrs = await callListMergeRequests({ labels: ['', ' '], approved_by_usernames: [''] }, {
|
|
190
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
191
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
192
|
+
});
|
|
193
|
+
assert.deepStrictEqual(mrs, []);
|
|
194
|
+
assert.ok(capturedUrl, 'Mock handler should have received a request');
|
|
195
|
+
assert.strictEqual(new URL(capturedUrl, 'http://localhost').search, '', 'Array filters with only blank entries should not be serialized');
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
mockGitLab.clearCustomHandlers();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
125
201
|
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-12345";
|
|
6
|
+
const TEST_PROJECT_ID = "123";
|
|
7
|
+
const TEST_GROUP_ID = "my-group";
|
|
8
|
+
// Milestone tools live in the non-default "milestones" toolset
|
|
9
|
+
const TOOLSET_ENV = { GITLAB_TOOLSETS: "milestones" };
|
|
10
|
+
// Helper to run an MCP tool through the stdio server
|
|
11
|
+
async function callTool(toolName, args, env) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
14
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
...TOOLSET_ENV,
|
|
18
|
+
...env,
|
|
19
|
+
GITLAB_READ_ONLY_MODE: "true",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
let output = "";
|
|
23
|
+
let errorOutput = "";
|
|
24
|
+
proc.stdout?.on("data", d => (output += d));
|
|
25
|
+
proc.stderr?.on("data", d => (errorOutput += d));
|
|
26
|
+
proc.on("close", code => {
|
|
27
|
+
if (code !== 0)
|
|
28
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
29
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
30
|
+
if (!line)
|
|
31
|
+
return reject(new Error("No JSON output found"));
|
|
32
|
+
try {
|
|
33
|
+
const response = JSON.parse(line);
|
|
34
|
+
if (response.error) {
|
|
35
|
+
reject(new Error(JSON.stringify(response.error)));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const content = response.result?.content?.[0]?.text;
|
|
39
|
+
if (content) {
|
|
40
|
+
try {
|
|
41
|
+
resolve(JSON.parse(content));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
resolve(response.result);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
reject(e);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
proc.stdin?.end(JSON.stringify({
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
id: 1,
|
|
58
|
+
method: "tools/call",
|
|
59
|
+
params: { name: toolName, arguments: args },
|
|
60
|
+
}) + "\n");
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
describe("milestone list iids filters", () => {
|
|
64
|
+
let mockGitLab;
|
|
65
|
+
let mockGitLabUrl;
|
|
66
|
+
before(async () => {
|
|
67
|
+
const mockPort = await findMockServerPort();
|
|
68
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
69
|
+
await mockGitLab.start();
|
|
70
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
71
|
+
});
|
|
72
|
+
after(async () => {
|
|
73
|
+
await mockGitLab.stop();
|
|
74
|
+
});
|
|
75
|
+
const env = () => ({
|
|
76
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
77
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
78
|
+
});
|
|
79
|
+
test("omits an empty iids filter for list_milestones", async () => {
|
|
80
|
+
let capturedUrl;
|
|
81
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/milestones`, (req, res) => {
|
|
82
|
+
capturedUrl = req.originalUrl;
|
|
83
|
+
res.json([]);
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const milestones = await callTool("list_milestones", { project_id: TEST_PROJECT_ID, iids: [] }, env());
|
|
87
|
+
assert.deepStrictEqual(milestones, []);
|
|
88
|
+
assert.ok(capturedUrl, "Mock handler should have received a request");
|
|
89
|
+
assert.strictEqual(new URL(capturedUrl, "http://localhost").search, "", "An empty iids array should not be serialized as `iids=`");
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
mockGitLab.clearCustomHandlers();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
test("forwards non-empty iids in bracket-array form", async () => {
|
|
96
|
+
let capturedUrl;
|
|
97
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/milestones`, (req, res) => {
|
|
98
|
+
capturedUrl = req.originalUrl;
|
|
99
|
+
res.json([]);
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
await callTool("list_milestones", { project_id: TEST_PROJECT_ID, iids: [1, 2] }, env());
|
|
103
|
+
assert.ok(capturedUrl, "Mock handler should have received a request");
|
|
104
|
+
const params = new URL(capturedUrl, "http://localhost").searchParams;
|
|
105
|
+
assert.deepStrictEqual(params.getAll("iids[]"), ["1", "2"]);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
mockGitLab.clearCustomHandlers();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
test("omits blank milestone filters", async () => {
|
|
112
|
+
let capturedUrl;
|
|
113
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/milestones`, (req, res) => {
|
|
114
|
+
capturedUrl = req.originalUrl;
|
|
115
|
+
res.json([]);
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
await callTool("list_milestones", { project_id: TEST_PROJECT_ID, iids: [], title: " ", search: "" }, env());
|
|
119
|
+
assert.ok(capturedUrl, "Mock handler should have received a request");
|
|
120
|
+
assert.strictEqual(new URL(capturedUrl, "http://localhost").search, "", "Blank milestone filters should not be serialized");
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
mockGitLab.clearCustomHandlers();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
test("omits an empty iids filter for list_group_milestones", async () => {
|
|
127
|
+
let capturedUrl;
|
|
128
|
+
mockGitLab.addMockHandler("get", `/groups/${TEST_GROUP_ID}/milestones`, (req, res) => {
|
|
129
|
+
capturedUrl = req.originalUrl;
|
|
130
|
+
res.json([]);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
const milestones = await callTool("list_group_milestones", { group_id: TEST_GROUP_ID, iids: [], state: "active" }, env());
|
|
134
|
+
assert.deepStrictEqual(milestones, []);
|
|
135
|
+
assert.ok(capturedUrl, "Mock handler should have received a request");
|
|
136
|
+
const params = new URL(capturedUrl, "http://localhost").searchParams;
|
|
137
|
+
assert.equal(params.has("iids"), false, "An empty iids array should not be serialized as `iids=`");
|
|
138
|
+
assert.deepStrictEqual(params.getAll("iids[]"), []);
|
|
139
|
+
assert.deepStrictEqual(params.getAll("state"), ["active"]);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
mockGitLab.clearCustomHandlers();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, test, after, before } from "node:test";
|
|
9
9
|
import assert from "node:assert";
|
|
10
|
+
import { allTools, deleteTools, destructiveTools } from "../tools/registry.js";
|
|
10
11
|
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
11
12
|
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
12
13
|
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
@@ -50,6 +51,29 @@ async function getToolNames(server) {
|
|
|
50
51
|
await client.disconnect();
|
|
51
52
|
return result.tools.map((t) => t.name);
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Asserts that a query passes the modify-mode guard *and* is forwarded to GitLab. The mock
|
|
56
|
+
* server has no `/api/graphql` route, so the call comes back as a downstream
|
|
57
|
+
* "GraphQL request failed" — that failure is the evidence the guard let the query through,
|
|
58
|
+
* which a "does not throw" check alone would not provide.
|
|
59
|
+
*/
|
|
60
|
+
async function assertQueryReachesGitLab(server, query) {
|
|
61
|
+
const client = await connectClient(server);
|
|
62
|
+
try {
|
|
63
|
+
let outcome;
|
|
64
|
+
try {
|
|
65
|
+
outcome = JSON.stringify(await client.callTool("execute_graphql", { query }));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
outcome = error instanceof Error ? error.message : String(error);
|
|
69
|
+
}
|
|
70
|
+
assert.ok(!outcome.includes("modify mode"), `the guard must not fire for "${query}", got: ${outcome}`);
|
|
71
|
+
assert.ok(outcome.includes("GraphQL request failed"), `"${query}" should be forwarded to GitLab after passing the guard, got: ${outcome}`);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await client.disconnect();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
53
77
|
const DELETE_SAMPLE_TOOLS = [
|
|
54
78
|
"delete_issue",
|
|
55
79
|
"delete_branch",
|
|
@@ -60,6 +84,16 @@ const DELETE_SAMPLE_TOOLS = [
|
|
|
60
84
|
"delete_project_variable",
|
|
61
85
|
"delete_webhook",
|
|
62
86
|
];
|
|
87
|
+
// Destructive teardown tools whose names do not start with `delete_`. All five are listed
|
|
88
|
+
// here (list-hiding) and exercised with explicit `callTool` literals below (call-rejection),
|
|
89
|
+
// so the tool-coverage detector sees the call path too.
|
|
90
|
+
const DESTRUCTIVE_SAMPLE_TOOLS = [
|
|
91
|
+
"cancel_pipeline",
|
|
92
|
+
"cancel_pipeline_job",
|
|
93
|
+
"stop_environment",
|
|
94
|
+
"stop_stale_environments",
|
|
95
|
+
"unprotect_branch",
|
|
96
|
+
];
|
|
63
97
|
const MODIFY_SAMPLE_TOOLS = [
|
|
64
98
|
"create_issue",
|
|
65
99
|
"update_issue",
|
|
@@ -67,9 +101,26 @@ const MODIFY_SAMPLE_TOOLS = [
|
|
|
67
101
|
"create_branch",
|
|
68
102
|
"push_files",
|
|
69
103
|
"merge_merge_request",
|
|
70
|
-
"stop_stale_environments",
|
|
71
104
|
];
|
|
72
105
|
const READ_SAMPLE_TOOLS = ["list_issues", "get_project", "list_merge_requests"];
|
|
106
|
+
describe("Modify-mode deny list invariants", () => {
|
|
107
|
+
test("deleteTools is a subset of destructiveTools", () => {
|
|
108
|
+
for (const name of deleteTools) {
|
|
109
|
+
assert.ok(destructiveTools.has(name), `"${name}" is blocked in modify mode but missing from destructiveTools`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test("every blocked tool exists in the registry", () => {
|
|
113
|
+
const names = new Set(allTools.map(tool => tool.name));
|
|
114
|
+
for (const name of deleteTools) {
|
|
115
|
+
assert.ok(names.has(name), `"${name}" is in deleteTools but not in allTools`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
test("every destructive sample tool is in the modify-mode deny list", () => {
|
|
119
|
+
for (const name of DESTRUCTIVE_SAMPLE_TOOLS) {
|
|
120
|
+
assert.ok(deleteTools.has(name), `"${name}" should be blocked in modify mode`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
73
124
|
describe("Permission Mode", { concurrency: 1 }, () => {
|
|
74
125
|
before(async () => {
|
|
75
126
|
const mockPort = await findMockServerPort();
|
|
@@ -94,7 +145,12 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
94
145
|
after(() => cleanupServers(servers));
|
|
95
146
|
test("exposes read, modify, and delete tools", async () => {
|
|
96
147
|
const names = await getToolNames(server);
|
|
97
|
-
for (const name of [
|
|
148
|
+
for (const name of [
|
|
149
|
+
...READ_SAMPLE_TOOLS,
|
|
150
|
+
...MODIFY_SAMPLE_TOOLS,
|
|
151
|
+
...DELETE_SAMPLE_TOOLS,
|
|
152
|
+
...DESTRUCTIVE_SAMPLE_TOOLS,
|
|
153
|
+
]) {
|
|
98
154
|
assert.ok(names.includes(name), `full mode: expected "${name}" to be present`);
|
|
99
155
|
}
|
|
100
156
|
});
|
|
@@ -117,6 +173,28 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
117
173
|
}
|
|
118
174
|
assert.ok(!names.some(n => n.startsWith("delete_")), "no delete_* tool should be listed");
|
|
119
175
|
});
|
|
176
|
+
test("hides destructive stop/cancel/unprotect tools", async () => {
|
|
177
|
+
const names = await getToolNames(server);
|
|
178
|
+
for (const name of DESTRUCTIVE_SAMPLE_TOOLS) {
|
|
179
|
+
assert.ok(!names.includes(name), `modify mode: expected "${name}" to be absent`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
test("rejects destructive stop/cancel/unprotect tool calls", async () => {
|
|
183
|
+
const client = await connectClient(server);
|
|
184
|
+
try {
|
|
185
|
+
await assert.rejects(() => client.callTool("cancel_pipeline", { project_id: "1", pipeline_id: 1 }), (error) => error.message.includes("not allowed in modify mode"), "cancel_pipeline should be rejected in modify mode");
|
|
186
|
+
await assert.rejects(() => client.callTool("cancel_pipeline_job", { project_id: "1", job_id: 1 }), (error) => error.message.includes("not allowed in modify mode"), "cancel_pipeline_job should be rejected in modify mode");
|
|
187
|
+
await assert.rejects(() => client.callTool("stop_environment", { project_id: "1", environment_id: 1 }), (error) => error.message.includes("not allowed in modify mode"), "stop_environment should be rejected in modify mode");
|
|
188
|
+
await assert.rejects(() => client.callTool("stop_stale_environments", {
|
|
189
|
+
project_id: "1",
|
|
190
|
+
before: "2026-01-01T00:00:00Z",
|
|
191
|
+
}), (error) => error.message.includes("not allowed in modify mode"), "stop_stale_environments should be rejected in modify mode");
|
|
192
|
+
await assert.rejects(() => client.callTool("unprotect_branch", { project_id: "1", branch: "main" }), (error) => error.message.includes("not allowed in modify mode"), "unprotect_branch should be rejected in modify mode");
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await client.disconnect();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
120
198
|
test("hides purge_dependency_proxy_cache", async () => {
|
|
121
199
|
const names = await getToolNames(server);
|
|
122
200
|
assert.ok(!names.includes("purge_dependency_proxy_cache"), "modify mode: purge_dependency_proxy_cache should be absent");
|
|
@@ -136,7 +214,7 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
136
214
|
try {
|
|
137
215
|
await assert.rejects(() => client.callTool("execute_graphql", {
|
|
138
216
|
query: 'mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }',
|
|
139
|
-
}), (error) => error.message.includes("
|
|
217
|
+
}), (error) => error.message.includes("destructive mutations in modify mode"), "delete mutation should be rejected in modify mode");
|
|
140
218
|
}
|
|
141
219
|
finally {
|
|
142
220
|
await client.disconnect();
|
|
@@ -171,17 +249,30 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
171
249
|
}
|
|
172
250
|
});
|
|
173
251
|
test("does not apply the modify-mode guard to non-delete mutations", async () => {
|
|
252
|
+
// issueSetSeverity writes, but nothing about it is destructive.
|
|
253
|
+
await assertQueryReachesGitLab(server, "mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }");
|
|
254
|
+
});
|
|
255
|
+
test("does not apply the modify-mode guard when the alias looks destructive", async () => {
|
|
256
|
+
// "stop" is only the caller-chosen label here; the field is issueSetSeverity.
|
|
257
|
+
await assertQueryReachesGitLab(server, "mutation { stop: issueSetSeverity(input: { severity: HIGH }) { errors } }");
|
|
258
|
+
});
|
|
259
|
+
test("rejects a destructive mutation hidden behind a harmless alias", async () => {
|
|
174
260
|
const client = await connectClient(server);
|
|
175
261
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
262
|
+
await assert.rejects(() => client.callTool("execute_graphql", {
|
|
263
|
+
query: "mutation { harmless: environmentStop(input: {}) { errors } }",
|
|
264
|
+
}), (error) => error.message.includes("destructive mutations in modify mode"), "a destructive field must be rejected even when aliased");
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await client.disconnect();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
test("rejects jobUnschedule", async () => {
|
|
271
|
+
const client = await connectClient(server);
|
|
272
|
+
try {
|
|
273
|
+
await assert.rejects(() => client.callTool("execute_graphql", {
|
|
274
|
+
query: 'mutation { jobUnschedule(input: { id: "gid://gitlab/Ci::Build/1" }) { errors } }',
|
|
275
|
+
}), (error) => error.message.includes("destructive mutations in modify mode"), "jobUnschedule must be rejected in modify mode");
|
|
185
276
|
}
|
|
186
277
|
finally {
|
|
187
278
|
await client.disconnect();
|
|
@@ -30,6 +30,7 @@ const RFC_FORWARDED_HEADERS = {
|
|
|
30
30
|
// Minimal 1x1 transparent PNG
|
|
31
31
|
const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
|
32
32
|
const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
|
|
33
|
+
const ATTACHMENT_TOKEN = 'glpat-attachment-test-token';
|
|
33
34
|
const FAKE_ZIP = Buffer.from('PK\x03\x04fake-zip-content-for-testing');
|
|
34
35
|
const MOCK_UPLOAD_RESPONSE = {
|
|
35
36
|
id: 99,
|
|
@@ -52,7 +53,7 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
|
|
|
52
53
|
const mockPort = await findMockServerPort();
|
|
53
54
|
mockGitLab = new MockGitLabServer({
|
|
54
55
|
port: mockPort,
|
|
55
|
-
validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN],
|
|
56
|
+
validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN, ATTACHMENT_TOKEN],
|
|
56
57
|
});
|
|
57
58
|
// Mock artifact download
|
|
58
59
|
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
|
|
@@ -70,6 +71,13 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
|
|
|
70
71
|
res.set('Content-Type', 'text/plain');
|
|
71
72
|
res.send('hello document content');
|
|
72
73
|
});
|
|
74
|
+
// Mock attachment whose name contains a slash. The uploads route takes one filename
|
|
75
|
+
// segment, so the proxy has to request it as `nested%2Fdocument.txt`; a handler on
|
|
76
|
+
// `nested/document.txt` would match the wrong route shape and must not be hit.
|
|
77
|
+
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/nested%2Fdocument.txt`, (_req, res) => {
|
|
78
|
+
res.set('Content-Type', 'text/plain');
|
|
79
|
+
res.send('nested document content');
|
|
80
|
+
});
|
|
73
81
|
// Mock large artifact (2MB) to verify streaming works for big files
|
|
74
82
|
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts`, (_req, res) => {
|
|
75
83
|
res.set('Content-Type', 'application/zip');
|
|
@@ -143,6 +151,11 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
|
|
|
143
151
|
const expectedSize = 2 * 1024 * 1024;
|
|
144
152
|
assert.strictEqual(buf.length, expectedSize, `Should receive full 2MB (got ${buf.length} bytes)`);
|
|
145
153
|
});
|
|
154
|
+
test('keeps a slash-bearing attachment filename inside one path segment', async () => {
|
|
155
|
+
const res = await fetch(`http://${HOST}:${serverPort}/downloads/attachment?project_id=${TEST_PROJECT_ID}&secret=${TEST_SECRET}&filename=${encodeURIComponent('nested/document.txt')}`, { headers: { 'Private-Token': ATTACHMENT_TOKEN } });
|
|
156
|
+
assert.strictEqual(res.status, 200, 'attachment proxy should route to one encoded segment');
|
|
157
|
+
assert.strictEqual(await res.text(), 'nested document content');
|
|
158
|
+
});
|
|
146
159
|
test('returns 429 after exceeding rate limit', async () => {
|
|
147
160
|
// Use a different token to get a fresh rate limit counter
|
|
148
161
|
const rateLimitToken = 'glpat-ratelimit-test-token';
|
|
@@ -89,8 +89,80 @@ describe("When graphqlQueryContainsDeleteOperation runs", () => {
|
|
|
89
89
|
test("should detect delete mutations with variable definitions", () => {
|
|
90
90
|
assert.equal(graphqlQueryContainsDeleteOperation("mutation DeleteIssue($id: IssueID!) { issueDelete(input: { id: $id }) { errors } }"), true);
|
|
91
91
|
});
|
|
92
|
+
test("should detect comma-prefixed delete mutations", () => {
|
|
93
|
+
assert.equal(graphqlQueryContainsDeleteOperation(', mutation M { issueDelete(input: { iid: "1" }) { id } }'), true);
|
|
94
|
+
});
|
|
95
|
+
test("should detect comma-prefixed delete mutations without whitespace", () => {
|
|
96
|
+
assert.equal(graphqlQueryContainsDeleteOperation(',mutation{issueDelete(input:{iid:"1"}){id}}'), true);
|
|
97
|
+
});
|
|
98
|
+
test("should detect delete mutations after a comma-separated query", () => {
|
|
99
|
+
assert.equal(graphqlQueryContainsDeleteOperation("query HealthCheck { x }, mutation M { issueDelete(input: {}) { id } }"), true);
|
|
100
|
+
});
|
|
101
|
+
test("should allow comma-separated queries", () => {
|
|
102
|
+
assert.equal(graphqlQueryContainsDeleteOperation("query A { a }, query B { b }"), false);
|
|
103
|
+
});
|
|
92
104
|
test("should conservatively block top-level fragment spreads in mutations", () => {
|
|
93
105
|
assert.equal(graphqlQueryContainsDeleteOperation("mutation { ...f } fragment f on Mutation { issueDelete(input: {}) { errors } }"), true);
|
|
94
106
|
});
|
|
95
107
|
});
|
|
108
|
+
describe("with destructive mutations that are not named delete", () => {
|
|
109
|
+
test("should detect environmentStop", () => {
|
|
110
|
+
assert.equal(graphqlQueryContainsDeleteOperation('mutation { environmentStop(input: { environmentId: "gid://gitlab/Environment/1" }) { errors } }'), true);
|
|
111
|
+
});
|
|
112
|
+
test("should detect pipelineCancel", () => {
|
|
113
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { pipelineCancel(input: {}) { errors } }"), true);
|
|
114
|
+
});
|
|
115
|
+
test("should detect clusterAgentTokenRevoke", () => {
|
|
116
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { clusterAgentTokenRevoke(input: {}) { errors } }"), true);
|
|
117
|
+
});
|
|
118
|
+
test("should detect jobUnschedule", () => {
|
|
119
|
+
// GitLab moves the scheduled job to manual and clears its scheduled state, so it
|
|
120
|
+
// is a teardown action even though no teardown verb is part of its name.
|
|
121
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { jobUnschedule(input: {}) { errors } }"), true);
|
|
122
|
+
});
|
|
123
|
+
test("should detect aliased destructive mutations", () => {
|
|
124
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { s: environmentStop(input: {}) { errors } }"), true);
|
|
125
|
+
});
|
|
126
|
+
test("should not treat an alias as a destructive mutation field", () => {
|
|
127
|
+
for (const query of [
|
|
128
|
+
"mutation { stop: issueSetSeverity(input: {}) { errors } }",
|
|
129
|
+
"mutation { revoke: issueSetSeverity(input: {}) { errors } }",
|
|
130
|
+
"mutation { terminate: updateIssue(input: {}) { issue { id } } }",
|
|
131
|
+
]) {
|
|
132
|
+
assert.equal(graphqlQueryContainsDeleteOperation(query), false, query);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
test("should not treat a spaced or comma-separated alias as a destructive field", () => {
|
|
136
|
+
for (const query of [
|
|
137
|
+
"mutation { stop : issueSetSeverity(input: {}) { errors } }",
|
|
138
|
+
"mutation { revoke,: issueSetSeverity(input: {}) { errors } }",
|
|
139
|
+
"mutation { terminate\n: updateIssue(input: {}) { issue { id } } }",
|
|
140
|
+
]) {
|
|
141
|
+
assert.equal(graphqlQueryContainsDeleteOperation(query), false, query);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
test("should still detect a destructive field after a spaced alias", () => {
|
|
145
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { harmless : environmentStop(input: {}) { errors } }"), true);
|
|
146
|
+
});
|
|
147
|
+
test("should still detect a destructive field hidden behind a harmless alias", () => {
|
|
148
|
+
for (const query of [
|
|
149
|
+
"mutation { harmless: environmentStop(input: {}) { errors } }",
|
|
150
|
+
"mutation { step: pipelineCancel(input: {}) { errors } }",
|
|
151
|
+
]) {
|
|
152
|
+
assert.equal(graphqlQueryContainsDeleteOperation(query), true, query);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
test("should detect destructive mutations in multi-operation documents", () => {
|
|
156
|
+
assert.equal(graphqlQueryContainsDeleteOperation("query A { project { id } } mutation B { environmentStop(input: {}) { errors } }"), true);
|
|
157
|
+
});
|
|
158
|
+
test("should still allow mutations without a destructive verb", () => {
|
|
159
|
+
for (const query of [
|
|
160
|
+
"mutation { issueMove(input: {}) { errors } }",
|
|
161
|
+
"mutation { mergeRequestMerge(input: {}) { errors } }",
|
|
162
|
+
"mutation { todoMarkDone(input: {}) { errors } }",
|
|
163
|
+
]) {
|
|
164
|
+
assert.equal(graphqlQueryContainsDeleteOperation(query), false, query);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
96
168
|
});
|