From 727f6f3c0e5cef2d0cd4cd1ef1c6fa8f4d3f69ec Mon Sep 17 00:00:00 2001 From: feelgood-interface <78543720+feelgood-interface@users.noreply.github.com> Date: Fri, 28 Feb 2025 11:17:56 +0100 Subject: [PATCH] feat(Azure Storage Node): New node (#12536) --- .../AzureStorageOAuth2Api.credentials.ts | 32 + .../AzureStorageSharedKeyApi.credentials.ts | 96 ++ .../Entra/test/GroupDescription.test.ts | 2 +- .../Entra/test/MicrosoftEntra.node.test.ts | 2 +- .../Entra/test/UserDescription.test.ts | 2 +- .../Microsoft/Storage/AzureStorage.node.json | 18 + .../Microsoft/Storage/AzureStorage.node.ts | 97 ++ .../Microsoft/Storage/GenericFunctions.ts | 582 +++++++ .../Microsoft/Storage/azureStorage.dark.svg | 14 + .../nodes/Microsoft/Storage/azureStorage.svg | 14 + .../nodes/Microsoft/Storage/compare-header.ts | 72 + .../Storage/descriptions/BlobDescription.ts | 1390 +++++++++++++++++ .../descriptions/ContainerDescription.ts | 600 +++++++ .../Microsoft/Storage/descriptions/index.ts | 2 + .../Storage/test/blob/create.test.ts | 72 + .../Storage/test/blob/delete.test.ts | 41 + .../Microsoft/Storage/test/blob/get.test.ts | 93 ++ .../Storage/test/blob/getAll.test.ts | 42 + .../test/blob/getAllLimitOptions.test.ts | 37 + .../Storage/test/container/create.test.ts | 44 + .../Storage/test/container/delete.test.ts | 41 + .../Storage/test/container/get.test.ts | 50 + .../Storage/test/container/getAll.test.ts | 42 + .../test/container/getAllLimitOptions.test.ts | 37 + .../Storage/test/credentials/oauth2.test.ts | 86 + .../test/credentials/sharedKey.test.ts | 158 ++ .../test/listSearch/listSearch.test.ts | 123 ++ .../test/workflows/blob_create.workflow.json | 109 ++ .../test/workflows/blob_delete.workflow.json | 51 + .../test/workflows/blob_get.workflow.json | 114 ++ .../test/workflows/blob_getAll.workflow.json | 82 + .../blob_getAllLimitOptions.workflow.json | 76 + .../workflows/container_create.workflow.json | 54 + .../workflows/container_delete.workflow.json | 48 + .../workflows/container_get.workflow.json | 61 + .../workflows/container_getAll.workflow.json | 73 + ...container_getAllLimitOptions.workflow.json | 59 + .../credentials_oauth2.workflow.json | 65 + .../credentials_sharedKey.workflow.json | 62 + packages/nodes-base/package.json | 3 + .../nodes-base/test/nodes/ExecuteWorkflow.ts | 25 +- .../test/nodes/FakeCredentialsMap.ts | 27 + packages/workflow/src/Interfaces.ts | 4 +- 43 files changed, 4696 insertions(+), 6 deletions(-) create mode 100644 packages/nodes-base/credentials/AzureStorageOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/azureStorage.dark.svg create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/azureStorage.svg create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/blob/create.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/blob/delete.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/blob/get.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAll.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAllLimitOptions.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/container/create.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/container/delete.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/container/get.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/container/getAll.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/container/getAllLimitOptions.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/credentials/oauth2.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/credentials/sharedKey.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_create.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_delete.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_get.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAll.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAllLimitOptions.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_create.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_delete.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_get.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAll.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAllLimitOptions.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_oauth2.workflow.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_sharedKey.workflow.json diff --git a/packages/nodes-base/credentials/AzureStorageOAuth2Api.credentials.ts b/packages/nodes-base/credentials/AzureStorageOAuth2Api.credentials.ts new file mode 100644 index 0000000000..cfb7d7a752 --- /dev/null +++ b/packages/nodes-base/credentials/AzureStorageOAuth2Api.credentials.ts @@ -0,0 +1,32 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class AzureStorageOAuth2Api implements ICredentialType { + name = 'azureStorageOAuth2Api'; + + displayName = 'Azure Storage OAuth2 API'; + + extends = ['microsoftOAuth2Api']; + + documentationUrl = 'azurestorage'; + + properties: INodeProperties[] = [ + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'hidden', + default: '=https://{{ $self["account"] }}.blob.core.windows.net', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: 'https://storage.azure.com/.default', + }, + ]; +} diff --git a/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts new file mode 100644 index 0000000000..f5e0f7a14c --- /dev/null +++ b/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts @@ -0,0 +1,96 @@ +import type { + ICredentialDataDecryptedObject, + ICredentialType, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; +import { createHmac } from 'node:crypto'; + +import { + getCanonicalizedHeadersString, + getCanonicalizedResourceString, + HeaderConstants, +} from '../nodes/Microsoft/Storage/GenericFunctions'; + +export class AzureStorageSharedKeyApi implements ICredentialType { + name = 'azureStorageSharedKeyApi'; + + displayName = 'Azure Storage Shared Key API'; + + documentationUrl = 'azurestorage'; + + properties: INodeProperties[] = [ + { + displayName: 'Account', + name: 'account', + description: 'Account name', + type: 'string', + default: '', + }, + { + displayName: 'Key', + name: 'key', + description: 'Account key', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'hidden', + default: '=https://{{ $self["account"] }}.blob.core.windows.net', + }, + ]; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + if (requestOptions.qs) { + for (const [key, value] of Object.entries(requestOptions.qs)) { + if (value === undefined) { + delete requestOptions.qs[key]; + } + } + } + if (requestOptions.headers) { + for (const [key, value] of Object.entries(requestOptions.headers)) { + if (value === undefined) { + delete requestOptions.headers[key]; + } + } + } + + requestOptions.method ??= 'GET'; + requestOptions.headers ??= {}; + + const stringToSign: string = [ + requestOptions.method.toUpperCase(), + requestOptions.headers[HeaderConstants.CONTENT_LANGUAGE] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_ENCODING] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_LENGTH] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_MD5] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_TYPE] ?? '', + requestOptions.headers[HeaderConstants.DATE] ?? '', + requestOptions.headers[HeaderConstants.IF_MODIFIED_SINCE] ?? '', + requestOptions.headers[HeaderConstants.IF_MATCH] ?? '', + requestOptions.headers[HeaderConstants.IF_NONE_MATCH] ?? '', + requestOptions.headers[HeaderConstants.IF_UNMODIFIED_SINCE] ?? '', + requestOptions.headers[HeaderConstants.RANGE] ?? '', + getCanonicalizedHeadersString(requestOptions) + + getCanonicalizedResourceString(requestOptions, credentials), + ].join('\n'); + + const signature: string = createHmac('sha256', Buffer.from(credentials.key as string, 'base64')) + .update(stringToSign, 'utf8') + .digest('base64'); + + requestOptions.headers[HeaderConstants.AUTHORIZATION] = + `SharedKey ${credentials.account as string}:${signature}`; + + return requestOptions; + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Entra/test/GroupDescription.test.ts b/packages/nodes-base/nodes/Microsoft/Entra/test/GroupDescription.test.ts index 632792ea96..26e0eb242d 100644 --- a/packages/nodes-base/nodes/Microsoft/Entra/test/GroupDescription.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Entra/test/GroupDescription.test.ts @@ -7,7 +7,7 @@ import type { WorkflowTestData } from '@test/nodes/types'; import { microsoftEntraApiResponse, microsoftEntraNodeResponse } from './mocks'; -describe('Gong Node', () => { +describe('Microsoft Entra Node', () => { const baseUrl = 'https://graph.microsoft.com/v1.0'; beforeEach(() => { diff --git a/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts b/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts index 428e64d94b..84f7f7169c 100644 --- a/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts @@ -15,7 +15,7 @@ import { microsoftEntraApiResponse, microsoftEntraNodeResponse } from './mocks'; import { FAKE_CREDENTIALS_DATA } from '../../../../test/nodes/FakeCredentialsMap'; import { MicrosoftEntra } from '../MicrosoftEntra.node'; -describe('Gong Node', () => { +describe('Microsoft Entra Node', () => { const baseUrl = 'https://graph.microsoft.com/v1.0'; beforeEach(() => { diff --git a/packages/nodes-base/nodes/Microsoft/Entra/test/UserDescription.test.ts b/packages/nodes-base/nodes/Microsoft/Entra/test/UserDescription.test.ts index b245e12f70..5a281a1bdc 100644 --- a/packages/nodes-base/nodes/Microsoft/Entra/test/UserDescription.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Entra/test/UserDescription.test.ts @@ -7,7 +7,7 @@ import type { WorkflowTestData } from '@test/nodes/types'; import { microsoftEntraApiResponse, microsoftEntraNodeResponse } from './mocks'; -describe('Gong Node', () => { +describe('Microsoft Entra Node', () => { const baseUrl = 'https://graph.microsoft.com/v1.0'; beforeEach(() => { diff --git a/packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.json b/packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.json new file mode 100644 index 0000000000..abe51cd6e8 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.azureStorage", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/microsoft/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.azurestorage/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.ts b/packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.ts new file mode 100644 index 0000000000..81940b2f45 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/AzureStorage.node.ts @@ -0,0 +1,97 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; + +import { blobFields, blobOperations, containerFields, containerOperations } from './descriptions'; +import { getBlobs, getContainers } from './GenericFunctions'; + +export class AzureStorage implements INodeType { + description: INodeTypeDescription = { + displayName: 'Azure Storage', + name: 'azureStorage', + icon: { + light: 'file:azureStorage.svg', + dark: 'file:azureStorage.dark.svg', + }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interact with Azure Storage API', + defaults: { + name: 'Azure Storage', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'azureStorageOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + { + name: 'azureStorageSharedKeyApi', + required: true, + displayOptions: { + show: { + authentication: ['sharedKey'], + }, + }, + }, + ], + requestDefaults: { + baseURL: '={{ $credentials.baseUrl }}', + }, + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'OAuth2', + value: 'oAuth2', + }, + { + name: 'Shared Key', + value: 'sharedKey', + }, + ], + default: 'sharedKey', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Blob', + value: 'blob', + }, + { + name: 'Container', + value: 'container', + }, + ], + default: 'container', + }, + + ...blobOperations, + ...blobFields, + ...containerOperations, + ...containerFields, + ], + }; + + methods = { + loadOptions: {}, + + listSearch: { + getBlobs, + getContainers, + }, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts new file mode 100644 index 0000000000..835dbf7012 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts @@ -0,0 +1,582 @@ +import { camelCase } from 'change-case'; +import type { + JsonObject, + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + INodeExecutionData, + IN8nHttpFullResponse, + INodeListSearchResult, + INodeListSearchItems, + INodeParameterResourceLocator, + ICredentialDataDecryptedObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { Parser } from 'xml2js'; +import { firstCharLowerCase, parseBooleans, parseNumbers } from 'xml2js/lib/processors'; + +import { compareHeader } from './compare-header'; + +export const XMsVersion = '2021-12-02'; + +export const HeaderConstants = { + AUTHORIZATION: 'authorization', + CONTENT_ENCODING: 'content-encoding', + CONTENT_LANGUAGE: 'content-language', + CONTENT_LENGTH: 'content-length', + CONTENT_MD5: 'content-md5', + CONTENT_TYPE: 'content-Type', + DATE: 'date', + ETAG: 'etag', + IF_MATCH: 'if-match', + IF_MODIFIED_SINCE: 'if-Modified-since', + IF_NONE_MATCH: 'if-none-match', + IF_UNMODIFIED_SINCE: 'if-unmodified-since', + ORIGIN: 'origin', + RANGE: 'range', + X_MS_COPY_SOURCE: 'x-ms-copy-source', + X_MS_DATE: 'x-ms-date', + X_MS_VERSION: 'x-ms-version', + X_MS_BLOB_TYPE: 'x-ms-blob-type', + X_MS_BLOB_CONTENT_DISPOSITION: 'x-ms-blob-content-disposition', + X_MS_BLOB_PUBLIC_ACCESS: 'x-ms-blob-public-access', + X_MS_HAS_IMMUTABILITY_POLICY: 'x-ms-has-immutability-policy', + X_MS_HAS_LEGAL_HOLD: 'x-ms-has-legal-hold', + X_MS_CONTENT_CRC64: 'x-ms-content-crc64', + X_MS_REQUEST_SERVER_ENCRYPTED: 'x-ms-request-server-encrypted', + X_MS_ENCRYPTION_SCOPE: 'x-ms-encryption-scope', + X_MS_VERSION_ID: 'x-ms-version-id', + X_MS_TAG_COUNT: 'x-ms-tag-count', + X_MS_COPY_PROGRESS: 'x-ms-copy-progress', + X_MS_INCREMENTAL_COPY: 'x-ms-incremental-copy', + X_MS_BLOB_SEQUENCE_NUMBER: 'x-ms-blob-sequence-number', + X_MS_BLOB_COMMITTED_BLOCK_COUNT: 'x-ms-blob-committed-block-count', + X_MS_SERVER_ENCRYPTED: 'x-ms-server-encrypted', + X_MS_ENCRYPTION_CONTEXT: 'x-ms-encryption-context', + X_MS_BLOB_CONTENT_MD5: 'x-ms-blob-content-md5', + X_MS_BLOB_SEALED: 'x-ms-blob-sealed', + X_MS_IMMUTABILITY_POLICY_UNTIL_DATE: 'x-ms-immutability-policy-until-date', + X_MS_IMMUTABILITY_POLICY_MODE: 'x-ms-immutability-policy-mode', + X_MS_LEGAL_HOLD: 'x-ms-legal-hold', + X_MS_DELETE_TYPE_PERMANENT: 'x-ms-delete-type-permanent', + X_MS_ACCESS_TIER: 'x-ms-access-tier', + X_MS_BLOB_CACHE_CONTROL: 'x-ms-blob-cache-control', + X_MS_LEASE_ID: 'x-ms-lease-id', + X_MS_BLOB_CONTENT_ENCODING: 'x-ms-blob-content-encoding', + X_MS_BLOB_CONTENT_LANGUAGE: 'x-ms-blob-content-language', + X_MS_BLOB_CONTENT_TYPE: 'x-ms-blob-content-type', + X_MS_EXPIRY_OPTION: 'x-ms-expiry-option', + X_MS_EXPIRY_TIME: 'x-ms-expiry-time', + X_MS_TAGS: 'x-ms-tags', + X_MS_UPN: 'x-ms-upn', + PREFIX_X_MS: 'x-ms-', + PREFIX_X_MS_META: 'x-ms-meta-', +}; + +export async function azureStorageApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + qs?: IDataObject, + headers?: IDataObject, + url?: string, +): Promise { + const authentication = this.getNodeParameter('authentication', 0) as 'oAuth2' | 'sharedKey'; + const credentialsType = + authentication === 'oAuth2' ? 'azureStorageOAuth2Api' : 'azureStorageSharedKeyApi'; + const credentials = await this.getCredentials<{ + baseUrl: string; + }>(credentialsType); + + const options: IHttpRequestOptions = { + method, + url: url ?? `${credentials.baseUrl}${endpoint}`, + headers, + body, + qs, + }; + + options.headers ??= {}; + options.headers[HeaderConstants.X_MS_DATE] = new Date().toUTCString(); + options.headers[HeaderConstants.X_MS_VERSION] = XMsVersion; + + // XML response + const response = (await this.helpers.requestWithAuthentication.call( + this, + credentialsType, + options, + )) as string; + + return response; +} + +export async function handleErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const resource = this.getNodeParameter('resource') as string; + const operation = this.getNodeParameter('operation') as string; + + const parser = new Parser({ + explicitArray: false, + tagNameProcessors: [firstCharLowerCase], + }); + const { error } = + ((await parser.parseStringPromise(data[0].json as unknown as string)) as { + error: { + code: string; + message: string; + headerName?: string; + headerValue?: string; + }; + }) ?? {}; + + if ( + error?.code === 'InvalidAuthenticationInfo' && + ((error as IDataObject)?.authenticationErrorDetail as string) === + 'Lifetime validation failed. The token is expired.' + ) { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: 'Lifetime validation failed. The token is expired.', + description: 'Please check your credentials and try again', + }); + } + + if (resource === 'blob') { + if (error?.code === 'ContainerNotFound') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: "The required container doesn't match any existing one", + description: "Double-check the value in the parameter 'Container Name' and try again", + }); + } + + if (operation === 'create') { + if ( + this.getNodeParameter('from') === 'url' && + ((error?.code === 'InvalidHeaderValue' && + error?.headerName === HeaderConstants.X_MS_COPY_SOURCE) || + error?.code === 'CannotVerifyCopySource') + ) { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: 'The provided URL is invalid', + description: "Double-check the value in the parameter 'URL' and try again", + }); + } + } else if (operation === 'delete') { + if (error?.code === 'BlobNotFound') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: "The required blob doesn't match any existing one", + description: "Double-check the value in the parameter 'Blob to Delete' and try again", + }); + } + } else if (operation === 'get') { + if (error?.code === 'BlobNotFound') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: "The required blob doesn't match any existing one", + description: "Double-check the value in the parameter 'Blob to Get' and try again", + }); + } + } else if (operation === 'getAll') { + if ( + error?.code === 'InvalidQueryParameterValue' && + (this.getNodeParameter('fields', []) as string[]).includes('permissions') + ) { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: + 'Permissions field is only supported for accounts with a hierarchical namespace enabled', + description: "Exclude 'Permissions' from 'Fields' and try again", + }); + } + if ( + error?.code === 'UnsupportedQueryParameter' && + (this.getNodeParameter('fields', []) as string[]).includes('deleted') && + (this.getNodeParameter('filter', []) as string[]).includes('deleted') + ) { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: "Including 'Deleted' field and filter is not supported", + description: "Exclude 'Deleted' from 'Fields' or 'Filter' and try again", + }); + } + } + } else if (resource === 'container') { + if (operation === 'create') { + if (error?.code === 'ContainerAlreadyExists') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: 'The specified container already exists', + description: "Use a unique value for 'Container Name' and try again", + }); + } + if (error?.code === 'PublicAccessNotPermitted') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: 'Public access is not permitted on this storage account', + description: "Check the 'Access Level' and try again", + }); + } + } else if (operation === 'delete') { + if (error?.code === 'ContainerNotFound') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: "The required container doesn't match any existing one", + description: + "Double-check the value in the parameter 'Container to Delete' and try again", + }); + } + } else if (operation === 'get') { + if (error?.code === 'ContainerNotFound') { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: "The required container doesn't match any existing one", + description: "Double-check the value in the parameter 'Container to Get' and try again", + }); + } + } else if (operation === 'getAll') { + } + } + + if (error) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + message: error.code, + description: error.message, + }); + } else { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + parseXml: true, + }); + } + } + + return data; +} + +export function getCanonicalizedHeadersString(requestOptions: IHttpRequestOptions): string { + let headersArray: Array<{ name: string; value: string }> = []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const [name, value] of Object.entries(requestOptions.headers!) as unknown as [ + string, + string, + ]) { + if (name.toLowerCase().startsWith(HeaderConstants.PREFIX_X_MS)) { + headersArray.push({ name, value }); + } + } + + headersArray.sort((a, b): number => { + return compareHeader(a.name.toLowerCase(), b.name.toLowerCase()); + }); + + // Remove duplicate headers + headersArray = headersArray.filter((value, index, array) => { + if (index > 0 && value.name.toLowerCase() === array[index - 1].name.toLowerCase()) { + return false; + } + return true; + }); + + let canonicalizedHeadersStringToSign: string = ''; + headersArray.forEach((header) => { + canonicalizedHeadersStringToSign += `${header.name + .toLowerCase() + .trimEnd()}:${header.value.trimStart()}\n`; + }); + + return canonicalizedHeadersStringToSign; +} + +export function getCanonicalizedResourceString( + requestOptions: IHttpRequestOptions, + credentials: ICredentialDataDecryptedObject, +): string { + const path: string = new URL(requestOptions.baseURL + requestOptions.url).pathname || '/'; + let canonicalizedResourceString = `/${credentials.account as string}${path}`; + if (requestOptions.qs && Object.keys(requestOptions.qs).length > 0) { + canonicalizedResourceString += + '\n' + + Object.keys(requestOptions.qs) + .sort() + .map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (key) => `${key.toLowerCase()}:${decodeURIComponent(requestOptions.qs![key] as string)}`, + ) + .join('\n'); + } + + return canonicalizedResourceString; +} + +export function parseHeaders(headers: IDataObject) { + const parseBooleanHeaders = [ + HeaderConstants.X_MS_DELETE_TYPE_PERMANENT, + HeaderConstants.X_MS_INCREMENTAL_COPY, + HeaderConstants.X_MS_SERVER_ENCRYPTED, + HeaderConstants.X_MS_BLOB_SEALED, + HeaderConstants.X_MS_REQUEST_SERVER_ENCRYPTED, + HeaderConstants.X_MS_HAS_IMMUTABILITY_POLICY, + HeaderConstants.X_MS_HAS_LEGAL_HOLD, + ]; + const parseNumberHeaders = [ + HeaderConstants.X_MS_TAG_COUNT, + HeaderConstants.CONTENT_LENGTH, + HeaderConstants.X_MS_BLOB_SEQUENCE_NUMBER, + HeaderConstants.X_MS_COPY_PROGRESS, + HeaderConstants.X_MS_BLOB_COMMITTED_BLOCK_COUNT, + ]; + + const result: IDataObject = {}; + + const metadataKeys = Object.keys(headers).filter((x) => + x.startsWith(HeaderConstants.PREFIX_X_MS_META), + ); + + for (const key in headers) { + if (metadataKeys.includes(key)) { + continue; + } + + let newKey = key.startsWith(HeaderConstants.PREFIX_X_MS) + ? camelCase(key.replace(HeaderConstants.PREFIX_X_MS, '')) + : camelCase(key); + newKey = newKey.replace('-', ''); + + const newValue = parseBooleanHeaders.includes(key) + ? parseBooleans(headers[key] as string) + : parseNumberHeaders.includes(key) + ? parseNumbers(headers[key] as string) + : headers[key]; + + result[newKey] = newValue; + } + + if (metadataKeys.length > 0) { + result.metadata = {}; + for (const key of metadataKeys) { + (result.metadata as IDataObject)[key.replace(HeaderConstants.PREFIX_X_MS_META, '')] = + headers[key]; + } + } + + return result; +} + +export async function parseBlobList( + xml: string, +): Promise<{ blobs: IDataObject[]; maxResults?: number; nextMarker?: string }> { + const parser = new Parser({ + explicitArray: false, + tagNameProcessors: [firstCharLowerCase, (name) => name.replace('-', '')], + valueProcessors: [ + function (value, name) { + if ( + [ + 'deleted', + 'isCurrentVersion', + 'serverEncrypted', + 'incrementalCopy', + 'accessTierInferred', + 'isSealed', + 'legalHold', + ].includes(name) + ) { + return parseBooleans(value); + } else if ( + [ + 'maxResults', + 'contentLength', + 'blobSequenceNumber', + 'remainingRetentionDays', + 'tagCount', + 'content-Length', + ].includes(name) + ) { + return parseNumbers(value); + } + return value; + }, + ], + }); + const data = (await parser.parseStringPromise(xml)) as { + enumerationResults: { + blobs: { blob: IDataObject | IDataObject[] }; + maxResults: number; + nextMarker: string; + prefix: string; + }; + }; + + if (typeof data.enumerationResults.blobs !== 'object') { + // No items + return { blobs: [] }; + } + + if (!Array.isArray(data.enumerationResults.blobs.blob)) { + // Single item + data.enumerationResults.blobs.blob = [data.enumerationResults.blobs.blob]; + } + + for (const blob of data.enumerationResults.blobs.blob) { + if (blob.tags) { + if (!Array.isArray(((blob.tags as IDataObject).tagSet as IDataObject).tag)) { + ((blob.tags as IDataObject).tagSet as IDataObject).tag = [ + ((blob.tags as IDataObject).tagSet as IDataObject).tag, + ]; + } + blob.tags = ((blob.tags as IDataObject).tagSet as IDataObject).tag; + } + } + + for (const container of data.enumerationResults.blobs.blob) { + if (container.metadata === '') { + delete container.metadata; + } + if (container.orMetadata === '') { + delete container.orMetadata; + } + } + + return { + blobs: data.enumerationResults.blobs.blob, + maxResults: data.enumerationResults.maxResults, + nextMarker: data.enumerationResults.nextMarker, + }; +} + +export async function parseContainerList( + xml: string, +): Promise<{ containers: IDataObject[]; maxResults?: number; nextMarker?: string }> { + const parser = new Parser({ + explicitArray: false, + tagNameProcessors: [firstCharLowerCase, (name) => name.replace('-', '')], + valueProcessors: [ + function (value, name) { + if ( + [ + 'deleted', + 'hasImmutabilityPolicy', + 'hasLegalHold', + 'preventEncryptionScopeOverride', + 'isImmutableStorageWithVersioningEnabled', + ].includes(name) + ) { + return parseBooleans(value); + } else if (['maxResults', 'remainingRetentionDays'].includes(name)) { + return parseNumbers(value); + } + return value; + }, + ], + }); + const data = (await parser.parseStringPromise(xml)) as { + enumerationResults: { + containers: { container: IDataObject | IDataObject[] }; + maxResults: number; + nextMarker: string; + prefix: string; + }; + }; + + if (typeof data.enumerationResults.containers !== 'object') { + // No items + return { containers: [] }; + } + + if (!Array.isArray(data.enumerationResults.containers.container)) { + // Single item + data.enumerationResults.containers.container = [data.enumerationResults.containers.container]; + } + + for (const container of data.enumerationResults.containers.container) { + if (container.metadata === '') { + delete container.metadata; + } + } + + return { + containers: data.enumerationResults.containers.container, + maxResults: data.enumerationResults.maxResults, + nextMarker: data.enumerationResults.nextMarker, + }; +} + +export async function getBlobs( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const container = this.getNodeParameter('container') as INodeParameterResourceLocator; + + let response: string; + + const qs: IDataObject = { + restype: 'container', + comp: 'list', + }; + + if (paginationToken) { + qs.marker = paginationToken; + response = await azureStorageApiRequest.call(this, 'GET', `/${container.value}`, {}, qs); + } else { + qs.maxresults = 5000; + if (filter) { + qs.prefix = filter; + } + response = await azureStorageApiRequest.call(this, 'GET', `/${container.value}`, {}, qs); + } + + const data = await parseBlobList(response); + + const results: INodeListSearchItems[] = data.blobs + .map((c) => ({ + name: c.name as string, + value: c.name as string, + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }), + ); + + return { + results, + paginationToken: data.nextMarker, + }; +} + +export async function getContainers( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + let response: string; + + const qs: IDataObject = { + comp: 'list', + }; + + if (paginationToken) { + qs.marker = paginationToken; + response = await azureStorageApiRequest.call(this, 'GET', '/', {}, qs); + } else { + qs.maxresults = 5000; + if (filter) { + qs.prefix = filter; + } + response = await azureStorageApiRequest.call(this, 'GET', '/', {}, qs); + } + + const data = await parseContainerList(response); + + const results: INodeListSearchItems[] = data.containers + .map((c) => ({ + name: c.name as string, + value: c.name as string, + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }), + ); + + return { + results, + paginationToken: data.nextMarker, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/azureStorage.dark.svg b/packages/nodes-base/nodes/Microsoft/Storage/azureStorage.dark.svg new file mode 100644 index 0000000000..5bb05463a6 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/azureStorage.dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Microsoft/Storage/azureStorage.svg b/packages/nodes-base/nodes/Microsoft/Storage/azureStorage.svg new file mode 100644 index 0000000000..5bb05463a6 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/azureStorage.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts b/packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts new file mode 100644 index 0000000000..4bcfb00a1f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* + * We need to imitate .Net culture-aware sorting, which is used in storage service. + * Below tables contain sort-keys for en-US culture. + */ + +const table_lv0 = new Uint32Array([ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x71c, 0x0, 0x71f, 0x721, + 0x723, 0x725, 0x0, 0x0, 0x0, 0x72d, 0x803, 0x0, 0x0, 0x733, 0x0, 0xd03, 0xd1a, 0xd1c, 0xd1e, + 0xd20, 0xd22, 0xd24, 0xd26, 0xd28, 0xd2a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe02, 0xe09, 0xe0a, + 0xe1a, 0xe21, 0xe23, 0xe25, 0xe2c, 0xe32, 0xe35, 0xe36, 0xe48, 0xe51, 0xe70, 0xe7c, 0xe7e, 0xe89, + 0xe8a, 0xe91, 0xe99, 0xe9f, 0xea2, 0xea4, 0xea6, 0xea7, 0xea9, 0x0, 0x0, 0x0, 0x743, 0x744, 0x748, + 0xe02, 0xe09, 0xe0a, 0xe1a, 0xe21, 0xe23, 0xe25, 0xe2c, 0xe32, 0xe35, 0xe36, 0xe48, 0xe51, 0xe70, + 0xe7c, 0xe7e, 0xe89, 0xe8a, 0xe91, 0xe99, 0xe9f, 0xea2, 0xea4, 0xea6, 0xea7, 0xea9, 0x0, 0x74c, + 0x0, 0x750, 0x0, +]); +const table_lv2 = new Uint32Array([ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, + 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, + 0x12, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, +]); +const table_lv4 = new Uint32Array([ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x8012, 0x0, 0x0, 0x0, 0x0, 0x0, 0x8212, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, +]); + +function isLessThan(lhs: string, rhs: string): boolean { + const tables = [table_lv0, table_lv2, table_lv4]; + let curr_level = 0; + let i = 0; + let j = 0; + while (curr_level < tables.length) { + if (curr_level === tables.length - 1 && i !== j) { + return i > j; + } + const weight1 = i < lhs.length ? tables[curr_level][lhs[i].charCodeAt(0)] : 0x1; + const weight2 = j < rhs.length ? tables[curr_level][rhs[j].charCodeAt(0)] : 0x1; + if (weight1 === 0x1 && weight2 === 0x1) { + i = 0; + j = 0; + ++curr_level; + } else if (weight1 === weight2) { + ++i; + ++j; + } else if (weight1 === 0) { + ++i; + } else if (weight2 === 0) { + ++j; + } else { + return weight1 < weight2; + } + } + return false; +} + +export function compareHeader(lhs: string, rhs: string): number { + if (isLessThan(lhs, rhs)) return -1; + + return 1; +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts new file mode 100644 index 0000000000..0b09bb3ef3 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts @@ -0,0 +1,1390 @@ +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeParameterResourceLocator, + INodeProperties, +} from 'n8n-workflow'; + +import { + handleErrorPostReceive, + HeaderConstants, + parseBlobList, + parseHeaders, + XMsVersion, +} from '../GenericFunctions'; + +export const blobOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['blob'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new blob or replace an existing one', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'PUT', + url: '=/{{ $parameter["container"] }}/{{ $parameter["blobCreate"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + _data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + return [ + { + json: parseHeaders(response.headers), + }, + ]; + }, + ], + }, + }, + action: 'Create blob', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a blob', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'DELETE', + url: '=/{{ $parameter["container"] }}/{{ $parameter["blob"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + _data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + return [ + { + json: parseHeaders(response.headers), + }, + ]; + }, + ], + }, + }, + action: 'Delete blob', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific blob', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + url: '=/{{ $parameter["container"] }}/{{ $parameter["blob"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + encoding: 'arraybuffer', + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + _data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + const headerData = parseHeaders(response.headers); + const simplify = this.getNodeParameter('options.simplify', true) as boolean; + if (simplify) { + delete headerData.acceptRanges; + delete headerData.server; + delete headerData.requestId; + delete headerData.version; + delete headerData.date; + delete headerData.connection; + } + + const { metadata, ...properties } = headerData; + + const newItem: INodeExecutionData = { + json: { + name: (this.getNodeParameter('blob') as INodeParameterResourceLocator).value, + properties, + ...(metadata ? { metadata: metadata as IDataObject } : {}), + }, + binary: {}, + }; + + let fileName: string | undefined; + if (headerData.contentDisposition) { + let fileNameMatch = + /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec( + headerData.contentDisposition as string, + ); + fileName = + fileNameMatch && fileNameMatch.length > 1 + ? fileNameMatch[3] || fileNameMatch[2] + : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = /filename="?([^"]*?)"?(;|$)/g.exec( + headerData.contentDisposition as string, + ); + fileName = + fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + newItem.binary!.data = await this.helpers.prepareBinaryData( + response.body as Buffer, + fileName, + headerData.contentType as string, + ); + + return [newItem]; + }, + ], + }, + }, + action: 'Get blob', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of blobs', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + qs: { + restype: 'container', + comp: 'list', + }, + url: '=/{{ $parameter["container"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + _response: IN8nHttpFullResponse, + ): Promise { + const bodyData = await parseBlobList(data[0].json as unknown as string); + if (this.getNodeParameter('options.simplify', true)) { + for (const blob of bodyData.blobs) { + const properties = blob.properties as IDataObject; + if (!properties.cacheControl) delete properties.cacheControl; + if (!properties.contentCRC64) delete properties.contentCRC64; + if (!properties.contentEncoding) delete properties.contentEncoding; + if (!properties.contentLanguage) delete properties.contentLanguage; + if (!properties.contentDisposition) delete properties.contentDisposition; + delete properties.accessTier; + delete properties.accessTierChangeTime; + delete properties.accessTierInferred; + } + } + return [ + { + json: bodyData, + }, + ]; + }, + ], + }, + }, + action: 'Get many blobs', + }, + ], + default: 'getAll', + }, +]; + +const createFields: INodeProperties[] = [ + { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + description: 'Container to create or replace a blob in', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Blob Name', + name: 'blobCreate', + default: '', + description: 'The name of the new or existing blob', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + placeholder: 'e.g. myblob', + required: true, + type: 'string', + validateType: 'string', + }, + { + displayName: 'From', + name: 'from', + default: 'binary', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + options: [ + { + name: 'Binary', + value: 'binary', + }, + { + name: 'URL', + value: 'url', + }, + ], + required: true, + type: 'options', + }, + { + displayName: 'Binary Contents', + name: 'binaryPropertyName', + default: 'data', + description: 'The name of the input binary field containing the file to be written', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + from: ['binary'], + }, + }, + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName') as string; + + const binaryData = this.helpers.assertBinaryData(binaryPropertyName); + const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(binaryPropertyName); + + requestOptions.headers ??= {}; + requestOptions.headers[HeaderConstants.CONTENT_LENGTH] = binaryDataBuffer.length; + requestOptions.headers[HeaderConstants.X_MS_BLOB_TYPE] = 'BlockBlob'; + if (binaryData.mimeType) { + requestOptions.headers[HeaderConstants.CONTENT_TYPE] = binaryData.mimeType; + } + if (binaryData.fileName) { + requestOptions.headers[HeaderConstants.X_MS_BLOB_CONTENT_DISPOSITION] = + `attachment; filename="${binaryData.fileName}"`; + } + + requestOptions.body = binaryDataBuffer; + + return requestOptions; + }, + ], + }, + }, + type: 'string', + }, + { + displayName: 'URL', + name: 'url', + default: '', + description: 'URL where to read of the blob contents from', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + from: ['url'], + }, + }, + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const url = this.getNodeParameter('url') as string; + + requestOptions.headers ??= {}; + + // Documentation specifies Content-Length: 0, but this causes invalid signature for SharedKey: + // Required. Specifies the number of bytes being transmitted in the request body. The value of this header must be set to 0. When the length isn't 0, the operation fails with the status code 400 (Bad Request). + // requestOptions.headers[HeaderConstants.CONTENT_LENGTH] = 0; + + requestOptions.headers[HeaderConstants.X_MS_BLOB_TYPE] = 'BlockBlob'; + requestOptions.headers[HeaderConstants.X_MS_COPY_SOURCE] = url; + + return requestOptions; + }, + ], + }, + }, + placeholder: 'e.g. https://example.com/image.jpg', + type: 'string', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Access Tier', + name: 'accessTier', + description: + 'The tier to be set on the blob. For detailed information about block blob tiering, see Hot, cool, and archive storage tiers.', + default: 'Hot', + options: [ + { + name: 'Archive', + value: 'Archive', + }, + { + name: 'Cold', + value: 'Cold', + }, + { + name: 'Cool', + value: 'Cool', + }, + { + name: 'Hot', + value: 'Hot', + }, + ], + routing: { + request: { + headers: { + [HeaderConstants.X_MS_ACCESS_TIER]: '={{ $value || undefined }}', + }, + }, + }, + type: 'options', + validateType: 'options', + }, + { + displayName: 'Blob Type', + name: 'blobType', + description: 'Specifies the type of blob to create: block, page, or append blob', + default: 'BlockBlob', + displayOptions: { + show: { + '/from': ['binary'], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_TYPE]: '={{ $value || undefined }}', + }, + }, + }, + type: 'options', + options: [ + { + name: 'Block Blob', + value: 'BlockBlob', + }, + { + name: 'Page Blob', + value: 'PageBlob', + }, + { + name: 'Append Blob', + value: 'AppendBlob', + }, + ], + validateType: 'string', + }, + { + displayName: 'Cache Control', + name: 'cacheControl', + description: "Sets the blob's cache control value", + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_CACHE_CONTROL]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Content CRC64', + name: 'contentCrc64', + description: 'CRC64 hash of the blob content to verify its integrity during transport', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_CONTENT_CRC64]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Content Encoding', + name: 'contentEncoding', + description: "Sets the blob's content encoding", + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_CONTENT_ENCODING]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Content Language', + name: 'contentLanguage', + description: "Sets the blob's content language", + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_CONTENT_LANGUAGE]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Content MD5', + name: 'contentMd5', + description: "Sets the blob's MD5 hash for integrity verification", + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_CONTENT_MD5]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Content Type', + name: 'contentType', + description: "Sets the blob's content type", + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_CONTENT_TYPE]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Encryption Context', + name: 'encryptionContext', + description: + 'If the value is set it will set blob system metadata. Max length-1024. Valid only when Hierarchical Namespace is enabled for the account.', + default: '', + displayOptions: { + show: { + '/from': ['binary'], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_ENCRYPTION_CONTEXT]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Encryption Scope', + name: 'encryptionScope', + description: 'Indicates the encryption scope for encrypting the request contents', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_ENCRYPTION_SCOPE]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Expiry Option', + name: 'expiryOption', + description: + 'Specifies the expiration date option for the request. This header is valid for accounts with hierarchical namespace enabled.', + default: 'Absolute', + options: [ + { + name: 'Expire', + value: 'Absolute', + description: 'Expiry Time must be specified', + }, + { + name: 'Never Expire', + value: 'NeverExpire', + description: 'Sets the blob to never expire or removes the current expiration date', + }, + ], + routing: { + request: { + headers: { + [HeaderConstants.X_MS_EXPIRY_OPTION]: '={{ $value || undefined }}', + }, + }, + }, + type: 'options', + validateType: 'options', + }, + { + displayName: 'Expiry Time', + name: 'expiryTime', + description: 'Specifies the time when the blob is set to expire as an absolute time', + default: '', + displayOptions: { + hide: { + '/options.expiryOption': ['NeverExpire'], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_EXPIRY_TIME]: + '={{ $value ? DateTime.fromISO($value).format("EEE, dd MMM yyyy HH:mm:ss ZZZ") : undefined }}', + }, + }, + }, + type: 'dateTime', + validateType: 'dateTime', + }, + { + displayName: 'Filename', + name: 'filename', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_CONTENT_DISPOSITION]: + "={{ $value ? 'attachment; filename=\"' + $value + '\"' : undefined }}", + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Immutability Policy Date', + name: 'immutabilityPolicyUntilDate', + description: + 'Specifies the retention-until date to be set on the blob. This is the date until which the blob can be protected from being modified or deleted.', + default: '', + displayOptions: { + show: { + '/from': ['binary'], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_IMMUTABILITY_POLICY_UNTIL_DATE]: + '={{ $value ? DateTime.fromISO($value).format("EEE, dd MMM yyyy HH:mm:ss ZZZ") : undefined }}', + }, + }, + }, + type: 'dateTime', + validateType: 'dateTime', + }, + { + displayName: 'Immutability Policy Mode', + name: 'immutabilityPolicyMode', + description: 'Specifies the immutability policy mode to be set on the blob', + default: 'unlocked', + displayOptions: { + show: { + '/from': ['binary'], + }, + }, + options: [ + { + name: 'Locked', + value: 'locked', + description: 'Users are prohibited from modifying the policy', + }, + { + name: 'Unlocked', + value: 'unlocked', + description: + 'Users can change the policy by increasing or decreasing the retention-until date', + }, + ], + routing: { + request: { + headers: { + [HeaderConstants.X_MS_IMMUTABILITY_POLICY_MODE]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Lease ID', + name: 'leaseId', + description: 'Required if the blob has an active lease', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_LEASE_ID]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Legal Hold', + name: 'legalHold', + description: 'Whether to set a legal hold on the blob', + default: false, + displayOptions: { + show: { + '/from': ['binary'], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_LEGAL_HOLD]: '={{ $value?.toString() || undefined }}', + }, + }, + }, + type: 'boolean', + validateType: 'boolean', + }, + { + displayName: 'Metadata', + name: 'metadata', + default: [], + description: 'A name-value pair to associate with the blob as metadata', + options: [ + { + name: 'metadataValues', + displayName: 'Metadata', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + default: '', + description: + 'Names must adhere to the naming rules for C# identifiers', + type: 'string', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + default: '', + type: 'string', + }, + ], + }, + ], + placeholder: 'Add metadata', + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + requestOptions.headers ??= {}; + const metadata = this.getNodeParameter('options.metadata') as IDataObject; + for (const data of metadata.metadataValues as IDataObject[]) { + requestOptions.headers[ + `${HeaderConstants.PREFIX_X_MS_META}${data.fieldName as string}` + ] = data.fieldValue as string; + } + return requestOptions; + }, + ], + }, + }, + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + }, + { + displayName: 'Origin', + name: 'origin', + description: + 'Specifies the origin from which the request is issued. The presence of this header results in cross-origin resource sharing (CORS) headers on the response. For more information, see CORS support for the Azure Storage services.', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.ORIGIN]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Tags', + name: 'tags', + description: 'Sets the given tags on the blob', + default: [], + options: [ + { + name: 'tagValues', + displayName: 'Tag', + values: [ + { + displayName: 'Tag Name', + name: 'tagName', + default: '', + type: 'string', + }, + { + displayName: 'Tag Value', + name: 'tagValue', + default: '', + type: 'string', + }, + ], + }, + ], + placeholder: 'Add tag', + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + requestOptions.headers ??= {}; + const tags = this.getNodeParameter('options.tags') as IDataObject; + requestOptions.headers[HeaderConstants.X_MS_TAGS] = ( + tags.tagValues as IDataObject[] + ) + .map( + (tag) => + `${encodeURIComponent(tag.tagName as string)}=${encodeURIComponent(tag.tagValue as string)}`, + ) + .join('&'); + return requestOptions; + }, + ], + }, + }, + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +const deleteFields: INodeProperties[] = [ + { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + description: 'Container to delete a blob from', + displayOptions: { + show: { + resource: ['blob'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Blob', + name: 'blob', + default: { + mode: 'list', + value: '', + }, + description: 'Blob to be deleted', + displayOptions: { + show: { + resource: ['blob'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getBlobs', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. myblob', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['blob'], + operation: ['delete'], + }, + }, + options: [ + { + displayName: 'Lease ID', + name: 'leaseId', + description: 'Required if the blob has an active lease', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_LEASE_ID]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +const getFields: INodeProperties[] = [ + { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + description: 'Container to get a blob from', + displayOptions: { + show: { + resource: ['blob'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Blob', + name: 'blob', + default: { + mode: 'list', + value: '', + }, + description: 'Blob to get', + displayOptions: { + show: { + resource: ['blob'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getBlobs', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. myblob', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['blob'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Lease ID', + name: 'leaseId', + description: + "If this header is specified, the operation is performed only if both of the following conditions are met:
  • The blob's lease is currently active.
  • The lease ID that's specified in the request matches the lease ID of the blob.
", + default: '', + routing: { + request: { + headers: { + [HeaderConstants.X_MS_LEASE_ID]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Origin', + name: 'origin', + description: + 'Specifies the origin from which the request is issued. The presence of this header results in cross-origin resource sharing (CORS) headers on the response.', + default: '', + routing: { + request: { + headers: { + [HeaderConstants.ORIGIN]: '={{ $value || undefined }}', + }, + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Simplify', + name: 'simplify', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'UPN', + name: 'upn', + description: + "Whether the user identity values that are returned in the response will be transformed from Microsoft Entra object IDs to User Principal Names. If the value is false, they're returned as Microsoft Entra object IDs. Valid for accounts with hierarchical namespace enabled.", + default: false, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_UPN]: '={{ $value?.toString() || undefined }}', + }, + }, + }, + type: 'boolean', + validateType: 'boolean', + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +const getAllFields: INodeProperties[] = [ + { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + description: 'Container to get blobs from', + displayOptions: { + show: { + resource: ['blob'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['blob'], + operation: ['getAll'], + }, + }, + routing: { + send: { + paginate: '={{ $value }}', + }, + operations: { + async pagination( + this: IExecutePaginationFunctions, + requestOptions: DeclarativeRestApiSettings.ResultOptions, + ): Promise { + let executions: INodeExecutionData[] = []; + let marker: string | undefined = undefined; + requestOptions.options.qs ??= {}; + + do { + requestOptions.options.qs.marker = marker; + const responseData = await this.makeRoutingRequest(requestOptions); + marker = responseData[0].json.nextMarker as string | undefined; + executions = executions.concat( + (responseData[0].json.blobs as IDataObject[]).map((item) => ({ json: item })), + ); + } while (marker); + + return executions; + }, + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + resource: ['blob'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + send: { + property: 'maxresults', + type: 'query', + value: '={{ $value }}', + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + _response: IN8nHttpFullResponse, + ): Promise { + return (data[0].json.blobs as IDataObject[]).map((item) => ({ json: item })); + }, + ], + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['blob'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Fields', + name: 'fields', + default: [], + description: 'The fields to add to the output', + options: [ + { + name: 'Copy', + value: 'copy', + description: + 'Specifies that metadata related to any current or previous Copy Blob operation should be included in the response', + }, + { + name: 'Deleted', + value: 'deleted', + description: 'Specifies that soft-deleted blobs should be included in the response', + }, + { + name: 'Deleted with Versions', + value: 'deletedwithversions', + description: + 'Specifies that deleted blobs with any versions (active or deleted) should be included in the response. Items permanently deleted appear until processed by garbage collection.', + }, + { + name: 'Immutability Policy', + value: 'immutabilitypolicy', + description: + 'Specifies that the enumeration should include the immutability policy until date, and the immutability policy mode of the blobs', + }, + { + name: 'Legal Hold', + value: 'legalhold', + description: 'Specifies that the enumeration should include the legal hold of blobs', + }, + { + name: 'Metadata', + value: 'metadata', + description: 'Specifies that blob metadata be returned in the response', + }, + { + name: 'Permissions', + value: 'permissions', + description: + 'Includes the owner, group, permissions, and access control list for the listed blobs or directories. Supported only for accounts with a hierarchical namespace enabled.', + }, + { + name: 'Snapshots', + value: 'snapshots', + description: + 'Specifies that snapshots should be included in the enumeration. Snapshots are listed from oldest to newest in the response.', + }, + { + name: 'Tags', + value: 'tags', + description: + 'Specifies that user-defined, blob index tags should be included in the response', + }, + { + name: 'Uncommitted Blobs', + value: 'uncommittedblobs', + description: + "Specifies that blobs for which blocks have been uploaded, but which haven't been committed", + }, + { + name: 'Versions', + value: 'versions', + description: 'Specifies that versions of blobs should be included in the enumeration', + }, + ], + routing: { + send: { + property: 'include', + type: 'query', + value: '={{ $value.join(",") || undefined }}', + }, + }, + type: 'multiOptions', + }, + { + displayName: 'Filter', + name: 'filter', + default: [], + description: 'The type of datasets to be returned', + options: [ + { + name: 'Deleted', + value: 'deleted', + description: + 'Only for accounts enabled with hierarchical namespace. When included, the list only contains soft-deleted blobs. POSIX ACL authorization fallback is not supported for listing soft-deleted blobs.', + }, + { + name: 'Files', + value: 'files', + description: + 'Only for accounts enabled with hierarchical namespace. When included, the list only contains files.', + }, + { + name: 'Directories', + value: 'directories', + description: + 'Only for accounts enabled with hierarchical namespace. When included, the list only contains directories.', + }, + ], + routing: { + send: { + property: 'showonly', + type: 'query', + value: '={{ $value.join(",") || undefined }}', + }, + }, + type: 'multiOptions', + }, + { + displayName: 'Simplify', + name: 'simplify', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'UPN', + name: 'upn', + description: + "Whether the user identity values that are returned in the response will be transformed from Microsoft Entra object IDs to User Principal Names. If the value is false, they're returned as Microsoft Entra object IDs. Valid for accounts with hierarchical namespace enabled.", + default: false, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_UPN]: '={{ $value?.toString() || undefined }}', + }, + }, + }, + type: 'boolean', + validateType: 'boolean', + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +export const blobFields: INodeProperties[] = [ + ...createFields, + ...deleteFields, + ...getFields, + ...getAllFields, +]; diff --git a/packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts new file mode 100644 index 0000000000..eff1902ce7 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts @@ -0,0 +1,600 @@ +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeProperties, + ResourceMapperValue, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { + handleErrorPostReceive, + HeaderConstants, + parseContainerList, + parseHeaders, + XMsVersion, +} from '../GenericFunctions'; + +export const containerOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['container'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'PUT', + qs: { + restype: 'container', + }, + url: '=/{{ $parameter["containerCreate"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + _data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + return [ + { + json: parseHeaders(response.headers), + }, + ]; + }, + ], + }, + }, + action: 'Create container', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'DELETE', + qs: { + restype: 'container', + }, + url: '=/{{ $parameter["container"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + _data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + return [ + { + json: parseHeaders(response.headers), + }, + ]; + }, + ], + }, + }, + action: 'Delete container', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + qs: { + restype: 'container', + }, + url: '=/{{ $parameter["container"] }}', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + _data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + const { metadata, ...properties } = parseHeaders(response.headers); + const simplify = this.getNodeParameter('options.simplify', true) as boolean; + if (simplify) { + delete properties.contentLength; + delete properties.server; + delete properties.requestId; + delete properties.version; + delete properties.date; + delete properties.connection; + } + return [ + { + json: { + name: (this.getNodeParameter('container') as ResourceMapperValue).value, + properties, + ...(metadata ? { metadata: metadata as IDataObject } : {}), + }, + }, + ]; + }, + ], + }, + }, + action: 'Get container', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of containers', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + qs: { + comp: 'list', + }, + url: '/', + headers: { + [HeaderConstants.X_MS_DATE]: '={{ new Date().toUTCString() }}', + [HeaderConstants.X_MS_VERSION]: XMsVersion, + }, + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + _response: IN8nHttpFullResponse, + ): Promise { + return [ + { + json: await parseContainerList(data[0].json as unknown as string), + }, + ]; + }, + ], + }, + }, + action: 'Get many container', + }, + ], + default: 'getAll', + }, +]; + +const createFields: INodeProperties[] = [ + { + displayName: 'Container Name', + name: 'containerCreate', + default: '', + description: 'The name of the new container', + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + placeholder: 'e.g. mycontainer', + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const container = this.getNodeParameter('containerCreate') as string; + if (container.length < 3 || container.length > 63) { + throw new NodeOperationError( + this.getNode(), + "'Container Name' must be from 3 through 63 characters long", + ); + } + if (/[A-Z]/.test(container)) { + throw new NodeOperationError( + this.getNode(), + "All letters in 'Container Name' must be lowercase", + ); + } + if (!/^[a-z0-9-]+$/.test(container)) { + throw new NodeOperationError( + this.getNode(), + "'Container Name' can contain only letters, numbers, and the hyphen/minus (-) character", + ); + } + if (!/^[a-z0-9].*[a-z0-9]$/.test(container)) { + throw new NodeOperationError( + this.getNode(), + "'Container Name' must start or end with a letter or number", + ); + } + if (/--/.test(container)) { + throw new NodeOperationError( + this.getNode(), + "Consecutive hyphens are not permitted in 'Container Name'", + ); + } + + return requestOptions; + }, + ], + }, + }, + type: 'string', + validateType: 'string', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Access Level', + name: 'accessLevel', + default: '', + options: [ + { + name: 'Blob', + value: 'blob', + description: + "Specifies public read access for blobs. Blob data within this container can be read via anonymous request, but container data isn't available. Clients can't enumerate blobs within the container via anonymous request.", + }, + { + name: 'Container', + value: 'container', + description: + "Specifies full public read access for container and blob data. Clients can enumerate blobs within the container via anonymous request, but they can't enumerate containers within the storage account.", + }, + { + name: 'Private', + value: '', + description: 'Container data is private to the account owner', + }, + ], + routing: { + request: { + headers: { + [HeaderConstants.X_MS_BLOB_PUBLIC_ACCESS]: '={{ $value || undefined }}', + }, + }, + }, + type: 'options', + validateType: 'options', + }, + { + displayName: 'Metadata', + name: 'metadata', + default: [], + description: 'A name-value pair to associate with the container as metadata', + options: [ + { + name: 'metadataValues', + displayName: 'Metadata', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + default: '', + description: + 'Names must adhere to the naming rules for C# identifiers', + type: 'string', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + default: '', + type: 'string', + }, + ], + }, + ], + placeholder: 'Add metadata', + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + requestOptions.headers ??= {}; + const metadata = this.getNodeParameter('options.metadata') as IDataObject; + for (const data of metadata.metadataValues as IDataObject[]) { + requestOptions.headers[ + `${HeaderConstants.PREFIX_X_MS_META}${data.fieldName as string}` + ] = data.fieldValue as string; + } + return requestOptions; + }, + ], + }, + }, + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +const deleteFields: INodeProperties[] = [ + { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + description: 'Select the container to delete', + displayOptions: { + show: { + resource: ['container'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, +]; + +const getFields: INodeProperties[] = [ + { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + description: 'Select the container to get', + displayOptions: { + show: { + resource: ['container'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['container'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Simplify', + name: 'simplify', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +const getAllFields: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + }, + }, + routing: { + send: { + paginate: '={{ $value }}', + }, + operations: { + async pagination( + this: IExecutePaginationFunctions, + requestOptions: DeclarativeRestApiSettings.ResultOptions, + ): Promise { + let executions: INodeExecutionData[] = []; + let marker: string | undefined = undefined; + requestOptions.options.qs ??= {}; + + do { + requestOptions.options.qs.marker = marker; + const responseData = await this.makeRoutingRequest(requestOptions); + marker = responseData[0].json.nextMarker as string | undefined; + executions = executions.concat( + (responseData[0].json.containers as IDataObject[]).map((item) => ({ json: item })), + ); + } while (marker); + + return executions; + }, + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + send: { + property: 'maxresults', + type: 'query', + value: '={{ $value }}', + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + _response: IN8nHttpFullResponse, + ): Promise { + return (data[0].json.containers as IDataObject[]).map((item) => ({ json: item })); + }, + ], + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Fields', + name: 'fields', + default: [], + description: 'The fields to add to the output', + options: [ + { + name: 'Metadata', + value: 'metadata', + }, + { + name: 'Deleted', + value: 'deleted', + }, + { + name: 'System', + value: 'system', + }, + ], + routing: { + send: { + property: 'include', + type: 'query', + value: '={{ $value.join(",") || undefined }}', + }, + }, + type: 'multiOptions', + }, + { + displayName: 'Filter', + name: 'filter', + default: '', + description: + 'Filters the results to return only containers with a name that begins with the specified prefix', + placeholder: 'e.g. mycontainer', + routing: { + send: { + property: 'prefix', + type: 'query', + value: '={{ $value ? $value : undefined }}', + }, + }, + type: 'string', + validateType: 'string', + }, + ], + placeholder: 'Add option', + type: 'collection', + }, +]; + +export const containerFields: INodeProperties[] = [ + ...createFields, + ...deleteFields, + ...getFields, + ...getAllFields, +]; diff --git a/packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts new file mode 100644 index 0000000000..d21ede5252 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts @@ -0,0 +1,2 @@ +export * from './BlobDescription'; +export * from './ContainerDescription'; diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/blob/create.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/create.test.ts new file mode 100644 index 0000000000..1475749e52 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/create.test.ts @@ -0,0 +1,72 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/blob_create.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should create blob', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'put', + path: '/mycontainer/myblob', + statusCode: 201, + requestHeaders: { + 'x-ms-access-tier': 'Hot', + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-cache-control': 'no-cache', + 'x-ms-content-crc64': '3EDB64E77CB16A4C', + 'x-ms-blob-content-encoding': 'utf8', + 'x-ms-blob-content-language': 'en-US', + 'x-ms-blob-content-md5': 'b97f46db5f3be7709d942eefe30e5b45', + 'x-ms-blob-content-type': 'application/json', + 'x-ms-encryption-context': 'context', + 'x-ms-encryption-scope': 'encryptionScope', + 'x-ms-expiry-option': 'Absolute', + 'x-ms-blob-content-disposition': 'attachment; filename="file.json"', + 'x-ms-immutability-policy-until-date': 'Wed, 01 Jan 2025 00:00:00 -0500', + 'x-ms-immutability-policy-mode': 'unlocked', + 'x-ms-lease-id': 'leaseId123', + 'x-ms-legal-hold': 'true', + 'x-ms-meta-key1': 'value1', + }, + responseBody: '', + responseHeaders: { + server: 'Azurite-Blob/3.33.0', + etag: '"0x22769D26D3F3740"', + 'last-modified': 'Thu, 23 Jan 2025 17:53:23 GMT', + 'content-md5': 'aWQGHD8kGQd5ZtEN/S1/aw==', + 'x-ms-request-id': '75b87ee3-a7f7-468d-b7d1-e7e7b3173dab', + 'x-ms-version': '2025-01-05', + date: 'Thu, 23 Jan 2025 17:53:23 GMT', + 'x-ms-request-server-encrypted': 'true', + 'keep-alive': 'timeout=5', + 'content-length': '0', + 'x-ms-version-id': 'Thu, 23 Jan 2025 17:53:23 GMT', + 'access-control-allow-credentials': 'access-control-allow-credentials', + 'access-control-allow-origin': 'access-control-allow-origin', + 'access-control-expose-headers': 'access-control-expose-headers', + 'x-ms-content-crc64': 'x-ms-content-crc64', + 'x-ms-encryption-key-sha256': 'x-ms-encryption-key-sha256', + 'x-ms-encryption-scope': 'x-ms-encryption-scope', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/blob/delete.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/delete.test.ts new file mode 100644 index 0000000000..05f79edbb9 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/delete.test.ts @@ -0,0 +1,41 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/blob_delete.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should delete blob', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'delete', + path: '/mycontainer/myblob', + statusCode: 202, + responseBody: '', + responseHeaders: { + 'x-ms-request-id': '75b87ee3-a7f7-468d-b7d1-e7e7b3173dab', + 'x-ms-version': '2025-01-05', + date: 'Thu, 23 Jan 2025 17:53:23 GMT', + 'x-ms-delete-type-permanent': 'true', + 'x-ms-client-request-id': 'x-ms-client-request-id', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/blob/get.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/get.test.ts new file mode 100644 index 0000000000..d4e8f09b07 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/get.test.ts @@ -0,0 +1,93 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/blob_get.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should get blob', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/mycontainer/myblob', + statusCode: 200, + responseBody: { + type: 'Buffer', + data: [ + 123, 10, 34, 100, 97, 116, 97, 34, 58, 123, 10, 34, 109, 121, 95, 102, 105, 101, + 108, 100, 95, 49, 34, 58, 34, 118, 97, 108, 117, 101, 34, 44, 10, 34, 109, 121, 95, + 102, 105, 101, 108, 100, 95, 50, 34, 58, 49, 10, 125, 10, 125, + ], + }, + responseHeaders: { + 'x-ms-request-id': '75b87ee3-a7f7-468d-b7d1-e7e7b3173dab', + 'x-ms-version': '2025-01-05', + date: 'Thu, 23 Jan 2025 17:53:23 GMT', + 'x-ms-client-request-id': 'x-ms-client-request-id', + 'last-modified': 'last-modified', + 'x-ms-creation-time': 'x-ms-creation-time', + 'x-ms-tag-count': 'x-ms-tag-count', + 'content-type': 'application/json', + 'content-range': 'content-range', + etag: '"0x22769D26D3F3740"', + 'content-md5': 'content-md5', + 'x-ms-content-crc64': 'x-ms-content-crc64', + 'content-encoding': 'content-encoding', + 'content-language': 'content-language', + 'cache-control': 'cache-control', + 'content-disposition': 'attachment; filename="file.json"', + 'x-ms-blob-sequence-number': 'x-ms-blob-sequence-number', + 'x-ms-blob-type': 'x-ms-blob-type', + 'x-ms-copy-completion-time': 'x-ms-copy-completion-time', + 'x-ms-copy-status-description': 'x-ms-copy-status-description', + 'x-ms-copy-id': 'x-ms-copy-id', + 'x-ms-copy-progress': 'x-ms-copy-progress', + 'x-ms-copy-source': 'x-ms-copy-source', + 'x-ms-copy-status': 'x-ms-copy-status', + 'x-ms-incremental-copy': 'x-ms-incremental-copy', + 'x-ms-lease-duration': 'x-ms-lease-duration', + 'x-ms-lease-state': 'x-ms-lease-state', + 'x-ms-lease-status': 'x-ms-lease-status', + 'accept-ranges': 'accept-ranges', + 'access-control-allow-origin': 'access-control-allow-origin', + 'access-control-expose-headers': 'access-control-expose-headers', + vary: 'vary', + 'access-control-allow-credentials': 'access-control-allow-credentials', + 'x-ms-blob-committed-block-count': 'x-ms-blob-committed-block-count', + 'x-ms-server-encrypted': 'x-ms-server-encrypted', + 'x-ms-encryption-key-sha256': 'x-ms-encryption-key-sha256', + 'x-ms-encryption-context': 'x-ms-encryption-context', + 'x-ms-encryption-scope': 'x-ms-encryption-scope', + 'x-ms-blob-content-md5': 'x-ms-blob-content-md5', + 'x-ms-last-access-time': 'x-ms-last-access-time', + 'x-ms-blob-sealed': 'x-ms-blob-sealed', + 'x-ms-immutability-policy-until-date': 'x-ms-immutability-policy-until-date', + 'x-ms-immutability-policy-mode': 'x-ms-immutability-policy-mode', + 'x-ms-legal-hold': 'x-ms-legal-hold', + 'x-ms-owner': 'x-ms-owner', + 'x-ms-group': 'x-ms-group', + 'x-ms-permissions': 'x-ms-permissions', + 'x-ms-acl': 'x-ms-acl', + 'x-ms-resource-type': 'x-ms-resource-type', + 'x-ms-meta-key1': 'value1', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAll.test.ts new file mode 100644 index 0000000000..9825dc79cf --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAll.test.ts @@ -0,0 +1,42 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/blob_getAll.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should get all blobs', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/mycontainer?restype=container&comp=list', + statusCode: 200, + responseBody: + '1myblob1Wed, 22 Jan 2025 18:53:15 GMTWed, 22 Jan 2025 18:53:15 GMT0x1F8268B228AA73037application/jsonaWQGHD8kGQd5ZtEN/S1/aw==BlockBlobunlockedavailabletrueHottrueWed, 22 Jan 2025 18:53:15 GMTmyblob2', + }, + { + method: 'get', + path: '/mycontainer?restype=container&comp=list&marker=myblob2', + statusCode: 200, + responseBody: + '1myblob1Wed, 22 Jan 2025 18:53:15 GMTWed, 22 Jan 2025 18:53:15 GMT0x1F8268B228AA73037application/jsonaWQGHD8kGQd5ZtEN/S1/aw==BlockBlobunlockedavailabletrueHottrueWed, 22 Jan 2025 18:53:15 GMT', + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAllLimitOptions.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAllLimitOptions.test.ts new file mode 100644 index 0000000000..a151a04285 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/blob/getAllLimitOptions.test.ts @@ -0,0 +1,37 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = [ + 'nodes/Microsoft/Storage/test/workflows/blob_getAllLimitOptions.workflow.json', + ]; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should get all blobs with limit and options', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/mycontainer?restype=container&comp=list&maxresults=1&include=copy%2Cdeleted%2Cdeletedwithversions%2Cimmutabilitypolicy%2Cmetadata%2Clegalhold%2Cversions%2Cuncommittedblobs%2Ctags%2Csnapshots%2Cpermissions&showonly=deleted%2Cfiles%2Cdirectories', + statusCode: 200, + responseBody: + '1myblob1Wed, 22 Jan 2025 18:53:15 GMTWed, 22 Jan 2025 18:53:15 GMT0x1F8268B228AA73037application/jsonaWQGHD8kGQd5ZtEN/S1/aw==BlockBlobunlockedavailabletrueHottrueWed, 22 Jan 2025 18:53:15 GMTmyblob2', + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/container/create.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/container/create.test.ts new file mode 100644 index 0000000000..5cbca9859d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/container/create.test.ts @@ -0,0 +1,44 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/container_create.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should create container', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'put', + path: '/mycontainer?restype=container', + statusCode: 201, + requestHeaders: { 'x-ms-blob-public-access': 'blob', 'x-ms-meta-key1': 'value1' }, + responseBody: '', + responseHeaders: { + etag: '"0x22769D26D3F3740"', + 'last-modified': 'Thu, 23 Jan 2025 17:53:23 GMT', + 'x-ms-request-id': '75b87ee3-a7f7-468d-b7d1-e7e7b3173dab', + 'x-ms-version': '2025-01-05', + date: 'Thu, 23 Jan 2025 17:53:23 GMT', + 'x-ms-request-server-encrypted': 'true', + 'x-ms-client-request-id': 'client-request-id-123', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/container/delete.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/container/delete.test.ts new file mode 100644 index 0000000000..3c24f07480 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/container/delete.test.ts @@ -0,0 +1,41 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/container_delete.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should delete container', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'delete', + path: '/mycontainer?restype=container', + statusCode: 202, + responseBody: '', + responseHeaders: { + 'content-length': '0', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': 'ca3a8907-601e-0050-1929-723410000000', + 'x-ms-version': '2020-10-02', + date: 'Wed, 29 Jan 2025 08:38:21 GMT', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/container/get.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/container/get.test.ts new file mode 100644 index 0000000000..50248d8fc2 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/container/get.test.ts @@ -0,0 +1,50 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/container_get.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should get container', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/mycontainer?restype=container', + statusCode: 200, + responseBody: '', + responseHeaders: { + 'content-length': '0', + 'last-modified': 'Tue, 28 Jan 2025 16:40:21 GMT', + etag: '"0x8DD3FBA74CF3620"', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': '49edb268-b01e-0053-6e29-72d574000000', + 'x-ms-version': '2020-10-02', + 'x-ms-lease-status': 'unlocked', + 'x-ms-lease-state': 'available', + 'x-ms-has-immutability-policy': 'false', + 'x-ms-has-legal-hold': 'false', + date: 'Wed, 29 Jan 2025 08:43:08 GMT', + 'x-ms-meta-key1': 'field1', + 'x-ms-blob-public-access': 'blob', + 'x-ms-lease-duration': 'infinite', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/container/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/container/getAll.test.ts new file mode 100644 index 0000000000..3b6ea5ceaf --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/container/getAll.test.ts @@ -0,0 +1,42 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/container_getAll.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should get all containers', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/?comp=list', + statusCode: 200, + responseBody: + 'mycontainer1mycontainer1true01DB7228F6BEE6E7Wed, 29 Jan 2025 08:37:00 GMT"0x8DD40401935032C"unlockedexpiredfalsefalsefalseWed, 29 Jan 2025 08:38:21 GMT7value1mycontainer2', + }, + { + method: 'get', + path: '/?comp=list&marker=mycontainer2', + statusCode: 200, + responseBody: + 'mycontainer1mycontainer1true01DB7228F6BEE6E7Wed, 29 Jan 2025 08:37:00 GMT"0x8DD40401935032C"unlockedexpiredfalsefalsefalseWed, 29 Jan 2025 08:38:21 GMT7value1', + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/container/getAllLimitOptions.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/container/getAllLimitOptions.test.ts new file mode 100644 index 0000000000..d859f16cc7 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/container/getAllLimitOptions.test.ts @@ -0,0 +1,37 @@ +import nock from 'nock'; + +import { equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = [ + 'nodes/Microsoft/Storage/test/workflows/container_getAllLimitOptions.workflow.json', + ]; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should get all containers with limit and options', () => { + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/?comp=list&maxresults=1&include=metadata%2Cdeleted%2Csystem&prefix=mycontainer', + statusCode: 200, + responseBody: + 'mycontainer1mycontainer1true01DB7228F6BEE6E7Wed, 29 Jan 2025 08:37:00 GMT"0x8DD40401935032C"unlockedexpiredfalsefalsefalseWed, 29 Jan 2025 08:38:21 GMT7value1mycontainer2', + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/credentials/oauth2.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/credentials/oauth2.test.ts new file mode 100644 index 0000000000..2e78af10fd --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/credentials/oauth2.test.ts @@ -0,0 +1,86 @@ +import type { + ICredentialDataDecryptedObject, + IDataObject, + IHttpRequestOptions, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { CredentialsHelper, equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/credentials_oauth2.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should use correct oauth2 credentials', () => { + beforeAll(() => { + nock.disableNetConnect(); + + jest + .spyOn(CredentialsHelper.prototype, 'authenticate') + .mockImplementation( + async ( + credentials: ICredentialDataDecryptedObject, + typeName: string, + requestParams: IHttpRequestOptions, + ): Promise => { + if (typeName === 'azureStorageOAuth2Api') { + return { + ...requestParams, + headers: { + authorization: `bearer ${(credentials.oauthTokenData as IDataObject).access_token as string}`, + }, + }; + } else { + return requestParams; + } + }, + ); + }); + + afterAll(() => { + nock.restore(); + jest.restoreAllMocks(); + }); + + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/mycontainer?restype=container', + statusCode: 200, + requestHeaders: { authorization: 'bearer ACCESSTOKEN' }, + responseBody: '', + responseHeaders: { + 'content-length': '0', + 'last-modified': 'Tue, 28 Jan 2025 16:40:21 GMT', + etag: '"0x8DD3FBA74CF3620"', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': '49edb268-b01e-0053-6e29-72d574000000', + 'x-ms-version': '2020-10-02', + 'x-ms-lease-status': 'unlocked', + 'x-ms-lease-state': 'available', + 'x-ms-has-immutability-policy': 'false', + 'x-ms-has-legal-hold': 'false', + date: 'Wed, 29 Jan 2025 08:43:08 GMT', + 'x-ms-meta-key1': 'field1', + 'x-ms-blob-public-access': 'blob', + 'x-ms-lease-duration': 'infinite', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/credentials/sharedKey.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/credentials/sharedKey.test.ts new file mode 100644 index 0000000000..7804c226de --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/credentials/sharedKey.test.ts @@ -0,0 +1,158 @@ +import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow'; +import nock from 'nock'; + +import { CredentialsHelper, equalityTest, setup, workflowToTests } from '@test/nodes/Helpers'; + +import { AzureStorageSharedKeyApi } from '../../../../../credentials/AzureStorageSharedKeyApi.credentials'; +import { FAKE_CREDENTIALS_DATA } from '../../../../../test/nodes/FakeCredentialsMap'; + +describe('Azure Storage Node', () => { + const workflows = ['nodes/Microsoft/Storage/test/workflows/credentials_sharedKey.workflow.json']; + const workflowTests = workflowToTests(workflows); + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('should use correct shared key credentials', () => { + beforeAll(() => { + nock.disableNetConnect(); + + jest + .spyOn(CredentialsHelper.prototype, 'authenticate') + .mockImplementation( + async ( + _credentials: ICredentialDataDecryptedObject, + typeName: string, + requestParams: IHttpRequestOptions, + ): Promise => { + if (typeName === 'azureStorageSharedKeyApi') { + return { + ...requestParams, + headers: { + authorization: + 'SharedKey Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==', + }, + }; + } else { + return requestParams; + } + }, + ); + }); + + afterAll(() => { + nock.restore(); + jest.restoreAllMocks(); + }); + + const nodeTypes = setup(workflowTests); + + for (const workflow of workflowTests) { + workflow.nock = { + baseUrl: 'https://myaccount.blob.core.windows.net', + mocks: [ + { + method: 'get', + path: '/mycontainer?restype=container', + statusCode: 200, + requestHeaders: { + authorization: + 'SharedKey Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==', + }, + responseBody: '', + responseHeaders: { + 'content-length': '0', + 'last-modified': 'Tue, 28 Jan 2025 16:40:21 GMT', + etag: '"0x8DD3FBA74CF3620"', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': '49edb268-b01e-0053-6e29-72d574000000', + 'x-ms-version': '2020-10-02', + 'x-ms-lease-status': 'unlocked', + 'x-ms-lease-state': 'available', + 'x-ms-has-immutability-policy': 'false', + 'x-ms-has-legal-hold': 'false', + date: 'Wed, 29 Jan 2025 08:43:08 GMT', + 'x-ms-meta-key1': 'field1', + 'x-ms-blob-public-access': 'blob', + 'x-ms-lease-duration': 'infinite', + }, + }, + ], + }; + test(workflow.description, async () => await equalityTest(workflow, nodeTypes)); + } + }); + + describe('authenticate', () => { + const azureStorageSharedKeyApi = new AzureStorageSharedKeyApi(); + + it('should remove undefined query parameters and headers', async () => { + const credentials: ICredentialDataDecryptedObject = { + account: FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.account, + key: FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.key, + }; + const requestOptions: IHttpRequestOptions = { + url: `${FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.baseUrl}/mycontainer`, + qs: { restype: 'container', query1: undefined }, + headers: { + 'Content-Length': undefined, + }, + method: 'GET', + }; + + const result = await azureStorageSharedKeyApi.authenticate(credentials, requestOptions); + + expect(result.qs).toEqual({ restype: 'container' }); + expect(result.headers).not.toHaveProperty('Content-Length'); + }); + + it('should default method to GET if not provided', async () => { + const credentials: ICredentialDataDecryptedObject = { + account: FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.account, + key: FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.key, + }; + const requestOptions: IHttpRequestOptions = { + url: `${FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.baseUrl}/mycontainer`, + qs: { restype: 'container' }, + headers: { + 'Content-Length': undefined, + }, + }; + + const result = await azureStorageSharedKeyApi.authenticate(credentials, requestOptions); + expect(result.method).toBe('GET'); + }); + + it('should generate a valid authorization header', async () => { + const credentials: ICredentialDataDecryptedObject = { + account: FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.account, + key: FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.key, + }; + const requestOptions: IHttpRequestOptions = { + url: `${FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi.baseUrl}/mycontainer/myblob`, + qs: { param1: 'value1' }, + headers: { + 'x-ms-date': 'Thu, 27 Feb 2025 11:05:49 GMT', + 'x-ms-version': '2021-12-02', + 'x-ms-blob-content-language': 'en-EN', + 'x-ms-blob-content-type': 'image/jpeg', + 'x-ms-expiry-option': 'Absolute', + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-content-disposition': 'attachment; filename="image.jpg"', + 'x-ms-meta-key1': 'value1', + 'x-ms-tags': 'tag1=value1', + }, + method: 'PUT', + }; + const result = await azureStorageSharedKeyApi.authenticate(credentials, requestOptions); + + expect(result.headers?.authorization).toBe( + 'SharedKey devstoreaccount1:6sSQ3N4yNFQynBs/iLptIRPS5DQeaFBocW+dyYbAdOI=', + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts new file mode 100644 index 0000000000..8e4688d00f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts @@ -0,0 +1,123 @@ +import type { ILoadOptionsFunctions, INodeParameterResourceLocator } from 'n8n-workflow'; +import nock from 'nock'; + +import { FAKE_CREDENTIALS_DATA } from '../../../../../test/nodes/FakeCredentialsMap'; +import { AzureStorage } from '../../AzureStorage.node'; +import { XMsVersion } from '../../GenericFunctions'; + +describe('Azure Storage Node', () => { + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('List search', () => { + it('should list search blobs', async () => { + const mockResponse = + '1myblob1Wed, 22 Jan 2025 18:53:15 GMTWed, 22 Jan 2025 18:53:15 GMT0x1F8268B228AA73037application/jsonaWQGHD8kGQd5ZtEN/S1/aw==BlockBlobunlockedavailabletrueHottrueWed, 22 Jan 2025 18:53:15 GMTmyblob2'; + const mockRequestWithAuthentication = jest.fn().mockReturnValue(mockResponse); + const mockGetNodeParameter = jest.fn((parameterName, _fallbackValue, _options) => { + if (parameterName === 'authentication') { + return 'sharedKey'; + } + if (parameterName === 'container') { + return { + value: 'mycontainer', + } as INodeParameterResourceLocator; + } + // eslint-disable-next-line n8n-local-rules/no-plain-errors + throw new Error('Unknown parameter'); + }); + const mockGetCredentials = jest.fn(async (type: string, _itemIndex?: number) => { + if (type === 'azureStorageSharedKeyApi') { + return FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi; + } + // eslint-disable-next-line n8n-local-rules/no-plain-errors + throw new Error('Unknown credentials'); + }); + const mockContext = { + getCredentials: mockGetCredentials, + getNodeParameter: mockGetNodeParameter, + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + } as unknown as ILoadOptionsFunctions; + jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00Z')); + const node = new AzureStorage(); + + const listSearchResult = await node.methods.listSearch.getBlobs.call(mockContext); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith('azureStorageSharedKeyApi', { + method: 'GET', + url: 'https://myaccount.blob.core.windows.net/mycontainer', + headers: { + 'x-ms-date': 'Wed, 01 Jan 2025 00:00:00 GMT', + 'x-ms-version': XMsVersion, + }, + qs: { + comp: 'list', + maxresults: 5000, + restype: 'container', + }, + body: {}, + }); + expect(listSearchResult).toEqual({ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + results: [{ name: 'myblob1', value: 'myblob1' }], + paginationToken: 'myblob2', + }); + }); + + it('should list search containers', async () => { + const mockResponse = + 'mycontainer1mycontainer1true01DB7228F6BEE6E7Wed, 29 Jan 2025 08:37:00 GMT"0x8DD40401935032C"unlockedexpiredfalsefalsefalseWed, 29 Jan 2025 08:38:21 GMT7value1mycontainer2'; + const mockRequestWithAuthentication = jest.fn().mockReturnValue(mockResponse); + const mockGetNodeParameter = jest.fn((parameterName, _fallbackValue, _options) => { + if (parameterName === 'authentication') { + return 'sharedKey'; + } + // eslint-disable-next-line n8n-local-rules/no-plain-errors + throw new Error('Unknown parameter'); + }); + const mockGetCredentials = jest.fn(async (type: string, _itemIndex?: number) => { + if (type === 'azureStorageSharedKeyApi') { + return FAKE_CREDENTIALS_DATA.azureStorageSharedKeyApi; + } + // eslint-disable-next-line n8n-local-rules/no-plain-errors + throw new Error('Unknown credentials'); + }); + const mockContext = { + getCredentials: mockGetCredentials, + getNodeParameter: mockGetNodeParameter, + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + } as unknown as ILoadOptionsFunctions; + jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00Z')); + const node = new AzureStorage(); + + const listSearchResult = await node.methods.listSearch.getContainers.call(mockContext); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith('azureStorageSharedKeyApi', { + method: 'GET', + url: 'https://myaccount.blob.core.windows.net/', + headers: { + 'x-ms-date': 'Wed, 01 Jan 2025 00:00:00 GMT', + 'x-ms-version': XMsVersion, + }, + qs: { + comp: 'list', + maxresults: 5000, + }, + body: {}, + }); + expect(listSearchResult).toEqual({ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + results: [{ name: 'mycontainer1', value: 'mycontainer1' }], + paginationToken: 'mycontainer2', + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_create.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_create.workflow.json new file mode 100644 index 0000000000..3e6605dd21 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_create.workflow.json @@ -0,0 +1,109 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "blob", + "operation": "create", + "container": { "__rl": true, "value": "mycontainer", "mode": "list" }, + "blobCreate": "myblob", + "options": { + "accessTier": "Hot", + "blobType": "BlockBlob", + "cacheControl": "no-cache", + "contentCrc64": "3EDB64E77CB16A4C", + "contentEncoding": "utf8", + "contentLanguage": "en-US", + "contentMd5": "b97f46db5f3be7709d942eefe30e5b45", + "contentType": "application/json", + "encryptionContext": "context", + "encryptionScope": "encryptionScope", + "expiryOption": "Absolute", + "filename": "file.json", + "immutabilityPolicyUntilDate": "2025-01-01T00:00:00", + "immutabilityPolicyMode": "unlocked", + "leaseId": "leaseId123", + "legalHold": true, + "metadata": { "metadataValues": [{ "fieldName": "key1", "fieldValue": "value1" }] }, + "origin": "http://contoso.com", + "tags": { "tagValues": [{ "tagName": "tag1", "tagValue": "value1" }] } + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [660, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + }, + { + "parameters": { + "mode": "jsonToBinary", + "convertAllData": false, + "options": { "useRawData": true } + }, + "name": "Move Binary Data", + "type": "n8n-nodes-base.moveBinaryData", + "typeVersion": 1, + "position": [440, 0], + "id": "221e00ed-8c9b-4313-a4d6-b87c64b09d80" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "{\n \"data\": {\n \"my_field_1\": \"value\",\n \"my_field_2\": 1\n }\n}\n", + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [220, 0], + "id": "6c03903b-0177-4e94-b1ad-4f6ad9e84f62", + "name": "Edit Fields" + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]] + }, + "Move Binary Data": { "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] }, + "Edit Fields": { "main": [[{ "node": "Move Binary Data", "type": "main", "index": 0 }]] } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "etag": "\"0x22769D26D3F3740\"", + "lastModified": "Thu, 23 Jan 2025 17:53:23 GMT", + "contentMd5": "aWQGHD8kGQd5ZtEN/S1/aw==", + "requestId": "75b87ee3-a7f7-468d-b7d1-e7e7b3173dab", + "version": "2025-01-05", + "date": "Thu, 23 Jan 2025 17:53:23 GMT", + "requestServerEncrypted": true, + "accessControlAllowCredentials": "access-control-allow-credentials", + "accessControlAllowOrigin": "access-control-allow-origin", + "accessControlExposeHeaders": "access-control-expose-headers", + "contentCrc64": "x-ms-content-crc64", + "contentLength": 0, + "encryptionKeySha256": "x-ms-encryption-key-sha256", + "encryptionScope": "x-ms-encryption-scope", + "versionId": "Thu, 23 Jan 2025 17:53:23 GMT", + "keepAlive": "timeout=5", + "server": "Azurite-Blob/3.33.0" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_delete.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_delete.workflow.json new file mode 100644 index 0000000000..2263898085 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_delete.workflow.json @@ -0,0 +1,51 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "blob", + "operation": "delete", + "container": { "__rl": true, "mode": "id", "value": "mycontainer" }, + "blob": { "__rl": true, "mode": "id", "value": "myblob" }, + "options": { "leaseId": "leaseId123" }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [660, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "requestId": "75b87ee3-a7f7-468d-b7d1-e7e7b3173dab", + "version": "2025-01-05", + "date": "Thu, 23 Jan 2025 17:53:23 GMT", + "deleteTypePermanent": true, + "clientRequestId": "x-ms-client-request-id" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_get.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_get.workflow.json new file mode 100644 index 0000000000..54b2800566 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_get.workflow.json @@ -0,0 +1,114 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "blob", + "operation": "get", + "container": { "__rl": true, "value": "mycontainer", "mode": "list" }, + "blob": { "__rl": true, "value": "myblob", "mode": "list" }, + "options": { + "leaseId": "leaseId123", + "origin": "origin123", + "simplify": false, + "upn": true + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [660, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + + "pinData": { + "Azure Storage": [ + { + "binary": { + "data": { + "fileExtension": "json", + "fileName": "file.json", + "fileSize": "51 B", + "fileType": "json", + "mimeType": "application/json" + } + }, + "json": { + "name": "myblob", + "properties": { + "clientRequestId": "x-ms-client-request-id", + "lastModified": "last-modified", + "creationTime": "x-ms-creation-time", + "tagCount": "x-ms-tag-count", + "contentType": "application/json", + "contentRange": "content-range", + "etag": "\"0x22769D26D3F3740\"", + "contentMd5": "content-md5", + "contentCrc64": "x-ms-content-crc64", + "contentEncoding": "content-encoding", + "contentLanguage": "content-language", + "cacheControl": "cache-control", + "contentDisposition": "attachment; filename=\"file.json\"", + "blobSequenceNumber": "x-ms-blob-sequence-number", + "blobType": "x-ms-blob-type", + "copyCompletionTime": "x-ms-copy-completion-time", + "copyStatusDescription": "x-ms-copy-status-description", + "copyId": "x-ms-copy-id", + "copyProgress": "x-ms-copy-progress", + "copySource": "x-ms-copy-source", + "copyStatus": "x-ms-copy-status", + "incrementalCopy": "x-ms-incremental-copy", + "leaseDuration": "x-ms-lease-duration", + "leaseState": "x-ms-lease-state", + "leaseStatus": "x-ms-lease-status", + "accessControlAllowOrigin": "access-control-allow-origin", + "accessControlExposeHeaders": "access-control-expose-headers", + "vary": "vary", + "accessControlAllowCredentials": "access-control-allow-credentials", + "blobCommittedBlockCount": "x-ms-blob-committed-block-count", + "serverEncrypted": "x-ms-server-encrypted", + "encryptionKeySha256": "x-ms-encryption-key-sha256", + "encryptionContext": "x-ms-encryption-context", + "encryptionScope": "x-ms-encryption-scope", + "blobContentMd5": "x-ms-blob-content-md5", + "lastAccessTime": "x-ms-last-access-time", + "blobSealed": "x-ms-blob-sealed", + "immutabilityPolicyUntilDate": "x-ms-immutability-policy-until-date", + "immutabilityPolicyMode": "x-ms-immutability-policy-mode", + "legalHold": "x-ms-legal-hold", + "owner": "x-ms-owner", + "group": "x-ms-group", + "permissions": "x-ms-permissions", + "acl": "x-ms-acl", + "resourceType": "x-ms-resource-type", + "acceptRanges": "accept-ranges", + "date": "Thu, 23 Jan 2025 17:53:23 GMT", + "requestId": "75b87ee3-a7f7-468d-b7d1-e7e7b3173dab", + "version": "2025-01-05" + }, + "metadata": { "key1": "value1" } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAll.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAll.workflow.json new file mode 100644 index 0000000000..03d0a4cea3 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAll.workflow.json @@ -0,0 +1,82 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "blob", + "operation": "getAll", + "container": { "__rl": true, "value": "mycontainer", "mode": "list" }, + "returnAll": true, + "options": { "simplify": false }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [660, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "myblob1", + "properties": { + "creationTime": "Wed, 22 Jan 2025 18:53:15 GMT", + "lastModified": "Wed, 22 Jan 2025 18:53:15 GMT", + "etag": "0x1F8268B228AA730", + "contentLength": 37, + "contentType": "application/json", + "contentMD5": "aWQGHD8kGQd5ZtEN/S1/aw==", + "blobType": "BlockBlob", + "leaseStatus": "unlocked", + "leaseState": "available", + "serverEncrypted": true, + "accessTier": "Hot", + "accessTierInferred": true, + "accessTierChangeTime": "Wed, 22 Jan 2025 18:53:15 GMT" + } + } + }, + { + "json": { + "name": "myblob1", + "properties": { + "creationTime": "Wed, 22 Jan 2025 18:53:15 GMT", + "lastModified": "Wed, 22 Jan 2025 18:53:15 GMT", + "etag": "0x1F8268B228AA730", + "contentLength": 37, + "contentType": "application/json", + "contentMD5": "aWQGHD8kGQd5ZtEN/S1/aw==", + "blobType": "BlockBlob", + "leaseStatus": "unlocked", + "leaseState": "available", + "serverEncrypted": true, + "accessTier": "Hot", + "accessTierInferred": true, + "accessTierChangeTime": "Wed, 22 Jan 2025 18:53:15 GMT" + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAllLimitOptions.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAllLimitOptions.workflow.json new file mode 100644 index 0000000000..a8c33499f4 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/blob_getAllLimitOptions.workflow.json @@ -0,0 +1,76 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "blob", + "operation": "getAll", + "container": { "__rl": true, "value": "mycontainer", "mode": "list" }, + "limit": 1, + "options": { + "fields": [ + "copy", + "deleted", + "deletedwithversions", + "immutabilitypolicy", + "metadata", + "legalhold", + "versions", + "uncommittedblobs", + "tags", + "snapshots", + "permissions" + ], + "filter": ["deleted", "files", "directories"], + "simplify": true, + "upn": true + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [660, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "myblob1", + "properties": { + "creationTime": "Wed, 22 Jan 2025 18:53:15 GMT", + "lastModified": "Wed, 22 Jan 2025 18:53:15 GMT", + "etag": "0x1F8268B228AA730", + "contentLength": 37, + "contentType": "application/json", + "contentMD5": "aWQGHD8kGQd5ZtEN/S1/aw==", + "blobType": "BlockBlob", + "leaseStatus": "unlocked", + "leaseState": "available", + "serverEncrypted": true + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_create.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_create.workflow.json new file mode 100644 index 0000000000..774ac143f2 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_create.workflow.json @@ -0,0 +1,54 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "create", + "containerCreate": "mycontainer", + "options": { + "accessLevel": "blob", + "metadata": { "metadataValues": [{ "fieldName": "key1", "fieldValue": "value1" }] } + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "etag": "\"0x22769D26D3F3740\"", + "lastModified": "Thu, 23 Jan 2025 17:53:23 GMT", + "requestId": "75b87ee3-a7f7-468d-b7d1-e7e7b3173dab", + "version": "2025-01-05", + "date": "Thu, 23 Jan 2025 17:53:23 GMT", + "clientRequestId": "client-request-id-123", + "requestServerEncrypted": true + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_delete.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_delete.workflow.json new file mode 100644 index 0000000000..579ceb974d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_delete.workflow.json @@ -0,0 +1,48 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "delete", + "container": { "__rl": true, "mode": "list", "value": "mycontainer" }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "contentLength": 0, + "requestId": "ca3a8907-601e-0050-1929-723410000000", + "server": "Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0", + "version": "2020-10-02", + "date": "Wed, 29 Jan 2025 08:38:21 GMT" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_get.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_get.workflow.json new file mode 100644 index 0000000000..39c78154ca --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_get.workflow.json @@ -0,0 +1,61 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "get", + "container": { "__rl": true, "mode": "list", "value": "mycontainer" }, + "options": { "simplify": false }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "mycontainer", + "properties": { + "lastModified": "Tue, 28 Jan 2025 16:40:21 GMT", + "etag": "\"0x8DD3FBA74CF3620\"", + "leaseStatus": "unlocked", + "leaseState": "available", + "hasImmutabilityPolicy": false, + "hasLegalHold": false, + "leaseDuration": "infinite", + "blobPublicAccess": "blob", + "contentLength": 0, + "date": "Wed, 29 Jan 2025 08:43:08 GMT", + "requestId": "49edb268-b01e-0053-6e29-72d574000000", + "server": "Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0", + "version": "2020-10-02" + }, + "metadata": { "key1": "field1" } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAll.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAll.workflow.json new file mode 100644 index 0000000000..1639a5f673 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAll.workflow.json @@ -0,0 +1,73 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { "operation": "getAll", "returnAll": true, "requestOptions": {} }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "mycontainer1", + "deleted": true, + "version": "01DB7228F6BEE6E7", + "properties": { + "lastModified": "Wed, 29 Jan 2025 08:37:00 GMT", + "etag": "\"0x8DD40401935032C\"", + "leaseStatus": "unlocked", + "leaseState": "expired", + "hasImmutabilityPolicy": false, + "hasLegalHold": false, + "immutableStorageWithVersioningEnabled": "false", + "deletedTime": "Wed, 29 Jan 2025 08:38:21 GMT", + "remainingRetentionDays": 7 + }, + "metadata": { "key1": "value1" } + } + }, + { + "json": { + "name": "mycontainer1", + "deleted": true, + "version": "01DB7228F6BEE6E7", + "properties": { + "lastModified": "Wed, 29 Jan 2025 08:37:00 GMT", + "etag": "\"0x8DD40401935032C\"", + "leaseStatus": "unlocked", + "leaseState": "expired", + "hasImmutabilityPolicy": false, + "hasLegalHold": false, + "immutableStorageWithVersioningEnabled": "false", + "deletedTime": "Wed, 29 Jan 2025 08:38:21 GMT", + "remainingRetentionDays": 7 + }, + "metadata": { "key1": "value1" } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAllLimitOptions.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAllLimitOptions.workflow.json new file mode 100644 index 0000000000..1b48f9b486 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/container_getAllLimitOptions.workflow.json @@ -0,0 +1,59 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "99f866fa-f63c-477d-a0d0-48fbdb8a344a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "getAll", + "limit": 1, + "options": { "fields": ["metadata", "deleted", "system"], "filter": "mycontainer" }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "ab1b6258-5c75-4893-90bf-ef591264420c", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "mycontainer1", + "deleted": true, + "version": "01DB7228F6BEE6E7", + "properties": { + "lastModified": "Wed, 29 Jan 2025 08:37:00 GMT", + "etag": "\"0x8DD40401935032C\"", + "leaseStatus": "unlocked", + "leaseState": "expired", + "hasImmutabilityPolicy": false, + "hasLegalHold": false, + "immutableStorageWithVersioningEnabled": "false", + "deletedTime": "Wed, 29 Jan 2025 08:38:21 GMT", + "remainingRetentionDays": 7 + }, + "metadata": { "key1": "value1" } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_oauth2.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_oauth2.workflow.json new file mode 100644 index 0000000000..e5e5c0829d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_oauth2.workflow.json @@ -0,0 +1,65 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "1307e408-a8a5-464e-b858-494953e2f43b", + "name": "When clicking 'Test workflow'" + }, + { + "parameters": { + "authentication": "oAuth2", + "resource": "container", + "operation": "get", + "container": { "__rl": true, "value": "mycontainer", "mode": "id" }, + "options": { "simplify": false }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "3429f7f2-dfca-4b72-8913-43a582e96e66", + "name": "Azure Storage", + "credentials": { + "azureStorageOAuth2Api": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage OAuth2 account" + } + } + } + ], + "connections": { + "When clicking 'Test workflow'": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "mycontainer", + "properties": { + "lastModified": "Tue, 28 Jan 2025 16:40:21 GMT", + "etag": "\"0x8DD3FBA74CF3620\"", + "leaseStatus": "unlocked", + "leaseState": "available", + "hasImmutabilityPolicy": false, + "hasLegalHold": false, + "leaseDuration": "infinite", + "blobPublicAccess": "blob", + "contentLength": 0, + "date": "Wed, 29 Jan 2025 08:43:08 GMT", + "requestId": "49edb268-b01e-0053-6e29-72d574000000", + "server": "Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0", + "version": "2020-10-02" + }, + "metadata": { + "key1": "field1" + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_sharedKey.workflow.json b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_sharedKey.workflow.json new file mode 100644 index 0000000000..025e61f735 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/workflows/credentials_sharedKey.workflow.json @@ -0,0 +1,62 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "1307e408-a8a5-464e-b858-494953e2f43b", + "name": "When clicking 'Test workflow'" + }, + { + "parameters": { + "resource": "container", + "operation": "get", + "container": { "__rl": true, "value": "mycontainer", "mode": "id" }, + "options": { "simplify": false }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureStorage", + "typeVersion": 1, + "position": [220, 0], + "id": "3429f7f2-dfca-4b72-8913-43a582e96e66", + "name": "Azure Storage", + "credentials": { + "azureStorageSharedKeyApi": { + "id": "VPmcFM58eDDexWQL", + "name": "Azure Storage Shared Key account" + } + } + } + ], + "connections": { + "When clicking 'Test workflow'": { + "main": [[{ "node": "Azure Storage", "type": "main", "index": 0 }]] + } + }, + "pinData": { + "Azure Storage": [ + { + "json": { + "name": "mycontainer", + "properties": { + "lastModified": "Tue, 28 Jan 2025 16:40:21 GMT", + "etag": "\"0x8DD3FBA74CF3620\"", + "leaseStatus": "unlocked", + "leaseState": "available", + "hasImmutabilityPolicy": false, + "hasLegalHold": false, + "leaseDuration": "infinite", + "blobPublicAccess": "blob", + "contentLength": 0, + "date": "Wed, 29 Jan 2025 08:43:08 GMT", + "requestId": "49edb268-b01e-0053-6e29-72d574000000", + "server": "Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0", + "version": "2020-10-02" + }, + "metadata": { "key1": "field1" } + } + } + ] + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index fad1697e80..5367fb2094 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -39,6 +39,8 @@ "dist/credentials/AutomizyApi.credentials.js", "dist/credentials/AutopilotApi.credentials.js", "dist/credentials/Aws.credentials.js", + "dist/credentials/AzureStorageOAuth2Api.credentials.js", + "dist/credentials/AzureStorageSharedKeyApi.credentials.js", "dist/credentials/BambooHrApi.credentials.js", "dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BaserowApi.credentials.js", @@ -640,6 +642,7 @@ "dist/nodes/Microsoft/Outlook/MicrosoftOutlook.node.js", "dist/nodes/Microsoft/Outlook/MicrosoftOutlookTrigger.node.js", "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js", + "dist/nodes/Microsoft/Storage/AzureStorage.node.js", "dist/nodes/Microsoft/Teams/MicrosoftTeams.node.js", "dist/nodes/Microsoft/ToDo/MicrosoftToDo.node.js", "dist/nodes/Mindee/Mindee.node.js", diff --git a/packages/nodes-base/test/nodes/ExecuteWorkflow.ts b/packages/nodes-base/test/nodes/ExecuteWorkflow.ts index 969001e619..2b114798bf 100644 --- a/packages/nodes-base/test/nodes/ExecuteWorkflow.ts +++ b/packages/nodes-base/test/nodes/ExecuteWorkflow.ts @@ -10,8 +10,29 @@ export async function executeWorkflow(testData: WorkflowTestData, nodeTypes: INo if (testData.nock) { const { baseUrl, mocks } = testData.nock; const agent = nock(baseUrl); - mocks.forEach(({ method, path, statusCode, requestBody, responseBody }) => - agent[method](path, requestBody).reply(statusCode, responseBody), + mocks.forEach( + ({ + method, + path, + statusCode, + requestBody, + requestHeaders, + responseBody, + responseHeaders, + }) => { + let mock = agent[method](path, requestBody); + + // nock interceptor reqheaders option is ignored, so we chain matchHeader() + // agent[method](path, requestBody, { reqheaders: requestHeaders }).reply(statusCode, responseBody, responseHeaders) + // https://github.com/nock/nock/issues/2545 + if (requestHeaders && Object.keys(requestHeaders).length > 0) { + Object.entries(requestHeaders).forEach(([key, value]) => { + mock = mock.matchHeader(key, value); + }); + } + + mock.reply(statusCode, responseBody, responseHeaders); + }, ); } const executionMode = testData.trigger?.mode ?? 'manual'; diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index e7fee750f8..48ee376218 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -54,6 +54,33 @@ BQIDAQAB airtableApi: { apiKey: 'key123', }, + azureStorageOAuth2Api: { + grantType: 'authorizationCode', + authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + accessTokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + clientId: 'CLIENTID', + clientSecret: 'CLIENTSECRET', + scope: 'https://storage.azure.com/user_impersonation', + authQueryParameters: 'response_mode=query', + authentication: 'body', + oauthTokenData: { + token_type: 'Bearer', + scope: 'https://storage.azure.com/user_impersonation', + expires_in: 4730, + ext_expires_in: 4730, + access_token: 'ACCESSTOKEN', + callbackQueryString: { + session_state: 'SESSIONSTATE', + }, + }, + account: 'myaccount', + baseUrl: 'https://myaccount.blob.core.windows.net', + }, + azureStorageSharedKeyApi: { + account: 'devstoreaccount1', + key: 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==', + baseUrl: 'https://myaccount.blob.core.windows.net', + }, gongApi: { baseUrl: 'https://api.gong.io', accessKey: 'accessKey123', diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 46a4713491..fba34bc0a0 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -6,7 +6,7 @@ import type * as express from 'express'; import type FormData from 'form-data'; import type { PathLike } from 'fs'; import type { IncomingHttpHeaders } from 'http'; -import type { RequestBodyMatcher } from 'nock'; +import type { ReplyHeaders, RequestBodyMatcher, RequestHeaderMatcher } from 'nock'; import type { Client as SSHClient } from 'ssh2'; import type { Readable } from 'stream'; import type { SecureContextOptions } from 'tls'; @@ -2412,8 +2412,10 @@ export interface WorkflowTestData { method: 'delete' | 'get' | 'patch' | 'post' | 'put'; path: string; requestBody?: RequestBodyMatcher; + requestHeaders?: Record; statusCode: number; responseBody: string | object; + responseHeaders?: ReplyHeaders; }>; }; trigger?: {