diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts new file mode 100644 index 00000000000..c6042ba535f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts @@ -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('../../../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; + const apiRequest = transport.microsoftApiRequest as Mock; + + const setParams = (params: Record) => { + ctx.getNodeParameter.mockImplementation( + (name: string, _itemIndex?: number, fallback?: unknown) => + (name in params ? params[name] : fallback) as never, + ); + }; + + const inMemoryBinary = (overrides: Partial = {}): 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(); + ctx.getNode.mockReturnValue(mock({ 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!', + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts new file mode 100644 index 00000000000..7ba96502c6a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts @@ -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('../../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; + const apiRequest = transport.microsoftApiRequest as Mock; + + const setParams = (params: Record) => { + ctx.getNodeParameter.mockImplementation( + (name: string, _itemIndex?: number, fallback?: unknown) => + (name in params ? params[name] : fallback) as never, + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(mock({ 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); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts new file mode 100644 index 00000000000..97855785110 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts @@ -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(); + + 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'); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/transport/index.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/transport/index.test.ts index a9e4139c7d7..2099aefbd1f 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/transport/index.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/transport/index.test.ts @@ -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); + }); + }); }); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/index.ts new file mode 100644 index 00000000000..fce917e7e0b --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/index.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts new file mode 100644 index 00000000000..ca8b7092986 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts @@ -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, +): Promise { + // 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' }, + ); +} diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/node.type.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/node.type.ts index ae1ad92843d..65c2f0d341a 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/node.type.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/node.type.ts @@ -1,6 +1,7 @@ import type { AllEntities } from 'n8n-workflow'; type NodeMap = { + file: 'upload'; list: 'get' | 'getAll'; }; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/router.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/router.ts index d1988a87e34..c4c81a303c9 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/router.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/router.ts @@ -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; +}; + +// 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 { + 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 }; +} diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/utils.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/utils.ts index c0130c28e49..d239dc4ae6b 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/utils.ts @@ -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 = { '@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') }}." + : ''), + }, + ); + } +} diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts index 2105f0f066d..0f02c16317d 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts @@ -1,7 +1,9 @@ +import { getFolders } from '../folder'; import { getLists } from '../list'; import { getSites } from '../site'; export const listSearch = { + getFolders, getSites, getLists, }; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/transport/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/transport/index.ts index fd22d6b7b94..def18cd2416 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/transport/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/transport/index.ts @@ -24,6 +24,10 @@ export const DEFAULT_GRAPH_BASE_URL = 'https://graph.microsoft.com'; export const REQUIRED_PERMISSIONS: Readonly< Record > = 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 = {},