Files
2026-07-13 12:38:34 +08:00

282 lines
10 KiB
TypeScript

/**
* Behavior matrix for the file upload / download gating introduced with
* `fileUploadDirs` and `fileDownloadDir`. Exercises the interaction between:
*
* - `dangerouslyAllowAutoUploadDownloadFiles` (off | on)
* - Caller (automatic via `tools.execute` | manual via `composio.files.*`)
* - Direction (upload | download)
* - `fileUploadDirs` shape (`undefined`, `false`, `[]`, `string[]`)
*
* Runtime filesystem operations and HTTP are mocked; we only assert what the
* SDK tries to do and what errors it raises.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import ComposioClient from '@composio/client';
import { Tools } from '../../src/models/Tools';
import { Files } from '../../src/models/Files.node';
import * as fileUtilsModule from '../../src/utils/fileUtils.node';
import { mockClient } from '../utils/mocks/client.mock';
import { MockProvider } from '../utils/mocks/provider.mock';
import {
ComposioFileUploadPathNotAllowedError,
ComposioFileNotFoundError,
} from '../../src/errors/FileModifierErrors';
import { getDefaultUploadDir } from '../../src/utils/fileDirs';
// Mock network-bound helpers so we can assert inputs without hitting the API.
vi.mock('../../src/utils/fileUtils.node', async importOriginal => {
const actual = await importOriginal<typeof import('../../src/utils/fileUtils.node')>();
return {
...actual,
downloadFileFromS3: vi.fn(),
// Keep the real `getFileDataAfterUploadingToS3` so path checks run, but
// stub the S3 upload happening inside it.
uploadFileToS3: vi.fn(),
};
});
const mkTemp = () => mkdtempSync(path.join(os.tmpdir(), 'composio-matrix-'));
const makeTools = (opts: {
dangerouslyAllowAutoUploadDownloadFiles?: boolean;
fileUploadDirs?: string[] | false;
fileDownloadDir?: string;
}) => {
return new Tools(mockClient as unknown as ComposioClient, {
provider: new MockProvider(),
...opts,
});
};
describe('File handling matrix — Tools constructor resolves allowlist correctly', () => {
afterEach(() => vi.clearAllMocks());
describe('dangerouslyAllowAutoUploadDownloadFiles = false (default)', () => {
it('leaves fileUploadAllowlist undefined (auto-upload code path is never run)', () => {
const t = makeTools({});
// `fileUploadAllowlist` lives on the private options the class forwards
// to FileToolModifier. When the flag is off we leave it undefined so the
// modifier isn't even constructed.
expect(
(t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions.fileUploadAllowlist
).toBeUndefined();
});
it('ignores fileUploadDirs entirely (including `false`)', () => {
const t = makeTools({ fileUploadDirs: false });
expect(
(t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions.fileUploadAllowlist
).toBeUndefined();
});
});
describe('dangerouslyAllowAutoUploadDownloadFiles = true', () => {
it('defaults fileUploadAllowlist to [~/.composio/temp] when fileUploadDirs is unset', () => {
const t = makeTools({ dangerouslyAllowAutoUploadDownloadFiles: true });
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([getDefaultUploadDir()]);
});
it('resolves to [] when fileUploadDirs is `false` (explicit "no local paths")', () => {
const t = makeTools({
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: false,
});
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([]);
});
it('resolves to [] when fileUploadDirs is `[]` (alias for `false`)', () => {
const t = makeTools({
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: [],
});
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([]);
});
it('uses user-provided dirs verbatim when fileUploadDirs is a non-empty array', () => {
const dir = mkTemp();
try {
const t = makeTools({
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: [dir],
});
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([path.resolve(dir)]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
describe('File handling matrix — manual Files API bypasses the allowlist', () => {
const mkClient = () =>
({
baseURL: 'https://api.composio.dev',
apiKey: 'test',
}) as unknown as ComposioClient;
beforeEach(() => vi.clearAllMocks());
it('composio.files.upload() accepts a local path that is NOT in any allowlist', async () => {
const tmp = mkTemp();
try {
const file = path.join(tmp, 'a.txt');
writeFileSync(file, 'x');
// Stub the actual S3 call so the manual upload can complete.
const stubbed = vi.spyOn(fileUtilsModule, 'getFileDataAfterUploadingToS3');
stubbed.mockResolvedValueOnce({ name: 'a.txt', mimetype: 'text/plain', s3key: 'k' });
const files = new Files(mkClient());
const result = await files.upload({
file,
toolSlug: 'T',
toolkitSlug: 'tk',
});
expect(result.s3key).toBe('k');
// The call must NOT include a `fileUploadAllowlist` — that's what keeps
// manual upload unconstrained.
const callArg = stubbed.mock.calls[0]?.[1] as { fileUploadAllowlist?: unknown };
expect(callArg.fileUploadAllowlist).toBeUndefined();
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('composio.files.download() works regardless of `dangerouslyAllowAutoUploadDownloadFiles`', async () => {
const downloadMock = vi.spyOn(fileUtilsModule, 'downloadFileFromS3');
downloadMock.mockResolvedValueOnce({
name: 'x.txt',
mimeType: 'text/plain',
s3Url: 'https://s3.example/x',
filePath: '/tmp/fake/x.txt',
});
const files = new Files(mkClient(), { fileDownloadDir: '/var/app/dl' });
const res = await files.download({
toolSlug: 'T',
s3Url: 'https://s3.example/x',
mimeType: 'text/plain',
});
expect(res.filePath).toBe('/tmp/fake/x.txt');
// The manual download path forwards the configured `fileDownloadDir`.
expect(downloadMock.mock.calls[0]?.[0]).toMatchObject({
fileDownloadDir: '/var/app/dl',
});
});
});
describe('File handling matrix — auto upload honors the resolved allowlist', () => {
let getFileDataSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
// Keep the real implementation so allowlist enforcement runs, but stub the
// S3 upload that happens inside it.
getFileDataSpy = vi.spyOn(fileUtilsModule, 'uploadFileToS3' as never);
(getFileDataSpy as unknown as { mockResolvedValue: (v: string) => void }).mockResolvedValue(
's3-key'
);
});
const callUpload = async (file: string, fileUploadAllowlist: string[] | undefined) => {
return fileUtilsModule.getFileDataAfterUploadingToS3(file, {
toolSlug: 'T',
toolkitSlug: 'tk',
client: mockClient as unknown as ComposioClient,
fileUploadAllowlist,
});
};
it('rejects local paths outside the allowlist with ComposioFileUploadPathNotAllowedError', async () => {
const allowed = mkTemp();
const outside = mkTemp();
try {
const file = path.join(outside, 'leak.txt');
writeFileSync(file, 'x');
await expect(callUpload(file, [allowed])).rejects.toBeInstanceOf(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(allowed, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
it('rejects ALL local paths when allowlist is [] (the `false` / fail-closed case)', async () => {
const tmp = mkTemp();
try {
const file = path.join(tmp, 'a.txt');
writeFileSync(file, 'x');
await expect(callUpload(file, [])).rejects.toBeInstanceOf(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('allows URLs even when allowlist is [] (URLs are never path-checked)', async () => {
// Stub global fetch so `readFile` (URL branch) doesn't hit the network.
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new Error('stubbed-network'));
// Passes the allowlist gate (because the URL isn't a local path), then
// fails at the network layer. Any non-allowlist error proves the gate
// didn't reject the URL.
const http = 'https://example.com/file.pdf';
await expect(callUpload(http, [])).rejects.toThrowError('stubbed-network');
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('raises ComposioFileNotFoundError when the file is missing under auto-upload', async () => {
const allowed = mkTemp();
try {
const missing = path.join(allowed, 'nope.txt');
await expect(callUpload(missing, [allowed])).rejects.toBeInstanceOf(
ComposioFileNotFoundError
);
} finally {
rmSync(allowed, { recursive: true, force: true });
}
});
it('does NOT throw an allowlist error when the file is inside an allowed dir', async () => {
// We can't easily stub the S3 upload from outside the module, so we just
// assert the error (if any) is not the allowlist rejection. The happy
// path for `assertPathInsideUploadDirs` itself is covered directly in
// `test/utils/uploadDirAllowlist.test.ts`.
const allowed = mkTemp();
try {
const file = path.join(allowed, 'ok.txt');
writeFileSync(file, 'x');
try {
await callUpload(file, [allowed]);
} catch (err) {
expect(err).not.toBeInstanceOf(ComposioFileUploadPathNotAllowedError);
expect(err).not.toBeInstanceOf(ComposioFileNotFoundError);
}
} finally {
rmSync(allowed, { recursive: true, force: true });
}
});
});