diff --git a/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts index 38e504c1056..4fd1713f828 100644 --- a/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts +++ b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts @@ -19,9 +19,8 @@ export async function nextCloudApiRequest( headers?: IDataObject, encoding?: null, query?: IDataObject, + useWebDavEndpoint: boolean = true, ) { - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); const authenticationMethod = this.getNodeParameter('authentication', 0); let credentials; @@ -45,15 +44,11 @@ export async function nextCloudApiRequest( options.encoding = null; } - options.uri = `${credentials.webDavUrl}/${encodeURI(endpoint)}`; - - if (resource === 'user' && operation === 'create') { - options.uri = options.uri.replace('/remote.php/webdav', ''); - } - - if (resource === 'file' && operation === 'share') { - options.uri = options.uri.replace('/remote.php/webdav', ''); - } + // Preserve the existing WebDAV path behavior: endpoints may start with '/', producing '//'. + // For non-WebDAV requests, strip the WebDAV suffix while preserving any subpath prefix. + options.uri = useWebDavEndpoint + ? `${credentials.webDavUrl}/${encodeURI(endpoint)}` + : `${credentials.webDavUrl.replace(/\/remote\.php\/webdav\/?$/, '')}/${encodeURI(endpoint)}`; const credentialType = authenticationMethod === 'accessToken' ? 'nextCloudApi' : 'nextCloudOAuth2Api'; diff --git a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts index 6cfb1211b9d..7cc636066dd 100644 --- a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts +++ b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts @@ -476,6 +476,12 @@ export class NextCloud implements INodeType { name: 'Group', value: 1, }, + { + name: 'Internal Link', + value: 200, + description: + 'Generates an internal Nextcloud URL (not a public share). Uses the file/folder ID from a PROPFIND call. The output is { link: "..." }. Do not use with shareWith fields.', + }, { name: 'Public Link', value: 3, @@ -876,18 +882,29 @@ export class NextCloud implements INodeType { credentials = await this.getCredentials('nextCloudOAuth2Api'); } - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - - let endpoint = ''; - let requestMethod: IHttpRequestMethods = 'GET'; - let responseData: any; - - let body: string | Buffer | IDataObject = ''; - const headers: IDataObject = {}; - let qs; + let resource: string = ''; + let operation: string = ''; + let lastOperationWasDownload = false; for (let i = 0; i < items.length; i++) { + let endpoint = ''; + let requestMethod: IHttpRequestMethods = 'GET'; + let responseData: any; + + let body: string | Buffer | IDataObject = ''; + const headers: IDataObject = {}; + let qs; + // Reinitialize per-iteration so state from a previous item never leaks. + let useWebDavEndpoint = true; + + resource = this.getNodeParameter('resource', i); + operation = this.getNodeParameter('operation', i); + + // Must be set before the try block so it still runs when download fails with continueOnFail + if (resource === 'file' && operation === 'download') { + lastOperationWasDownload = true; + } + try { if (resource === 'file') { if (operation === 'download') { @@ -927,6 +944,7 @@ export class NextCloud implements INodeType { // list // ---------------------------------- + // PROPFIND is not in the IHttpRequestMethods enum but is required for WebDAV PROPFIND requests requestMethod = 'PROPFIND' as IHttpRequestMethods; endpoint = this.getNodeParameter('path', i) as string; } @@ -963,32 +981,49 @@ export class NextCloud implements INodeType { // share // ---------------------------------- - requestMethod = 'POST'; + const shareType = this.getNodeParameter('shareType', i) as number; + const sharePath = this.getNodeParameter('path', i) as string; - endpoint = 'ocs/v2.php/apps/files_sharing/api/v1/shares'; + if (shareType === 200) { + // Internal Link: not a real OCS share, derive the link from oc:fileid via PROPFIND. + // PROPFIND is not in the IHttpRequestMethods enum but is required for WebDAV PROPFIND requests + requestMethod = 'PROPFIND' as IHttpRequestMethods; + endpoint = sharePath; + headers['Content-Type'] = 'application/xml'; + headers.Depth = '0'; + body = ` + + +`; + // useWebDavEndpoint stays true (default) for WebDAV PROPFIND. + } else { + // Regular OCS share. + requestMethod = 'POST'; + useWebDavEndpoint = false; + endpoint = 'ocs/v2.php/apps/files_sharing/api/v1/shares'; + headers['OCS-APIRequest'] = true; + headers['Content-Type'] = 'application/x-www-form-urlencoded'; - headers['OCS-APIRequest'] = true; - headers['Content-Type'] = 'application/x-www-form-urlencoded'; + const bodyParameters = this.getNodeParameter('options', i) as IDataObject; - const bodyParameters = this.getNodeParameter('options', i); + bodyParameters.path = sharePath; + bodyParameters.shareType = shareType; - bodyParameters.path = this.getNodeParameter('path', i) as string; - bodyParameters.shareType = this.getNodeParameter('shareType', i) as number; + if (shareType === 0) { + bodyParameters.shareWith = this.getNodeParameter('user', i) as string; + } else if (shareType === 7) { + bodyParameters.shareWith = this.getNodeParameter('circleId', i) as string; + } else if (shareType === 4) { + bodyParameters.shareWith = this.getNodeParameter('email', i) as string; + } else if (shareType === 1) { + bodyParameters.shareWith = this.getNodeParameter('groupId', i) as string; + } - if (bodyParameters.shareType === 0) { - bodyParameters.shareWith = this.getNodeParameter('user', i) as string; - } else if (bodyParameters.shareType === 7) { - bodyParameters.shareWith = this.getNodeParameter('circleId', i) as number; - } else if (bodyParameters.shareType === 4) { - bodyParameters.shareWith = this.getNodeParameter('email', i) as string; - } else if (bodyParameters.shareType === 1) { - bodyParameters.shareWith = this.getNodeParameter('groupId', i) as number; + body = new URLSearchParams(bodyParameters as Record).toString(); } - - // @ts-ignore - body = new URLSearchParams(bodyParameters).toString(); } } else if (resource === 'user') { + useWebDavEndpoint = false; if (operation === 'create') { // ---------------------------------- // user:create @@ -1102,6 +1137,7 @@ export class NextCloud implements INodeType { headers, encoding, qs, + useWebDavEndpoint, ); } catch (error) { if (this.continueOnFail()) { @@ -1139,39 +1175,152 @@ export class NextCloud implements INodeType { endpoint, ); } else if (['file', 'folder'].includes(resource) && operation === 'share') { - const jsonResponseData: IDataObject = await new Promise((resolve, reject) => { - parseString( - responseData as string, - { - explicitArray: false, - tagNameProcessors: [sanitizeXmlName], - attrNameProcessors: [sanitizeXmlName], - }, - (err, data) => { - if (err) { - return reject(err); - } + const shareType = this.getNodeParameter('shareType', i) as number; - if (data.ocs.meta.status !== 'ok') { - return reject( - new NodeApiError( - this.getNode(), - (data.ocs.meta.message as JsonObject) || (data.ocs.meta.status as JsonObject), - ), - ); - } + if (shareType === 200) { + // Internal Link: responseData is the PROPFIND multistatus XML. + if (typeof responseData !== 'string') { + throw new NodeOperationError( + this.getNode(), + 'Could not retrieve internal link: unexpected response type from NextCloud', + { itemIndex: i }, + ); + } - resolve(data.ocs.data as IDataObject); - }, + const propfindData: IDataObject = await new Promise((resolve, reject) => { + parseString( + responseData, + { + explicitArray: false, + tagNameProcessors: [sanitizeXmlName], + attrNameProcessors: [sanitizeXmlName], + }, + (err, data) => { + if (err) { + return reject(err); + } + if (!data || typeof data !== 'object') { + return reject( + new NodeOperationError( + this.getNode(), + 'Could not retrieve internal link: invalid XML response structure', + { itemIndex: i }, + ), + ); + } + resolve(data); + }, + ); + }); + + const multistatus = propfindData['d:multistatus'] as IDataObject | undefined; + if (!multistatus) { + throw new NodeOperationError( + this.getNode(), + 'Could not retrieve internal link: malformed PROPFIND response', + { itemIndex: i }, + ); + } + + const responses = multistatus['d:response']; + if (!responses) { + throw new NodeOperationError( + this.getNode(), + 'Could not retrieve internal link: malformed PROPFIND response', + { itemIndex: i }, + ); + } + + const responseList: IDataObject[] = Array.isArray(responses) + ? (responses as IDataObject[]) + : [responses as IDataObject]; + + const matchedResponse = responseList[0]; + + let props: IDataObject | undefined; + const propstat = matchedResponse['d:propstat']; + if (Array.isArray(propstat)) { + props = (propstat[0] as IDataObject)['d:prop'] as IDataObject | undefined; + } else if (propstat && typeof propstat === 'object') { + props = (propstat as IDataObject)['d:prop'] as IDataObject | undefined; + } + + const fileid = props?.['oc:fileid']; + if (typeof fileid !== 'string' || fileid.length === 0) { + throw new NodeOperationError( + this.getNode(), + 'Could not retrieve internal link: oc:fileid not found in PROPFIND response', + { itemIndex: i }, + ); + } + + const webDavBase = (credentials.webDavUrl as string).replace( + /\/remote\.php\/webdav\/?$/, + '', ); - }); - const executionData = this.helpers.constructExecutionMetaData( - wrapData(jsonResponseData), - { itemData: { item: i } }, - ); + if (webDavBase === credentials.webDavUrl) { + throw new NodeOperationError( + this.getNode(), + 'WebDAV URL must end with /remote.php/webdav for generating an internal link. Please check your Nextcloud credentials.', + { itemIndex: i }, + ); + } - returnData.push(...executionData); + const internalLink = `${webDavBase}/f/${fileid}`; + const executionData = this.helpers.constructExecutionMetaData( + wrapData({ link: internalLink }), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else { + if (typeof responseData !== 'string') { + throw new NodeOperationError( + this.getNode(), + 'Unexpected response type from NextCloud OCS share endpoint', + { itemIndex: i }, + ); + } + const jsonResponseData: IDataObject = await new Promise((resolve, reject) => { + parseString( + responseData, + { + explicitArray: false, + tagNameProcessors: [sanitizeXmlName], + attrNameProcessors: [sanitizeXmlName], + }, + (err, data) => { + if (err) { + return reject(err); + } + + if (data.ocs.meta.status !== 'ok') { + return reject( + new NodeApiError( + this.getNode(), + (data.ocs.meta.message as JsonObject) || + (data.ocs.meta.status as JsonObject), + ), + ); + } + + if (!data?.ocs?.data || typeof data.ocs.data !== 'object') { + return reject( + new NodeApiError(this.getNode(), { error: 'Invalid OCS response structure' }), + ); + } + resolve(data.ocs.data); + }, + ); + }); + + const executionData = this.helpers.constructExecutionMetaData( + wrapData(jsonResponseData), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } } else if (resource === 'user') { if (operation !== 'getAll') { const jsonResponseData: IDataObject = await new Promise((resolve, reject) => { @@ -1331,9 +1480,12 @@ export class NextCloud implements INodeType { } throw error; } + if (resource === 'file' && operation === 'download') { + lastOperationWasDownload = true; + } } - if (resource === 'file' && operation === 'download') { + if (lastOperationWasDownload) { // For file downloads the files get attached to the existing items return [items]; } else { diff --git a/packages/nodes-base/nodes/NextCloud/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/NextCloud/test/GenericFunctions.test.ts new file mode 100644 index 00000000000..a2475b00333 --- /dev/null +++ b/packages/nodes-base/nodes/NextCloud/test/GenericFunctions.test.ts @@ -0,0 +1,258 @@ +import type { + IDataObject, + IExecuteFunctions, + IHookFunctions, + IHttpRequestMethods, + INode, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import type { Mock } from 'vitest'; + +import { nextCloudApiRequest } from '../GenericFunctions'; + +const webDavUrl = 'https://nextcloud.example.com/remote.php/webdav'; +const baseUrl = 'https://nextcloud.example.com'; + +type Authentication = 'accessToken' | 'oAuth2'; + +function buildFunctions(authentication: Authentication = 'accessToken') { + const requestWithAuthentication = vi.fn(); + const getCredentials = vi.fn(async () => ({ webDavUrl })); + const getNodeParameter = vi.fn((parameterName: string) => { + if (parameterName === 'authentication') return authentication; + return undefined; + }); + + const functions = { + getCredentials, + getNode: vi.fn( + () => + ({ + id: 'nextcloud-node', + name: 'Nextcloud', + type: 'n8n-nodes-base.nextCloud', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }) as INode, + ), + getNodeParameter, + helpers: { + requestWithAuthentication, + }, + } as unknown as IHookFunctions & IExecuteFunctions; + + return { functions, getCredentials, getNodeParameter, requestWithAuthentication }; +} + +function requestOptions(requestWithAuthentication: Mock) { + return requestWithAuthentication.mock.calls[0][1] as IDataObject; +} + +describe('NextCloud GenericFunctions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses access token credentials and builds a WebDAV request by default', async () => { + const { functions, getCredentials, requestWithAuthentication } = buildFunctions(); + requestWithAuthentication.mockResolvedValue({ status: 'ok' }); + + const response = await nextCloudApiRequest.call(functions, 'GET', '/test.txt', ''); + + expect(response).toEqual({ status: 'ok' }); + expect(getCredentials).toHaveBeenCalledWith('nextCloudApi'); + expect(requestWithAuthentication).toHaveBeenCalledWith( + 'nextCloudApi', + expect.objectContaining({ + method: 'GET', + uri: `${webDavUrl}//test.txt`, + body: '', + headers: undefined, + qs: {}, + json: false, + }), + ); + expect(requestOptions(requestWithAuthentication).uri).toEqual( + expect.stringContaining('/remote.php/webdav'), + ); + }); + + it('handles non-standard WebDAV URLs gracefully', async () => { + const customUrl = 'https://custom.example.com/dav'; + const { functions, getCredentials, requestWithAuthentication } = buildFunctions(); + getCredentials.mockResolvedValue({ webDavUrl: customUrl }); + requestWithAuthentication.mockResolvedValue({ status: 'ok' }); + + await nextCloudApiRequest.call( + functions, + 'GET', + '/test.txt', + '', + undefined, + undefined, + undefined, + true, + ); + + expect(requestOptions(requestWithAuthentication).uri).toBe(`${customUrl}//test.txt`); + expect(requestOptions(requestWithAuthentication).uri).toEqual(expect.stringContaining('/dav')); + }); + + it('uses OAuth2 credentials when OAuth2 authentication is selected', async () => { + const { functions, getCredentials, requestWithAuthentication } = buildFunctions('oAuth2'); + requestWithAuthentication.mockResolvedValue({ status: 'ok' }); + + await nextCloudApiRequest.call(functions, 'GET', '/test.txt', ''); + + expect(getCredentials).toHaveBeenCalledWith('nextCloudOAuth2Api'); + expect(requestWithAuthentication).toHaveBeenCalledWith( + 'nextCloudOAuth2Api', + expect.objectContaining({ + method: 'GET', + uri: `${webDavUrl}//test.txt`, + }), + ); + }); + + it('removes the WebDAV path for OCS requests', async () => { + const { functions, requestWithAuthentication } = buildFunctions(); + requestWithAuthentication.mockResolvedValue(''); + + await nextCloudApiRequest.call( + functions, + 'POST', + 'ocs/v1.php/cloud/users', + 'userid=alice', + { 'OCS-APIRequest': true }, + undefined, + undefined, + false, + ); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'POST', + uri: `${baseUrl}/ocs/v1.php/cloud/users`, + body: 'userid=alice', + headers: { 'OCS-APIRequest': true }, + qs: {}, + json: false, + }); + expect(requestOptions(requestWithAuthentication).uri).not.toEqual( + expect.stringContaining('/remote.php/webdav'), + ); + }); + + it('strips non-standard WebDAV path for OCS requests while preserving subpath', async () => { + const customUrl = 'https://custom.example.com/nextcloud/remote.php/webdav'; + const { functions, getCredentials, requestWithAuthentication } = buildFunctions(); + getCredentials.mockResolvedValue({ webDavUrl: customUrl }); + requestWithAuthentication.mockResolvedValue(''); + + await nextCloudApiRequest.call( + functions, + 'POST', + 'ocs/v1.php/cloud/users', + '', + {}, + undefined, + undefined, + false, + ); + + expect(requestOptions(requestWithAuthentication).uri).toBe( + 'https://custom.example.com/nextcloud/ocs/v1.php/cloud/users', + ); + }); + + it('passes body, headers, query, and null encoding to requestWithAuthentication', async () => { + const { functions, requestWithAuthentication } = buildFunctions(); + const body = Buffer.from('file content'); + const headers = { Depth: '0', Destination: `${webDavUrl}//to.txt` }; + const query = { limit: 1 }; + requestWithAuthentication.mockResolvedValue(Buffer.from('response')); + + await nextCloudApiRequest.call( + functions, + 'PROPFIND' as IHttpRequestMethods, + '/test.txt', + body, + headers, + null, + query, + true, + ); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUrl}//test.txt`, + body, + headers, + encoding: null, + qs: query, + json: false, + }); + }); + + it('URL-encodes endpoint characters while preserving path separators', async () => { + const { functions, requestWithAuthentication } = buildFunctions(); + requestWithAuthentication.mockResolvedValue({}); + + await nextCloudApiRequest.call(functions, 'GET', '/folder name/test file.txt', ''); + + expect(requestOptions(requestWithAuthentication).uri).toBe( + `${webDavUrl}//folder%20name/test%20file.txt`, + ); + }); + + it('throws NodeOperationError when Nextcloud responds with a fatal error page', async () => { + const { functions, requestWithAuthentication } = buildFunctions(); + requestWithAuthentication.mockResolvedValue('Fatal error broken response'); + + const promise = nextCloudApiRequest.call(functions, 'GET', '/test.txt', ''); + + await expect(promise).rejects.toThrow(NodeOperationError); + await expect(promise).rejects.toThrow("NextCloud responded with a 'Fatal error'"); + }); + + it('strips standard remote.php/webdav path for OCS requests', async () => { + const { functions, requestWithAuthentication } = buildFunctions(); + requestWithAuthentication.mockResolvedValue(''); + + await nextCloudApiRequest.call( + functions, + 'POST', + 'ocs/v1.php/cloud/users', + '', + {}, + undefined, + undefined, + false, + ); + + expect(requestOptions(requestWithAuthentication).uri).toBe( + 'https://nextcloud.example.com/ocs/v1.php/cloud/users', + ); + }); + + it('handles non-standard WebDAV URLs with subpath gracefully', async () => { + const customUrl = 'https://custom.example.com/nextcloud/dav'; + const { functions, getCredentials, requestWithAuthentication } = buildFunctions(); + getCredentials.mockResolvedValue({ webDavUrl: customUrl }); + requestWithAuthentication.mockResolvedValue({ status: 'ok' }); + + await nextCloudApiRequest.call( + functions, + 'GET', + '/test.txt', + '', + undefined, + undefined, + undefined, + true, + ); + + expect(requestOptions(requestWithAuthentication).uri).toBe(`${customUrl}//test.txt`); + expect(requestOptions(requestWithAuthentication).uri).toEqual(expect.stringContaining('/dav')); + }); +}); diff --git a/packages/nodes-base/nodes/NextCloud/test/NextCloud.node.test.ts b/packages/nodes-base/nodes/NextCloud/test/NextCloud.node.test.ts new file mode 100644 index 00000000000..f0f6e6cbce4 --- /dev/null +++ b/packages/nodes-base/nodes/NextCloud/test/NextCloud.node.test.ts @@ -0,0 +1,950 @@ +import type { + IDataObject, + IExecuteFunctions, + INode, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import type { Mock } from 'vitest'; + +import { NextCloud } from '../NextCloud.node'; + +const webDavUrl = 'https://nextcloud.example.com/remote.php/webdav'; +const baseUrl = 'https://nextcloud.example.com'; + +const ocsSuccessResponse = ` + + ok + 123https://nc.example.com/s/abc +`; + +const ocsUserResponse = ` + + ok + alicealice@example.comAlice +`; + +const ocsUserListResponse = ` + + ok + alicebob +`; + +const ocsErrorResponse = ` + + failureUser not found +`; + +const webDavFilePropfindResponse = ` + + + /remote.php/webdav/test.txt + 55555 + +`; + +const webDavFolderListResponse = ` + + + /remote.php/webdav/projects/ + Mon, 01 Jan 2024"folder-etag" + + + /remote.php/webdav/projects/file1.txt + Tue, 02 Jan 20241024text/plain"file-etag" + +`; + +const webDavMissingFileIdResponse = ` + + + /remote.php/webdav/test.txt + + +`; + +const webDavFolderPropfindResponse = ` + + + /remote.php/webdav/projects/ + 77777 + + + /remote.php/webdav/projects/file1.txt + 88888 + +`; + +type ParameterValue = string | number | boolean | IDataObject; + +interface BuildExecuteFunctionsOptions { + parameters: Record | Array>; + inputData?: INodeExecutionData[]; + authentication?: 'accessToken' | 'oAuth2'; + continueOnFail?: boolean; +} + +const nextCloudNode = new NextCloud(); + +function buildExecuteFunctions({ + parameters, + inputData = [{ json: {} }], + authentication = 'accessToken', + continueOnFail = false, +}: BuildExecuteFunctionsOptions) { + const requestWithAuthentication = vi.fn(); + const getCredentials = vi.fn(async () => ({ webDavUrl })); + const prepareBinaryData = vi.fn(async () => ({ + data: 'prepared-binary-data', + mimeType: 'text/plain', + fileName: 'test.txt', + })); + const getBinaryDataBuffer = vi.fn(async () => Buffer.from('binary upload')); + const assertBinaryData = vi.fn(); + const constructExecutionMetaData = vi.fn( + (data: INodeExecutionData[], metadata?: { itemData?: { item: number } }) => + data.map((item) => ({ ...item, pairedItem: metadata?.itemData })), + ); + + const parameterForItem = (itemIndex: number) => + Array.isArray(parameters) ? (parameters[itemIndex] ?? parameters[0]) : parameters; + + const executeFunctions = { + continueOnFail: vi.fn(() => continueOnFail), + getCredentials, + getInputData: vi.fn(() => inputData), + getNode: vi.fn( + () => + ({ + id: 'nextcloud-node', + name: 'Nextcloud', + type: 'n8n-nodes-base.nextCloud', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }) as INode, + ), + getNodeParameter: vi.fn( + (parameterName: string, itemIndex: number, defaultValue?: ParameterValue) => { + if (parameterName === 'authentication') return authentication; + const itemParameters = parameterForItem(itemIndex); + if (parameterName in itemParameters) return itemParameters[parameterName]; + return defaultValue; + }, + ), + helpers: { + assertBinaryData, + constructExecutionMetaData, + getBinaryDataBuffer, + prepareBinaryData, + requestWithAuthentication, + }, + } as unknown as IExecuteFunctions; + + return { + assertBinaryData, + constructExecutionMetaData, + executeFunctions, + getBinaryDataBuffer, + getCredentials, + prepareBinaryData, + requestWithAuthentication, + }; +} + +async function executeNode(executeFunctions: IExecuteFunctions) { + return (await (nextCloudNode as INodeType).execute!.call( + executeFunctions, + )) as INodeExecutionData[][]; +} + +function requestOptions(requestWithAuthentication: Mock, callIndex = 0) { + return requestWithAuthentication.mock.calls[callIndex][1] as IDataObject; +} + +function expectWebDavUri(uri: unknown) { + expect(uri).toEqual(expect.stringContaining('/remote.php/webdav')); +} + +function expectOcsUri(uri: unknown) { + expect(uri).not.toEqual(expect.stringContaining('/remote.php/webdav')); +} + +function webDavUri(path: string) { + return `${webDavUrl}/${path}`; +} + +describe('NextCloud Node', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe.each(['accessToken', 'oAuth2'] as const)('authentication: %s', (authentication) => { + it('uses the matching credential type', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + authentication, + parameters: { + resource: 'file', + operation: 'delete', + path: '/test.txt', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'deleted' }); + + await executeNode(executeFunctions); + + expect(requestWithAuthentication).toHaveBeenCalledWith( + authentication === 'accessToken' ? 'nextCloudApi' : 'nextCloudOAuth2Api', + expect.any(Object), + ); + }); + }); + + describe('file', () => { + it('downloads a file as binary data', async () => { + const downloadBuffer = Buffer.from('downloaded file'); + const { executeFunctions, prepareBinaryData, requestWithAuthentication } = + buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'download', + path: '/test.txt', + binaryPropertyName: 'data', + }, + }); + requestWithAuthentication.mockResolvedValue(downloadBuffer); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'GET', + uri: `${webDavUri('/test.txt')}`, + encoding: null, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(prepareBinaryData).toHaveBeenCalledWith(downloadBuffer, '/test.txt'); + expect(result[0][0]).toEqual({ + json: {}, + pairedItem: { item: 0 }, + binary: { + data: { + data: 'prepared-binary-data', + mimeType: 'text/plain', + fileName: 'test.txt', + }, + }, + }); + }); + + it('uploads a text file', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'upload', + path: '/test.txt', + binaryDataUpload: false, + fileContent: 'hello world', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'uploaded' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PUT', + uri: `${webDavUri('/test.txt')}`, + body: 'hello world', + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'uploaded' }, pairedItem: { item: 0 } }]]); + }); + + it('uploads a binary file', async () => { + const { executeFunctions, getBinaryDataBuffer, requestWithAuthentication } = + buildExecuteFunctions({ + inputData: [ + { json: {}, binary: { data: { data: 'binary-data', mimeType: 'text/plain' } } }, + ], + parameters: { + resource: 'file', + operation: 'upload', + path: '/test.txt', + binaryDataUpload: true, + binaryPropertyName: 'data', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'uploaded' }); + + const result = await executeNode(executeFunctions); + + expect(getBinaryDataBuffer).toHaveBeenCalledWith(0, 'data'); + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PUT', + uri: `${webDavUri('/test.txt')}`, + body: Buffer.from('binary upload'), + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'uploaded' }, pairedItem: { item: 0 } }]]); + }); + + it('copies a file', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'copy', + path: '/from.txt', + toPath: '/to.txt', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'copied' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'COPY', + uri: `${webDavUri('/from.txt')}`, + headers: { Destination: `${webDavUri('/to.txt')}` }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'copied' }, pairedItem: { item: 0 } }]]); + }); + + it('moves a file', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'move', + path: '/from.txt', + toPath: '/to.txt', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'moved' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'MOVE', + uri: `${webDavUri('/from.txt')}`, + headers: { Destination: `${webDavUri('/to.txt')}` }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'moved' }, pairedItem: { item: 0 } }]]); + }); + + it('deletes a file', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'delete', + path: '/test.txt', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'deleted' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'DELETE', + uri: `${webDavUri('/test.txt')}`, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'deleted' }, pairedItem: { item: 0 } }]]); + }); + }); + + describe('folder', () => { + it('creates a folder', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'folder', + operation: 'create', + path: '/projects', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'created' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'MKCOL', + uri: `${webDavUri('/projects')}`, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'created' }, pairedItem: { item: 0 } }]]); + }); + + it('lists a folder', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'folder', + operation: 'list', + path: '/projects', + }, + }); + requestWithAuthentication.mockResolvedValue(webDavFolderListResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUri('/projects')}`, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([ + [ + { + json: { + path: 'projects/file1.txt', + type: 'file', + lastModified: 'Tue, 02 Jan 2024', + contentLength: '1024', + contentType: 'text/plain', + eTag: 'file-etag', + }, + pairedItem: { item: 0 }, + }, + ], + ]); + }); + + it('copies a folder', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'folder', + operation: 'copy', + path: '/projects', + toPath: '/archive/projects', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'copied' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'COPY', + uri: `${webDavUri('/projects')}`, + headers: { Destination: `${webDavUri('/archive/projects')}` }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'copied' }, pairedItem: { item: 0 } }]]); + }); + + it('moves a folder', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'folder', + operation: 'move', + path: '/projects', + toPath: '/archive/projects', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'moved' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'MOVE', + uri: `${webDavUri('/projects')}`, + headers: { Destination: `${webDavUri('/archive/projects')}` }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'moved' }, pairedItem: { item: 0 } }]]); + }); + + it('deletes a folder', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'folder', + operation: 'delete', + path: '/projects', + }, + }); + requestWithAuthentication.mockResolvedValue({ status: 'deleted' }); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'DELETE', + uri: `${webDavUri('/projects')}`, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'deleted' }, pairedItem: { item: 0 } }]]); + }); + }); + + describe('user', () => { + it('creates a user', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'create', + userId: 'alice', + email: 'alice@example.com', + additionalFields: { displayName: 'Alice' }, + }, + }); + requestWithAuthentication.mockResolvedValue(ocsSuccessResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'POST', + uri: `${baseUrl}/ocs/v1.php/cloud/users`, + headers: { + 'OCS-APIRequest': true, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'userid=alice&email=alice@example.com&displayName=Alice', + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([ + [{ json: { id: '123', url: 'https://nc.example.com/s/abc' }, pairedItem: { item: 0 } }], + ]); + }); + + it('deletes a user', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'delete', + userId: 'alice', + }, + }); + requestWithAuthentication.mockResolvedValue(ocsSuccessResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'DELETE', + uri: `${baseUrl}/ocs/v1.php/cloud/users/alice`, + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'ok' }, pairedItem: { item: 0 } }]]); + }); + + it('gets a user', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'get', + userId: 'alice', + }, + }); + requestWithAuthentication.mockResolvedValue(ocsUserResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'GET', + uri: `${baseUrl}/ocs/v1.php/cloud/users/alice`, + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([ + [ + { + json: { id: 'alice', email: 'alice@example.com', displayname: 'Alice' }, + pairedItem: { item: 0 }, + }, + ], + ]); + }); + + it('gets all users without a limit', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'getAll', + returnAll: true, + options: { search: 'a' }, + }, + }); + requestWithAuthentication.mockResolvedValue(ocsUserListResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'GET', + uri: `${baseUrl}/ocs/v1.php/cloud/users`, + qs: { search: 'a' }, + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([ + [ + { json: { id: 'alice' }, pairedItem: { item: 0 } }, + { json: { id: 'bob' }, pairedItem: { item: 0 } }, + ], + ]); + }); + + it('gets users with a limit', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'getAll', + returnAll: false, + limit: 1, + options: {}, + }, + }); + requestWithAuthentication.mockResolvedValue(ocsUserListResponse); + + await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'GET', + uri: `${baseUrl}/ocs/v1.php/cloud/users`, + qs: { limit: 1 }, + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + }); + + it('updates a user', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'update', + userId: 'alice', + updateFields: { + field: { + key: 'email', + value: 'alice.updated@example.com', + }, + }, + }, + }); + requestWithAuthentication.mockResolvedValue(ocsSuccessResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PUT', + uri: `${baseUrl}/ocs/v1.php/cloud/users/alice`, + body: 'key=email&value=alice.updated@example.com', + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + expect(result).toEqual([[{ json: { status: 'ok' }, pairedItem: { item: 0 } }]]); + }); + }); + + describe.each([ + { resource: 'file', path: '/test.txt' }, + { resource: 'folder', path: '/projects' }, + ])('share: $resource', ({ resource, path }) => { + it.each([ + { shareType: 0, name: 'user', parameterName: 'user', shareWith: 'alice' }, + { shareType: 1, name: 'group', parameterName: 'groupId', shareWith: 'engineering' }, + { shareType: 3, name: 'public link', parameterName: undefined, shareWith: undefined }, + { shareType: 4, name: 'email', parameterName: 'email', shareWith: 'alice@example.com' }, + { shareType: 7, name: 'circle', parameterName: 'circleId', shareWith: 'circle-1' }, + ])('creates a $name share', async ({ shareType, parameterName, shareWith }) => { + const parameters: Record = { + resource, + operation: 'share', + path, + shareType, + options: shareType === 3 ? { password: 'secret' } : {}, + }; + if (parameterName && shareWith) parameters[parameterName] = shareWith; + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ parameters }); + requestWithAuthentication.mockResolvedValue(ocsSuccessResponse); + + const result = await executeNode(executeFunctions); + + const body = requestOptions(requestWithAuthentication).body as string; + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'POST', + uri: `${baseUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares`, + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + expect(body).toContain(`path=${encodeURIComponent(path)}`); + expect(body).toContain(`shareType=${shareType}`); + if (shareWith) expect(body).toContain(`shareWith=${encodeURIComponent(shareWith)}`); + if (shareType === 3) expect(body).toContain('password=secret'); + expect(result).toEqual([ + [{ json: { id: '123', url: 'https://nc.example.com/s/abc' }, pairedItem: { item: 0 } }], + ]); + }); + + it('returns an internal link from the WebDAV file id', async () => { + const { constructExecutionMetaData, executeFunctions, requestWithAuthentication } = + buildExecuteFunctions({ + parameters: { + resource, + operation: 'share', + path, + shareType: 200, + options: {}, + }, + }); + requestWithAuthentication.mockResolvedValue(webDavFilePropfindResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUrl}/${encodeURI(path)}`, + headers: { + Depth: '0', + 'Content-Type': 'application/xml', + }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(constructExecutionMetaData).toHaveBeenCalledWith( + [{ json: { link: `${baseUrl}/f/55555` } }], + { itemData: { item: 0 } }, + ); + expect(result).toEqual([[{ json: { link: `${baseUrl}/f/55555` }, pairedItem: { item: 0 } }]]); + }); + + it('returns an internal link from folder PROPFIND with multiple responses', async () => { + const { constructExecutionMetaData, executeFunctions, requestWithAuthentication } = + buildExecuteFunctions({ + parameters: { + resource: 'folder', + operation: 'share', + path: '/projects', + shareType: 200, + options: {}, + }, + }); + requestWithAuthentication.mockResolvedValue(webDavFolderPropfindResponse); + + const result = await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUrl}//projects`, + headers: { + Depth: '0', + 'Content-Type': 'application/xml', + }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + // Should use the first response (folder) fileid, not the child file + expect(constructExecutionMetaData).toHaveBeenCalledWith( + [{ json: { link: `${baseUrl}/f/77777` } }], + { itemData: { item: 0 } }, + ); + expect(result).toEqual([[{ json: { link: `${baseUrl}/f/77777` }, pairedItem: { item: 0 } }]]); + }); + + it('throws when webDavUrl does not match the expected pattern for internal links', async () => { + const { executeFunctions, getCredentials, requestWithAuthentication } = buildExecuteFunctions( + { + parameters: { + resource, + operation: 'share', + path, + shareType: 200, + options: {}, + }, + }, + ); + // Override credential to a non-standard WebDAV URL + getCredentials.mockResolvedValue({ webDavUrl: 'https://nc.example.com/dav' }); + requestWithAuthentication.mockResolvedValue(webDavFilePropfindResponse); + + const promise = executeNode(executeFunctions); + await expect(promise).rejects.toThrow(NodeOperationError); + await expect(promise).rejects.toThrow('must end with /remote.php/webdav'); + }); + }); + + describe('errors', () => { + it('throws NodeApiError on an OCS error response', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'user', + operation: 'get', + userId: 'missing-user', + }, + }); + requestWithAuthentication.mockResolvedValue(ocsErrorResponse); + + await expect(executeNode(executeFunctions)).rejects.toThrow(NodeApiError); + }); + + it('propagates WebDAV request errors', async () => { + const webDavError = new Error('404 Not Found'); + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'delete', + path: '/missing.txt', + }, + }); + requestWithAuthentication.mockRejectedValue(webDavError); + + await expect(executeNode(executeFunctions)).rejects.toThrow(webDavError); + }); + + it('throws NodeOperationError when an internal link PROPFIND response has no file id', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'share', + path: '/test.txt', + shareType: 200, + options: {}, + }, + }); + requestWithAuthentication.mockResolvedValue(webDavMissingFileIdResponse); + + const promise = executeNode(executeFunctions); + await expect(promise).rejects.toThrow(NodeOperationError); + await expect(promise).rejects.toThrow('oc:fileid not found'); + }); + + it('throws NodeOperationError when an internal link PROPFIND response is not a string', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + parameters: { + resource: 'file', + operation: 'share', + path: '/test.txt', + shareType: 200, + options: {}, + }, + }); + requestWithAuthentication.mockResolvedValue({}); + + const promise = executeNode(executeFunctions); + await expect(promise).rejects.toThrow(NodeOperationError); + await expect(promise).rejects.toThrow('unexpected response type'); + }); + + it('wraps request errors when continueOnFail is true', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + continueOnFail: true, + parameters: { + resource: 'folder', + operation: 'delete', + path: '/missing', + }, + }); + requestWithAuthentication.mockRejectedValue(new Error('404 Not Found')); + + const result = await executeNode(executeFunctions); + + expect(result).toEqual([[{ json: { error: '404 Not Found' }, pairedItem: { item: 0 } }]]); + }); + + it('returns original items when a file download fails with continueOnFail', async () => { + const inputItem = { json: {} }; + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + continueOnFail: true, + inputData: [inputItem], + parameters: { + resource: 'file', + operation: 'download', + path: '/large-file.mp4', + binaryPropertyName: 'data', + }, + }); + requestWithAuthentication.mockRejectedValue(new Error('Network timeout')); + + const result = await executeNode(executeFunctions); + + // Should return the original items (with error attached), not returnData + expect(result).toEqual([ + [ + { + json: { error: 'Network timeout' }, + }, + ], + ]); + }); + }); + + describe('multi item execution', () => { + it('keeps WebDAV endpoint usage scoped per item', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + inputData: [{ json: {} }, { json: {} }], + parameters: [ + { + resource: 'file', + operation: 'share', + path: '/test.txt', + shareType: 200, + options: {}, + binaryPropertyName: 'data', + }, + { + resource: 'file', + operation: 'share', + path: '/second.txt', + shareType: 200, + options: {}, + }, + ], + }); + requestWithAuthentication.mockResolvedValue(webDavFilePropfindResponse); + + await executeNode(executeFunctions); + + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUri('/test.txt')}`, + }); + expectWebDavUri(requestOptions(requestWithAuthentication).uri); + expect(requestOptions(requestWithAuthentication, 1)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUri('/second.txt')}`, + }); + expectWebDavUri(requestOptions(requestWithAuthentication, 1).uri); + }); + + it('does not leak OCS headers from a regular share into a subsequent Internal Link request', async () => { + const { executeFunctions, requestWithAuthentication } = buildExecuteFunctions({ + inputData: [{ json: {} }, { json: {} }], + parameters: [ + { + resource: 'file', + operation: 'share', + path: '/first.txt', + shareType: 3, // public link share + options: {}, + }, + { + resource: 'file', + operation: 'share', + path: '/second.txt', + shareType: 200, // internal link + options: {}, + }, + ], + }); + requestWithAuthentication + .mockResolvedValueOnce(ocsSuccessResponse) + .mockResolvedValueOnce(webDavFilePropfindResponse); + + const result = await executeNode(executeFunctions); + + // First request: OCS endpoint with OCS-APIRequest header + expect(requestOptions(requestWithAuthentication)).toMatchObject({ + method: 'POST', + uri: expect.stringContaining('/ocs/v2.php/apps/files_sharing/api/v1/shares'), // fixed slash + headers: { 'OCS-APIRequest': true }, + }); + expectOcsUri(requestOptions(requestWithAuthentication).uri); + + // Second request: WebDAV PROPFIND, must NOT have OCS headers + expect(requestOptions(requestWithAuthentication, 1)).toMatchObject({ + method: 'PROPFIND', + uri: `${webDavUri('/second.txt')}`, + headers: { + 'Content-Type': 'application/xml', + Depth: '0', + }, + }); + expectWebDavUri(requestOptions(requestWithAuthentication, 1).uri); + expect(requestOptions(requestWithAuthentication, 1).headers).not.toHaveProperty( + 'OCS-APIRequest', + ); + + // Results: first item gets OCS share data, second gets internal link + expect(result[0][0].json).toHaveProperty('id'); + expect(result[0][1].json).toEqual({ link: `${baseUrl}/f/55555` }); + }); + }); +});