import { promises as fs } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { Readable } from 'node:stream';
// Mock config to keep tools in dev mode without reading env
jest.mock('@/config', () => ({
getTeamCityUrl: () => 'https://example.test',
getTeamCityToken: () => 'token',
getMCPMode: () => 'dev',
}));
jest.mock('@/utils/logger/index', () => {
const debug = jest.fn();
const info = jest.fn();
const warn = jest.fn();
const error = jest.fn();
const logToolExecution = jest.fn();
const logTeamCityRequest = jest.fn();
const logLifecycle = jest.fn();
const child = jest.fn();
const mockLoggerInstance = {
debug,
info,
warn,
error,
logToolExecution,
logTeamCityRequest,
logLifecycle,
child,
generateRequestId: () => 'test-request',
};
child.mockReturnValue(mockLoggerInstance);
return {
getLogger: () => mockLoggerInstance,
logger: mockLoggerInstance,
debug,
info,
warn,
error,
};
});
describe('tools: download_build_artifacts', () => {
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it('returns base64 content for each artifact', async () => {
const downloadArtifact = jest
.fn()
.mockResolvedValueOnce({
name: 'first.bin',
path: 'first.bin',
size: 6,
content: Buffer.from('first!').toString('base64'),
mimeType: 'application/octet-stream',
})
.mockResolvedValueOnce({
name: 'second.txt',
path: 'second.txt',
size: 6,
content: Buffer.from('second').toString('base64'),
mimeType: 'text/plain',
});
const ArtifactManager = jest.fn().mockImplementation(() => ({ downloadArtifact }));
const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue({});
const getInstance = jest.fn().mockReturnValue({});
jest.doMock('@/teamcity/artifact-manager', () => ({
ArtifactManager,
}));
jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI }));
jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } }));
let handler:
| ((args: unknown) => Promise<{ content?: Array<{ text?: string }>; success?: boolean }>)
| undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getRequiredTool } = require('@/tools');
handler = getRequiredTool('download_build_artifacts').handler;
});
if (!handler) {
throw new Error('download_build_artifacts handler not found');
}
const response = await handler({
buildId: '123',
artifactPaths: ['first.bin', 'second.txt'],
encoding: 'base64',
});
expect(downloadArtifact).toHaveBeenNthCalledWith(1, '123', 'first.bin', {
encoding: 'base64',
maxSize: undefined,
});
expect(downloadArtifact).toHaveBeenNthCalledWith(2, '123', 'second.txt', {
encoding: 'base64',
maxSize: undefined,
});
const payload = JSON.parse(response.content?.[0]?.text ?? '{}');
expect(Array.isArray(payload.artifacts)).toBe(true);
expect(payload.artifacts).toHaveLength(2);
expect(payload.artifacts[0]).toMatchObject({
name: 'first.bin',
encoding: 'base64',
success: true,
});
expect(payload.artifacts[0]?.content).toBe(Buffer.from('first!').toString('base64'));
expect(payload.artifacts[1]).toMatchObject({
name: 'second.txt',
encoding: 'base64',
success: true,
});
});
it('streams artifacts to disk when requested', async () => {
const firstChunks = ['hello'];
const secondChunks = ['world'];
const downloadArtifact = jest
.fn()
.mockResolvedValueOnce({
name: 'logs/app.log',
path: 'logs/app.log',
size: 5,
content: Readable.from(firstChunks),
mimeType: 'text/plain',
})
.mockResolvedValueOnce({
name: 'metrics.json',
path: 'metrics.json',
size: 5,
content: Readable.from(secondChunks),
mimeType: 'application/json',
});
const ArtifactManager = jest.fn().mockImplementation(() => ({ downloadArtifact }));
const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue({});
const getInstance = jest.fn().mockReturnValue({});
jest.doMock('@/teamcity/artifact-manager', () => ({
ArtifactManager,
}));
jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI }));
jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } }));
const tempRoot = await mkdtemp(join(tmpdir(), 'artifact-batch-'));
const preexistingPath = join(tempRoot, 'logs', 'app.log');
await fs.mkdir(dirname(preexistingPath), { recursive: true });
await fs.writeFile(preexistingPath, 'existing');
let handler:
| ((args: unknown) => Promise<{ content?: Array<{ text?: string }>; success?: boolean }>)
| undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getRequiredTool } = require('@/tools');
handler = getRequiredTool('download_build_artifacts').handler;
});
if (!handler) {
throw new Error('download_build_artifacts handler not found');
}
const response = await handler({
buildId: '456',
artifactPaths: ['logs/app.log', 'metrics.json'],
encoding: 'stream',
outputDir: tempRoot,
});
expect(downloadArtifact).toHaveBeenNthCalledWith(1, '456', 'logs/app.log', {
encoding: 'stream',
maxSize: undefined,
});
expect(downloadArtifact).toHaveBeenNthCalledWith(2, '456', 'metrics.json', {
encoding: 'stream',
maxSize: undefined,
});
const payload = JSON.parse(response.content?.[0]?.text ?? '{}');
expect(Array.isArray(payload.artifacts)).toBe(true);
expect(payload.artifacts).toHaveLength(2);
const [first, second] = payload.artifacts as Array<{
name?: string;
outputPath?: string;
encoding?: string;
success?: boolean;
}>;
expect(first).toBeDefined();
expect(second).toBeDefined();
if (!first || !second || !first.outputPath || !second.outputPath) {
await fs.rm(tempRoot, { recursive: true, force: true });
throw new Error('Expected stream artifacts with output paths');
}
expect(first.success).toBe(true);
expect(second.success).toBe(true);
expect(first.encoding).toBe('stream');
expect(second.encoding).toBe('stream');
expect(first.outputPath.startsWith(tempRoot)).toBe(true);
expect(second.outputPath.startsWith(tempRoot)).toBe(true);
const firstRelative = relative(tempRoot, first.outputPath);
expect(firstRelative).not.toBe('logs/app.log');
expect(firstRelative).toMatch(/logs[\\/].*app-1\.log$/);
const firstContent = await fs.readFile(first.outputPath, 'utf8');
const secondContent = await fs.readFile(second.outputPath, 'utf8');
expect(firstContent).toBe(firstChunks.join(''));
expect(secondContent).toBe(secondChunks.join(''));
await fs.rm(tempRoot, { recursive: true, force: true });
});
it('sanitizes unsafe artifact paths when streaming to an output directory', async () => {
const firstChunks = ['alpha'];
const secondChunks = ['omega'];
const downloadArtifact = jest
.fn()
.mockResolvedValueOnce({
name: '../secrets.env',
path: '../secrets.env',
size: 5,
content: Readable.from(firstChunks),
mimeType: 'text/plain',
})
.mockResolvedValueOnce({
name: '/absolute.log',
path: '/absolute.log',
size: 5,
content: Readable.from(secondChunks),
mimeType: 'text/plain',
});
const ArtifactManager = jest.fn().mockImplementation(() => ({ downloadArtifact }));
const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue({});
const getInstance = jest.fn().mockReturnValue({});
jest.doMock('@/teamcity/artifact-manager', () => ({
ArtifactManager,
}));
jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI }));
jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } }));
const tempRoot = await mkdtemp(join(tmpdir(), 'artifact-sanitize-'));
let handler:
| ((args: unknown) => Promise<{ content?: Array<{ text?: string }>; success?: boolean }>)
| undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getRequiredTool } = require('@/tools');
handler = getRequiredTool('download_build_artifacts').handler;
});
if (!handler) {
await fs.rm(tempRoot, { recursive: true, force: true });
throw new Error('download_build_artifacts handler not found');
}
const response = await handler({
buildId: '789',
artifactPaths: ['../secrets.env', '/absolute.log'],
encoding: 'stream',
outputDir: tempRoot,
});
const payload = JSON.parse(response.content?.[0]?.text ?? '{}');
const artifacts = payload.artifacts as Array<{
path?: string;
outputPath?: string;
encoding?: string;
success?: boolean;
}>;
const [first, second] = artifacts ?? [];
if (!first || !second || !first.outputPath || !second.outputPath) {
await fs.rm(tempRoot, { recursive: true, force: true });
throw new Error('Expected sanitized streamed artifacts with output paths');
}
expect(first.success).toBe(true);
expect(second.success).toBe(true);
expect(first.encoding).toBe('stream');
expect(second.encoding).toBe('stream');
const firstRelative = relative(tempRoot, first.outputPath);
const secondRelative = relative(tempRoot, second.outputPath);
expect(firstRelative.startsWith('..')).toBe(false);
expect(secondRelative.startsWith('..')).toBe(false);
expect(firstRelative.includes('..')).toBe(false);
expect(secondRelative.includes('..')).toBe(false);
const firstContent = await fs.readFile(first.outputPath, 'utf8');
const secondContent = await fs.readFile(second.outputPath, 'utf8');
expect(firstContent).toBe(firstChunks.join(''));
expect(secondContent).toBe(secondChunks.join(''));
await fs.rm(tempRoot, { recursive: true, force: true });
});
});