-
Notifications
You must be signed in to change notification settings - Fork 341
Expand file tree
/
Copy pathtest-download-attachment.ts
More file actions
218 lines (187 loc) · 7.44 KB
/
Copy pathtest-download-attachment.ts
File metadata and controls
218 lines (187 loc) · 7.44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
const MOCK_TOKEN = 'glpat-mock-token-12345';
const TEST_PROJECT_ID = '123';
const TEST_SECRET = 'testsecret123';
// Minimum valid 1x1 transparent PNG
const MINIMAL_PNG_BUF = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
// Unique suffix per test run to avoid conflicts on concurrent executions
const RUN_ID = Math.random().toString(36).slice(2, 8);
interface ContentBlock {
type: string;
text?: string;
data?: string;
mimeType?: string;
isError?: boolean;
}
interface JsonRpcResponse {
result?: { content?: ContentBlock[] };
error?: { message: string; code?: number };
}
/**
* Spawn build/index.js, send a single download_attachment JSON-RPC call, and
* return the raw parsed JSON-RPC response (either {result:...} or {error:...}).
*/
function callDownloadAttachment(
args: Record<string, unknown>,
env: NodeJS.ProcessEnv,
timeoutMs = 15_000,
): Promise<JsonRpcResponse> {
return new Promise((resolve, reject) => {
const proc = spawn('node', ['build/index.js'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GITLAB_TEST_MODE: 'true', ...env, GITLAB_READ_ONLY_MODE: 'true' },
});
const timer = setTimeout(() => {
proc.kill();
reject(new Error(`Process timed out after ${timeoutMs}ms`));
}, timeoutMs);
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (d: Buffer) => (stdout += d.toString()));
proc.stderr?.on('data', (d: Buffer) => (stderr += d.toString()));
proc.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`Failed to spawn process: ${err.message}`));
});
proc.on('close', () => {
clearTimeout(timer);
// Find the JSON-RPC response line matching our request id
const lines = stdout.split('\n').filter(l => l.trim().startsWith('{'));
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.id === 1) { resolve(parsed); return; }
} catch { /* try next line */ }
}
reject(new Error(`No matching JSON-RPC response found.\nstderr: ${stderr}`));
});
proc.stdin?.end(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'download_attachment', arguments: args },
}) + '\n'
);
});
}
describe('download_attachment', () => {
let mockGitLab: MockGitLabServer;
let env: NodeJS.ProcessEnv;
before(async () => {
const port = await findMockServerPort(9100);
mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
await mockGitLab.start();
env = {
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
};
// PNG upload endpoint
mockGitLab.addMockHandler(
'get',
`/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`,
(_req, res) => { res.set('Content-Type', 'image/png').send(MINIMAL_PNG_BUF); }
);
// Plain-text upload endpoint
mockGitLab.addMockHandler(
'get',
`/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`,
(_req, res) => { res.set('Content-Type', 'text/plain').send('hello world'); }
);
});
after(async () => {
await mockGitLab.stop();
});
test('image file without local_path returns base64 image content block', async () => {
const raw = await callDownloadAttachment(
{ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png' },
env
);
const content = raw.result?.content;
assert.ok(Array.isArray(content), 'result.content should be an array');
const imageBlock = content.find(c => c.type === 'image');
assert.ok(imageBlock, 'Should contain an image content block');
assert.strictEqual(imageBlock.mimeType, 'image/png', 'mimeType should be image/png');
assert.ok(
typeof imageBlock.data === 'string' && imageBlock.data.length > 0,
'Image block should have non-empty base64 data'
);
});
test('non-image file is saved to disk and returns file_path', async () => {
const raw = await callDownloadAttachment(
{ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'document.txt' },
env
);
const text = raw.result?.content?.[0]?.text;
assert.ok(text, 'Should have text content');
const parsed = JSON.parse(text);
try {
assert.strictEqual(parsed.success, true, 'success should be true');
assert.ok(typeof parsed.file_path === 'string', 'file_path should be a string');
assert.ok(parsed.file_path.endsWith('document.txt'), 'file_path should end with document.txt');
} finally {
if (parsed.file_path && fs.existsSync(parsed.file_path)) {
fs.unlinkSync(parsed.file_path);
}
}
});
test('image file with local_path is saved to disk and returns file_path', async () => {
// Must be a relative path – the implementation rejects absolute paths as traversal
const localPath = `omc-test-save-${RUN_ID}`;
try {
const raw = await callDownloadAttachment(
{ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png', local_path: localPath },
env
);
const text = raw.result?.content?.[0]?.text;
assert.ok(text, 'Should have text content');
const parsed = JSON.parse(text);
assert.strictEqual(parsed.success, true, 'success should be true');
assert.ok(typeof parsed.file_path === 'string', 'file_path should be a string');
assert.ok(parsed.file_path.includes('image.png'), 'file_path should include image.png');
} finally {
fs.rmSync(localPath, { recursive: true, force: true });
}
});
test('local_path with ".." returns path traversal error', async () => {
const raw = await callDownloadAttachment(
{ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png', local_path: '../../../tmp' },
env
);
// MCP SDK may return a JSON-RPC error or an isError content block; both must mention "traversal"
const isRpcError =
typeof raw.error?.message === 'string' &&
raw.error.message.toLowerCase().includes('traversal');
const isContentError =
Array.isArray(raw.result?.content) &&
raw.result.content.some(
c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal')
);
assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
});
test('non-existent local_path directory is auto-created before saving', async () => {
const baseDir = `omc-test-newdir-${RUN_ID}`;
const localPath = `${baseDir}/subdir`;
fs.rmSync(baseDir, { recursive: true, force: true });
try {
const raw = await callDownloadAttachment(
{ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'document.txt', local_path: localPath },
env
);
const text = raw.result?.content?.[0]?.text;
assert.ok(text, 'Should have text content');
const parsed = JSON.parse(text);
assert.strictEqual(parsed.success, true, 'success should be true');
assert.ok(fs.existsSync(parsed.file_path), 'Saved file should exist on disk');
} finally {
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
});