@zereight/mcp-gitlab 2.1.17 → 2.1.19
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 +1 -0
- package/README.md +5 -0
- package/README.zh-CN.md +1 -0
- package/build/config.js +7 -0
- package/build/index.js +221 -6
- package/build/oauth-proxy.js +75 -27
- package/build/schemas.js +109 -0
- package/build/test/mcp-oauth-tests.js +193 -10
- package/build/test/remote-auth-simple-test.js +26 -1
- package/build/test/schema-tests.js +105 -3
- package/build/test/stateless/callback-proxy.test.js +1 -1
- package/build/test/stateless/client-id.test.js +1 -0
- package/build/test/test-dependency-proxy.js +191 -0
- package/build/test/test-geteffectiveprojectid.js +60 -0
- package/build/test/test-protected-branches.js +155 -0
- package/build/test/test-toolset-filtering.js +4 -1
- package/build/tools/registry.js +70 -1
- package/package.json +2 -2
|
@@ -79,7 +79,6 @@ function addOAuthEndpoints(mockGitLab, validToken, clientId, baseUrl) {
|
|
|
79
79
|
// Test suite: Discovery endpoints
|
|
80
80
|
// ---------------------------------------------------------------------------
|
|
81
81
|
describe("MCP OAuth — Discovery Endpoints", () => {
|
|
82
|
-
let mcpUrl;
|
|
83
82
|
let mcpBaseUrl;
|
|
84
83
|
let mockGitLab;
|
|
85
84
|
let servers = [];
|
|
@@ -94,7 +93,6 @@ describe("MCP OAuth — Discovery Endpoints", () => {
|
|
|
94
93
|
addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
|
|
95
94
|
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
96
95
|
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
97
|
-
mcpUrl = `${mcpBaseUrl}/mcp`;
|
|
98
96
|
const server = await launchServer({
|
|
99
97
|
mode: TransportMode.STREAMABLE_HTTP,
|
|
100
98
|
port: mcpPort,
|
|
@@ -285,13 +283,6 @@ describe("MCP OAuth — /mcp Auth Enforcement", () => {
|
|
|
285
283
|
describe("MCP OAuth — BoundedClientCache", () => {
|
|
286
284
|
// Access the internal class via a minimal provider (it's not exported directly)
|
|
287
285
|
// by driving it through the public clientsStore API.
|
|
288
|
-
function makeClient(id, redirectUri = "https://example.com/cb") {
|
|
289
|
-
return {
|
|
290
|
-
client_id: id,
|
|
291
|
-
redirect_uris: [redirectUri],
|
|
292
|
-
token_endpoint_auth_method: "none",
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
286
|
async function buildCachingProvider() {
|
|
296
287
|
// Spin up a DCR stub that returns a stable client_id from the request body.
|
|
297
288
|
// The stub reads client_id from the incoming body (set by the SDK's register
|
|
@@ -380,7 +371,7 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
|
|
|
380
371
|
test("verifyAccessToken throws on non-OK response", async () => {
|
|
381
372
|
// Spin up a tiny local server that always returns 401
|
|
382
373
|
const { createServer } = await import("node:http");
|
|
383
|
-
const stub = createServer((
|
|
374
|
+
const stub = createServer((_req, res) => {
|
|
384
375
|
res.writeHead(401);
|
|
385
376
|
res.end(JSON.stringify({ error: "invalid_token" }));
|
|
386
377
|
});
|
|
@@ -599,3 +590,195 @@ describe("MCP OAuth — Header Auth Fallback", () => {
|
|
|
599
590
|
console.log(" ✓ No auth still returns 401");
|
|
600
591
|
});
|
|
601
592
|
});
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Test suite: Group Allowlist (unit tests — exchangeAuthorizationCode)
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
describe("MCP OAuth — Group Allowlist", () => {
|
|
597
|
+
let stubServer;
|
|
598
|
+
let stubUrl;
|
|
599
|
+
// Mutable state lets each test control the stub's response without
|
|
600
|
+
// spinning up a new server.
|
|
601
|
+
let groupsForPage = () => [];
|
|
602
|
+
let totalPages = 1;
|
|
603
|
+
before(async () => {
|
|
604
|
+
const { createServer } = await import("node:http");
|
|
605
|
+
stubServer = createServer((req, res) => {
|
|
606
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
607
|
+
res.setHeader("Content-Type", "application/json");
|
|
608
|
+
// Token exchange — passthrough mode calls POST /oauth/token
|
|
609
|
+
if (req.method === "POST" && url.pathname === "/oauth/token") {
|
|
610
|
+
res.writeHead(200).end(JSON.stringify({
|
|
611
|
+
access_token: MOCK_OAUTH_TOKEN,
|
|
612
|
+
token_type: "bearer",
|
|
613
|
+
expires_in: 7200,
|
|
614
|
+
refresh_token: "mock-refresh",
|
|
615
|
+
scope: "api",
|
|
616
|
+
}));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (req.method === "GET" && url.pathname === "/api/v4/groups") {
|
|
620
|
+
const page = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
|
|
621
|
+
res
|
|
622
|
+
.writeHead(200, { "x-total-pages": String(totalPages) })
|
|
623
|
+
.end(JSON.stringify(groupsForPage(page)));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
res.writeHead(404).end("{}");
|
|
627
|
+
});
|
|
628
|
+
await new Promise(resolve => stubServer.listen(0, "127.0.0.1", resolve));
|
|
629
|
+
const addr = stubServer.address();
|
|
630
|
+
stubUrl = `http://127.0.0.1:${addr.port}`;
|
|
631
|
+
});
|
|
632
|
+
after(() => {
|
|
633
|
+
stubServer.close();
|
|
634
|
+
});
|
|
635
|
+
async function makeProvider(allowedGroups = undefined, callbackProxyEnabled = false) {
|
|
636
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
637
|
+
return createGitLabOAuthProvider(stubUrl, "test-app-id", "GitLab MCP Server", false, undefined, // customScopes
|
|
638
|
+
allowedGroups, callbackProxyEnabled, callbackProxyEnabled ? "https://mcp.example.test/callback" : "" // callbackUrl
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
function exchange(provider) {
|
|
642
|
+
return provider.exchangeAuthorizationCode({}, "mock-auth-code");
|
|
643
|
+
}
|
|
644
|
+
test("no allowedGroups — groups API not called, tokens returned", async () => {
|
|
645
|
+
const provider = await makeProvider(undefined);
|
|
646
|
+
groupsForPage = () => []; // wrong groups — proves endpoint isn't called
|
|
647
|
+
totalPages = 1;
|
|
648
|
+
const tokens = await exchange(provider);
|
|
649
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
650
|
+
console.log(" ✓ No allowedGroups: token issued without group check");
|
|
651
|
+
});
|
|
652
|
+
test("user is direct member of the allowed group → token issued", async () => {
|
|
653
|
+
const provider = await makeProvider(["my-org"]);
|
|
654
|
+
groupsForPage = () => [{ full_path: "my-org" }];
|
|
655
|
+
totalPages = 1;
|
|
656
|
+
const tokens = await exchange(provider);
|
|
657
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
658
|
+
console.log(" ✓ Direct group member: token issued");
|
|
659
|
+
});
|
|
660
|
+
test("user in first-level subgroup of allowed group → token issued", async () => {
|
|
661
|
+
const provider = await makeProvider(["my-org"]);
|
|
662
|
+
groupsForPage = () => [{ full_path: "my-org/team-a" }];
|
|
663
|
+
totalPages = 1;
|
|
664
|
+
const tokens = await exchange(provider);
|
|
665
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
666
|
+
console.log(" ✓ First-level subgroup member: token issued");
|
|
667
|
+
});
|
|
668
|
+
test("user in nested subgroup → token issued", async () => {
|
|
669
|
+
const provider = await makeProvider(["my-org"]);
|
|
670
|
+
groupsForPage = () => [{ full_path: "my-org/team-a/squad-1" }];
|
|
671
|
+
totalPages = 1;
|
|
672
|
+
const tokens = await exchange(provider);
|
|
673
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
674
|
+
console.log(" ✓ Nested subgroup member: token issued");
|
|
675
|
+
});
|
|
676
|
+
test("user not in any matching group → token issuance denied", async () => {
|
|
677
|
+
const provider = await makeProvider(["my-org"]);
|
|
678
|
+
groupsForPage = () => [{ full_path: "other-org" }];
|
|
679
|
+
totalPages = 1;
|
|
680
|
+
await assert.rejects(() => exchange(provider), (err) => {
|
|
681
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
682
|
+
return true;
|
|
683
|
+
});
|
|
684
|
+
console.log(" ✓ Non-member: token issuance denied");
|
|
685
|
+
});
|
|
686
|
+
test("prefix spoofing rejected — my-org2 does not match my-org", async () => {
|
|
687
|
+
const provider = await makeProvider(["my-org"]);
|
|
688
|
+
groupsForPage = () => [{ full_path: "my-org2" }];
|
|
689
|
+
totalPages = 1;
|
|
690
|
+
await assert.rejects(() => exchange(provider), (err) => {
|
|
691
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
692
|
+
return true;
|
|
693
|
+
});
|
|
694
|
+
console.log(" ✓ my-org2 correctly rejected (not a sub-path of my-org)");
|
|
695
|
+
});
|
|
696
|
+
test("user matches second of multiple allowed groups → token issued", async () => {
|
|
697
|
+
const provider = await makeProvider(["team-x", "my-org"]);
|
|
698
|
+
groupsForPage = () => [{ full_path: "my-org/backend" }];
|
|
699
|
+
totalPages = 1;
|
|
700
|
+
const tokens = await exchange(provider);
|
|
701
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
702
|
+
console.log(" ✓ Match on second allowed group: token issued");
|
|
703
|
+
});
|
|
704
|
+
test("match found on page 2 of paginated groups response → token issued", async () => {
|
|
705
|
+
const provider = await makeProvider(["my-org"]);
|
|
706
|
+
totalPages = 2;
|
|
707
|
+
groupsForPage = page => page === 1 ? [{ full_path: "other-org" }] : [{ full_path: "my-org/team-b" }];
|
|
708
|
+
const tokens = await exchange(provider);
|
|
709
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
710
|
+
console.log(" ✓ Group found on page 2: token issued");
|
|
711
|
+
});
|
|
712
|
+
test("user not in group across all pages → token issuance denied", async () => {
|
|
713
|
+
const provider = await makeProvider(["my-org"]);
|
|
714
|
+
totalPages = 2;
|
|
715
|
+
groupsForPage = () => [{ full_path: "other-org" }];
|
|
716
|
+
await assert.rejects(() => exchange(provider), (err) => {
|
|
717
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
718
|
+
return true;
|
|
719
|
+
});
|
|
720
|
+
console.log(" ✓ Non-member across all pages: token issuance denied");
|
|
721
|
+
});
|
|
722
|
+
test("callback-proxy exchange also enforces allowed groups", async () => {
|
|
723
|
+
const provider = await makeProvider(["my-org"], true);
|
|
724
|
+
groupsForPage = () => [{ full_path: "my-org/team-a" }];
|
|
725
|
+
totalPages = 1;
|
|
726
|
+
const proxyCode = "proxy-code-for-group-test";
|
|
727
|
+
const clientId = "test-client";
|
|
728
|
+
const redirectUri = "https://client.example.test/callback";
|
|
729
|
+
provider._storedTokens.set(proxyCode, {
|
|
730
|
+
tokens: {
|
|
731
|
+
access_token: MOCK_OAUTH_TOKEN,
|
|
732
|
+
token_type: "bearer",
|
|
733
|
+
expires_in: 7200,
|
|
734
|
+
refresh_token: "mock-refresh",
|
|
735
|
+
scope: "api",
|
|
736
|
+
},
|
|
737
|
+
clientId,
|
|
738
|
+
clientCodeChallenge: "",
|
|
739
|
+
clientRedirectUri: redirectUri,
|
|
740
|
+
createdAt: Date.now(),
|
|
741
|
+
});
|
|
742
|
+
const tokens = await provider.exchangeAuthorizationCode({
|
|
743
|
+
client_id: clientId,
|
|
744
|
+
}, proxyCode, undefined, redirectUri);
|
|
745
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
746
|
+
console.log(" ✓ Callback-proxy exchange enforces allowedGroups before issuing tokens");
|
|
747
|
+
});
|
|
748
|
+
test("callback-proxy exchange denies users outside allowed groups", async () => {
|
|
749
|
+
const provider = await makeProvider(["my-org"], true);
|
|
750
|
+
groupsForPage = () => [{ full_path: "other-org/team-a" }];
|
|
751
|
+
totalPages = 1;
|
|
752
|
+
const proxyCode = "proxy-code-for-group-deny-test";
|
|
753
|
+
const clientId = "test-client-deny";
|
|
754
|
+
const redirectUri = "https://client.example.test/callback";
|
|
755
|
+
provider._storedTokens.set(proxyCode, {
|
|
756
|
+
tokens: {
|
|
757
|
+
access_token: MOCK_OAUTH_TOKEN,
|
|
758
|
+
token_type: "bearer",
|
|
759
|
+
expires_in: 7200,
|
|
760
|
+
refresh_token: "mock-refresh",
|
|
761
|
+
scope: "api",
|
|
762
|
+
},
|
|
763
|
+
clientId,
|
|
764
|
+
clientCodeChallenge: "",
|
|
765
|
+
clientRedirectUri: redirectUri,
|
|
766
|
+
createdAt: Date.now(),
|
|
767
|
+
});
|
|
768
|
+
await assert.rejects(() => provider.exchangeAuthorizationCode({
|
|
769
|
+
client_id: clientId,
|
|
770
|
+
}, proxyCode, undefined, redirectUri), (err) => {
|
|
771
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
772
|
+
return true;
|
|
773
|
+
});
|
|
774
|
+
console.log(" ✓ Callback-proxy exchange denies users outside allowedGroups");
|
|
775
|
+
});
|
|
776
|
+
test("matching is case-insensitive", async () => {
|
|
777
|
+
const provider = await makeProvider(["My-Org"]);
|
|
778
|
+
groupsForPage = () => [{ full_path: "my-org/team-a" }];
|
|
779
|
+
totalPages = 1;
|
|
780
|
+
const tokens = await exchange(provider);
|
|
781
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
782
|
+
console.log(" ✓ Case-insensitive match: token issued");
|
|
783
|
+
});
|
|
784
|
+
});
|
|
@@ -10,7 +10,6 @@ import { CustomHeaderClient } from './clients/custom-header-client.js';
|
|
|
10
10
|
// Test constants
|
|
11
11
|
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
12
12
|
const MOCK_JOB_TOKEN = 'glcbt-mock-job-token-9876';
|
|
13
|
-
const TEST_PROJECT_ID = '123';
|
|
14
13
|
// Port ranges to avoid collisions
|
|
15
14
|
const MOCK_GITLAB_PORT_BASE = 9000;
|
|
16
15
|
const MOCK_GITLAB_PORT_OFFSET = 500; // Offset for timeout test suite
|
|
@@ -22,6 +21,26 @@ const TIMEOUT_BUFFER_MS = 1000; // Extra time beyond timeout to ensure expiratio
|
|
|
22
21
|
const TIMEOUT_TEST_WAIT_MS = SESSION_TIMEOUT_SECONDS * 1000 + TIMEOUT_BUFFER_MS;
|
|
23
22
|
const KEEPALIVE_INTERVAL_MS = 2000; // Must be less than SESSION_TIMEOUT_SECONDS
|
|
24
23
|
const KEEPALIVE_REQUEST_COUNT = 3; // Number of keepalive requests to test
|
|
24
|
+
async function getMetrics(mcpUrl) {
|
|
25
|
+
const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics');
|
|
26
|
+
const response = await fetch(metricsUrl);
|
|
27
|
+
assert.strictEqual(response.status, 200, 'metrics endpoint should be available');
|
|
28
|
+
return (await response.json());
|
|
29
|
+
}
|
|
30
|
+
async function waitForSessionDecrease(mcpUrl, beforeTimeout, timeoutMs = 3000) {
|
|
31
|
+
const startedAt = Date.now();
|
|
32
|
+
let last = await getMetrics(mcpUrl);
|
|
33
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
34
|
+
if (last.activeSessions < beforeTimeout.activeSessions &&
|
|
35
|
+
last.authenticatedSessions < beforeTimeout.authenticatedSessions) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
39
|
+
last = await getMetrics(mcpUrl);
|
|
40
|
+
}
|
|
41
|
+
assert.ok(last.activeSessions < beforeTimeout.activeSessions, `activeSessions should decrease after timeout (${last.activeSessions} !< ${beforeTimeout.activeSessions})`);
|
|
42
|
+
assert.ok(last.authenticatedSessions < beforeTimeout.authenticatedSessions, `authenticatedSessions should decrease after timeout (${last.authenticatedSessions} !< ${beforeTimeout.authenticatedSessions})`);
|
|
43
|
+
}
|
|
25
44
|
console.log('🔐 Remote Authorization Test Suite');
|
|
26
45
|
console.log('');
|
|
27
46
|
describe('Remote Authorization - Basic Functionality', () => {
|
|
@@ -178,6 +197,7 @@ describe('Remote Authorization - Session Timeout', () => {
|
|
|
178
197
|
test('session timeout expiration - inactivity expires auth', async () => {
|
|
179
198
|
// Add a small delay to ensure server is ready/clean from previous test
|
|
180
199
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
200
|
+
const baseline = await getMetrics(mcpUrl);
|
|
181
201
|
// Step 1: Connect WITH auth header to establish session
|
|
182
202
|
const clientWithAuth = new CustomHeaderClient({
|
|
183
203
|
'authorization': `Bearer ${MOCK_TOKEN}`
|
|
@@ -190,9 +210,14 @@ describe('Remote Authorization - Session Timeout', () => {
|
|
|
190
210
|
const sessionId = clientWithAuth.getSessionId();
|
|
191
211
|
assert.ok(sessionId, 'Session ID should exist');
|
|
192
212
|
console.log(` ℹ️ Session ID: ${sessionId}`);
|
|
213
|
+
const beforeTimeout = await getMetrics(mcpUrl);
|
|
214
|
+
assert.strictEqual(beforeTimeout.activeSessions, baseline.activeSessions + 1, 'Session should occupy one additional active slot');
|
|
215
|
+
assert.strictEqual(beforeTimeout.authenticatedSessions, baseline.authenticatedSessions + 1, 'Session should add one authenticated session');
|
|
193
216
|
// Step 2: Wait for timeout WITHOUT making any requests
|
|
194
217
|
console.log(` ⏳ Waiting ${TIMEOUT_TEST_WAIT_MS / 1000}s for timeout without activity...`);
|
|
195
218
|
await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
|
|
219
|
+
await waitForSessionDecrease(mcpUrl, beforeTimeout);
|
|
220
|
+
console.log(' ✓ Timeout closed transport and released active session slot');
|
|
196
221
|
// Step 3: Try to make request WITHOUT auth header - should fail with 401
|
|
197
222
|
try {
|
|
198
223
|
const response = await fetch(mcpUrl, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema, GitLabMarkdownUploadSchema, } from '../schemas.js';
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema, GitLabMarkdownUploadSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, } from '../schemas.js';
|
|
3
3
|
function runGetFileContentsSchemaTests() {
|
|
4
4
|
console.log('🧪 Testing GetFileContentsSchema...');
|
|
5
5
|
const cases = [
|
|
@@ -1385,6 +1385,106 @@ function runGitLabUserFullSchemaTests() {
|
|
|
1385
1385
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1386
1386
|
return { passed, failed };
|
|
1387
1387
|
}
|
|
1388
|
+
function runGitLabDependencyProxySchemaTests() {
|
|
1389
|
+
console.log('🧪 Testing GitLabDependencyProxySchema...');
|
|
1390
|
+
const cases = [
|
|
1391
|
+
{
|
|
1392
|
+
name: 'schema:dependency_proxy:minimal',
|
|
1393
|
+
input: { enabled: true, blob_count: 0, total_size: '0' },
|
|
1394
|
+
shouldFail: false,
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
name: 'schema:dependency_proxy:full',
|
|
1398
|
+
input: {
|
|
1399
|
+
enabled: true,
|
|
1400
|
+
blob_count: 42,
|
|
1401
|
+
total_size: '1234567890',
|
|
1402
|
+
image_prefix: 'gitlab.example.com:5050/my-group/dependency_proxy/containers',
|
|
1403
|
+
ttl_policy: { enabled: true, ttl: 90 },
|
|
1404
|
+
},
|
|
1405
|
+
shouldFail: false,
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
name: 'schema:dependency_proxy:nulls-allowed',
|
|
1409
|
+
input: { enabled: false, blob_count: null, total_size: null, image_prefix: null, ttl_policy: null },
|
|
1410
|
+
shouldFail: false,
|
|
1411
|
+
},
|
|
1412
|
+
];
|
|
1413
|
+
let passed = 0;
|
|
1414
|
+
let failed = 0;
|
|
1415
|
+
cases.forEach(testCase => {
|
|
1416
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
1417
|
+
const parsed = GitLabDependencyProxySchema.safeParse(testCase.input);
|
|
1418
|
+
if (testCase.shouldFail) {
|
|
1419
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
1420
|
+
if (parsed.success)
|
|
1421
|
+
result.error = 'Expected schema validation to fail';
|
|
1422
|
+
}
|
|
1423
|
+
else if (parsed.success) {
|
|
1424
|
+
result.status = 'passed';
|
|
1425
|
+
}
|
|
1426
|
+
else {
|
|
1427
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
1428
|
+
}
|
|
1429
|
+
if (result.status === 'passed') {
|
|
1430
|
+
passed++;
|
|
1431
|
+
console.log(`✅ ${result.name}`);
|
|
1432
|
+
}
|
|
1433
|
+
else {
|
|
1434
|
+
failed++;
|
|
1435
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1439
|
+
return { passed, failed };
|
|
1440
|
+
}
|
|
1441
|
+
function runGitLabDependencyProxyBlobSchemaTests() {
|
|
1442
|
+
console.log('🧪 Testing GitLabDependencyProxyBlobSchema...');
|
|
1443
|
+
const cases = [
|
|
1444
|
+
{
|
|
1445
|
+
name: 'schema:dependency_proxy_blob:basic',
|
|
1446
|
+
input: { file_name: 'sha256:abc123', size: '2.5 MiB', created_at: '2026-01-01T00:00:00Z' },
|
|
1447
|
+
shouldFail: false,
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
name: 'schema:dependency_proxy_blob:no-created-at',
|
|
1451
|
+
input: { file_name: 'sha256:def456', size: '512 KiB' },
|
|
1452
|
+
shouldFail: false,
|
|
1453
|
+
},
|
|
1454
|
+
{
|
|
1455
|
+
name: 'schema:dependency_proxy_blob:size-must-be-string',
|
|
1456
|
+
input: { file_name: 'sha256:ghi789', size: 12345 },
|
|
1457
|
+
shouldFail: true,
|
|
1458
|
+
},
|
|
1459
|
+
];
|
|
1460
|
+
let passed = 0;
|
|
1461
|
+
let failed = 0;
|
|
1462
|
+
cases.forEach(testCase => {
|
|
1463
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
1464
|
+
const parsed = GitLabDependencyProxyBlobSchema.safeParse(testCase.input);
|
|
1465
|
+
if (testCase.shouldFail) {
|
|
1466
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
1467
|
+
if (parsed.success)
|
|
1468
|
+
result.error = 'Expected schema validation to fail';
|
|
1469
|
+
}
|
|
1470
|
+
else if (parsed.success) {
|
|
1471
|
+
result.status = 'passed';
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
1475
|
+
}
|
|
1476
|
+
if (result.status === 'passed') {
|
|
1477
|
+
passed++;
|
|
1478
|
+
console.log(`✅ ${result.name}`);
|
|
1479
|
+
}
|
|
1480
|
+
else {
|
|
1481
|
+
failed++;
|
|
1482
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1486
|
+
return { passed, failed };
|
|
1487
|
+
}
|
|
1388
1488
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1389
1489
|
const getFileContentsResult = runGetFileContentsSchemaTests();
|
|
1390
1490
|
const fileContentResult = runGitLabFileContentSchemaTests();
|
|
@@ -1403,8 +1503,10 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1403
1503
|
const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
|
|
1404
1504
|
const gitLabUserFullResult = runGitLabUserFullSchemaTests();
|
|
1405
1505
|
const gitLabMarkdownUploadResult = runGitLabMarkdownUploadSchemaTests();
|
|
1406
|
-
const
|
|
1407
|
-
const
|
|
1506
|
+
const dependencyProxyResult = runGitLabDependencyProxySchemaTests();
|
|
1507
|
+
const dependencyProxyBlobResult = runGitLabDependencyProxyBlobSchemaTests();
|
|
1508
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + approvedByUsernamesResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed + dependencyProxyResult.passed + dependencyProxyBlobResult.passed;
|
|
1509
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + approvedByUsernamesResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed + dependencyProxyResult.failed + dependencyProxyBlobResult.failed;
|
|
1408
1510
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
1409
1511
|
if (totalFailed > 0) {
|
|
1410
1512
|
process.exit(1);
|
|
@@ -34,7 +34,7 @@ function loadMaterial(current, previous) {
|
|
|
34
34
|
return m;
|
|
35
35
|
}
|
|
36
36
|
function makeProvider(material, { callbackProxy = true } = {}) {
|
|
37
|
-
return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
|
|
37
|
+
return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
|
|
38
38
|
? {
|
|
39
39
|
material,
|
|
40
40
|
clientTtlSeconds: 86400,
|
|
@@ -31,6 +31,7 @@ function loadMaterial(current, previous) {
|
|
|
31
31
|
function makeProvider(material, { callbackProxy = false } = {}) {
|
|
32
32
|
return createGitLabOAuthProvider("https://gitlab.example.com", "real-gitlab-app-id", "GitLab MCP Server (test)", false, // readOnly
|
|
33
33
|
undefined, // customScopes
|
|
34
|
+
undefined, // allowedGroups
|
|
34
35
|
callbackProxy, callbackProxy ? "https://mcp.example.com/callback" : "", material
|
|
35
36
|
? {
|
|
36
37
|
material,
|
|
@@ -0,0 +1,191 @@
|
|
|
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-dependency-proxy";
|
|
6
|
+
const TEST_GROUP_PATH = "my-group";
|
|
7
|
+
async function callTool(toolName, args, env) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
10
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
11
|
+
env: { ...process.env, ...env },
|
|
12
|
+
});
|
|
13
|
+
let output = "";
|
|
14
|
+
let errorOutput = "";
|
|
15
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
16
|
+
proc.stderr?.on("data", (d) => (errorOutput += d));
|
|
17
|
+
proc.on("close", code => {
|
|
18
|
+
if (code !== 0) {
|
|
19
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
20
|
+
}
|
|
21
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
22
|
+
if (!line)
|
|
23
|
+
return reject(new Error("No JSON output found"));
|
|
24
|
+
try {
|
|
25
|
+
const response = JSON.parse(line);
|
|
26
|
+
if (response.error) {
|
|
27
|
+
reject(new Error(response.error?.message ?? String(response.error)));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const content = response.result?.content?.[0]?.text;
|
|
31
|
+
resolve(content ? JSON.parse(content) : response.result);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
reject(e);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
proc.stdin?.end(JSON.stringify({
|
|
39
|
+
jsonrpc: "2.0",
|
|
40
|
+
id: 1,
|
|
41
|
+
method: "tools/call",
|
|
42
|
+
params: { name: toolName, arguments: args },
|
|
43
|
+
}) + "\n");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
describe("dependency proxy tools", () => {
|
|
47
|
+
let mockServer;
|
|
48
|
+
let mockPort;
|
|
49
|
+
let baseEnv;
|
|
50
|
+
before(async () => {
|
|
51
|
+
mockPort = await findMockServerPort();
|
|
52
|
+
mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
53
|
+
// Mock GraphQL endpoint for get/list operations
|
|
54
|
+
mockServer.addRootHandler("post", "/api/graphql", (req, res) => {
|
|
55
|
+
const { query } = req.body;
|
|
56
|
+
if (query.includes("updateDependencyProxySettings")) {
|
|
57
|
+
res.json({ data: { updateDependencyProxySettings: { errors: [] } } });
|
|
58
|
+
}
|
|
59
|
+
else if (query.includes("dependencyProxySetting")) {
|
|
60
|
+
res.json({
|
|
61
|
+
data: {
|
|
62
|
+
group: {
|
|
63
|
+
dependencyProxySetting: { enabled: true },
|
|
64
|
+
dependencyProxyBlobCount: 3,
|
|
65
|
+
dependencyProxyTotalSize: "15728640",
|
|
66
|
+
dependencyProxyImagePrefix: `localhost:${mockPort}/my-group/dependency_proxy/containers`,
|
|
67
|
+
dependencyProxyImageTtlPolicy: { enabled: true, ttl: 90 },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (query.includes("dependencyProxyBlobs")) {
|
|
73
|
+
res.json({
|
|
74
|
+
data: {
|
|
75
|
+
group: {
|
|
76
|
+
dependencyProxyBlobs: {
|
|
77
|
+
nodes: [
|
|
78
|
+
{ fileName: "sha256:abc123", size: "5 MiB", createdAt: "2026-01-01T00:00:00Z" },
|
|
79
|
+
{ fileName: "sha256:def456", size: "10 MiB", createdAt: "2026-01-02T00:00:00Z" },
|
|
80
|
+
],
|
|
81
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
res.status(400).json({ errors: [{ message: "Unexpected GraphQL query" }] });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// Mock REST endpoint for purge
|
|
92
|
+
mockServer.addMockHandler("delete", `/groups/${TEST_GROUP_PATH}/dependency_proxy/cache`, (_req, res) => {
|
|
93
|
+
res.status(202).send();
|
|
94
|
+
});
|
|
95
|
+
await mockServer.start();
|
|
96
|
+
baseEnv = {
|
|
97
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
98
|
+
GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
|
|
99
|
+
GITLAB_TOOLSETS: "dependency_proxy",
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
after(async () => {
|
|
103
|
+
await mockServer.stop();
|
|
104
|
+
});
|
|
105
|
+
test("get_dependency_proxy_settings returns enabled status and blob info", async () => {
|
|
106
|
+
const result = await callTool("get_dependency_proxy_settings", { group_id: TEST_GROUP_PATH }, baseEnv);
|
|
107
|
+
assert.strictEqual(result.enabled, true);
|
|
108
|
+
assert.strictEqual(result.blob_count, 3);
|
|
109
|
+
assert.strictEqual(result.total_size, "15728640");
|
|
110
|
+
assert.ok(result.ttl_policy?.enabled);
|
|
111
|
+
});
|
|
112
|
+
test("list_dependency_proxy_blobs returns blobs with string sizes", async () => {
|
|
113
|
+
const result = await callTool("list_dependency_proxy_blobs", { group_id: TEST_GROUP_PATH }, baseEnv);
|
|
114
|
+
assert.ok(Array.isArray(result.blobs));
|
|
115
|
+
assert.strictEqual(result.blobs.length, 2);
|
|
116
|
+
assert.strictEqual(typeof result.blobs[0].size, "string");
|
|
117
|
+
assert.strictEqual(result.blobs[0].file_name, "sha256:abc123");
|
|
118
|
+
});
|
|
119
|
+
test("purge_dependency_proxy_cache returns scheduled status", async () => {
|
|
120
|
+
const result = await callTool("purge_dependency_proxy_cache", { group_id: TEST_GROUP_PATH }, baseEnv);
|
|
121
|
+
assert.strictEqual(result.status, "success");
|
|
122
|
+
assert.ok(result.message.includes("scheduled"));
|
|
123
|
+
});
|
|
124
|
+
test("update_dependency_proxy_settings enables the proxy and returns settings", async () => {
|
|
125
|
+
const result = await callTool("update_dependency_proxy_settings", { group_id: TEST_GROUP_PATH, enabled: true }, baseEnv);
|
|
126
|
+
assert.strictEqual(result.enabled, true);
|
|
127
|
+
});
|
|
128
|
+
test("update_dependency_proxy_settings rejects empty options", async () => {
|
|
129
|
+
await assert.rejects(() => callTool("update_dependency_proxy_settings", { group_id: TEST_GROUP_PATH }, baseEnv), /At least one of enabled, identity, or secret must be provided/);
|
|
130
|
+
});
|
|
131
|
+
test("dependency_proxy tools are absent when toolset is not activated", async () => {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
134
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
135
|
+
env: {
|
|
136
|
+
...process.env,
|
|
137
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
138
|
+
GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
|
|
139
|
+
// No GITLAB_TOOLSETS — default toolsets only
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
let output = "";
|
|
143
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
144
|
+
proc.on("close", () => {
|
|
145
|
+
try {
|
|
146
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
147
|
+
if (!line)
|
|
148
|
+
return reject(new Error("No JSON output found"));
|
|
149
|
+
const response = JSON.parse(line);
|
|
150
|
+
const tools = response.result?.tools ?? [];
|
|
151
|
+
const names = tools.map(t => t.name);
|
|
152
|
+
assert.ok(!names.includes("get_dependency_proxy_settings"), "tool should not be in default toolset");
|
|
153
|
+
assert.ok(!names.includes("purge_dependency_proxy_cache"), "tool should not be in default toolset");
|
|
154
|
+
resolve();
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
reject(e);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
test("write tools are absent from tools/list in read-only mode", async () => {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
166
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
167
|
+
env: { ...process.env, ...baseEnv, GITLAB_READ_ONLY_MODE: "true" },
|
|
168
|
+
});
|
|
169
|
+
let output = "";
|
|
170
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
171
|
+
proc.on("close", () => {
|
|
172
|
+
try {
|
|
173
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
174
|
+
if (!line)
|
|
175
|
+
return reject(new Error("No JSON output found"));
|
|
176
|
+
const response = JSON.parse(line);
|
|
177
|
+
const names = (response.result?.tools ?? []).map((t) => t.name);
|
|
178
|
+
assert.ok(!names.includes("purge_dependency_proxy_cache"), "purge should be absent in read-only mode");
|
|
179
|
+
assert.ok(!names.includes("update_dependency_proxy_settings"), "update should be absent in read-only mode");
|
|
180
|
+
assert.ok(names.includes("get_dependency_proxy_settings"), "get should be present in read-only mode");
|
|
181
|
+
assert.ok(names.includes("list_dependency_proxy_blobs"), "list should be present in read-only mode");
|
|
182
|
+
resolve();
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
reject(e);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|