@zereight/mcp-gitlab 2.1.29 → 2.1.38
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 +72 -20
- package/README.md +120 -82
- package/README.zh-CN.md +72 -20
- package/build/config.js +13 -0
- package/build/downloads/proxy.js +199 -0
- package/build/index.js +850 -490
- package/build/schemas.js +115 -28
- package/build/scripts/check-skill-sync.js +137 -0
- package/build/scripts/generate-tool-docs.js +8 -3
- package/build/server/metrics.js +84 -0
- package/build/server/transport-mode.js +25 -0
- package/build/test/callback-proxy-tests.js +1 -1
- package/build/test/client-pool-test.js +1 -1
- package/build/test/dynamic-api-url-allowlist.test.js +2 -2
- package/build/test/dynamic-api-url-test.js +4 -4
- package/build/test/dynamic-routing-tests.js +4 -4
- package/build/test/group-milestone-schema.test.js +45 -0
- package/build/test/mcp-oauth-tests.js +29 -4
- package/build/test/mcp-server-name.test.js +87 -0
- package/build/test/multi-server-test.js +3 -3
- package/build/test/no-proxy-integration-test.js +1 -1
- package/build/test/nullable-gitlab-response-fields.test.js +22 -0
- package/build/test/path-segment-encoding.test.js +11 -0
- package/build/test/remote-auth-simple-test.js +226 -199
- package/build/test/server/metrics.test.js +47 -0
- package/build/test/sse-auth-guard.test.js +2 -2
- package/build/test/stateless/session-id-integration.test.js +3 -3
- package/build/test/streamable-http-concurrent-session.test.js +64 -1
- package/build/test/streamable-http-dns-rebinding.test.js +185 -0
- package/build/test/streamable-http-static-token-auth.test.js +25 -22
- package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
- package/build/test/test-ci-catalog.js +1 -1
- package/build/test/test-ci-lint.js +1 -1
- package/build/test/test-ci-variables.js +8 -5
- package/build/test/test-dependency-proxy.js +11 -7
- package/build/test/test-deployment-tools.js +16 -2
- package/build/test/test-download-attachment.js +1 -1
- package/build/test/test-get-file-blame.js +1 -1
- package/build/test/test-geteffectiveprojectid.js +221 -8
- package/build/test/test-issue-description-patch.js +1 -1
- package/build/test/test-job-artifacts.js +1 -1
- package/build/test/test-list-issues.js +1 -1
- package/build/test/test-list-merge-requests.js +1 -1
- package/build/test/test-list-project-members.js +1 -1
- package/build/test/test-merge-request-approval-state-tools.js +1 -1
- package/build/test/test-merge-request-pipelines.js +1 -1
- package/build/test/test-mr-diffs-filter.js +1 -1
- package/build/test/test-mr-file-diffs.js +2 -2
- package/build/test/test-oauth-proxy-rate-limit.js +1 -1
- package/build/test/test-permission-mode.js +216 -0
- package/build/test/test-protected-branches.js +23 -4
- package/build/test/test-remote-downloads.js +2 -2
- package/build/test/test-search-code.js +1 -1
- package/build/test/test-tags.js +1 -1
- package/build/test/test-todos.js +1 -1
- package/build/test/test-token-optimizations.js +3 -3
- package/build/test/test-toolset-filtering.js +3 -3
- package/build/test/test-update-issue-slim.js +141 -0
- package/build/test/test-upload-markdown.js +1 -1
- package/build/test/utils/download-token.test.js +35 -0
- package/build/test/utils/forwarded-public-base-url.test.js +9 -1
- package/build/test/utils/graphql-query.test.js +64 -1
- package/build/test/utils/mock-gitlab-server.js +24 -31
- package/build/test/utils/server-launcher.js +1 -2
- package/build/test/utils/version-check.test.js +52 -0
- package/build/tools/registry.js +93 -5
- package/build/utils/download-token.js +71 -0
- package/build/utils/forwarded-public-base-url.js +22 -0
- package/build/utils/graphql-query.js +109 -12
- package/build/utils/version-check.js +40 -0
- package/package.json +5 -4
|
@@ -49,7 +49,7 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
|
|
|
49
49
|
let server;
|
|
50
50
|
let serverPort;
|
|
51
51
|
before(async () => {
|
|
52
|
-
const mockPort = await findMockServerPort(
|
|
52
|
+
const mockPort = await findMockServerPort();
|
|
53
53
|
mockGitLab = new MockGitLabServer({
|
|
54
54
|
port: mockPort,
|
|
55
55
|
validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN],
|
|
@@ -222,7 +222,7 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
|
|
|
222
222
|
let serverPort;
|
|
223
223
|
let sessionId;
|
|
224
224
|
before(async () => {
|
|
225
|
-
const mockPort = await findMockServerPort(
|
|
225
|
+
const mockPort = await findMockServerPort();
|
|
226
226
|
mockGitLab = new MockGitLabServer({
|
|
227
227
|
port: mockPort,
|
|
228
228
|
validTokens: [MOCK_TOKEN],
|
|
@@ -58,7 +58,7 @@ let mockGitLab;
|
|
|
58
58
|
let mockGitLabUrl;
|
|
59
59
|
describe("Search Code Tools", () => {
|
|
60
60
|
before(async () => {
|
|
61
|
-
const mockPort = await findMockServerPort(
|
|
61
|
+
const mockPort = await findMockServerPort();
|
|
62
62
|
mockGitLab = new MockGitLabServer({
|
|
63
63
|
port: mockPort,
|
|
64
64
|
validTokens: [MOCK_TOKEN],
|
package/build/test/test-tags.js
CHANGED
|
@@ -88,7 +88,7 @@ describe("tag tools", () => {
|
|
|
88
88
|
let mockGitLab;
|
|
89
89
|
let mockGitLabUrl;
|
|
90
90
|
before(async () => {
|
|
91
|
-
const mockPort = await findMockServerPort(
|
|
91
|
+
const mockPort = await findMockServerPort();
|
|
92
92
|
mockGitLab = new MockGitLabServer({
|
|
93
93
|
port: mockPort,
|
|
94
94
|
validTokens: [MOCK_TOKEN],
|
package/build/test/test-todos.js
CHANGED
|
@@ -105,7 +105,7 @@ describe("GitLab todos tools", () => {
|
|
|
105
105
|
let mockGitLab;
|
|
106
106
|
let mockGitLabUrl;
|
|
107
107
|
before(async () => {
|
|
108
|
-
const mockPort = await findMockServerPort(
|
|
108
|
+
const mockPort = await findMockServerPort();
|
|
109
109
|
mockGitLab = new MockGitLabServer({
|
|
110
110
|
port: mockPort,
|
|
111
111
|
validTokens: [MOCK_TOKEN],
|
|
@@ -129,7 +129,7 @@ describe("Tool Policy (Proposal F)", { concurrency: 1 }, () => {
|
|
|
129
129
|
let mockGitLab;
|
|
130
130
|
let mockGitLabUrl;
|
|
131
131
|
before(async () => {
|
|
132
|
-
const mockPort = await findMockServerPort(
|
|
132
|
+
const mockPort = await findMockServerPort();
|
|
133
133
|
mockGitLab = new MockGitLabServer({
|
|
134
134
|
port: mockPort,
|
|
135
135
|
validTokens: [MOCK_TOKEN],
|
|
@@ -277,7 +277,7 @@ describe("Policy Edge Cases", { concurrency: 1 }, () => {
|
|
|
277
277
|
let mockGitLab;
|
|
278
278
|
let mockGitLabUrl;
|
|
279
279
|
before(async () => {
|
|
280
|
-
const mockPort = await findMockServerPort(
|
|
280
|
+
const mockPort = await findMockServerPort();
|
|
281
281
|
mockGitLab = new MockGitLabServer({
|
|
282
282
|
port: mockPort,
|
|
283
283
|
validTokens: [MOCK_TOKEN],
|
|
@@ -557,7 +557,7 @@ describe("Dynamic Discovery (Proposal H)", { concurrency: 1 }, () => {
|
|
|
557
557
|
let mockGitLab;
|
|
558
558
|
let mockGitLabUrl;
|
|
559
559
|
before(async () => {
|
|
560
|
-
const mockPort = await findMockServerPort(
|
|
560
|
+
const mockPort = await findMockServerPort();
|
|
561
561
|
mockGitLab = new MockGitLabServer({
|
|
562
562
|
port: mockPort,
|
|
563
563
|
validTokens: [MOCK_TOKEN],
|
|
@@ -24,7 +24,7 @@ const TOOLSET_TOOL_COUNTS = {
|
|
|
24
24
|
labels: 5,
|
|
25
25
|
ci: 4,
|
|
26
26
|
pipelines: 19,
|
|
27
|
-
milestones:
|
|
27
|
+
milestones: 17,
|
|
28
28
|
wiki: 10,
|
|
29
29
|
releases: 7,
|
|
30
30
|
tags: 5,
|
|
@@ -75,7 +75,7 @@ const TOOLSET_SAMPLE_TOOLS = {
|
|
|
75
75
|
labels: ["list_labels", "create_label"],
|
|
76
76
|
ci: ["validate_ci_lint", "validate_project_ci_lint", "list_ci_catalog_resources", "get_ci_catalog_resource"],
|
|
77
77
|
pipelines: ["list_pipelines", "create_pipeline", "cancel_pipeline_job", "list_deployments", "list_job_artifacts"],
|
|
78
|
-
milestones: ["list_milestones", "create_milestone", "
|
|
78
|
+
milestones: ["list_milestones", "create_milestone", "list_group_milestones", "get_group_milestone_burndown_events"],
|
|
79
79
|
wiki: ["list_wiki_pages", "create_wiki_page", "list_group_wiki_pages", "create_group_wiki_page"],
|
|
80
80
|
releases: ["list_releases", "create_release", "download_release_asset"],
|
|
81
81
|
tags: ["list_tags", "create_tag", "get_tag_signature"],
|
|
@@ -128,7 +128,7 @@ async function nextMcpPort() {
|
|
|
128
128
|
}
|
|
129
129
|
describe("Toolset Filtering", { concurrency: 1 }, () => {
|
|
130
130
|
before(async () => {
|
|
131
|
-
const mockPort = await findMockServerPort(
|
|
131
|
+
const mockPort = await findMockServerPort();
|
|
132
132
|
mockGitLab = new MockGitLabServer({
|
|
133
133
|
port: mockPort,
|
|
134
134
|
validTokens: [MOCK_TOKEN],
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the update_issue slim response (issue #563).
|
|
3
|
+
* By default update_issue returns a slim confirmation; full_response=true
|
|
4
|
+
* returns the complete updated issue object.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, before, after } from "node:test";
|
|
7
|
+
import assert from "node:assert";
|
|
8
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
9
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
10
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
11
|
+
const MOCK_TOKEN = "glpat-slim-update-test-token";
|
|
12
|
+
describe("update_issue slim response", () => {
|
|
13
|
+
let mockGitLab;
|
|
14
|
+
let server;
|
|
15
|
+
let client;
|
|
16
|
+
const MOCK_PORT_BASE = 9650;
|
|
17
|
+
const MCP_PORT_BASE = 3650;
|
|
18
|
+
before(async () => {
|
|
19
|
+
const mockPort = await findMockServerPort();
|
|
20
|
+
mockGitLab = new MockGitLabServer({
|
|
21
|
+
port: mockPort,
|
|
22
|
+
validTokens: [MOCK_TOKEN],
|
|
23
|
+
});
|
|
24
|
+
await mockGitLab.start();
|
|
25
|
+
server = await launchServer({
|
|
26
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
27
|
+
port: await findAvailablePort(MCP_PORT_BASE),
|
|
28
|
+
timeout: 10000,
|
|
29
|
+
env: {
|
|
30
|
+
STREAMABLE_HTTP: "true",
|
|
31
|
+
REMOTE_AUTHORIZATION: "true",
|
|
32
|
+
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
33
|
+
GITLAB_ACCESS_TOKEN: MOCK_TOKEN,
|
|
34
|
+
GITLAB_TOOLSETS: "issues",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
client = new CustomHeaderClient({
|
|
38
|
+
headers: {
|
|
39
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
await client.connect(`http://${HOST}:${server.port ?? 0}/mcp`);
|
|
43
|
+
});
|
|
44
|
+
after(async () => {
|
|
45
|
+
await client?.disconnect();
|
|
46
|
+
cleanupServers([server]);
|
|
47
|
+
await mockGitLab?.stop();
|
|
48
|
+
});
|
|
49
|
+
test("default response is slim: no description echo", async () => {
|
|
50
|
+
const result = await client.callTool("update_issue", {
|
|
51
|
+
project_id: "test/project",
|
|
52
|
+
issue_iid: "1",
|
|
53
|
+
description: "A very long description that should not be echoed back",
|
|
54
|
+
});
|
|
55
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
56
|
+
assert.strictEqual(data.iid, "1");
|
|
57
|
+
assert.strictEqual(data.state, "opened");
|
|
58
|
+
assert.ok(data.title, "title should be present");
|
|
59
|
+
assert.ok(data.web_url, "web_url should be present");
|
|
60
|
+
assert.ok(data.updated_at, "updated_at should be present");
|
|
61
|
+
assert.strictEqual(data.description, undefined, "description must not be echoed");
|
|
62
|
+
assert.strictEqual(data.author, undefined, "author must not be included");
|
|
63
|
+
assert.strictEqual(data.milestone, undefined, "milestone must not be included");
|
|
64
|
+
});
|
|
65
|
+
test("slim response reflects state changes", async () => {
|
|
66
|
+
const result = await client.callTool("update_issue", {
|
|
67
|
+
project_id: "test/project",
|
|
68
|
+
issue_iid: "2",
|
|
69
|
+
state_event: "close",
|
|
70
|
+
});
|
|
71
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
72
|
+
assert.strictEqual(data.state, "closed");
|
|
73
|
+
assert.strictEqual(data.description, undefined);
|
|
74
|
+
});
|
|
75
|
+
test('full_response="false" string keeps the response slim', async () => {
|
|
76
|
+
const result = await client.callTool("update_issue", {
|
|
77
|
+
project_id: "test/project",
|
|
78
|
+
issue_iid: "3",
|
|
79
|
+
description: "A long description that should still not be echoed",
|
|
80
|
+
full_response: "false",
|
|
81
|
+
});
|
|
82
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
83
|
+
assert.strictEqual(data.description, undefined);
|
|
84
|
+
assert.strictEqual(data.author, undefined);
|
|
85
|
+
assert.strictEqual(data.iid, "3");
|
|
86
|
+
});
|
|
87
|
+
test("full_response=true returns the complete issue object", async () => {
|
|
88
|
+
const result = await client.callTool("update_issue", {
|
|
89
|
+
project_id: "test/project",
|
|
90
|
+
issue_iid: "4",
|
|
91
|
+
description: "Full description expected in response",
|
|
92
|
+
full_response: true,
|
|
93
|
+
});
|
|
94
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
95
|
+
assert.strictEqual(data.description, "Full description expected in response");
|
|
96
|
+
assert.ok(data.author, "author should be present in full response");
|
|
97
|
+
assert.strictEqual(data.iid, "4");
|
|
98
|
+
});
|
|
99
|
+
test("full_response is not forwarded to the GitLab API", async () => {
|
|
100
|
+
const result = await client.callTool("update_issue", {
|
|
101
|
+
project_id: "test/project",
|
|
102
|
+
issue_iid: "5",
|
|
103
|
+
title: "Updated title",
|
|
104
|
+
full_response: true,
|
|
105
|
+
});
|
|
106
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
107
|
+
assert.strictEqual(data.title, "Updated title");
|
|
108
|
+
assert.strictEqual(data.full_response, undefined, "full_response must not leak into the issue");
|
|
109
|
+
});
|
|
110
|
+
test("get_issue slims the milestone by default", async () => {
|
|
111
|
+
const result = await client.callTool("get_issue", {
|
|
112
|
+
project_id: "test/project",
|
|
113
|
+
issue_iid: "100",
|
|
114
|
+
});
|
|
115
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
116
|
+
assert.ok(data.milestone, "milestone should be present");
|
|
117
|
+
assert.strictEqual(data.milestone.title, "Sprint 42");
|
|
118
|
+
assert.ok(data.milestone.web_url, "milestone web_url should be present");
|
|
119
|
+
assert.strictEqual(data.milestone.description, undefined, "milestone description must not be echoed by default");
|
|
120
|
+
assert.ok(data.description, "issue description should still be present");
|
|
121
|
+
});
|
|
122
|
+
test("get_issue full_response=true returns the full milestone", async () => {
|
|
123
|
+
const result = await client.callTool("get_issue", {
|
|
124
|
+
project_id: "test/project",
|
|
125
|
+
issue_iid: "100",
|
|
126
|
+
full_response: true,
|
|
127
|
+
});
|
|
128
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
129
|
+
assert.ok(data.milestone, "milestone should be present");
|
|
130
|
+
assert.ok(typeof data.milestone.description === "string" && data.milestone.description.length > 0, "milestone description should be included with full_response");
|
|
131
|
+
});
|
|
132
|
+
test("get_issue without milestone is unaffected", async () => {
|
|
133
|
+
const result = await client.callTool("get_issue", {
|
|
134
|
+
project_id: "test/project",
|
|
135
|
+
issue_iid: "6",
|
|
136
|
+
});
|
|
137
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
138
|
+
assert.strictEqual(data.milestone, null);
|
|
139
|
+
assert.strictEqual(data.iid, "6");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -68,7 +68,7 @@ describe('upload_markdown', () => {
|
|
|
68
68
|
lastRawBody = undefined;
|
|
69
69
|
});
|
|
70
70
|
before(async () => {
|
|
71
|
-
const port = await findMockServerPort(
|
|
71
|
+
const port = await findMockServerPort();
|
|
72
72
|
mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
|
|
73
73
|
await mockGitLab.start();
|
|
74
74
|
env = {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { createDownloadToken, decryptDownloadToken } from "../../utils/download-token.js";
|
|
4
|
+
describe("download token helpers", () => {
|
|
5
|
+
test("round-trips auth, api URL, and resource binding", () => {
|
|
6
|
+
const token = createDownloadToken("Authorization", "Bearer glpat-example-token", "https://gitlab.example.com/api/v4", {
|
|
7
|
+
type: "attachment",
|
|
8
|
+
params: { project_id: "group/project", secret: "abc", filename: "file.txt" },
|
|
9
|
+
});
|
|
10
|
+
const decrypted = decryptDownloadToken(token);
|
|
11
|
+
assert.deepStrictEqual(decrypted, {
|
|
12
|
+
header: "Authorization",
|
|
13
|
+
token: "Bearer glpat-example-token",
|
|
14
|
+
apiUrl: "https://gitlab.example.com/api/v4",
|
|
15
|
+
resourceType: "attachment",
|
|
16
|
+
resourceParams: { project_id: "group/project", secret: "abc", filename: "file.txt" },
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
test("returns null for invalid tokens", () => {
|
|
20
|
+
assert.strictEqual(decryptDownloadToken("not-a-valid-token"), null);
|
|
21
|
+
});
|
|
22
|
+
test("returns null for expired tokens", () => {
|
|
23
|
+
const originalNow = Date.now;
|
|
24
|
+
const issuedAt = 1_700_000_000_000;
|
|
25
|
+
try {
|
|
26
|
+
Date.now = () => issuedAt;
|
|
27
|
+
const token = createDownloadToken("Private-Token", "glpat-example-token");
|
|
28
|
+
Date.now = () => issuedAt + 301_000;
|
|
29
|
+
assert.strictEqual(decryptDownloadToken(token), null);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
Date.now = originalNow;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test } from "node:test";
|
|
2
2
|
import assert from "node:assert";
|
|
3
|
-
import { getForwardedPublicBaseUrl } from "../../utils/forwarded-public-base-url.js";
|
|
3
|
+
import { getForwardedPublicBaseUrl, getForwardedRequestHost } from "../../utils/forwarded-public-base-url.js";
|
|
4
4
|
function req(headers) {
|
|
5
5
|
return { headers };
|
|
6
6
|
}
|
|
@@ -36,3 +36,11 @@ describe("getForwardedPublicBaseUrl", () => {
|
|
|
36
36
|
}), true), "https://mcp.example.com");
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
describe("getForwardedRequestHost", () => {
|
|
40
|
+
test("returns undefined unless proxy headers are trusted", () => {
|
|
41
|
+
assert.strictEqual(getForwardedRequestHost(req({ "x-forwarded-host": "mcp.example.com" }), false), undefined);
|
|
42
|
+
});
|
|
43
|
+
test("returns the forwarded host when proxy headers are trusted", () => {
|
|
44
|
+
assert.strictEqual(getForwardedRequestHost(req({ "x-forwarded-host": "mcp.example.com" }), true), "mcp.example.com");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import { graphqlQueryContainsWriteOperation } from "../../utils/graphql-query.js";
|
|
3
|
+
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "../../utils/graphql-query.js";
|
|
4
4
|
describe("When graphqlQueryContainsWriteOperation runs", () => {
|
|
5
5
|
describe("with read-only GraphQL documents", () => {
|
|
6
6
|
test("should allow explicit query operations", () => {
|
|
@@ -15,6 +15,18 @@ describe("When graphqlQueryContainsWriteOperation runs", () => {
|
|
|
15
15
|
test("should ignore mutation text inside string literals", () => {
|
|
16
16
|
assert.equal(graphqlQueryContainsWriteOperation('query { search(query: "mutation") { nodes { id } } }'), false);
|
|
17
17
|
});
|
|
18
|
+
test("should ignore escaped triple quotes inside block strings", () => {
|
|
19
|
+
assert.equal(graphqlQueryContainsWriteOperation('query { project(fullPath: """contains \\""" not a mutation""") { id } }'), false);
|
|
20
|
+
});
|
|
21
|
+
test("should not treat subscription field names as write operations", () => {
|
|
22
|
+
assert.equal(graphqlQueryContainsWriteOperation("query { project { id }, subscription { count } }"), false);
|
|
23
|
+
});
|
|
24
|
+
test("should allow query with operation name 'mutation'", () => {
|
|
25
|
+
assert.equal(graphqlQueryContainsWriteOperation("query mutation { project { id } }"), false);
|
|
26
|
+
});
|
|
27
|
+
test("should allow query with operation name 'subscription'", () => {
|
|
28
|
+
assert.equal(graphqlQueryContainsWriteOperation("query subscription { project { id } }"), false);
|
|
29
|
+
});
|
|
18
30
|
});
|
|
19
31
|
describe("with write GraphQL documents", () => {
|
|
20
32
|
test("should detect mutation operations", () => {
|
|
@@ -29,5 +41,56 @@ describe("When graphqlQueryContainsWriteOperation runs", () => {
|
|
|
29
41
|
test("should detect semicolon-separated write operations", () => {
|
|
30
42
|
assert.equal(graphqlQueryContainsWriteOperation("query A { a }; mutation B { b }"), true);
|
|
31
43
|
});
|
|
44
|
+
test("should detect comma-prefixed write operations", () => {
|
|
45
|
+
assert.equal(graphqlQueryContainsWriteOperation(",mutation { destroyProject(input: { projectId: 1 }) { errors } }"), true);
|
|
46
|
+
});
|
|
47
|
+
test("should detect comma-separated write operations", () => {
|
|
48
|
+
assert.equal(graphqlQueryContainsWriteOperation("query A { a }, mutation B { b }"), true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("When graphqlQueryContainsDeleteOperation runs", () => {
|
|
53
|
+
describe("with non-delete documents", () => {
|
|
54
|
+
test("should allow plain queries even when they mention delete words", () => {
|
|
55
|
+
assert.equal(graphqlQueryContainsDeleteOperation("query { project { removeSourceBranchAfterMerge } }"), false);
|
|
56
|
+
});
|
|
57
|
+
test("should allow non-delete mutations", () => {
|
|
58
|
+
assert.equal(graphqlQueryContainsDeleteOperation('mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }'), false);
|
|
59
|
+
});
|
|
60
|
+
test("should not count delete-like argument names inside parentheses", () => {
|
|
61
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { mergeRequestUpdate(input: { removeSourceBranch: true }) { errors } }"), false);
|
|
62
|
+
});
|
|
63
|
+
test("should not count delete-like fields nested below the mutation root", () => {
|
|
64
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { issueSetLabels(input: {}) { issue { canBeDeleted } } }"), false);
|
|
65
|
+
});
|
|
66
|
+
test("should ignore delete text inside comments", () => {
|
|
67
|
+
assert.equal(graphqlQueryContainsDeleteOperation("# issueDelete\nmutation { issueSetLabels { errors } }"), false);
|
|
68
|
+
});
|
|
69
|
+
test("should ignore delete text inside string literals", () => {
|
|
70
|
+
assert.equal(graphqlQueryContainsDeleteOperation('mutation { createNote(input: { body: "projectDelete" }) { errors } }'), false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("with delete documents", () => {
|
|
74
|
+
test("should detect delete mutation fields", () => {
|
|
75
|
+
assert.equal(graphqlQueryContainsDeleteOperation('mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }'), true);
|
|
76
|
+
});
|
|
77
|
+
test("should detect destroy mutation fields", () => {
|
|
78
|
+
assert.equal(graphqlQueryContainsDeleteOperation('mutation { destroyBoard(input: { id: "gid://gitlab/Board/1" }) { errors } }'), true);
|
|
79
|
+
});
|
|
80
|
+
test("should detect remove mutation fields", () => {
|
|
81
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { awardEmojiRemove(input: {}) { errors } }"), true);
|
|
82
|
+
});
|
|
83
|
+
test("should detect aliased delete mutation fields", () => {
|
|
84
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { a: issueDelete(input: {}) { errors } }"), true);
|
|
85
|
+
});
|
|
86
|
+
test("should detect delete mutations in multi-operation documents", () => {
|
|
87
|
+
assert.equal(graphqlQueryContainsDeleteOperation("query A { project { id } } mutation B { labelDelete(input: {}) { errors } }"), true);
|
|
88
|
+
});
|
|
89
|
+
test("should detect delete mutations with variable definitions", () => {
|
|
90
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation DeleteIssue($id: IssueID!) { issueDelete(input: { id: $id }) { errors } }"), true);
|
|
91
|
+
});
|
|
92
|
+
test("should conservatively block top-level fragment spreads in mutations", () => {
|
|
93
|
+
assert.equal(graphqlQueryContainsDeleteOperation("mutation { ...f } fragment f on Mutation { issueDelete(input: {}) { errors } }"), true);
|
|
94
|
+
});
|
|
32
95
|
});
|
|
33
96
|
});
|
|
@@ -342,7 +342,16 @@ export class MockGitLabServer {
|
|
|
342
342
|
},
|
|
343
343
|
assignees: [],
|
|
344
344
|
labels: [],
|
|
345
|
-
milestone:
|
|
345
|
+
milestone: issueIid === 100
|
|
346
|
+
? {
|
|
347
|
+
id: 42,
|
|
348
|
+
iid: 7,
|
|
349
|
+
title: "Sprint 42",
|
|
350
|
+
description: "A very long milestone description ".repeat(50),
|
|
351
|
+
state: "active",
|
|
352
|
+
web_url: `https://gitlab.mock/project/${projectId}/milestones/7`,
|
|
353
|
+
}
|
|
354
|
+
: null,
|
|
346
355
|
});
|
|
347
356
|
});
|
|
348
357
|
// PUT /api/v4/projects/:projectId/issues/:issue_iid - Update issue
|
|
@@ -545,11 +554,16 @@ export class MockGitLabServer {
|
|
|
545
554
|
});
|
|
546
555
|
}
|
|
547
556
|
async start() {
|
|
548
|
-
return new Promise(resolve => {
|
|
557
|
+
return new Promise((resolve, reject) => {
|
|
549
558
|
this.server = this.app.listen(this.config.port, "127.0.0.1", () => {
|
|
559
|
+
const address = this.server?.address();
|
|
560
|
+
if (typeof address === "object" && address) {
|
|
561
|
+
this.config.port = address.port;
|
|
562
|
+
}
|
|
550
563
|
console.log(`Mock GitLab API listening on http://127.0.0.1:${this.config.port}`);
|
|
551
564
|
resolve();
|
|
552
565
|
});
|
|
566
|
+
this.server.once("error", reject);
|
|
553
567
|
});
|
|
554
568
|
}
|
|
555
569
|
async stop() {
|
|
@@ -572,38 +586,17 @@ export class MockGitLabServer {
|
|
|
572
586
|
getUrl() {
|
|
573
587
|
return `http://127.0.0.1:${this.config.port}`;
|
|
574
588
|
}
|
|
589
|
+
getPort() {
|
|
590
|
+
return this.config.port;
|
|
591
|
+
}
|
|
575
592
|
}
|
|
576
593
|
/**
|
|
577
|
-
* Helper to find available port for mock server
|
|
594
|
+
* Helper to find available port for mock server (OS-assigned ephemeral port).
|
|
595
|
+
* Returns 0 to signal MockGitLabServer.start() to use OS-assigned port,
|
|
596
|
+
* avoiding TOCTOU race where another test grabs the port before binding.
|
|
578
597
|
*/
|
|
579
|
-
export async function findMockServerPort(
|
|
580
|
-
|
|
581
|
-
const tryPort = async (port, attemptsLeft) => {
|
|
582
|
-
if (attemptsLeft === 0) {
|
|
583
|
-
throw new Error(`Could not find available port after ${maxAttempts} attempts starting from ${basePort}`);
|
|
584
|
-
}
|
|
585
|
-
return new Promise((resolve, reject) => {
|
|
586
|
-
const server = net.createServer();
|
|
587
|
-
server.unref();
|
|
588
|
-
server.on("error", async () => {
|
|
589
|
-
try {
|
|
590
|
-
const nextPort = await tryPort(port + 1, attemptsLeft - 1);
|
|
591
|
-
resolve(nextPort);
|
|
592
|
-
}
|
|
593
|
-
catch (err) {
|
|
594
|
-
reject(err);
|
|
595
|
-
}
|
|
596
|
-
});
|
|
597
|
-
server.listen(port, "127.0.0.1", () => {
|
|
598
|
-
const addr = server.address();
|
|
599
|
-
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
600
|
-
server.close(() => {
|
|
601
|
-
resolve(actualPort);
|
|
602
|
-
});
|
|
603
|
-
});
|
|
604
|
-
});
|
|
605
|
-
};
|
|
606
|
-
return tryPort(basePort, maxAttempts);
|
|
598
|
+
export async function findMockServerPort() {
|
|
599
|
+
return 0;
|
|
607
600
|
}
|
|
608
601
|
/**
|
|
609
602
|
* Reset request counter (useful for rate limit testing)
|
|
@@ -58,8 +58,7 @@ export async function launchServer(config) {
|
|
|
58
58
|
throw new Error(`${TransportMode.STDIO} mode is not supported for server testing, because it uses process communication.`);
|
|
59
59
|
}
|
|
60
60
|
const serverPath = path.resolve(process.cwd(), "build/index.js");
|
|
61
|
-
console.log("Launcher: Spawning server process
|
|
62
|
-
console.log("Launcher: Spawning server process with env:", serverEnv);
|
|
61
|
+
console.log("Launcher: Spawning server process", { mode, port });
|
|
63
62
|
const serverProcess = spawn("node", [serverPath], {
|
|
64
63
|
env: serverEnv,
|
|
65
64
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { checkForNewVersion, isNewerVersion } from "../../utils/version-check.js";
|
|
4
|
+
function fetchReturning(status, body) {
|
|
5
|
+
return (async () => new Response(JSON.stringify(body), { status }));
|
|
6
|
+
}
|
|
7
|
+
describe("When isNewerVersion compares versions", () => {
|
|
8
|
+
test("should detect newer patch, minor, and major versions", () => {
|
|
9
|
+
assert.equal(isNewerVersion("2.1.30", "2.1.29"), true);
|
|
10
|
+
assert.equal(isNewerVersion("2.2.0", "2.1.29"), true);
|
|
11
|
+
assert.equal(isNewerVersion("3.0.0", "2.1.29"), true);
|
|
12
|
+
});
|
|
13
|
+
test("should return false for equal or older versions", () => {
|
|
14
|
+
assert.equal(isNewerVersion("2.1.29", "2.1.29"), false);
|
|
15
|
+
assert.equal(isNewerVersion("2.1.28", "2.1.29"), false);
|
|
16
|
+
assert.equal(isNewerVersion("1.9.9", "2.0.0"), false);
|
|
17
|
+
});
|
|
18
|
+
test("should return false for malformed versions", () => {
|
|
19
|
+
assert.equal(isNewerVersion("unknown", "2.1.29"), false);
|
|
20
|
+
assert.equal(isNewerVersion("2.1.30", "unknown"), false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("When checkForNewVersion runs", () => {
|
|
24
|
+
test("should return the latest version when the registry has a newer one", async () => {
|
|
25
|
+
const latest = await checkForNewVersion("2.1.29", fetchReturning(200, { version: "2.1.31" }));
|
|
26
|
+
assert.equal(latest, "2.1.31");
|
|
27
|
+
});
|
|
28
|
+
test("should return null when already on the latest version", async () => {
|
|
29
|
+
const latest = await checkForNewVersion("2.1.31", fetchReturning(200, { version: "2.1.31" }));
|
|
30
|
+
assert.equal(latest, null);
|
|
31
|
+
});
|
|
32
|
+
test("should return null without fetching when current version is unknown", async () => {
|
|
33
|
+
let called = false;
|
|
34
|
+
const spyFetch = (async () => {
|
|
35
|
+
called = true;
|
|
36
|
+
return new Response("{}", { status: 200 });
|
|
37
|
+
});
|
|
38
|
+
assert.equal(await checkForNewVersion("unknown", spyFetch), null);
|
|
39
|
+
assert.equal(called, false);
|
|
40
|
+
});
|
|
41
|
+
test("should return null on registry errors instead of throwing", async () => {
|
|
42
|
+
const failingFetch = (async () => {
|
|
43
|
+
throw new Error("network down");
|
|
44
|
+
});
|
|
45
|
+
assert.equal(await checkForNewVersion("2.1.29", failingFetch), null);
|
|
46
|
+
assert.equal(await checkForNewVersion("2.1.29", fetchReturning(500, {})), null);
|
|
47
|
+
});
|
|
48
|
+
test("should return null when the registry returns a non-release version", async () => {
|
|
49
|
+
const latest = await checkForNewVersion("2.1.29", fetchReturning(200, { version: "2.2.0-beta.1" }));
|
|
50
|
+
assert.equal(latest, null);
|
|
51
|
+
});
|
|
52
|
+
});
|