mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(Nextcloud Node): Fix broken user actions and folder sharing, add internal link share type (#32002)
Co-authored-by: Jörn Bungartz <joern@bungartz.pm> Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 = `<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop><oc:fileid/></d:prop>
|
||||
</d:propfind>`;
|
||||
// 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<string, string>).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 {
|
||||
|
||||
@@ -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('<ocs />');
|
||||
|
||||
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('<ocs />');
|
||||
|
||||
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('<b>Fatal error</b> 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('<ocs />');
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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 = `<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta><status>ok</status></meta>
|
||||
<data><id>123</id><url>https://nc.example.com/s/abc</url></data>
|
||||
</ocs>`;
|
||||
|
||||
const ocsUserResponse = `<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta><status>ok</status></meta>
|
||||
<data><id>alice</id><email>alice@example.com</email><displayname>Alice</displayname></data>
|
||||
</ocs>`;
|
||||
|
||||
const ocsUserListResponse = `<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta><status>ok</status></meta>
|
||||
<data><users><element>alice</element><element>bob</element></users></data>
|
||||
</ocs>`;
|
||||
|
||||
const ocsErrorResponse = `<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta><status>failure</status><message>User not found</message></meta>
|
||||
</ocs>`;
|
||||
|
||||
const webDavFilePropfindResponse = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/webdav/test.txt</d:href>
|
||||
<d:propstat><d:prop><oc:fileid>55555</oc:fileid></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`;
|
||||
|
||||
const webDavFolderListResponse = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/webdav/projects/</d:href>
|
||||
<d:propstat><d:prop><d:getlastmodified>Mon, 01 Jan 2024</d:getlastmodified><d:resourcetype><d:collection /></d:resourcetype><d:getetag>"folder-etag"</d:getetag></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/webdav/projects/file1.txt</d:href>
|
||||
<d:propstat><d:prop><d:getlastmodified>Tue, 02 Jan 2024</d:getlastmodified><d:getcontentlength>1024</d:getcontentlength><d:getcontenttype>text/plain</d:getcontenttype><d:resourcetype></d:resourcetype><d:getetag>"file-etag"</d:getetag></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`;
|
||||
|
||||
const webDavMissingFileIdResponse = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/webdav/test.txt</d:href>
|
||||
<d:propstat><d:prop></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`;
|
||||
|
||||
const webDavFolderPropfindResponse = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/webdav/projects/</d:href>
|
||||
<d:propstat><d:prop><oc:fileid>77777</oc:fileid></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/webdav/projects/file1.txt</d:href>
|
||||
<d:propstat><d:prop><oc:fileid>88888</oc:fileid></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`;
|
||||
|
||||
type ParameterValue = string | number | boolean | IDataObject;
|
||||
|
||||
interface BuildExecuteFunctionsOptions {
|
||||
parameters: Record<string, ParameterValue> | Array<Record<string, ParameterValue>>;
|
||||
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<string, ParameterValue> = {
|
||||
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` });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user