diff --git a/backend/src/north/north-azure-blob/manifest.ts b/backend/src/north/north-azure-blob/manifest.ts index c033f5ed28..15a12c04b1 100644 --- a/backend/src/north/north-azure-blob/manifest.ts +++ b/backend/src/north/north-azure-blob/manifest.ts @@ -34,7 +34,7 @@ const manifest: NorthConnectorManifest = { { key: 'authentication', type: 'OibSelect', - options: ['sasToken', 'accessKey', 'aad'], + options: ['external', 'sasToken', 'accessKey', 'aad', 'powershell'], label: 'Authentication', defaultValue: 'accessKey', newRow: true, diff --git a/backend/src/north/north-azure-blob/north-azure-blob.spec.ts b/backend/src/north/north-azure-blob/north-azure-blob.spec.ts index ddf50fc598..d0fd2a8eb6 100644 --- a/backend/src/north/north-azure-blob/north-azure-blob.spec.ts +++ b/backend/src/north/north-azure-blob/north-azure-blob.spec.ts @@ -9,7 +9,7 @@ import RepositoryService from '../../service/repository.service'; import RepositoryServiceMock from '../../tests/__mocks__/repository-service.mock'; import { NorthConnectorDTO } from '../../../../shared/model/north-connector.model'; import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob'; -import { DefaultAzureCredential } from '@azure/identity'; +import { AzurePowerShellCredential, DefaultAzureCredential } from '@azure/identity'; import ValueCacheServiceMock from '../../tests/__mocks__/value-cache-service.mock'; import FileCacheServiceMock from '../../tests/__mocks__/file-cache-service.mock'; import { NorthAzureBlobSettings } from '../../../../shared/model/north-settings.model'; @@ -28,8 +28,9 @@ jest.mock('@azure/storage-blob', () => ({ })), StorageSharedKeyCredential: jest.fn() })); -jest.mock('@azure/identity', () => ({ DefaultAzureCredential: jest.fn() })); +jest.mock('@azure/identity', () => ({ DefaultAzureCredential: jest.fn(), AzurePowerShellCredential: jest.fn() })); jest.mock('node:fs/promises'); +jest.mock('../../service/utils'); jest.mock( '../../service/cache/archive.service', () => @@ -184,4 +185,99 @@ describe('NorthAzureBlob', () => { expect(getBlockBlobClientMock).toHaveBeenCalledWith('example.file'); expect(uploadMock).toHaveBeenCalledWith('content', 666); }); + + it('should properly handle files with external auth', async () => { + const filePath = '/path/to/file/example-123.file'; + (fs.stat as jest.Mock).mockImplementationOnce(() => Promise.resolve({ size: 666 })); + (fs.readFile as jest.Mock).mockImplementationOnce(() => Promise.resolve('content')); + const defaultAzureCredential = jest.fn(); + (DefaultAzureCredential as jest.Mock).mockImplementationOnce(() => defaultAzureCredential); + + configuration.settings.authentication = 'external'; + const north = new NorthAzureBlob(configuration, encryptionService, repositoryService, logger, 'baseFolder'); + + await north.start(); + await north.handleFile(filePath); + + expect(fs.stat).toHaveBeenCalledWith(filePath); + expect(fs.readFile).toHaveBeenCalledWith(filePath); + expect(DefaultAzureCredential).toHaveBeenCalled(); + expect(BlobServiceClient).toHaveBeenCalledWith( + `https://${configuration.settings.account}.blob.core.windows.net`, + defaultAzureCredential + ); + expect(getContainerClientMock).toHaveBeenCalledWith(configuration.settings.container); + expect(getBlockBlobClientMock).toHaveBeenCalledWith('example.file'); + expect(uploadMock).toHaveBeenCalledWith('content', 666); + }); + + it('should properly handle files with powershell credentials', async () => { + const filePath = '/path/to/file/example-123.file'; + (fs.stat as jest.Mock).mockImplementationOnce(() => Promise.resolve({ size: 666 })); + (fs.readFile as jest.Mock).mockImplementationOnce(() => Promise.resolve('content')); + const defaultAzureCredential = jest.fn(); + (AzurePowerShellCredential as jest.Mock).mockImplementationOnce(() => defaultAzureCredential); + + configuration.settings.path = 'my path'; + configuration.settings.authentication = 'powershell'; + const north = new NorthAzureBlob(configuration, encryptionService, repositoryService, logger, 'baseFolder'); + + await north.start(); + await north.handleFile(filePath); + + expect(fs.stat).toHaveBeenCalledWith(filePath); + expect(fs.readFile).toHaveBeenCalledWith(filePath); + expect(AzurePowerShellCredential).toHaveBeenCalled(); + expect(BlobServiceClient).toHaveBeenCalledWith( + `https://${configuration.settings.account}.blob.core.windows.net`, + defaultAzureCredential + ); + expect(getContainerClientMock).toHaveBeenCalledWith(configuration.settings.container); + expect(getBlockBlobClientMock).toHaveBeenCalledWith('my path/example.file'); + expect(uploadMock).toHaveBeenCalledWith('content', 666); + }); + + it('should successfully test', async () => { + const defaultAzureCredential = jest.fn(); + (AzurePowerShellCredential as jest.Mock).mockImplementationOnce(() => defaultAzureCredential); + const exists = jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false); + (getContainerClientMock as jest.Mock).mockImplementation(() => { + return { + exists: exists + }; + }); + const north = new NorthAzureBlob(configuration, encryptionService, repositoryService, logger, 'baseFolder'); + await north.testConnection(); + expect(AzurePowerShellCredential).toHaveBeenCalled(); + expect(BlobServiceClient).toHaveBeenCalledWith( + `https://${configuration.settings.account}.blob.core.windows.net`, + defaultAzureCredential + ); + expect(getContainerClientMock).toHaveBeenCalledWith(configuration.settings.container); + expect(logger.info).toHaveBeenCalledWith(`Access to container ${configuration.settings.container} ok`); + await expect(north.testConnection()).rejects.toThrow(new Error(`Container ${configuration.settings.container} does not exist`)); + }); + + it('should manage test error', async () => { + const defaultAzureCredential = jest.fn(); + (AzurePowerShellCredential as jest.Mock).mockImplementationOnce(() => defaultAzureCredential); + (getContainerClientMock as jest.Mock).mockImplementation(() => { + throw new Error('connection error'); + }); + const north = new NorthAzureBlob(configuration, encryptionService, repositoryService, logger, 'baseFolder'); + + await expect(north.testConnection()).rejects.toThrow(new Error('connection error')); + expect(getContainerClientMock).toHaveBeenCalledWith(configuration.settings.container); + expect(logger.error).toHaveBeenCalledWith(`Error testing Azure Blob connection. ${new Error('connection error')}`); + }); + + it('should manage bad authentication type', async () => { + // @ts-ignore + configuration.settings.authentication = 'bad'; + const north = new NorthAzureBlob(configuration, encryptionService, repositoryService, logger, 'baseFolder'); + + await expect(north.start()).rejects.toThrow( + new Error(`Authentication "${configuration.settings.authentication}" not supported for North "${configuration.name}"`) + ); + }); }); diff --git a/backend/src/north/north-azure-blob/north-azure-blob.ts b/backend/src/north/north-azure-blob/north-azure-blob.ts index 0d16e274a5..ec617d35c1 100644 --- a/backend/src/north/north-azure-blob/north-azure-blob.ts +++ b/backend/src/north/north-azure-blob/north-azure-blob.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import pino from 'pino'; import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob'; -import { DefaultAzureCredential } from '@azure/identity'; +import { AzurePowerShellCredential, DefaultAzureCredential } from '@azure/identity'; import NorthConnector from '../north-connector'; import manifest from '../north-azure-blob/manifest'; import { NorthConnectorDTO } from '../../../../shared/model/north-connector.model'; @@ -27,6 +27,11 @@ export default class NorthAzureBlob extends NorthConnector { + await super.start(); + await this.prepareConnection(); + } + + async prepareConnection(): Promise { this.logger.info( `Connecting to Azure Blob Storage for account ${this.connector.settings.account} and container ${this.connector.settings.container}` ); @@ -51,8 +56,21 @@ export default class NorthAzureBlob extends NorthConnector { + this.logger.info('Testing Azure Blob connection'); + await this.prepareConnection(); + + let result = false; + try { + result = await this.blobClient!.getContainerClient(this.connector.settings.container).exists(); + } catch (error) { + this.logger.error(`Error testing Azure Blob connection. ${error}`); + throw error; + } + if (result) { + this.logger.info(`Access to container ${this.connector.settings.container} ok`); + } else { + throw new Error(`Container ${this.connector.settings.container} does not exist`); + } + } } diff --git a/shared/model/north-settings.model.ts b/shared/model/north-settings.model.ts index a467a37b14..71bc138d5e 100644 --- a/shared/model/north-settings.model.ts +++ b/shared/model/north-settings.model.ts @@ -2,7 +2,7 @@ // This file is auto-generated by the script located backend/src/settings-interface.generator.ts // To update the typescript model run npm run generate-settings-interface on the backend project -const NORTH_AZURE_BLOB_SETTINGS_AUTHENTICATIONS = ['sasToken', 'accessKey', 'aad'] as const +const NORTH_AZURE_BLOB_SETTINGS_AUTHENTICATIONS = ['external', 'sasToken', 'accessKey', 'aad', 'powershell'] as const export type NorthAzureBlobSettingsAuthentication = (typeof NORTH_AZURE_BLOB_SETTINGS_AUTHENTICATIONS)[number]; const NORTH_CSV_TO_HTTP_SETTINGS_REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH'] as const diff --git a/shared/model/south-settings.model.ts b/shared/model/south-settings.model.ts index 7c92f9d354..91aa87346d 100644 --- a/shared/model/south-settings.model.ts +++ b/shared/model/south-settings.model.ts @@ -616,4 +616,4 @@ export type SouthItemSettings = | SouthOracleItemSettings | SouthPostgreSQLItemSettings | SouthSlimsItemSettings - | SouthSQLiteItemSettings + | SouthSQLiteItemSettings \ No newline at end of file