n8n/packages/nodes-base/nodes/KoBoToolbox/test/GenericFunctions.test.ts
2026-01-22 14:15:06 +00:00

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);
});
});
});
});