@zereight/mcp-gitlab 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/build/config.js +1 -0
- package/build/index.js +30 -7
- package/build/oauth-proxy.js +250 -46
- package/build/schemas.js +8 -2
- package/build/test/callback-proxy-tests.js +321 -0
- package/build/test/dynamic-routing-tests.js +49 -0
- package/build/test/schema-tests.js +154 -3
- package/build/test/test-json-schema.js +148 -0
- package/build/utils/schema.js +40 -6
- package/package.json +2 -2
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback Proxy Mode Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for GITLAB_OAUTH_CALLBACK_PROXY=true — the mode where the MCP server
|
|
5
|
+
* intercepts the OAuth callback from GitLab, exchanges the code for tokens
|
|
6
|
+
* server-side, and redirects to the MCP client with a proxy code.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, after, before } from "node:test";
|
|
9
|
+
import assert from "node:assert";
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
12
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
13
|
+
const MOCK_GITLAB_PORT_BASE = 9400;
|
|
14
|
+
const MCP_SERVER_PORT_BASE = 3400;
|
|
15
|
+
const MOCK_ACCESS_TOKEN = "ya29.mock-callback-proxy-token-123456";
|
|
16
|
+
const MOCK_REFRESH_TOKEN = "mock-refresh-token-abcdef";
|
|
17
|
+
const MOCK_APP_ID = "test-callback-proxy-app-id";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function generatePKCE() {
|
|
22
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
23
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
24
|
+
return { verifier, challenge };
|
|
25
|
+
}
|
|
26
|
+
/** Register a client via DCR and return the virtual client_id. */
|
|
27
|
+
async function registerClient(mcpBaseUrl, redirectUri) {
|
|
28
|
+
const res = await fetch(`${mcpBaseUrl}/register`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
redirect_uris: [redirectUri],
|
|
33
|
+
client_name: "callback-proxy-test",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
assert.strictEqual(res.status, 201, `DCR failed: ${res.status}`);
|
|
37
|
+
const data = (await res.json());
|
|
38
|
+
return data.client_id;
|
|
39
|
+
}
|
|
40
|
+
/** Hit /authorize and return the redirect Location without following it. */
|
|
41
|
+
async function authorize(mcpBaseUrl, clientId, redirectUri, codeChallenge, state) {
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
response_type: "code",
|
|
44
|
+
client_id: clientId,
|
|
45
|
+
redirect_uri: redirectUri,
|
|
46
|
+
code_challenge: codeChallenge,
|
|
47
|
+
code_challenge_method: "S256",
|
|
48
|
+
state,
|
|
49
|
+
scope: "api",
|
|
50
|
+
});
|
|
51
|
+
const res = await fetch(`${mcpBaseUrl}/authorize?${params}`, {
|
|
52
|
+
redirect: "manual",
|
|
53
|
+
});
|
|
54
|
+
assert.strictEqual(res.status, 302, `Expected 302, got ${res.status}`);
|
|
55
|
+
const location = res.headers.get("location");
|
|
56
|
+
assert.ok(location, "Missing Location header");
|
|
57
|
+
return new URL(location);
|
|
58
|
+
}
|
|
59
|
+
/** Simulate GitLab redirecting to the MCP server's /callback. */
|
|
60
|
+
async function simulateGitLabCallback(mcpBaseUrl, code, state) {
|
|
61
|
+
const res = await fetch(`${mcpBaseUrl}/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, { redirect: "manual" });
|
|
62
|
+
return {
|
|
63
|
+
status: res.status,
|
|
64
|
+
location: res.headers.get("location"),
|
|
65
|
+
body: await res.text(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Exchange a proxy code for tokens via /token. */
|
|
69
|
+
async function exchangeToken(mcpBaseUrl, clientId, code, codeVerifier, redirectUri) {
|
|
70
|
+
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
73
|
+
body: new URLSearchParams({
|
|
74
|
+
grant_type: "authorization_code",
|
|
75
|
+
client_id: clientId,
|
|
76
|
+
code,
|
|
77
|
+
code_verifier: codeVerifier,
|
|
78
|
+
redirect_uri: redirectUri,
|
|
79
|
+
}).toString(),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
status: res.status,
|
|
83
|
+
body: (await res.json()),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Test suite
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
describe("Callback Proxy Mode", () => {
|
|
90
|
+
let mcpBaseUrl;
|
|
91
|
+
let mockGitLab;
|
|
92
|
+
let mockGitLabUrl;
|
|
93
|
+
let servers = [];
|
|
94
|
+
const clientRedirectUri = "http://localhost:19999/oauth/callback";
|
|
95
|
+
// Track the last code+verifier GitLab received so we can verify the proxy PKCE
|
|
96
|
+
let lastTokenRequest = {};
|
|
97
|
+
before(async () => {
|
|
98
|
+
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
|
|
99
|
+
mockGitLab = new MockGitLabServer({
|
|
100
|
+
port: mockPort,
|
|
101
|
+
validTokens: [MOCK_ACCESS_TOKEN],
|
|
102
|
+
});
|
|
103
|
+
await mockGitLab.start();
|
|
104
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
105
|
+
// Mock GitLab token exchange — returns tokens and records the request
|
|
106
|
+
mockGitLab.addRootHandler("post", "/oauth/token", (req, res) => {
|
|
107
|
+
// Body may be URL-encoded (from handleCallback) — parse manually if needed
|
|
108
|
+
let body = req.body ?? {};
|
|
109
|
+
if (typeof body === "string") {
|
|
110
|
+
body = Object.fromEntries(new URLSearchParams(body));
|
|
111
|
+
}
|
|
112
|
+
else if (!body.grant_type && req.headers["content-type"]?.includes("urlencoded")) {
|
|
113
|
+
// express.json() didn't parse it — collect raw body
|
|
114
|
+
let raw = "";
|
|
115
|
+
req.on("data", (chunk) => { raw += chunk.toString(); });
|
|
116
|
+
req.on("end", () => {
|
|
117
|
+
lastTokenRequest = Object.fromEntries(new URLSearchParams(raw));
|
|
118
|
+
res.json({
|
|
119
|
+
access_token: MOCK_ACCESS_TOKEN,
|
|
120
|
+
token_type: "Bearer",
|
|
121
|
+
expires_in: 7200,
|
|
122
|
+
refresh_token: MOCK_REFRESH_TOKEN,
|
|
123
|
+
scope: "api",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
lastTokenRequest = body;
|
|
129
|
+
res.json({
|
|
130
|
+
access_token: MOCK_ACCESS_TOKEN,
|
|
131
|
+
token_type: "Bearer",
|
|
132
|
+
expires_in: 7200,
|
|
133
|
+
refresh_token: MOCK_REFRESH_TOKEN,
|
|
134
|
+
scope: "api",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// Mock token introspection
|
|
138
|
+
mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
|
|
139
|
+
const auth = req.headers["authorization"];
|
|
140
|
+
const token = auth?.replace(/^Bearer\s+/i, "");
|
|
141
|
+
if (token !== MOCK_ACCESS_TOKEN) {
|
|
142
|
+
res.status(401).json({ error: "invalid_token" });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
res.json({
|
|
146
|
+
resource_owner_id: 42,
|
|
147
|
+
scopes: ["api"],
|
|
148
|
+
expires_in_seconds: 7200,
|
|
149
|
+
application: { uid: MOCK_APP_ID },
|
|
150
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
154
|
+
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
155
|
+
const server = await launchServer({
|
|
156
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
157
|
+
port: mcpPort,
|
|
158
|
+
timeout: 5000,
|
|
159
|
+
env: {
|
|
160
|
+
STREAMABLE_HTTP: "true",
|
|
161
|
+
GITLAB_MCP_OAUTH: "true",
|
|
162
|
+
GITLAB_OAUTH_CALLBACK_PROXY: "true",
|
|
163
|
+
GITLAB_OAUTH_APP_ID: MOCK_APP_ID,
|
|
164
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
165
|
+
MCP_SERVER_URL: mcpBaseUrl,
|
|
166
|
+
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
servers.push(server);
|
|
170
|
+
});
|
|
171
|
+
after(async () => {
|
|
172
|
+
cleanupServers(servers);
|
|
173
|
+
if (mockGitLab)
|
|
174
|
+
await mockGitLab.stop();
|
|
175
|
+
});
|
|
176
|
+
// ---- Happy path --------------------------------------------------------
|
|
177
|
+
test("full flow: authorize → callback → token exchange", async () => {
|
|
178
|
+
const clientPKCE = generatePKCE();
|
|
179
|
+
const clientState = "test-state-123";
|
|
180
|
+
// 1. Register client
|
|
181
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
182
|
+
// 2. Authorize — should redirect to GitLab with fixed callback URL
|
|
183
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, clientState);
|
|
184
|
+
assert.strictEqual(gitlabUrl.hostname, new URL(mockGitLabUrl).hostname);
|
|
185
|
+
assert.strictEqual(gitlabUrl.pathname, "/oauth/authorize");
|
|
186
|
+
// redirect_uri should be the MCP server's /callback, NOT the client's
|
|
187
|
+
assert.strictEqual(gitlabUrl.searchParams.get("redirect_uri"), `${mcpBaseUrl}/callback`);
|
|
188
|
+
// State should be a proxy state, NOT the client's original state
|
|
189
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
190
|
+
assert.ok(proxyState);
|
|
191
|
+
assert.notStrictEqual(proxyState, clientState);
|
|
192
|
+
// 3. Simulate GitLab callback to MCP server
|
|
193
|
+
const callbackResult = await simulateGitLabCallback(mcpBaseUrl, "gitlab-auth-code-xyz", proxyState);
|
|
194
|
+
assert.strictEqual(callbackResult.status, 302);
|
|
195
|
+
assert.ok(callbackResult.location);
|
|
196
|
+
const clientCallbackUrl = new URL(callbackResult.location);
|
|
197
|
+
// Should redirect to the CLIENT's callback URL
|
|
198
|
+
assert.ok(clientCallbackUrl.href.startsWith(clientRedirectUri));
|
|
199
|
+
// Should include a proxy code (not the GitLab code)
|
|
200
|
+
const proxyCode = clientCallbackUrl.searchParams.get("code");
|
|
201
|
+
assert.ok(proxyCode);
|
|
202
|
+
assert.notStrictEqual(proxyCode, "gitlab-auth-code-xyz");
|
|
203
|
+
// Should restore the client's original state
|
|
204
|
+
assert.strictEqual(clientCallbackUrl.searchParams.get("state"), clientState);
|
|
205
|
+
// 4. Exchange proxy code for tokens
|
|
206
|
+
const tokenResult = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
207
|
+
assert.strictEqual(tokenResult.status, 200);
|
|
208
|
+
assert.strictEqual(tokenResult.body.access_token, MOCK_ACCESS_TOKEN);
|
|
209
|
+
assert.strictEqual(tokenResult.body.refresh_token, MOCK_REFRESH_TOKEN);
|
|
210
|
+
});
|
|
211
|
+
// ---- One-time use proxy code -------------------------------------------
|
|
212
|
+
test("proxy code cannot be reused", async () => {
|
|
213
|
+
const clientPKCE = generatePKCE();
|
|
214
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
215
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-reuse");
|
|
216
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
217
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-reuse", proxyState);
|
|
218
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
219
|
+
// First exchange succeeds
|
|
220
|
+
const first = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
221
|
+
assert.strictEqual(first.status, 200);
|
|
222
|
+
// Second exchange with same code fails
|
|
223
|
+
const second = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
224
|
+
assert.notStrictEqual(second.status, 200);
|
|
225
|
+
});
|
|
226
|
+
// ---- Client binding -----------------------------------------------------
|
|
227
|
+
test("proxy code cannot be redeemed by a different client_id", async () => {
|
|
228
|
+
const clientPKCE = generatePKCE();
|
|
229
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
230
|
+
const otherClientId = await registerClient(mcpBaseUrl, "http://localhost:19998/oauth/callback");
|
|
231
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-client-binding");
|
|
232
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
233
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-client-binding", proxyState);
|
|
234
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
235
|
+
const result = await exchangeToken(mcpBaseUrl, otherClientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
236
|
+
assert.notStrictEqual(result.status, 200);
|
|
237
|
+
});
|
|
238
|
+
test("proxy code cannot be redeemed with a different redirect_uri", async () => {
|
|
239
|
+
const clientPKCE = generatePKCE();
|
|
240
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
241
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-redirect-binding");
|
|
242
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
243
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-redirect-binding", proxyState);
|
|
244
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
245
|
+
const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, "http://localhost:19998/oauth/callback");
|
|
246
|
+
assert.notStrictEqual(result.status, 200);
|
|
247
|
+
});
|
|
248
|
+
// ---- Unknown state parameter -------------------------------------------
|
|
249
|
+
test("callback with unknown state returns 400", async () => {
|
|
250
|
+
const result = await simulateGitLabCallback(mcpBaseUrl, "some-code", "unknown-state-value");
|
|
251
|
+
assert.strictEqual(result.status, 400);
|
|
252
|
+
assert.ok(result.body.includes("Unknown or expired"));
|
|
253
|
+
});
|
|
254
|
+
// ---- PKCE verification failure -----------------------------------------
|
|
255
|
+
test("token exchange with wrong code_verifier fails", async () => {
|
|
256
|
+
const clientPKCE = generatePKCE();
|
|
257
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
258
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-pkce");
|
|
259
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
260
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-pkce", proxyState);
|
|
261
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
262
|
+
// Exchange with WRONG verifier
|
|
263
|
+
const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, "wrong-verifier-value", clientRedirectUri);
|
|
264
|
+
assert.notStrictEqual(result.status, 200);
|
|
265
|
+
});
|
|
266
|
+
// ---- PKCE verifier omitted ---------------------------------------------
|
|
267
|
+
test("token exchange without code_verifier fails when challenge was stored", async () => {
|
|
268
|
+
const clientPKCE = generatePKCE();
|
|
269
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
270
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-no-verifier");
|
|
271
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
272
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-no-verifier", proxyState);
|
|
273
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
274
|
+
// Exchange WITHOUT verifier — should fail
|
|
275
|
+
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
278
|
+
body: new URLSearchParams({
|
|
279
|
+
grant_type: "authorization_code",
|
|
280
|
+
client_id: clientId,
|
|
281
|
+
code: proxyCode,
|
|
282
|
+
redirect_uri: clientRedirectUri,
|
|
283
|
+
}).toString(),
|
|
284
|
+
});
|
|
285
|
+
assert.notStrictEqual(res.status, 200);
|
|
286
|
+
});
|
|
287
|
+
// ---- Replayed state parameter ------------------------------------------
|
|
288
|
+
test("state cannot be replayed after callback", async () => {
|
|
289
|
+
const clientPKCE = generatePKCE();
|
|
290
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
291
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-replay");
|
|
292
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
293
|
+
// First callback succeeds
|
|
294
|
+
const first = await simulateGitLabCallback(mcpBaseUrl, "code-replay-1", proxyState);
|
|
295
|
+
assert.strictEqual(first.status, 302);
|
|
296
|
+
// Second callback with same state fails
|
|
297
|
+
const second = await simulateGitLabCallback(mcpBaseUrl, "code-replay-2", proxyState);
|
|
298
|
+
assert.strictEqual(second.status, 400);
|
|
299
|
+
});
|
|
300
|
+
// ---- Dual PKCE verification --------------------------------------------
|
|
301
|
+
test("proxy uses its own PKCE pair with GitLab", async () => {
|
|
302
|
+
const clientPKCE = generatePKCE();
|
|
303
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
304
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-dual-pkce");
|
|
305
|
+
// The code_challenge sent to GitLab should NOT be the client's challenge
|
|
306
|
+
const gitlabChallenge = gitlabUrl.searchParams.get("code_challenge");
|
|
307
|
+
assert.ok(gitlabChallenge);
|
|
308
|
+
assert.notStrictEqual(gitlabChallenge, clientPKCE.challenge);
|
|
309
|
+
// Complete the flow to verify the proxy verifier was sent to GitLab
|
|
310
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
311
|
+
await simulateGitLabCallback(mcpBaseUrl, "code-dual-pkce", proxyState);
|
|
312
|
+
// The token request to GitLab should have a code_verifier that matches
|
|
313
|
+
// the proxy challenge (not the client's verifier)
|
|
314
|
+
assert.ok(lastTokenRequest.code_verifier);
|
|
315
|
+
assert.notStrictEqual(lastTokenRequest.code_verifier, clientPKCE.verifier);
|
|
316
|
+
const computedChallenge = createHash("sha256")
|
|
317
|
+
.update(lastTokenRequest.code_verifier)
|
|
318
|
+
.digest("base64url");
|
|
319
|
+
assert.strictEqual(computedChallenge, gitlabChallenge);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -230,6 +230,55 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
|
|
|
230
230
|
await validateToolCalls(client, headerMockServer, MOCK_TOKEN_HEADER);
|
|
231
231
|
await client.disconnect();
|
|
232
232
|
});
|
|
233
|
+
test('should preserve legacy tree array response and return keyset metadata when requested', async () => {
|
|
234
|
+
const client = new CustomHeaderClient({
|
|
235
|
+
headers: {
|
|
236
|
+
'authorization': `Bearer ${MOCK_TOKEN_HEADER}`,
|
|
237
|
+
'X-GitLab-API-URL': `${headerMockServer.getUrl()}/api/v4`,
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
await client.connect(mcpUrl);
|
|
241
|
+
headerMockServer.clearCustomHandlers();
|
|
242
|
+
headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
|
|
243
|
+
assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
|
|
244
|
+
assert.strictEqual(req.query.pagination, undefined);
|
|
245
|
+
res.json([createMockTreeItem('legacy-blob')]);
|
|
246
|
+
});
|
|
247
|
+
const legacyResult = await client.callTool('get_repository_tree', { project_id: '4' });
|
|
248
|
+
const legacyContent = JSON.parse(legacyResult.content[0].text);
|
|
249
|
+
assert.ok(Array.isArray(legacyContent));
|
|
250
|
+
assert.strictEqual(legacyContent[0].id, 'legacy-blob');
|
|
251
|
+
headerMockServer.clearCustomHandlers();
|
|
252
|
+
headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
|
|
253
|
+
assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
|
|
254
|
+
assert.strictEqual(req.query.pagination, 'keyset');
|
|
255
|
+
res.set('x-next-page-token', 'token-blob');
|
|
256
|
+
res.json([createMockTreeItem('keyset-blob')]);
|
|
257
|
+
});
|
|
258
|
+
const keysetResult = await client.callTool('get_repository_tree', {
|
|
259
|
+
project_id: '4',
|
|
260
|
+
pagination: 'keyset',
|
|
261
|
+
});
|
|
262
|
+
const keysetContent = JSON.parse(keysetResult.content[0].text);
|
|
263
|
+
assert.ok(!Array.isArray(keysetContent));
|
|
264
|
+
assert.strictEqual(keysetContent.items[0].id, 'keyset-blob');
|
|
265
|
+
assert.strictEqual(keysetContent.next_page_token, 'token-blob');
|
|
266
|
+
headerMockServer.clearCustomHandlers();
|
|
267
|
+
headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
|
|
268
|
+
assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
|
|
269
|
+
assert.strictEqual(req.query.pagination, 'keyset');
|
|
270
|
+
res.set('x-next-page', 'fallback-token');
|
|
271
|
+
res.json([createMockTreeItem('fallback-blob')]);
|
|
272
|
+
});
|
|
273
|
+
const fallbackResult = await client.callTool('get_repository_tree', {
|
|
274
|
+
project_id: '4',
|
|
275
|
+
pagination: 'keyset',
|
|
276
|
+
});
|
|
277
|
+
const fallbackContent = JSON.parse(fallbackResult.content[0].text);
|
|
278
|
+
assert.strictEqual(fallbackContent.items[0].id, 'fallback-blob');
|
|
279
|
+
assert.strictEqual(fallbackContent.next_page_token, 'fallback-token');
|
|
280
|
+
await client.disconnect();
|
|
281
|
+
});
|
|
233
282
|
});
|
|
234
283
|
});
|
|
235
284
|
// Helper functions to create schema-compliant mock objects
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabTreeItemSchema } from '../schemas.js';
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabTreeItemSchema, GetMergeRequestSchema, GetRepositoryTreeSchema } from '../schemas.js';
|
|
3
3
|
function runGetFileContentsSchemaTests() {
|
|
4
4
|
console.log('🧪 Testing GetFileContentsSchema...');
|
|
5
5
|
const cases = [
|
|
@@ -371,6 +371,83 @@ function runCreateIssueNoteSchemaTests() {
|
|
|
371
371
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
372
372
|
return { passed, failed };
|
|
373
373
|
}
|
|
374
|
+
function runGetMergeRequestSchemaTests() {
|
|
375
|
+
console.log('\n🧪 Testing GetMergeRequestSchema...');
|
|
376
|
+
const cases = [
|
|
377
|
+
{
|
|
378
|
+
name: 'schema:get_merge_request:with-project-id-and-merge-request-iid',
|
|
379
|
+
input: { project_id: 'my/project', merge_request_iid: '42' },
|
|
380
|
+
expected: { project_id: 'my/project', merge_request_iid: '42' },
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: 'schema:get_merge_request:with-project-id-and-source-branch',
|
|
384
|
+
input: { project_id: 'my/project', source_branch: 'feature-branch' },
|
|
385
|
+
expected: { project_id: 'my/project', source_branch: 'feature-branch' },
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'schema:get_merge_request:with-all-params',
|
|
389
|
+
input: { project_id: 'my/project', merge_request_iid: '42', source_branch: 'feature-branch' },
|
|
390
|
+
expected: {
|
|
391
|
+
project_id: 'my/project',
|
|
392
|
+
merge_request_iid: '42',
|
|
393
|
+
source_branch: 'feature-branch',
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: 'schema:get_merge_request:coerced-merge-request-iid',
|
|
398
|
+
input: { project_id: 'my/project', merge_request_iid: 24 },
|
|
399
|
+
expected: { project_id: 'my/project', merge_request_iid: '24' },
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'schema:get_merge_request:coerced-source-branch',
|
|
403
|
+
input: { project_id: 'my/project', source_branch: 'feature' },
|
|
404
|
+
expected: { project_id: 'my/project', source_branch: 'feature' },
|
|
405
|
+
},
|
|
406
|
+
];
|
|
407
|
+
let passed = 0;
|
|
408
|
+
let failed = 0;
|
|
409
|
+
cases.forEach(testCase => {
|
|
410
|
+
const result = {
|
|
411
|
+
name: testCase.name,
|
|
412
|
+
status: 'failed'
|
|
413
|
+
};
|
|
414
|
+
const parsed = GetMergeRequestSchema.safeParse(testCase.input);
|
|
415
|
+
if (testCase.shouldFail) {
|
|
416
|
+
if (parsed.success) {
|
|
417
|
+
result.error = 'Expected schema validation to fail';
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
result.status = 'passed';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (parsed.success) {
|
|
424
|
+
const expected = testCase.expected || {};
|
|
425
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
426
|
+
const actual = parsed.data[key];
|
|
427
|
+
return actual === value;
|
|
428
|
+
});
|
|
429
|
+
if (matches) {
|
|
430
|
+
result.status = 'passed';
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
438
|
+
}
|
|
439
|
+
if (result.status === 'passed') {
|
|
440
|
+
passed++;
|
|
441
|
+
console.log(`✅ ${result.name}`);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
failed++;
|
|
445
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
449
|
+
return { passed, failed };
|
|
450
|
+
}
|
|
374
451
|
function runEmojiReactionSchemaTests() {
|
|
375
452
|
console.log('\n🧪 Testing Emoji Reaction Schemas...');
|
|
376
453
|
const cases = [
|
|
@@ -625,17 +702,91 @@ function runGitLabTreeItemSchemaTests() {
|
|
|
625
702
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
626
703
|
return { passed, failed };
|
|
627
704
|
}
|
|
705
|
+
function runGetRepositoryTreeSchemaTests() {
|
|
706
|
+
console.log('\n=== GetRepositoryTree Schema Tests ===');
|
|
707
|
+
const cases = [
|
|
708
|
+
{
|
|
709
|
+
name: 'schema:get_repository_tree:minimal-project-id',
|
|
710
|
+
input: { project_id: 'my/project' },
|
|
711
|
+
expected: { project_id: 'my/project', path: undefined, ref: undefined, recursive: undefined, per_page: undefined, page_token: undefined, pagination: undefined },
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: 'schema:get_repository_tree:with-keyset-pagination',
|
|
715
|
+
input: { project_id: 'my/project', pagination: 'keyset', per_page: 100 },
|
|
716
|
+
expected: { project_id: 'my/project', pagination: 'keyset', per_page: 100 },
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
name: 'schema:get_repository_tree:page-token-for-next-page',
|
|
720
|
+
input: { project_id: 'my/project', pagination: 'keyset', per_page: 100, page_token: 'eyJpZCI6IjEyMyJ9' },
|
|
721
|
+
expected: { project_id: 'my/project', pagination: 'keyset', per_page: 100, page_token: 'eyJpZCI6IjEyMyJ9' },
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
name: 'schema:get_repository_tree:per-page-coerces-from-string',
|
|
725
|
+
input: { project_id: 'my/project', per_page: '50' },
|
|
726
|
+
expected: { project_id: 'my/project', per_page: 50 },
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
name: 'schema:get_repository_tree:recursive-coerces-from-string',
|
|
730
|
+
input: { project_id: 'my/project', recursive: 'true' },
|
|
731
|
+
expected: { project_id: 'my/project', recursive: true },
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: 'schema:get_repository_tree:with-path-and-ref',
|
|
735
|
+
input: { project_id: 'my/project', path: 'src/', ref: 'main' },
|
|
736
|
+
expected: { project_id: 'my/project', path: 'src/', ref: 'main' },
|
|
737
|
+
},
|
|
738
|
+
];
|
|
739
|
+
let passed = 0;
|
|
740
|
+
let failed = 0;
|
|
741
|
+
cases.forEach(testCase => {
|
|
742
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
743
|
+
const parsed = GetRepositoryTreeSchema.safeParse(testCase.input);
|
|
744
|
+
if (testCase.shouldFail) {
|
|
745
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
746
|
+
if (parsed.success)
|
|
747
|
+
result.error = 'Expected schema validation to fail';
|
|
748
|
+
}
|
|
749
|
+
else if (parsed.success) {
|
|
750
|
+
const expected = testCase.expected || {};
|
|
751
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
752
|
+
const actual = parsed.data[key];
|
|
753
|
+
return JSON.stringify(actual) === JSON.stringify(value);
|
|
754
|
+
});
|
|
755
|
+
if (matches) {
|
|
756
|
+
result.status = 'passed';
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
764
|
+
}
|
|
765
|
+
if (result.status === 'passed') {
|
|
766
|
+
passed++;
|
|
767
|
+
console.log(`✅ ${result.name}`);
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
failed++;
|
|
771
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
775
|
+
return { passed, failed };
|
|
776
|
+
}
|
|
628
777
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
629
778
|
const getFileContentsResult = runGetFileContentsSchemaTests();
|
|
630
779
|
const fileContentResult = runGitLabFileContentSchemaTests();
|
|
631
780
|
const createPipelineResult = runCreatePipelineSchemaTests();
|
|
632
781
|
const createIssueNoteResult = runCreateIssueNoteSchemaTests();
|
|
782
|
+
const getMergeRequestResult = runGetMergeRequestSchemaTests();
|
|
633
783
|
const emojiReactionResult = runEmojiReactionSchemaTests();
|
|
634
784
|
const repositorySchemaResult = runGitLabRepositorySchemaTests();
|
|
635
785
|
const labelsCoercionResult = runLabelsCoercionSchemaTests();
|
|
636
786
|
const treeItemResult = runGitLabTreeItemSchemaTests();
|
|
637
|
-
const
|
|
638
|
-
const
|
|
787
|
+
const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
|
|
788
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + treeItemResult.passed + repositoryTreeResult.passed;
|
|
789
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + treeItemResult.failed + repositoryTreeResult.failed;
|
|
639
790
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
640
791
|
if (totalFailed > 0) {
|
|
641
792
|
process.exit(1);
|