@zereight/mcp-gitlab 2.1.69 ā 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +10 -4
- package/README.md +238 -233
- package/README.pt-BR.md +4 -4
- package/README.zh-CN.md +6 -4
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +456 -237
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +226 -57
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/build/utils/url.js +15 -0
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -812
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -482
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -128
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- package/build/test-resolve-issue-note.js +0 -127
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
|
-
/**
|
|
3
|
-
* OAuth Authentication Tests
|
|
4
|
-
* Tests for GitLab OAuth2 authentication flow
|
|
5
|
-
*/
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
import * as http from 'http';
|
|
9
|
-
import * as net from 'net';
|
|
10
|
-
import { GitLabOAuth } from '../oauth.js';
|
|
11
|
-
// Test configuration
|
|
12
|
-
const TEST_CLIENT_ID = process.env.GITLAB_OAUTH_CLIENT_ID || 'test-client-id';
|
|
13
|
-
const TEST_REDIRECT_URI = process.env.GITLAB_OAUTH_REDIRECT_URI || 'http://127.0.0.1:8888/callback';
|
|
14
|
-
const TEST_GITLAB_URL = process.env.GITLAB_API_URL?.replace('/api/v4', '') || 'https://gitlab.com';
|
|
15
|
-
const TEST_TOKEN_PATH = path.join(process.cwd(), '.test-gitlab-token.json');
|
|
16
|
-
const testResults = [];
|
|
17
|
-
// Helper function to run a single test
|
|
18
|
-
async function runTest(name, testFn, skip = false) {
|
|
19
|
-
if (skip) {
|
|
20
|
-
console.log(`āļø SKIPPED: ${name}`);
|
|
21
|
-
testResults.push({ name, status: 'skipped', duration: 0 });
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
const startTime = Date.now();
|
|
25
|
-
try {
|
|
26
|
-
console.log(`š§Ŗ Testing: ${name}`);
|
|
27
|
-
await testFn();
|
|
28
|
-
const duration = Date.now() - startTime;
|
|
29
|
-
console.log(`ā
PASSED: ${name} (${duration}ms)`);
|
|
30
|
-
testResults.push({ name, status: 'passed', duration });
|
|
31
|
-
}
|
|
32
|
-
catch (error) {
|
|
33
|
-
const duration = Date.now() - startTime;
|
|
34
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
35
|
-
console.log(`ā FAILED: ${name} (${duration}ms)`);
|
|
36
|
-
console.log(` Error: ${errorMsg}`);
|
|
37
|
-
testResults.push({ name, status: 'failed', duration, error: errorMsg });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
// Helper function to assert conditions
|
|
41
|
-
function assert(condition, message) {
|
|
42
|
-
if (!condition) {
|
|
43
|
-
throw new Error(`Assertion failed: ${message}`);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
// Helper function to check if port is available
|
|
47
|
-
async function isPortAvailable(port) {
|
|
48
|
-
return new Promise((resolve) => {
|
|
49
|
-
const server = net.createServer();
|
|
50
|
-
server.once('error', (err) => {
|
|
51
|
-
if (err.code === 'EADDRINUSE') {
|
|
52
|
-
resolve(false);
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
resolve(true);
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
server.once('listening', () => {
|
|
59
|
-
server.close();
|
|
60
|
-
resolve(true);
|
|
61
|
-
});
|
|
62
|
-
server.listen(port, '127.0.0.1');
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// Clean up test token file
|
|
66
|
-
function cleanupTestToken() {
|
|
67
|
-
if (fs.existsSync(TEST_TOKEN_PATH)) {
|
|
68
|
-
fs.unlinkSync(TEST_TOKEN_PATH);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// Test 1: GitLabOAuth class instantiation
|
|
72
|
-
async function testOAuthInstantiation() {
|
|
73
|
-
const oauth = new GitLabOAuth({
|
|
74
|
-
clientId: TEST_CLIENT_ID,
|
|
75
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
76
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
77
|
-
scopes: ['api'],
|
|
78
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
79
|
-
});
|
|
80
|
-
assert(oauth !== null, 'OAuth instance should be created');
|
|
81
|
-
assert(typeof oauth.getAccessToken === 'function', 'Should have getAccessToken method');
|
|
82
|
-
assert(typeof oauth.clearToken === 'function', 'Should have clearToken method');
|
|
83
|
-
assert(typeof oauth.hasValidToken === 'function', 'Should have hasValidToken method');
|
|
84
|
-
}
|
|
85
|
-
// Test 2: Token storage path configuration
|
|
86
|
-
async function testTokenStoragePath() {
|
|
87
|
-
const customPath = path.join(process.cwd(), '.custom-test-token.json');
|
|
88
|
-
const oauth = new GitLabOAuth({
|
|
89
|
-
clientId: TEST_CLIENT_ID,
|
|
90
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
91
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
92
|
-
scopes: ['api'],
|
|
93
|
-
tokenStoragePath: customPath,
|
|
94
|
-
});
|
|
95
|
-
assert(oauth !== null, 'OAuth instance with custom path should be created');
|
|
96
|
-
// Clean up
|
|
97
|
-
if (fs.existsSync(customPath)) {
|
|
98
|
-
fs.unlinkSync(customPath);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Test 3: Scope configuration
|
|
102
|
-
async function testScopeConfiguration() {
|
|
103
|
-
const oauth = new GitLabOAuth({
|
|
104
|
-
clientId: TEST_CLIENT_ID,
|
|
105
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
106
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
107
|
-
scopes: ['api'],
|
|
108
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
109
|
-
});
|
|
110
|
-
assert(oauth !== null, 'OAuth instance with api scope should be created');
|
|
111
|
-
}
|
|
112
|
-
// Test 4: Multiple scopes (should still work but is redundant)
|
|
113
|
-
async function testMultipleScopesRedundant() {
|
|
114
|
-
const oauth = new GitLabOAuth({
|
|
115
|
-
clientId: TEST_CLIENT_ID,
|
|
116
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
117
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
118
|
-
scopes: ['api', 'read_user', 'read_api', 'write_repository'],
|
|
119
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
120
|
-
});
|
|
121
|
-
assert(oauth !== null, 'OAuth instance with multiple scopes should be created');
|
|
122
|
-
}
|
|
123
|
-
// Test 5: hasValidToken returns false when no token exists
|
|
124
|
-
async function testHasValidTokenNoToken() {
|
|
125
|
-
cleanupTestToken();
|
|
126
|
-
const oauth = new GitLabOAuth({
|
|
127
|
-
clientId: TEST_CLIENT_ID,
|
|
128
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
129
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
130
|
-
scopes: ['api'],
|
|
131
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
132
|
-
});
|
|
133
|
-
const hasToken = oauth.hasValidToken();
|
|
134
|
-
assert(hasToken === false, 'Should return false when no token exists');
|
|
135
|
-
}
|
|
136
|
-
// Test 6: hasValidToken returns true with valid token
|
|
137
|
-
async function testHasValidTokenWithToken() {
|
|
138
|
-
const tokenData = {
|
|
139
|
-
access_token: 'test-token',
|
|
140
|
-
token_type: 'Bearer',
|
|
141
|
-
created_at: Date.now(),
|
|
142
|
-
expires_in: 7200, // 2 hours
|
|
143
|
-
};
|
|
144
|
-
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
145
|
-
const oauth = new GitLabOAuth({
|
|
146
|
-
clientId: TEST_CLIENT_ID,
|
|
147
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
148
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
149
|
-
scopes: ['api'],
|
|
150
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
151
|
-
});
|
|
152
|
-
const hasToken = oauth.hasValidToken();
|
|
153
|
-
assert(hasToken === true, 'Should return true with valid token');
|
|
154
|
-
cleanupTestToken();
|
|
155
|
-
}
|
|
156
|
-
// Test 7: hasValidToken returns false with expired token
|
|
157
|
-
async function testHasValidTokenExpired() {
|
|
158
|
-
const tokenData = {
|
|
159
|
-
access_token: 'test-token',
|
|
160
|
-
token_type: 'Bearer',
|
|
161
|
-
created_at: Date.now() - 10000000, // 2.7+ hours ago
|
|
162
|
-
expires_in: 7200, // 2 hours
|
|
163
|
-
};
|
|
164
|
-
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
165
|
-
const oauth = new GitLabOAuth({
|
|
166
|
-
clientId: TEST_CLIENT_ID,
|
|
167
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
168
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
169
|
-
scopes: ['api'],
|
|
170
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
171
|
-
});
|
|
172
|
-
const hasToken = oauth.hasValidToken();
|
|
173
|
-
assert(hasToken === false, 'Should return false with expired token');
|
|
174
|
-
cleanupTestToken();
|
|
175
|
-
}
|
|
176
|
-
// Test 8: clearToken removes token file
|
|
177
|
-
async function testClearToken() {
|
|
178
|
-
const tokenData = {
|
|
179
|
-
access_token: 'test-token',
|
|
180
|
-
token_type: 'Bearer',
|
|
181
|
-
created_at: Date.now(),
|
|
182
|
-
};
|
|
183
|
-
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
184
|
-
const oauth = new GitLabOAuth({
|
|
185
|
-
clientId: TEST_CLIENT_ID,
|
|
186
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
187
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
188
|
-
scopes: ['api'],
|
|
189
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
190
|
-
});
|
|
191
|
-
oauth.clearToken();
|
|
192
|
-
assert(!fs.existsSync(TEST_TOKEN_PATH), 'Token file should be deleted');
|
|
193
|
-
}
|
|
194
|
-
// Test 9: Token file has correct permissions (Unix only)
|
|
195
|
-
async function testTokenFilePermissions() {
|
|
196
|
-
if (process.platform === 'win32') {
|
|
197
|
-
throw new Error('Skipping permission test on Windows');
|
|
198
|
-
}
|
|
199
|
-
const tokenData = {
|
|
200
|
-
access_token: 'test-token',
|
|
201
|
-
token_type: 'Bearer',
|
|
202
|
-
created_at: Date.now(),
|
|
203
|
-
};
|
|
204
|
-
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
205
|
-
const stats = fs.statSync(TEST_TOKEN_PATH);
|
|
206
|
-
const mode = stats.mode & 0o777;
|
|
207
|
-
assert(mode === 0o600, `Token file should have 0600 permissions, got ${mode.toString(8)}`);
|
|
208
|
-
cleanupTestToken();
|
|
209
|
-
}
|
|
210
|
-
// Test 10: Port availability check
|
|
211
|
-
async function testPortAvailability() {
|
|
212
|
-
const port = 8888;
|
|
213
|
-
const available = await isPortAvailable(port);
|
|
214
|
-
// We just check that the function works, not the actual availability
|
|
215
|
-
assert(typeof available === 'boolean', 'Port availability check should return boolean');
|
|
216
|
-
}
|
|
217
|
-
// Test: External OAuth token script
|
|
218
|
-
async function testOAuthTokenScript() {
|
|
219
|
-
const scriptPath = path.join(process.cwd(), '.test-oauth-token-script.sh');
|
|
220
|
-
const writeScript = (output) => {
|
|
221
|
-
fs.writeFileSync(scriptPath, `#!/bin/sh\nprintf '%s\\n' '${output}'\n`, { mode: 0o700 });
|
|
222
|
-
};
|
|
223
|
-
const oauth = () => new GitLabOAuth({
|
|
224
|
-
clientId: TEST_CLIENT_ID,
|
|
225
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
226
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
227
|
-
scopes: ['api'],
|
|
228
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
229
|
-
tokenScript: scriptPath,
|
|
230
|
-
});
|
|
231
|
-
try {
|
|
232
|
-
writeScript('script-token-123');
|
|
233
|
-
const plainToken = await oauth().getAccessToken();
|
|
234
|
-
assert(plainToken === 'script-token-123', 'Should return plain token from external script');
|
|
235
|
-
assert(oauth().hasValidToken(), 'Token script should count as a valid token source');
|
|
236
|
-
writeScript(JSON.stringify({ access_token: 'json-token-123' }));
|
|
237
|
-
const jsonToken = await oauth().getAccessToken();
|
|
238
|
-
assert(jsonToken === 'json-token-123', 'Should extract access_token from JSON output');
|
|
239
|
-
writeScript(JSON.stringify({ expires_in: 3600 }));
|
|
240
|
-
try {
|
|
241
|
-
await oauth().getAccessToken();
|
|
242
|
-
assert(false, 'Should reject JSON output without a token field');
|
|
243
|
-
}
|
|
244
|
-
catch (error) {
|
|
245
|
-
assert(error instanceof Error &&
|
|
246
|
-
error.message.includes('OAuth token script JSON must include a string access_token or token field'), 'Should explain missing token field in JSON output');
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
finally {
|
|
250
|
-
if (fs.existsSync(scriptPath)) {
|
|
251
|
-
fs.unlinkSync(scriptPath);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// Test 11: OAuth redirect URI parsing
|
|
256
|
-
async function testRedirectUriParsing() {
|
|
257
|
-
const redirectUri = 'http://127.0.0.1:8888/callback';
|
|
258
|
-
const url = new URL(redirectUri);
|
|
259
|
-
assert(url.port === '8888', 'Should correctly parse port from redirect URI');
|
|
260
|
-
assert(url.pathname === '/callback', 'Should correctly parse path from redirect URI');
|
|
261
|
-
assert(url.hostname === '127.0.0.1', 'Should correctly parse hostname from redirect URI');
|
|
262
|
-
}
|
|
263
|
-
// Test 12: Token expiration calculation
|
|
264
|
-
async function testTokenExpirationCalculation() {
|
|
265
|
-
const now = Date.now();
|
|
266
|
-
const expiresIn = 7200; // 2 hours in seconds
|
|
267
|
-
const buffer = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
268
|
-
const expiryTime = now + (expiresIn * 1000);
|
|
269
|
-
const shouldRefreshAt = expiryTime - buffer;
|
|
270
|
-
assert(shouldRefreshAt < expiryTime, 'Refresh time should be before expiry');
|
|
271
|
-
assert(shouldRefreshAt > now, 'Refresh time should be in the future for new token');
|
|
272
|
-
}
|
|
273
|
-
// Test 13: Concurrent OAuth server handling (shared server concept)
|
|
274
|
-
async function testSharedServerConcept() {
|
|
275
|
-
// Test that multiple instances can theoretically share a port
|
|
276
|
-
const port = 9999;
|
|
277
|
-
// First instance: start server
|
|
278
|
-
const server = http.createServer((req, res) => {
|
|
279
|
-
res.writeHead(200);
|
|
280
|
-
res.end('OK');
|
|
281
|
-
});
|
|
282
|
-
await new Promise((resolve) => {
|
|
283
|
-
server.listen(port, '127.0.0.1', () => resolve());
|
|
284
|
-
});
|
|
285
|
-
// Check port is now in use
|
|
286
|
-
const inUse = !(await isPortAvailable(port));
|
|
287
|
-
assert(inUse === true, 'Port should be in use after server starts');
|
|
288
|
-
// Clean up
|
|
289
|
-
await new Promise((resolve) => {
|
|
290
|
-
server.close(() => resolve());
|
|
291
|
-
});
|
|
292
|
-
// Check port is available again
|
|
293
|
-
const available = await isPortAvailable(port);
|
|
294
|
-
assert(available === true, 'Port should be available after server closes');
|
|
295
|
-
}
|
|
296
|
-
// Test 14: Environment variable configuration
|
|
297
|
-
async function testEnvironmentVariableConfig() {
|
|
298
|
-
const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
|
|
299
|
-
const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || 'http://127.0.0.1:8888/callback';
|
|
300
|
-
assert(typeof clientId === 'string' || clientId === undefined, 'Client ID should be string or undefined');
|
|
301
|
-
assert(typeof redirectUri === 'string', 'Redirect URI should be string');
|
|
302
|
-
const url = new URL(redirectUri);
|
|
303
|
-
assert(url.protocol === 'http:', 'Redirect URI should use http protocol for localhost');
|
|
304
|
-
}
|
|
305
|
-
// Test 15: Token data structure validation
|
|
306
|
-
async function testTokenDataStructure() {
|
|
307
|
-
const tokenData = {
|
|
308
|
-
access_token: 'glpat-test123456789',
|
|
309
|
-
refresh_token: 'refresh-test123456789',
|
|
310
|
-
token_type: 'Bearer',
|
|
311
|
-
expires_in: 7200,
|
|
312
|
-
created_at: Date.now(),
|
|
313
|
-
};
|
|
314
|
-
assert(typeof tokenData.access_token === 'string', 'access_token should be string');
|
|
315
|
-
assert(typeof tokenData.token_type === 'string', 'token_type should be string');
|
|
316
|
-
assert(typeof tokenData.created_at === 'number', 'created_at should be number');
|
|
317
|
-
assert(tokenData.expires_in === undefined || typeof tokenData.expires_in === 'number', 'expires_in should be number or undefined');
|
|
318
|
-
}
|
|
319
|
-
// Test 16: Invalid token storage path handling
|
|
320
|
-
async function testInvalidTokenStoragePath() {
|
|
321
|
-
const invalidPath = '/root/nonexistent/directory/.token.json';
|
|
322
|
-
const oauth = new GitLabOAuth({
|
|
323
|
-
clientId: TEST_CLIENT_ID,
|
|
324
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
325
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
326
|
-
scopes: ['api'],
|
|
327
|
-
tokenStoragePath: invalidPath,
|
|
328
|
-
});
|
|
329
|
-
// Should create instance even with invalid path (error occurs during save)
|
|
330
|
-
assert(oauth !== null, 'Should create instance with invalid path');
|
|
331
|
-
}
|
|
332
|
-
// Test 17: Self-hosted GitLab URL configuration
|
|
333
|
-
async function testSelfHostedGitLabUrl() {
|
|
334
|
-
const selfHostedUrl = 'https://gitlab.example.com';
|
|
335
|
-
const oauth = new GitLabOAuth({
|
|
336
|
-
clientId: TEST_CLIENT_ID,
|
|
337
|
-
redirectUri: TEST_REDIRECT_URI,
|
|
338
|
-
gitlabUrl: selfHostedUrl,
|
|
339
|
-
scopes: ['api'],
|
|
340
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
341
|
-
});
|
|
342
|
-
assert(oauth !== null, 'Should create instance with self-hosted URL');
|
|
343
|
-
}
|
|
344
|
-
// Test 18: Custom port in redirect URI
|
|
345
|
-
async function testCustomPortInRedirectUri() {
|
|
346
|
-
const customRedirectUri = 'http://127.0.0.1:9999/callback';
|
|
347
|
-
const oauth = new GitLabOAuth({
|
|
348
|
-
clientId: TEST_CLIENT_ID,
|
|
349
|
-
redirectUri: customRedirectUri,
|
|
350
|
-
gitlabUrl: TEST_GITLAB_URL,
|
|
351
|
-
scopes: ['api'],
|
|
352
|
-
tokenStoragePath: TEST_TOKEN_PATH,
|
|
353
|
-
});
|
|
354
|
-
assert(oauth !== null, 'Should create instance with custom port');
|
|
355
|
-
const url = new URL(customRedirectUri);
|
|
356
|
-
assert(url.port === '9999', 'Should correctly parse custom port');
|
|
357
|
-
}
|
|
358
|
-
// Main test runner
|
|
359
|
-
async function runOAuthTests() {
|
|
360
|
-
console.log('š GitLab OAuth Authentication Tests\n');
|
|
361
|
-
console.log('='.repeat(50));
|
|
362
|
-
// Core functionality tests
|
|
363
|
-
await runTest('OAuth class instantiation', testOAuthInstantiation);
|
|
364
|
-
await runTest('Token storage path configuration', testTokenStoragePath);
|
|
365
|
-
await runTest('Scope configuration with api only', testScopeConfiguration);
|
|
366
|
-
await runTest('Multiple scopes configuration (redundant)', testMultipleScopesRedundant);
|
|
367
|
-
// Token management tests
|
|
368
|
-
await runTest('hasValidToken returns false without token', testHasValidTokenNoToken);
|
|
369
|
-
await runTest('hasValidToken returns true with valid token', testHasValidTokenWithToken);
|
|
370
|
-
await runTest('hasValidToken returns false with expired token', testHasValidTokenExpired);
|
|
371
|
-
await runTest('clearToken removes token file', testClearToken);
|
|
372
|
-
await runTest('Token file has correct permissions', testTokenFilePermissions, process.platform === 'win32');
|
|
373
|
-
await runTest('External OAuth token script', testOAuthTokenScript, process.platform === 'win32');
|
|
374
|
-
// Network and configuration tests
|
|
375
|
-
await runTest('Port availability check', testPortAvailability);
|
|
376
|
-
await runTest('OAuth redirect URI parsing', testRedirectUriParsing);
|
|
377
|
-
await runTest('Token expiration calculation', testTokenExpirationCalculation);
|
|
378
|
-
await runTest('Shared server concept', testSharedServerConcept);
|
|
379
|
-
// Configuration tests
|
|
380
|
-
await runTest('Environment variable configuration', testEnvironmentVariableConfig);
|
|
381
|
-
await runTest('Token data structure validation', testTokenDataStructure);
|
|
382
|
-
await runTest('Invalid token storage path handling', testInvalidTokenStoragePath);
|
|
383
|
-
await runTest('Self-hosted GitLab URL configuration', testSelfHostedGitLabUrl);
|
|
384
|
-
await runTest('Custom port in redirect URI', testCustomPortInRedirectUri);
|
|
385
|
-
// Cleanup
|
|
386
|
-
cleanupTestToken();
|
|
387
|
-
// Print summary
|
|
388
|
-
console.log('\n' + '='.repeat(50));
|
|
389
|
-
console.log('š Test Results Summary\n');
|
|
390
|
-
const passed = testResults.filter(r => r.status === 'passed').length;
|
|
391
|
-
const failed = testResults.filter(r => r.status === 'failed').length;
|
|
392
|
-
const skipped = testResults.filter(r => r.status === 'skipped').length;
|
|
393
|
-
const total = testResults.length;
|
|
394
|
-
console.log(`Total tests: ${total}`);
|
|
395
|
-
console.log(`ā
Passed: ${passed}`);
|
|
396
|
-
console.log(`ā Failed: ${failed}`);
|
|
397
|
-
console.log(`āļø Skipped: ${skipped}`);
|
|
398
|
-
if (total > 0) {
|
|
399
|
-
const successRate = ((passed / (total - skipped)) * 100).toFixed(1);
|
|
400
|
-
console.log(`Success rate: ${successRate}%`);
|
|
401
|
-
}
|
|
402
|
-
// Show failed tests
|
|
403
|
-
const failedTests = testResults.filter(r => r.status === 'failed');
|
|
404
|
-
if (failedTests.length > 0) {
|
|
405
|
-
console.log('\nā Failed Tests:');
|
|
406
|
-
failedTests.forEach(test => {
|
|
407
|
-
console.log(` - ${test.name}`);
|
|
408
|
-
console.log(` ${test.error}`);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
// Save results to file
|
|
412
|
-
const reportPath = 'test-results-oauth.json';
|
|
413
|
-
fs.writeFileSync(reportPath, JSON.stringify(testResults, null, 2));
|
|
414
|
-
console.log(`\nš Detailed results saved to ${reportPath}`);
|
|
415
|
-
return failed === 0;
|
|
416
|
-
}
|
|
417
|
-
// Run tests if this is the main module
|
|
418
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
419
|
-
runOAuthTests()
|
|
420
|
-
.then(success => {
|
|
421
|
-
process.exit(success ? 0 : 1);
|
|
422
|
-
})
|
|
423
|
-
.catch(error => {
|
|
424
|
-
console.error('Error running tests:', error);
|
|
425
|
-
process.exit(1);
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
export { runOAuthTests, testResults };
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { test } from "node:test";
|
|
4
|
-
import { encodeGitLabPath, encodeGitLabPathSegment } from "../utils/url.js";
|
|
5
|
-
const readSource = (relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
|
6
|
-
const indexSource = readSource("../index.ts");
|
|
7
|
-
const rawPathSegmentPatterns = [
|
|
8
|
-
"/issues/${issueIid}",
|
|
9
|
-
"/merge_requests/${mergeRequestIid}",
|
|
10
|
-
"/discussions/${discussionId}",
|
|
11
|
-
"/notes/${noteId}",
|
|
12
|
-
"/links/${issueLinkId}",
|
|
13
|
-
"/draft_notes/${draftNoteId}",
|
|
14
|
-
"/versions/${versionId}",
|
|
15
|
-
"/groups/${groupId}",
|
|
16
|
-
"/jobs/${jobId}",
|
|
17
|
-
"/pipelines/${pipelineId}",
|
|
18
|
-
"/milestones/${milestoneId}",
|
|
19
|
-
"/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}",
|
|
20
|
-
"/uploads/${secret}/${filename}",
|
|
21
|
-
"/downloads/${directAssetPath}",
|
|
22
|
-
"/${entity}/${entityIid}",
|
|
23
|
-
];
|
|
24
|
-
test("GitLab URL path IDs are encoded before interpolation", () => {
|
|
25
|
-
for (const pattern of rawPathSegmentPatterns) {
|
|
26
|
-
assert.equal(indexSource.includes(pattern), false, `${pattern} must use encodeGitLabPathSegment`);
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
/**
|
|
30
|
-
* encodeURIComponent() does not validate what it encodes: "../../user" becomes
|
|
31
|
-
* "..%2F..%2Fuser", which a server that decodes the separator turns back into a
|
|
32
|
-
* traversal. Every value that lands in a URL path goes through
|
|
33
|
-
* encodeGitLabPathSegment()/encodeGitLabPath() instead; plain encoding stays only
|
|
34
|
-
* for query-string values, where a "/" is data and not a path separator.
|
|
35
|
-
*/
|
|
36
|
-
const queryStringEncoders = [
|
|
37
|
-
"?source_branch=${encodeURIComponent(branchName)}",
|
|
38
|
-
"?access_token=${encodeURIComponent(token)}",
|
|
39
|
-
];
|
|
40
|
-
const serverSources = [
|
|
41
|
-
["index.ts", "../index.ts"],
|
|
42
|
-
["downloads/proxy.ts", "../downloads/proxy.ts"],
|
|
43
|
-
["oauth-proxy.ts", "../oauth-proxy.ts"],
|
|
44
|
-
];
|
|
45
|
-
for (const [name, relativePath] of serverSources) {
|
|
46
|
-
test(`${name} validates values before they reach a URL path`, () => {
|
|
47
|
-
for (const line of readSource(relativePath).split("\n")) {
|
|
48
|
-
if (!line.includes("encodeURIComponent("))
|
|
49
|
-
continue;
|
|
50
|
-
assert.ok(queryStringEncoders.some(allowed => line.includes(allowed)), `${name} encodes a URL value without validating it: ${line.trim()}`);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
test("plain encoding of a project path would leave the traversal visible", () => {
|
|
55
|
-
// The payload this guard exists for: the old encoder emits the separators, so a
|
|
56
|
-
// server that decodes them routes the request to /api/v4/user.
|
|
57
|
-
const payload = "../../user";
|
|
58
|
-
const legacy = `https://gitlab.com/api/v4/projects/${encodeURIComponent(payload)}`;
|
|
59
|
-
assert.equal(new URL(legacy).pathname, "/api/v4/projects/..%2F..%2Fuser");
|
|
60
|
-
});
|
|
61
|
-
test("the validated encoder keeps an ordinary project path inside one segment", () => {
|
|
62
|
-
const url = new URL(`https://gitlab.com/api/v4/projects/${encodeGitLabPathSegment("group/project")}/issues/${encodeGitLabPathSegment("1")}/notes`);
|
|
63
|
-
assert.equal(url.pathname, "/api/v4/projects/group%2Fproject/issues/1/notes");
|
|
64
|
-
});
|
|
65
|
-
test("path traversal payloads are rejected instead of encoded into one segment", () => {
|
|
66
|
-
// GHSA-7c3w-fxgh-frc7 / #587: an unencoded job_id with ../ escaped to /api/v4/user
|
|
67
|
-
for (const payload of [
|
|
68
|
-
"../../../user",
|
|
69
|
-
"1/../../../../admin/ci/variables",
|
|
70
|
-
"..%2F..%2Fuser",
|
|
71
|
-
"..%252F..%252Fuser",
|
|
72
|
-
"/etc/passwd",
|
|
73
|
-
]) {
|
|
74
|
-
assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/, payload);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
test("a slash-separated path cannot start outside its route prefix", () => {
|
|
78
|
-
assert.throws(() => encodeGitLabPath("/etc/passwd"), /Cannot use value as a GitLab URL path/);
|
|
79
|
-
assert.throws(() => encodeGitLabPath("1/../../../../admin/ci/variables"), /Cannot use value as a GitLab URL path segment/);
|
|
80
|
-
});
|
|
81
|
-
test("a broken escape next to a valid one is rejected, not passed through", () => {
|
|
82
|
-
// A guard cannot inspect a value it fails to decode: "%2E%2E%2F%ZZ" still holds a
|
|
83
|
-
// real escape that may hide a separator, so it must not be forwarded unvalidated.
|
|
84
|
-
assert.throws(() => encodeGitLabPathSegment("%2E%2E%2F%ZZ"), /Cannot use value as a GitLab URL path segment/);
|
|
85
|
-
});
|
|
86
|
-
test("the artifact path goes through the shared multi-segment validator", () => {
|
|
87
|
-
// getJobArtifactFile() was the last call site that split the path itself. The
|
|
88
|
-
// hand-rolled version kept empty segments, so "a//b" reached the URL as an extra
|
|
89
|
-
// separator instead of being rejected like every other call site.
|
|
90
|
-
assert.ok(indexSource.includes("const encodedArtifactPath = encodeGitLabPath(artifactPath);"), "artifact paths must go through encodeGitLabPath");
|
|
91
|
-
assert.equal(indexSource.includes(".map(segment => encodeGitLabPathSegment(segment))"), false, "artifact paths must not be split into segments by hand");
|
|
92
|
-
});
|
|
93
|
-
test("a percent sign that starts no escape stays a literal character", () => {
|
|
94
|
-
// "bad%zz/path" cannot be decoded either, but it holds no escape at all, so the
|
|
95
|
-
// guard sees the entire value and the percent sign is encoded as data. Rejecting
|
|
96
|
-
// it would break every file or branch name containing "%".
|
|
97
|
-
assert.equal(encodeGitLabPathSegment("bad%zz/path"), "bad%25zz%2Fpath");
|
|
98
|
-
});
|