mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
|
import { NodeOperationError } from 'n8n-workflow';
|
|
import { validateAttachmentUrl, downloadAttachments } from '../GenericFunctions';
|
|
|
|
describe('KoBoToolbox GenericFunctions', () => {
|
|
describe('validateAttachmentUrl', () => {
|
|
describe('valid URLs', () => {
|
|
it('should accept URL with exact domain match', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/file.jpg', 'https://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept URL with subdomain of allowed domain', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://sub.example.com/file.jpg', 'https://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept URL with nested subdomain', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://deep.sub.example.com/file.jpg', 'https://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept allowed domain without protocol', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/file.jpg', 'example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept HTTP URLs', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('http://example.com/file.jpg', 'http://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should be case-insensitive for domain matching', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://EXAMPLE.COM/file.jpg', 'https://example.com');
|
|
}).not.toThrow();
|
|
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/file.jpg', 'https://EXAMPLE.COM');
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('KoBoToolbox special domains', () => {
|
|
it('should accept kobotoolbox.org domain when allowed domain is kobotoolbox.org', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://kf.kobotoolbox.org/file.jpg', 'https://kobotoolbox.org');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept humanitarianresponse.info domain when allowed domain is humanitarianresponse.info', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl(
|
|
'https://kf.humanitarianresponse.info/file.jpg',
|
|
'https://humanitarianresponse.info',
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept cross-domain between kobotoolbox.org subdomains', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl(
|
|
'https://kc.kobotoolbox.org/file.jpg',
|
|
'https://kf.kobotoolbox.org',
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept cross-domain between humanitarianresponse.info subdomains', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl(
|
|
'https://kc.humanitarianresponse.info/file.jpg',
|
|
'https://kf.humanitarianresponse.info',
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept attachment from kobotoolbox.org when allowed is humanitarianresponse.info', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl(
|
|
'https://kc.kobotoolbox.org/file.jpg',
|
|
'https://kf.humanitarianresponse.info',
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should accept attachment from humanitarianresponse.info when allowed is kobotoolbox.org', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl(
|
|
'https://kc.humanitarianresponse.info/file.jpg',
|
|
'https://kf.kobotoolbox.org',
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('invalid URLs', () => {
|
|
it('should throw error for invalid attachment URL format', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('not-a-url', 'https://example.com');
|
|
}).toThrow('Invalid attachment URL format');
|
|
});
|
|
|
|
it('should throw error for non-HTTP(S) protocol', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('ftp://example.com/file.jpg', 'https://example.com');
|
|
}).toThrow('Attachment URL must use HTTP or HTTPS');
|
|
});
|
|
|
|
it('should throw error for file:// protocol', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('file:///etc/passwd', 'https://example.com');
|
|
}).toThrow('Attachment URL must use HTTP or HTTPS');
|
|
});
|
|
|
|
it('should throw error for javascript: protocol', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('javascript:alert(1)', 'https://example.com');
|
|
}).toThrow('Attachment URL must use HTTP or HTTPS');
|
|
});
|
|
|
|
it('should throw error for invalid allowed domain configuration', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/file.jpg', 'not a valid domain!!!');
|
|
}).toThrow('Invalid allowed domain configuration');
|
|
});
|
|
|
|
it('should throw error for mismatched domains', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://malicious.com/file.jpg', 'https://example.com');
|
|
}).toThrow(
|
|
'Attachment URL domain (malicious.com) does not match allowed domain (example.com)',
|
|
);
|
|
});
|
|
|
|
it('should throw error for domain that looks like subdomain but is not', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://fakeexample.com/file.jpg', 'https://example.com');
|
|
}).toThrow(
|
|
'Attachment URL domain (fakeexample.com) does not match allowed domain (example.com)',
|
|
);
|
|
});
|
|
|
|
it('should throw error when attachment is not from KoBoToolbox domains but allowed domain is', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://malicious.com/file.jpg', 'https://kf.kobotoolbox.org');
|
|
}).toThrow(
|
|
'Attachment URL domain (malicious.com) does not match allowed domain (kf.kobotoolbox.org)',
|
|
);
|
|
});
|
|
|
|
it('should throw error for loopback IP address when allowed domain is public', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('http://127.0.0.1/file.jpg', 'https://example.com');
|
|
}).toThrow('Attachment URL domain (127.0.0.1) does not match allowed domain (example.com)');
|
|
});
|
|
|
|
it('should throw error for cloud metadata endpoint when allowed domain is public', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('http://169.254.169.254/file.jpg', 'https://example.com');
|
|
}).toThrow(
|
|
'Attachment URL domain (169.254.169.254) does not match allowed domain (example.com)',
|
|
);
|
|
});
|
|
|
|
it('should throw error for localhost when allowed domain is public', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('http://localhost/file.jpg', 'https://example.com');
|
|
}).toThrow('Attachment URL domain (localhost) does not match allowed domain (example.com)');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle URLs with ports', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com:8080/file.jpg', 'https://example.com:8080');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle URLs with query parameters', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/file.jpg?token=abc123', 'https://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle URLs with fragments', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/file.jpg#section', 'https://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle URLs with paths', () => {
|
|
expect(() => {
|
|
validateAttachmentUrl('https://example.com/path/to/file.jpg', 'https://example.com');
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('downloadAttachments', () => {
|
|
let mockExecuteFunctions: IExecuteFunctions;
|
|
let mockGetCredentials: jest.Mock;
|
|
let mockHttpRequest: jest.Mock;
|
|
let mockPrepareBinaryData: jest.Mock;
|
|
let mockGetNode: jest.Mock;
|
|
|
|
beforeEach(() => {
|
|
mockGetCredentials = jest.fn();
|
|
mockHttpRequest = jest.fn();
|
|
mockPrepareBinaryData = jest.fn();
|
|
mockGetNode = jest.fn();
|
|
|
|
mockExecuteFunctions = {
|
|
getCredentials: mockGetCredentials,
|
|
helpers: {
|
|
httpRequest: mockHttpRequest,
|
|
prepareBinaryData: mockPrepareBinaryData,
|
|
},
|
|
getNode: mockGetNode,
|
|
} as unknown as IExecuteFunctions;
|
|
|
|
mockGetCredentials.mockResolvedValue({
|
|
URL: 'https://kf.kobotoolbox.org',
|
|
token: 'test-token-123',
|
|
});
|
|
|
|
mockGetNode.mockReturnValue({ name: 'KoBoToolbox' });
|
|
});
|
|
|
|
describe('successful downloads', () => {
|
|
it('should download single attachment without redirect', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
name: 'Test Submission',
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/api/v2/assets/123/data/456/attachments/789',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
dataPropertyAttachmentsPrefixName: 'attachment_',
|
|
};
|
|
|
|
const mockBuffer = Buffer.from('fake-image-data');
|
|
mockHttpRequest.mockResolvedValue({
|
|
body: mockBuffer,
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({
|
|
data: 'base64-encoded-data',
|
|
mimeType: 'image/jpeg',
|
|
fileName: 'photo.jpg',
|
|
});
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.json).toEqual(submission);
|
|
expect(result.binary).toBeDefined();
|
|
expect(result.binary!.attachment_0).toBeDefined();
|
|
expect(mockHttpRequest).toHaveBeenCalledWith({
|
|
url: 'https://kf.kobotoolbox.org/api/v2/assets/123/data/456/attachments/789',
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Token test-token-123',
|
|
},
|
|
ignoreHttpStatusErrors: true,
|
|
returnFullResponse: true,
|
|
disableFollowRedirect: true,
|
|
encoding: 'arraybuffer',
|
|
});
|
|
expect(mockPrepareBinaryData).toHaveBeenCalledWith(mockBuffer, 'photo.jpg');
|
|
});
|
|
|
|
it('should download multiple attachments', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [
|
|
{
|
|
filename: 'photo1.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment1',
|
|
},
|
|
{
|
|
filename: 'photo2.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment2',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
dataPropertyAttachmentsPrefixName: 'file_',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
body: Buffer.from('data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({
|
|
data: 'base64-data',
|
|
});
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.binary!.file_0).toBeDefined();
|
|
expect(result.binary!.file_1).toBeDefined();
|
|
expect(mockHttpRequest).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should follow redirects up to 5 times', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
dataPropertyAttachmentsPrefixName: 'attachment_',
|
|
};
|
|
|
|
mockHttpRequest
|
|
.mockResolvedValueOnce({
|
|
headers: { location: 'https://kc.kobotoolbox.org/redirect1' },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
headers: { location: 'https://kc.kobotoolbox.org/redirect2' },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
body: Buffer.from('final-data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.binary!.attachment_0).toBeDefined();
|
|
expect(mockHttpRequest).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should use question naming scheme when configured', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
photo_question: 'photo.jpg',
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'question',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
body: Buffer.from('data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.binary!.photo_question).toBeDefined();
|
|
});
|
|
|
|
it('should handle attachment with suffix in filename', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
picture: 'My Picture.jpg',
|
|
_attachments: [
|
|
{
|
|
filename: 'My_Picture_0OdlaKJ.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'question',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
body: Buffer.from('data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.binary!.picture).toBeDefined();
|
|
});
|
|
|
|
it('should handle submission with "attachments" field instead of "_attachments"', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
dataPropertyAttachmentsPrefixName: 'attachment_',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
body: Buffer.from('data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.binary!.attachment_0).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('no attachments', () => {
|
|
it('should return submission without binary when no attachments', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
name: 'Test Submission',
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
};
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.json).toEqual(submission);
|
|
expect(result.binary).toBeUndefined();
|
|
expect(mockHttpRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return submission without binary when attachments array is empty', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
};
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.json).toEqual(submission);
|
|
expect(result.binary).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should throw NodeOperationError for invalid attachment URL', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://malicious.com/file.jpg',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
};
|
|
|
|
await expect(
|
|
downloadAttachments.call(mockExecuteFunctions, submission, options),
|
|
).rejects.toThrow(NodeOperationError);
|
|
});
|
|
|
|
it('should throw NodeOperationError for invalid redirect URL', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
headers: { location: 'https://malicious.com/redirect' },
|
|
});
|
|
|
|
await expect(
|
|
downloadAttachments.call(mockExecuteFunctions, submission, options),
|
|
).rejects.toThrow(NodeOperationError);
|
|
});
|
|
|
|
it('should handle missing response body gracefully', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
dataPropertyAttachmentsPrefixName: 'attachment_',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
headers: {},
|
|
});
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.json).toEqual(submission);
|
|
expect(result.binary).toEqual({});
|
|
expect(mockPrepareBinaryData).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should fallback to index naming when question not found', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
other_field: 'value',
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'question',
|
|
dataPropertyAttachmentsPrefixName: 'file_',
|
|
};
|
|
|
|
mockHttpRequest.mockResolvedValue({
|
|
body: Buffer.from('data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
expect(result.binary!.file_0).toBeDefined();
|
|
});
|
|
|
|
it('should handle maximum redirect limit', async () => {
|
|
const submission: IDataObject = {
|
|
_id: 123,
|
|
_attachments: [
|
|
{
|
|
filename: 'photo.jpg',
|
|
download_url: 'https://kf.kobotoolbox.org/attachment',
|
|
},
|
|
],
|
|
};
|
|
|
|
const options: IDataObject = {
|
|
version: 'download_url',
|
|
binaryNamingScheme: 'index',
|
|
dataPropertyAttachmentsPrefixName: 'attachment_',
|
|
};
|
|
|
|
// Mock 4 redirects then final response (loop condition: !final && redir < 5)
|
|
// redir starts at 0, increments after each redirect
|
|
// Loop runs when redir is 0,1,2,3,4 (5 iterations max)
|
|
mockHttpRequest
|
|
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r1' } })
|
|
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r2' } })
|
|
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r3' } })
|
|
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r4' } })
|
|
.mockResolvedValueOnce({
|
|
body: Buffer.from('data'),
|
|
headers: {},
|
|
});
|
|
|
|
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
|
|
|
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
|
|
|
// Should make 5 total requests: 4 redirects + 1 final
|
|
expect(result.binary!.attachment_0).toBeDefined();
|
|
expect(mockHttpRequest).toHaveBeenCalledTimes(5);
|
|
});
|
|
});
|
|
});
|
|
});
|