mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-31 03:47:57 +08:00
feat(Microsoft SharePoint Node): Add v2 File Upload with folder dropdown (no-changelog) (#34431)
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import type { IBinaryData, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { execute } from '../../../v2/actions/file/upload.operation';
|
||||
import { versionDescription } from '../../../v2/actions/versionDescription';
|
||||
import {
|
||||
MAX_SIMPLE_UPLOAD_BYTES,
|
||||
SHAREPOINT_ILLEGAL_FILE_NAME_CHARS,
|
||||
} from '../../../v2/helpers/utils';
|
||||
import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node';
|
||||
import * as transport from '../../../v2/transport';
|
||||
import type * as _importType0 from '../../../v2/transport';
|
||||
|
||||
vi.mock('../../../v2/transport', async () => {
|
||||
const originalModule = await vi.importActual<typeof _importType0>('../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
microsoftApiRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const SITE_ID = 'contoso.sharepoint.com,g1,g2';
|
||||
const ENCODED_SITE_ID = encodeURIComponent(SITE_ID);
|
||||
const FOLDER_ID = '01SPEVVYBKV2ZKHGJASRA2HC7MOGBMUMAA';
|
||||
const FILE_BYTES = Buffer.from('col1,col2\n1,2\n');
|
||||
|
||||
const DRIVE_ITEM_REPLY = {
|
||||
id: '01SPEVVYELNAJ4S3XKNBBIEUJZOWXGE64U',
|
||||
name: 'report.csv',
|
||||
size: FILE_BYTES.byteLength,
|
||||
webUrl: 'https://contoso.sharepoint.com/sites/mysite/Shared%20Documents/report.csv',
|
||||
};
|
||||
|
||||
describe('Microsoft SharePoint v2 — File: Upload', () => {
|
||||
let ctx: DeepMockProxy<IExecuteFunctions>;
|
||||
const apiRequest = transport.microsoftApiRequest as Mock;
|
||||
|
||||
const setParams = (params: Record<string, unknown>) => {
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, _itemIndex?: number, fallback?: unknown) =>
|
||||
(name in params ? params[name] : fallback) as never,
|
||||
);
|
||||
};
|
||||
|
||||
const inMemoryBinary = (overrides: Partial<IBinaryData> = {}): IBinaryData =>
|
||||
({
|
||||
data: FILE_BYTES.toString('base64'),
|
||||
mimeType: 'text/csv',
|
||||
fileName: 'original.csv',
|
||||
...overrides,
|
||||
}) as IBinaryData;
|
||||
|
||||
const baseParams = {
|
||||
site: { mode: 'id', value: SITE_ID },
|
||||
folder: FOLDER_ID,
|
||||
fileName: 'report.csv',
|
||||
binaryPropertyName: 'data',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 2 }));
|
||||
setParams(baseParams);
|
||||
ctx.helpers.assertBinaryData.mockReturnValue(inMemoryBinary());
|
||||
apiRequest.mockResolvedValue(DRIVE_ITEM_REPLY);
|
||||
});
|
||||
|
||||
it('uploads the binary bytes to the chosen folder and returns the drive item', async () => {
|
||||
const response = await execute.call(ctx, 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
const [method, resource, body, , , headers] = apiRequest.mock.calls[0];
|
||||
expect(method).toBe('PUT');
|
||||
expect(resource).toBe(
|
||||
`/v1.0/sites/${ENCODED_SITE_ID}/drive/items/${FOLDER_ID}:/report.csv:/content`,
|
||||
);
|
||||
expect(Buffer.isBuffer(body)).toBe(true);
|
||||
expect((body as Buffer).equals(FILE_BYTES)).toBe(true);
|
||||
expect(headers).toEqual({ 'Content-Type': 'text/csv' });
|
||||
expect(response).toEqual(DRIVE_ITEM_REPLY);
|
||||
});
|
||||
|
||||
it('encodes the file name and folder ID into their path segments', async () => {
|
||||
setParams({ ...baseParams, folder: 'weird folder#id', fileName: 'Q1 report #final.csv' });
|
||||
|
||||
await execute.call(ctx, 0);
|
||||
|
||||
const [, resource] = apiRequest.mock.calls[0];
|
||||
expect(resource).toBe(
|
||||
`/v1.0/sites/${ENCODED_SITE_ID}/drive/items/weird%20folder%23id:/Q1%20report%20%23final.csv:/content`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to an octet-stream content type when the binary has none', async () => {
|
||||
ctx.helpers.assertBinaryData.mockReturnValue(
|
||||
inMemoryBinary({ mimeType: undefined as unknown as string }),
|
||||
);
|
||||
|
||||
await execute.call(ctx, 0);
|
||||
|
||||
const [, , , , , headers] = apiRequest.mock.calls[0];
|
||||
expect(headers).toEqual({ 'Content-Type': 'application/octet-stream' });
|
||||
});
|
||||
|
||||
it('reads filesystem-mode binary data through the buffer helper after the size check', async () => {
|
||||
ctx.helpers.assertBinaryData.mockReturnValue(
|
||||
inMemoryBinary({ id: 'binary-data-id', data: '' }),
|
||||
);
|
||||
ctx.helpers.getBinaryMetadata.mockResolvedValue({
|
||||
fileSize: FILE_BYTES.byteLength,
|
||||
mimeType: 'text/csv',
|
||||
fileName: 'original.csv',
|
||||
});
|
||||
ctx.helpers.getBinaryDataBuffer.mockResolvedValue(FILE_BYTES);
|
||||
|
||||
await execute.call(ctx, 0);
|
||||
|
||||
expect(ctx.helpers.getBinaryMetadata).toHaveBeenCalledWith('binary-data-id');
|
||||
expect(ctx.helpers.getBinaryDataBuffer).toHaveBeenCalledWith(0, 'data');
|
||||
const [, , body] = apiRequest.mock.calls[0];
|
||||
expect((body as Buffer).equals(FILE_BYTES)).toBe(true);
|
||||
});
|
||||
|
||||
describe('filename validation (before anything is sent)', () => {
|
||||
it.each(SHAREPOINT_ILLEGAL_FILE_NAME_CHARS)(
|
||||
'rejects a file name containing %s and names the character',
|
||||
async (char) => {
|
||||
setParams({ ...baseParams, fileName: `report${char}v2.csv` });
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow(
|
||||
`contains characters that SharePoint doesn't allow: ${char}`,
|
||||
);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an empty file name', async () => {
|
||||
setParams({ ...baseParams, fileName: ' ' });
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow('File name must be set!');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('size cap (before anything is sent)', () => {
|
||||
it('rejects an oversized file from its metadata, without reading it', async () => {
|
||||
ctx.helpers.assertBinaryData.mockReturnValue(
|
||||
inMemoryBinary({ id: 'binary-data-id', data: '' }),
|
||||
);
|
||||
ctx.helpers.getBinaryMetadata.mockResolvedValue({
|
||||
fileSize: MAX_SIMPLE_UPLOAD_BYTES + 1,
|
||||
mimeType: 'application/zip',
|
||||
fileName: 'big.zip',
|
||||
});
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow(
|
||||
'The file is 251 MB, which is larger than the 250 MB limit for SharePoint uploads',
|
||||
);
|
||||
expect(ctx.helpers.getBinaryDataBuffer).not.toHaveBeenCalled();
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a file exactly at the limit', async () => {
|
||||
ctx.helpers.assertBinaryData.mockReturnValue(
|
||||
inMemoryBinary({ id: 'binary-data-id', data: '' }),
|
||||
);
|
||||
ctx.helpers.getBinaryMetadata.mockResolvedValue({
|
||||
fileSize: MAX_SIMPLE_UPLOAD_BYTES,
|
||||
mimeType: 'application/zip',
|
||||
fileName: 'exactly-250mb.zip',
|
||||
});
|
||||
ctx.helpers.getBinaryDataBuffer.mockResolvedValue(FILE_BYTES);
|
||||
|
||||
await expect(execute.call(ctx, 0)).resolves.toEqual(DRIVE_ITEM_REPLY);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the 'Site' parameter is empty", async () => {
|
||||
setParams({ ...baseParams, site: { mode: 'id', value: '' } });
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects when the 'Parent Folder' parameter is empty", async () => {
|
||||
setParams({ ...baseParams, folder: '' });
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow("The 'Parent Folder' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a pasted site URL before uploading', async () => {
|
||||
setParams({
|
||||
...baseParams,
|
||||
site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' },
|
||||
});
|
||||
apiRequest.mockImplementation(async (_method: string, resource: string) =>
|
||||
resource.startsWith('/v1.0/sites/contoso.sharepoint.com:')
|
||||
? { id: SITE_ID }
|
||||
: DRIVE_ITEM_REPLY,
|
||||
);
|
||||
|
||||
const response = await execute.call(ctx, 0);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com:/sites/mysite',
|
||||
{},
|
||||
{ $select: 'id' },
|
||||
);
|
||||
expect(response).toEqual(DRIVE_ITEM_REPLY);
|
||||
});
|
||||
|
||||
describe('router wiring', () => {
|
||||
beforeEach(() => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.continueOnFail.mockReturnValue(false);
|
||||
ctx.helpers.returnJsonArray.mockImplementation((data) =>
|
||||
(Array.isArray(data) ? data : [data]).map((json) => ({ json })),
|
||||
);
|
||||
ctx.helpers.constructExecutionMetaData.mockImplementation((inputData, options) =>
|
||||
inputData.map((data) => ({ ...data, pairedItem: options?.itemData })),
|
||||
);
|
||||
});
|
||||
|
||||
it('routes resource file / operation upload and wraps the drive item with paired-item data', async () => {
|
||||
const node = new MicrosoftSharePointV2(versionDescription);
|
||||
setParams({ ...baseParams, resource: 'file', operation: 'upload' });
|
||||
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result).toEqual([[{ json: DRIVE_ITEM_REPLY, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('rejects an unsupported file operation', async () => {
|
||||
const node = new MicrosoftSharePointV2(versionDescription);
|
||||
setParams({ ...baseParams, resource: 'file', operation: 'download' });
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow(
|
||||
'The operation "download" is not supported!',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { versionDescription } from '../../v2/actions/versionDescription';
|
||||
import { folderRLC, getFolders } from '../../v2/folder';
|
||||
import { MicrosoftSharePointV2 } from '../../v2/MicrosoftSharePointV2.node';
|
||||
import * as transport from '../../v2/transport';
|
||||
import type * as _importType0 from '../../v2/transport';
|
||||
|
||||
vi.mock('../../v2/transport', async () => {
|
||||
const originalModule = await vi.importActual<typeof _importType0>('../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
microsoftApiRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const SITE_ID = 'contoso.sharepoint.com,g1,g2';
|
||||
const ENCODED_SITE_ID = encodeURIComponent(SITE_ID);
|
||||
|
||||
describe('Microsoft SharePoint v2 — folder selection', () => {
|
||||
let ctx: DeepMockProxy<ILoadOptionsFunctions>;
|
||||
const apiRequest = transport.microsoftApiRequest as Mock;
|
||||
|
||||
const setParams = (params: Record<string, unknown>) => {
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, _itemIndex?: number, fallback?: unknown) =>
|
||||
(name in params ? params[name] : fallback) as never,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<ILoadOptionsFunctions>();
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 2 }));
|
||||
setParams({ site: { mode: 'id', value: SITE_ID } });
|
||||
});
|
||||
|
||||
it('lists only folders, asking Graph for folders and re-checking the reply', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
value: [
|
||||
{ id: 'folder-1', name: 'Reports', folder: { childCount: 3 } },
|
||||
{ id: 'file-1', name: 'notes.txt' },
|
||||
{ id: 'folder-2', name: 'Archive', folder: { childCount: 0 } },
|
||||
{ name: 'No ID', folder: {} },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getFolders.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
`/v1.0/sites/${ENCODED_SITE_ID}/drive/items`,
|
||||
{},
|
||||
{
|
||||
$select: 'id,name,folder',
|
||||
$filter: 'folder ne null',
|
||||
},
|
||||
);
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'Reports', value: 'folder-1' },
|
||||
{ name: 'Archive', value: 'folder-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a folder without a name by its ID', async () => {
|
||||
apiRequest.mockResolvedValue({ value: [{ id: 'bare-folder', folder: {} }] });
|
||||
|
||||
const result = await getFolders.call(ctx);
|
||||
|
||||
expect(result.results).toEqual([{ name: 'bare-folder', value: 'bare-folder' }]);
|
||||
});
|
||||
|
||||
it('filters the fetched results by name, case-insensitively', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
value: [
|
||||
{ id: 'folder-1', name: 'Quarterly Reports', folder: {} },
|
||||
{ id: 'folder-2', name: 'Archive', folder: {} },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getFolders.call(ctx, 'reports');
|
||||
|
||||
expect(result.results).toEqual([{ name: 'Quarterly Reports', value: 'folder-1' }]);
|
||||
});
|
||||
|
||||
it('walks further pages while filtering, so matches beyond page one are found', async () => {
|
||||
const nextLink = 'https://graph.microsoft.com/v1.0/sites/s/drive/items?$skiptoken=p2';
|
||||
apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ id: 'file-1', name: 'archive-notes.txt' }],
|
||||
'@odata.nextLink': nextLink,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ id: 'folder-2', name: 'Archive', folder: {} }],
|
||||
});
|
||||
|
||||
const result = await getFolders.call(ctx, 'archive');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink);
|
||||
expect(result.results).toEqual([{ name: 'Archive', value: 'folder-2' }]);
|
||||
expect(result.paginationToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('caps the filtered page walk and hands back the residual next-page link', async () => {
|
||||
apiRequest.mockImplementation(async () => ({
|
||||
value: [{ id: 'file-x', name: 'not-a-folder.txt' }],
|
||||
'@odata.nextLink': 'https://graph.microsoft.com/next',
|
||||
}));
|
||||
|
||||
const result = await getFolders.call(ctx, 'archive');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(10);
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.paginationToken).toBe('https://graph.microsoft.com/next');
|
||||
});
|
||||
|
||||
it('stops at the first page that yields folders when no filter is set', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
value: [{ id: 'folder-1', name: 'Reports', folder: {} }],
|
||||
'@odata.nextLink': 'https://graph.microsoft.com/next',
|
||||
});
|
||||
|
||||
const result = await getFolders.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(result.paginationToken).toBe('https://graph.microsoft.com/next');
|
||||
});
|
||||
|
||||
it('walks past pages holding no folders when no filter is set', async () => {
|
||||
const nextLink = 'https://graph.microsoft.com/v1.0/sites/s/drive/items?$skiptoken=p2';
|
||||
apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ id: 'file-1', name: 'a.txt' }],
|
||||
'@odata.nextLink': nextLink,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ id: 'folder-1', name: 'Reports', folder: {} }],
|
||||
});
|
||||
|
||||
const result = await getFolders.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink);
|
||||
expect(result.results).toEqual([{ name: 'Reports', value: 'folder-1' }]);
|
||||
});
|
||||
|
||||
it('caps the unfiltered walk over folder-less pages and hands back the residual link', async () => {
|
||||
apiRequest.mockImplementation(async () => ({
|
||||
value: [{ id: 'file-x', name: 'not-a-folder.txt' }],
|
||||
'@odata.nextLink': 'https://graph.microsoft.com/next',
|
||||
}));
|
||||
|
||||
const result = await getFolders.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(10);
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.paginationToken).toBe('https://graph.microsoft.com/next');
|
||||
});
|
||||
|
||||
it('resolves a pasted site URL before listing folders', async () => {
|
||||
setParams({ site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' } });
|
||||
apiRequest.mockImplementation(async (_method: string, resource: string) =>
|
||||
resource.startsWith('/v1.0/sites/contoso.sharepoint.com:')
|
||||
? { id: SITE_ID }
|
||||
: { value: [{ id: 'folder-1', name: 'Reports', folder: {} }] },
|
||||
);
|
||||
|
||||
const result = await getFolders.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/v1.0/sites/contoso.sharepoint.com:/sites/mysite',
|
||||
{},
|
||||
{ $select: 'id' },
|
||||
);
|
||||
expect(result.results).toEqual([{ name: 'Reports', value: 'folder-1' }]);
|
||||
});
|
||||
|
||||
it('rejects when no site has been chosen yet', async () => {
|
||||
setParams({ site: { mode: 'id', value: '' } });
|
||||
|
||||
await expect(getFolders.call(ctx)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands back the next-page link and requests it exactly as returned', async () => {
|
||||
const nextLink = `https://graph.microsoft.com/v1.0/sites/${ENCODED_SITE_ID}/drive/items?$skiptoken=abc`;
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
value: [{ id: 'folder-1', name: 'Reports', folder: {} }],
|
||||
'@odata.nextLink': nextLink,
|
||||
});
|
||||
|
||||
const firstPage = await getFolders.call(ctx);
|
||||
expect(firstPage.paginationToken).toBe(nextLink);
|
||||
|
||||
apiRequest.mockResolvedValueOnce({ value: [{ id: 'folder-2', name: 'Archive', folder: {} }] });
|
||||
const secondPage = await getFolders.call(ctx, undefined, nextLink);
|
||||
|
||||
expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink);
|
||||
expect(secondPage.results).toEqual([{ name: 'Archive', value: 'folder-2' }]);
|
||||
expect(secondPage.paginationToken).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('field shape', () => {
|
||||
it('offers search first, with an ID mode alongside', () => {
|
||||
expect(folderRLC.modes?.map((mode) => mode.name)).toEqual(['list', 'id']);
|
||||
expect(folderRLC.modes?.[0].typeOptions?.searchListMethod).toBe('getFolders');
|
||||
expect(folderRLC.modes?.[0].typeOptions?.searchable).toBe(true);
|
||||
expect(folderRLC.default).toEqual({ mode: 'list', value: '' });
|
||||
});
|
||||
|
||||
it('re-fetches when the chosen site changes', () => {
|
||||
expect(folderRLC.typeOptions?.loadOptionsDependsOn).toEqual(['site.value']);
|
||||
});
|
||||
|
||||
it('is wired into the node as a list-search method', () => {
|
||||
const node = new MicrosoftSharePointV2(versionDescription);
|
||||
|
||||
expect(node.methods?.listSearch?.getFolders).toBe(getFolders);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
SHAREPOINT_ILLEGAL_FILE_NAME_CHARS,
|
||||
validateSharePointFileName,
|
||||
} from '../../../v2/helpers/utils';
|
||||
|
||||
describe('Microsoft SharePoint v2 — validateSharePointFileName', () => {
|
||||
const node = mock<INode>();
|
||||
|
||||
it('accepts an ordinary file name', () => {
|
||||
expect(() => validateSharePointFileName(node, 'report.pdf', 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([undefined, '', ' '])('rejects a missing or blank name (%j)', (fileName) => {
|
||||
expect(() => validateSharePointFileName(node, fileName as string | undefined, 0)).toThrow(
|
||||
'File name must be set!',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(SHAREPOINT_ILLEGAL_FILE_NAME_CHARS)('rejects a name containing %s', (char) => {
|
||||
expect(() => validateSharePointFileName(node, `a${char}b.txt`, 0)).toThrow(
|
||||
`contains characters that SharePoint doesn't allow: ${char}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('names every offending character at once', () => {
|
||||
expect(() => validateSharePointFileName(node, 'a:b*c.txt', 0)).toThrow(
|
||||
"contains characters that SharePoint doesn't allow: * :",
|
||||
);
|
||||
});
|
||||
|
||||
it('suggests a colon-free timestamp format only when a colon is present', () => {
|
||||
try {
|
||||
validateSharePointFileName(node, 'report 12:30.pdf', 0);
|
||||
expect.unreachable('should have thrown');
|
||||
} catch (error) {
|
||||
expect((error as { description?: string }).description).toContain('colon-free format');
|
||||
}
|
||||
|
||||
try {
|
||||
validateSharePointFileName(node, 'report?.pdf', 0);
|
||||
expect.unreachable('should have thrown');
|
||||
} catch (error) {
|
||||
expect((error as { description?: string }).description).not.toContain('colon-free format');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -531,4 +531,26 @@ describe('Microsoft SharePoint v2 Transport', () => {
|
||||
expect(getSharePointCredentialType.call(ctx)).toBe('microsoftOAuth2Api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary bodies', () => {
|
||||
it('passes a Buffer body through raw, with the caller Content-Type winning', async () => {
|
||||
setParams({ authentication: 'microsoftOAuth2Api' });
|
||||
const payload = Buffer.from('binary-bytes');
|
||||
|
||||
await microsoftApiRequest.call(
|
||||
ctx,
|
||||
'PUT',
|
||||
'/v1.0/sites/s/drive/items/f:/a.png:/content',
|
||||
payload,
|
||||
{},
|
||||
undefined,
|
||||
{ 'Content-Type': 'image/png' },
|
||||
);
|
||||
|
||||
const options = mockRequestOAuth2.mock.calls[0][1];
|
||||
expect(options.body).toBe(payload);
|
||||
expect(options.headers['Content-Type']).toBe('image/png');
|
||||
expect(options.json).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as upload from './upload.operation';
|
||||
|
||||
export { upload };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Upload',
|
||||
value: 'upload',
|
||||
description: 'Upload a file to a folder',
|
||||
action: 'Upload file',
|
||||
},
|
||||
],
|
||||
default: 'upload',
|
||||
},
|
||||
|
||||
...upload.description,
|
||||
];
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
|
||||
import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../../utils/utilities';
|
||||
import { folderRLC } from '../../folder';
|
||||
import { untilSiteSelected } from '../../list';
|
||||
import { MAX_SIMPLE_UPLOAD_BYTES, validateSharePointFileName } from '../../helpers/utils';
|
||||
import { resolveSiteId, siteRLC } from '../../site';
|
||||
import { microsoftApiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
...siteRLC,
|
||||
description: 'Select the site to upload to',
|
||||
},
|
||||
{
|
||||
...folderRLC,
|
||||
description: 'Select the folder to upload the file to',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
...untilSiteSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'File Name',
|
||||
name: 'fileName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. report.pdf',
|
||||
description: 'Name the file will have in SharePoint',
|
||||
},
|
||||
{
|
||||
displayName: 'Input Binary Field',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'data',
|
||||
hint: 'The name of the input binary field containing the file to upload',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['upload'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
i: number,
|
||||
siteIdCache?: Map<string, string>,
|
||||
): Promise<IDataObject> {
|
||||
// https://learn.microsoft.com/en-us/graph/api/driveitem-put-content
|
||||
const fileName = this.getNodeParameter('fileName', i) as string;
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const folderId = (
|
||||
this.getNodeParameter('folder', i, '', { extractValue: true }) as string
|
||||
).trim();
|
||||
|
||||
validateSharePointFileName(this.getNode(), fileName, i);
|
||||
|
||||
if (folderId === '') {
|
||||
throw new NodeOperationError(this.getNode(), "The 'Parent Folder' parameter is empty", {
|
||||
description: "Set the folder ID (or 'root' for the library root) and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
|
||||
|
||||
let body: Buffer | undefined;
|
||||
let fileSize: number;
|
||||
if (binaryData.id) {
|
||||
({ fileSize } = await this.helpers.getBinaryMetadata(binaryData.id));
|
||||
} else {
|
||||
body = Buffer.from(binaryData.data, BINARY_ENCODING);
|
||||
fileSize = body.byteLength;
|
||||
}
|
||||
if (fileSize > MAX_SIMPLE_UPLOAD_BYTES) {
|
||||
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The file is ${sizeMb} MB, which is larger than the 250 MB limit for SharePoint uploads`,
|
||||
{
|
||||
itemIndex: i,
|
||||
description:
|
||||
'Files over 250 MB need to be uploaded in pieces, which this operation does not support yet.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const siteId = await resolveSiteId.call(this, i, siteIdCache);
|
||||
|
||||
body ??= await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
|
||||
|
||||
return await microsoftApiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/v1.0/sites/${encodeURIComponent(siteId)}/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content`,
|
||||
body,
|
||||
{},
|
||||
undefined,
|
||||
{ 'Content-Type': binaryData.mimeType ?? 'application/octet-stream' },
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
file: 'upload';
|
||||
list: 'get' | 'getAll';
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type IExecuteFunctions, type INodeExecutionData, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as file from './file';
|
||||
import * as list from './list';
|
||||
import type { MicrosoftSharePointType } from './node.type';
|
||||
|
||||
@@ -23,6 +24,19 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
switch (sharePointTypeData.resource) {
|
||||
case 'file':
|
||||
if (!(sharePointTypeData.operation in file)) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
responseData = await file[sharePointTypeData.operation].execute.call(
|
||||
this,
|
||||
i,
|
||||
siteIdCache,
|
||||
);
|
||||
break;
|
||||
case 'list':
|
||||
if (!(sharePointTypeData.operation in list)) {
|
||||
throw new NodeOperationError(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as file from './file';
|
||||
import * as list from './list';
|
||||
import { SERVICE_PRINCIPAL_AUTH } from '../transport';
|
||||
|
||||
@@ -72,6 +73,10 @@ export const versionDescription: INodeTypeDescription = {
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'File',
|
||||
value: 'file',
|
||||
},
|
||||
{
|
||||
name: 'List',
|
||||
value: 'list',
|
||||
@@ -80,6 +85,7 @@ export const versionDescription: INodeTypeDescription = {
|
||||
default: 'list',
|
||||
},
|
||||
|
||||
...file.description,
|
||||
...list.description,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { resolveSiteId } from '../site';
|
||||
import { microsoftApiRequest } from '../transport';
|
||||
|
||||
export const folderRLC: INodeProperties = {
|
||||
displayName: 'Parent Folder',
|
||||
name: 'folder',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
default: { mode: 'list', value: '' },
|
||||
description: 'The folder to operate on, within the site’s default document library',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['site.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'getFolders',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. 01SPEVVYBKV2ZKHGJASRA2HC7MOGBMUMAA, or root for the library root',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type FolderSearchReply = {
|
||||
'@odata.nextLink'?: string;
|
||||
value?: Array<{ id?: string; name?: string; folder?: unknown }>;
|
||||
};
|
||||
|
||||
// A drive page can yield nothing to show (files-only, or no filter match); those pages are
|
||||
// walked here in one call — the editor would otherwise re-request per empty page — capped
|
||||
// so a single dropdown request can't crawl an arbitrarily large drive
|
||||
const SEARCH_PAGE_LIMIT = 10;
|
||||
|
||||
export async function getFolders(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const siteId = paginationToken ? '' : await resolveSiteId.call(this, 0);
|
||||
|
||||
const filterLower = filter?.toLowerCase();
|
||||
const results: INodeListSearchResult['results'] = [];
|
||||
let nextToken = paginationToken;
|
||||
let pagesLeft = SEARCH_PAGE_LIMIT;
|
||||
|
||||
do {
|
||||
const response = nextToken
|
||||
? ((await microsoftApiRequest.call(this, 'GET', '', {}, {}, nextToken)) as FolderSearchReply)
|
||||
: ((await microsoftApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/v1.0/sites/${encodeURIComponent(siteId)}/drive/items`,
|
||||
{},
|
||||
{
|
||||
$select: 'id,name,folder',
|
||||
// Graph rejects this enumeration without a $filter but ignores this
|
||||
// one, so it must stay; the reply is re-checked below
|
||||
$filter: 'folder ne null',
|
||||
},
|
||||
)) as FolderSearchReply);
|
||||
|
||||
for (const item of response.value ?? []) {
|
||||
if (!item.id || !item.folder) continue;
|
||||
if (filterLower && !(item.name ?? '').toLowerCase().includes(filterLower)) continue;
|
||||
results.push({
|
||||
name: item.name ?? String(item.id),
|
||||
value: String(item.id),
|
||||
});
|
||||
}
|
||||
|
||||
nextToken = response['@odata.nextLink'];
|
||||
pagesLeft -= 1;
|
||||
} while (
|
||||
nextToken !== undefined &&
|
||||
pagesLeft > 0 &&
|
||||
(filterLower !== undefined || results.length === 0)
|
||||
);
|
||||
|
||||
return { results, paginationToken: nextToken };
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
/** v1's Simplify $select list — the exact trimmed fields v2 keeps returning; Get Many reuses it. */
|
||||
export const LIST_SIMPLIFY_SELECT =
|
||||
'id,name,displayName,description,createdDateTime,lastModifiedDateTime,webUrl';
|
||||
|
||||
/** Shape shared by every Graph collection reply a listSearch method here consumes. */
|
||||
export type GraphSearchReply<T> = { '@odata.nextLink'?: string; value?: T[] };
|
||||
|
||||
/** Characters SharePoint forbids in file names; Graph rejects them with a misleading 400. */
|
||||
export const SHAREPOINT_ILLEGAL_FILE_NAME_CHARS = ['"', '*', ':', '<', '>', '?', '/', '\\', '|'];
|
||||
|
||||
/** Graph's cap for a single-request `PUT …:/content` upload; larger files need an upload session. */
|
||||
export const MAX_SIMPLE_UPLOAD_BYTES = 250 * 1024 * 1024;
|
||||
|
||||
export function validateSharePointFileName(
|
||||
node: INode,
|
||||
fileName: string | undefined,
|
||||
itemIndex: number,
|
||||
): asserts fileName is string {
|
||||
if (fileName === undefined || fileName.trim() === '') {
|
||||
throw new NodeOperationError(node, 'File name must be set!', { itemIndex });
|
||||
}
|
||||
|
||||
const illegalChars = SHAREPOINT_ILLEGAL_FILE_NAME_CHARS.filter((char) => fileName.includes(char));
|
||||
if (illegalChars.length > 0) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`The file name "${fileName}" contains characters that SharePoint doesn't allow: ${illegalChars.join(' ')}`,
|
||||
{
|
||||
itemIndex,
|
||||
description:
|
||||
`SharePoint file names can't contain any of these characters: ${SHAREPOINT_ILLEGAL_FILE_NAME_CHARS.join(' ')}. Remove them from the file name and try again.` +
|
||||
(illegalChars.includes(':')
|
||||
? " If you're inserting a timestamp, use a colon-free format such as {{ $now.toFormat('yyyy-MM-dd_HH-mm-ss') }}."
|
||||
: ''),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getFolders } from '../folder';
|
||||
import { getLists } from '../list';
|
||||
import { getSites } from '../site';
|
||||
|
||||
export const listSearch = {
|
||||
getFolders,
|
||||
getSites,
|
||||
getLists,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,10 @@ export const DEFAULT_GRAPH_BASE_URL = 'https://graph.microsoft.com';
|
||||
export const REQUIRED_PERMISSIONS: Readonly<
|
||||
Record<string, { delegated: string; application: string }>
|
||||
> = Object.freeze({
|
||||
'file:upload': {
|
||||
delegated: 'Sites.ReadWrite.All',
|
||||
application: 'Sites.ReadWrite.All (or Sites.Selected granted with write access for this site)',
|
||||
},
|
||||
'list:get': {
|
||||
delegated: 'Sites.Read.All',
|
||||
application: 'Sites.Read.All (or Sites.Selected granted for this site)',
|
||||
@@ -155,7 +159,7 @@ export async function microsoftApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
body: IDataObject = {},
|
||||
body: IDataObject | Buffer = {},
|
||||
qs: IDataObject = {},
|
||||
uri?: string,
|
||||
headers: IDataObject = {},
|
||||
|
||||
Reference in New Issue
Block a user