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