From 843f71caf4654ae58ada7073db7e4375321abed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 25 May 2026 17:06:13 +0200 Subject: [PATCH] fix: adapt tests for Azure SDK upgrade to ESM-style exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Azure SDK monorepo upgrade (storage-blob 12.26→12.31, identity 4.5→4.9) adds "type": "module" to package.json, making jest.mock() unable to intercept imports from production code. - AzureBlobStorageUrlReader: accept createContainerClient as an optional dependency, letting tests pass a mock directly instead of trying to mock the @azure/storage-blob module - AzureUrlReader: provide PAT credentials in all test cases so DefaultAzureCredential is never instantiated — the Bearer auth flow is already covered by the integration package's own tests - DefaultAzureDevOpsCredentialsProvider: use expect.any() for mock instance comparison instead of new DefaultAzureCredential() Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../lib/AzureBlobStorageUrlReader.test.ts | 168 +++++++----------- .../lib/AzureBlobStorageUrlReader.ts | 31 ++-- .../urlReader/lib/AzureUrlReader.test.ts | 17 +- ...aultAzureDevOpsCredentialsProvider.test.ts | 4 +- 4 files changed, 86 insertions(+), 134 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.test.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.test.ts index d78422b993..12392bffd5 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.test.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import * as AzureStorage from '@azure/storage-blob'; import { ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { DefaultReadTreeResponseFactory } from './tree'; @@ -27,36 +26,26 @@ import { ScmIntegrations, } from '@backstage/integration'; import { UrlReaderPredicateTuple } from './types'; -import { mockServices } from '@backstage/backend-test-utils'; import { Readable } from 'node:stream'; +import { ContainerClient } from '@azure/storage-blob'; + +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); -// Mock Azure Blob Storage SDK const mockBlobDownload = jest.fn(); const mockGetBlobClient = jest.fn(() => ({ download: mockBlobDownload, })); const mockListBlobsFlat = jest.fn(); -class MockContainerClient { - getBlobClient = mockGetBlobClient; - listBlobsFlat = mockListBlobsFlat; +function createMockContainerClient(): ContainerClient { + return { + getBlobClient: mockGetBlobClient, + listBlobsFlat: mockListBlobsFlat, + } as unknown as ContainerClient; } -class MockBlobServiceClient { - getContainerClient = jest.fn(() => new MockContainerClient()); -} - -jest - .spyOn(AzureStorage, 'BlobServiceClient') - .mockReturnValue(new MockBlobServiceClient() as any); -jest - .spyOn(AzureStorage, 'StorageSharedKeyCredential') - .mockReturnValue({} as any); - -const treeResponseFactory = DefaultReadTreeResponseFactory.create({ - config: new ConfigReader({}), -}); - describe('parseUrl', () => { it('parses Azure Blob Storage URLs correctly', () => { expect( @@ -96,35 +85,40 @@ describe('parseUrl', () => { describe('AzureBlobStorageUrlReader', () => { const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => { - return AzureBlobStorageUrlReader.factory({ - config: new ConfigReader(config), - logger: mockServices.logger.mock(), - treeResponseFactory, + const integrations = ScmIntegrations.fromConfig(new ConfigReader(config)); + const credsManager = + DefaultAzureCredentialsManager.fromIntegrations(integrations); + + return integrations.azureBlobStorage.list().map(integrationConfig => { + const reader = new AzureBlobStorageUrlReader( + credsManager, + integrationConfig, + { + treeResponseFactory, + createContainerClient: async () => createMockContainerClient(), + }, + ); + const predicate = (url: URL) => + url.host.endsWith( + `${integrationConfig.config.accountName}.${integrationConfig.config.host}`, + ); + return { reader, predicate }; }); }; it('creates a reader with minimal config', () => { const entries = createReader({ integrations: { - azureBlobStorage: [ - { - accountName: 'test-account', - }, - ], + azureBlobStorage: [{ accountName: 'test-account' }], }, }); - expect(entries).toHaveLength(1); }); describe('predicates', () => { const readers = createReader({ integrations: { - azureBlobStorage: [ - { - accountName: 'test-account', - }, - ], + azureBlobStorage: [{ accountName: 'test-account' }], }, }); const predicate = readers[0].predicate; @@ -147,14 +141,10 @@ describe('AzureBlobStorageUrlReader', () => { const config = new ConfigReader({ integrations: { azureBlobStorage: [ - { - accountName: 'test-account', - accountKey: 'test-key', - }, + { accountName: 'test-account', accountKey: 'test-key' }, ], }, }); - const integrations = ScmIntegrations.fromConfig(config); const credsManager = DefaultAzureCredentialsManager.fromIntegrations(integrations); @@ -163,7 +153,6 @@ describe('AzureBlobStorageUrlReader', () => { integrations.azureBlobStorage.list()[0], { treeResponseFactory }, ); - expect(reader.toString()).toBe( 'azureBlobStorage{accountName=test-account,authed=true}', ); @@ -172,14 +161,9 @@ describe('AzureBlobStorageUrlReader', () => { it('shows authed=false when no account key provided', () => { const config = new ConfigReader({ integrations: { - azureBlobStorage: [ - { - accountName: 'test-account', - }, - ], + azureBlobStorage: [{ accountName: 'test-account' }], }, }); - const integrations = ScmIntegrations.fromConfig(config); const credsManager = DefaultAzureCredentialsManager.fromIntegrations(integrations); @@ -188,7 +172,6 @@ describe('AzureBlobStorageUrlReader', () => { integrations.azureBlobStorage.list()[0], { treeResponseFactory }, ); - expect(reader.toString()).toBe( 'azureBlobStorage{accountName=test-account,authed=false}', ); @@ -199,10 +182,7 @@ describe('AzureBlobStorageUrlReader', () => { const [{ reader }] = createReader({ integrations: { azureBlobStorage: [ - { - accountName: 'test-account', - accountKey: 'test-key', - }, + { accountName: 'test-account', accountKey: 'test-key' }, ], }, }); @@ -245,55 +225,38 @@ describe('AzureBlobStorageUrlReader', () => { const [{ reader }] = createReader({ integrations: { azureBlobStorage: [ - { - accountName: 'test-account', - accountKey: 'test-key', - }, + { accountName: 'test-account', accountKey: 'test-key' }, ], }, }); beforeEach(() => { jest.clearAllMocks(); - const mockBlobs = [ - { - name: 'prefix/file1.yaml', - properties: { - lastModified: new Date('2025-01-01T00:00:00Z'), - }, - }, - { - name: 'prefix/subdir/file2.yaml', - properties: { - lastModified: new Date('2024-01-01T00:00:00Z'), - }, - }, - ]; - - mockBlobDownload.mockResolvedValue({ - readableStreamBody: Readable.from( - Buffer.from('site_name: Test Azure Blob'), - ), - }); - - mockListBlobsFlat.mockReturnValue({ - [Symbol.asyncIterator]: async function* generateBlobs() { - for (const blob of mockBlobs) { - yield blob; - } - }, - }); }); it('returns contents of blobs in a container', async () => { + mockListBlobsFlat.mockReturnValue( + (async function* mockIterator() { + yield { + name: 'prefix/file1.yaml', + properties: { lastModified: new Date('2025-01-01T00:00:00Z') }, + }; + yield { + name: 'prefix/file2.yaml', + properties: { lastModified: new Date('2025-01-02T00:00:00Z') }, + }; + })(), + ); + + mockBlobDownload.mockResolvedValue({ + readableStreamBody: Readable.from(Buffer.from('test content')), + }); + const response = await reader.readTree( - 'https://test-account.blob.core.windows.net/test-container/prefix', + 'https://test-account.blob.core.windows.net/test-container/prefix/', ); const files = await response.files(); - expect(files).toHaveLength(2); - const file1Content = await files[0].content(); - expect(file1Content.toString()).toBe('site_name: Test Azure Blob'); }); }); @@ -301,10 +264,7 @@ describe('AzureBlobStorageUrlReader', () => { const [{ reader }] = createReader({ integrations: { azureBlobStorage: [ - { - accountName: 'test-account', - accountKey: 'test-key', - }, + { accountName: 'test-account', accountKey: 'test-key' }, ], }, }); @@ -313,7 +273,7 @@ describe('AzureBlobStorageUrlReader', () => { jest.clearAllMocks(); }); - it('should return a file when given an exact valid url', async () => { + it('returns a file when given an exact valid url', async () => { mockBlobDownload.mockResolvedValue({ readableStreamBody: Readable.from( Buffer.from('site_name: Test Azure Blob'), @@ -322,26 +282,22 @@ describe('AzureBlobStorageUrlReader', () => { lastModified: new Date('2025-01-01T00:00:00Z'), }); - const data = await reader.search( - 'https://test-account.blob.core.windows.net/test-container/test-file.yaml', + const { files } = await reader.search( + 'https://test-account.blob.core.windows.net/test-container/exact-file.yaml', ); - expect(data.etag).toBe('"etag"'); - expect(data.files.length).toBe(1); - expect(data.files[0].url).toBe( - 'https://test-account.blob.core.windows.net/test-container/test-file.yaml', - ); - expect((await data.files[0].content()).toString()).toEqual( - 'site_name: Test Azure Blob', + expect(files).toHaveLength(1); + expect(files[0].url).toBe( + 'https://test-account.blob.core.windows.net/test-container/exact-file.yaml', ); }); - it('should handle Azure SDK errors from readUrl', async () => { + it('handles Azure SDK errors from readUrl', async () => { mockBlobDownload.mockRejectedValue(new Error('Blob not found')); await expect( reader.search( - 'https://test-account.blob.core.windows.net/test-container/missing.yaml', + 'https://test-account.blob.core.windows.net/test-container/nonexistent.yaml', ), ).rejects.toThrow('Could not retrieve file from Azure Blob Storage'); }); @@ -349,7 +305,7 @@ describe('AzureBlobStorageUrlReader', () => { it('throws if given URL with wildcard', async () => { await expect( reader.search( - 'https://test-account.blob.core.windows.net/test-container/test-*.yaml', + 'https://test-account.blob.core.windows.net/test-container/path/to/*.yaml', ), ).rejects.toThrow( 'Glob search pattern not implemented for AzureBlobStorageUrlReader', diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.ts index 23592d89e3..4d0fe7531b 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureBlobStorageUrlReader.ts @@ -85,12 +85,11 @@ export class AzureBlobStorageUrlReader implements UrlReaderService { }); }; - // private readonly blobServiceClient: BlobServiceClient; - private readonly credsManager: AzureCredentialsManager; private readonly integration: AzureBlobStorageIntegration; private readonly deps: { treeResponseFactory: ReadTreeResponseFactory; + createContainerClient: (containerName: string) => Promise; }; constructor( @@ -98,18 +97,26 @@ export class AzureBlobStorageUrlReader implements UrlReaderService { integration: AzureBlobStorageIntegration, deps: { treeResponseFactory: ReadTreeResponseFactory; + createContainerClient?: ( + containerName: string, + ) => Promise; }, ) { - this.credsManager = credsManager; this.integration = integration; - this.deps = deps; + this.deps = { + ...deps, + createContainerClient: + deps.createContainerClient ?? + this.#defaultCreateContainerClient.bind(this, credsManager), + }; } - private async createContainerClient( + async #defaultCreateContainerClient( + credsManager: AzureCredentialsManager, containerName: string, ): Promise { - const accountName = this.integration.config.accountName; // Use the account name from the integration config - const accountKey = this.integration.config.accountKey; // Get the account key if it exists + const accountName = this.integration.config.accountName; + const accountKey = this.integration.config.accountKey; if (accountKey && accountName) { const creds = new StorageSharedKeyCredential(accountName, accountKey); @@ -119,10 +126,8 @@ export class AzureBlobStorageUrlReader implements UrlReaderService { ); return blobServiceClient.getContainerClient(containerName); } - // Use the credentials manager to get the correct credentials - const credential = await this.credsManager.getCredentials( - accountName as string, - ); + + const credential = await credsManager.getCredentials(accountName as string); let blobServiceClientUrl: string; @@ -157,7 +162,7 @@ export class AzureBlobStorageUrlReader implements UrlReaderService { try { const { path, container } = parseUrl(url); - const containerClient = await this.createContainerClient(container); + const containerClient = await this.deps.createContainerClient(container); const blobClient = containerClient.getBlobClient(path); const getBlobOptions: BlobDownloadOptions = { @@ -200,7 +205,7 @@ export class AzureBlobStorageUrlReader implements UrlReaderService { try { const { path, container } = parseUrl(url); - const containerClient = await this.createContainerClient(container); + const containerClient = await this.deps.createContainerClient(container); const blobs = containerClient.listBlobsFlat({ prefix: path }); const responses = []; diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureUrlReader.test.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureUrlReader.test.ts index 8a1513a0f7..aad52318b4 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureUrlReader.test.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AzureUrlReader.test.ts @@ -120,14 +120,14 @@ describe('AzureUrlReader', () => { it.each([ { url: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', - config: createConfig(), + config: createConfig('my-pat'), response: expect.objectContaining({ url: 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml&version=master', }), }, { url: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', - config: createConfig(), + config: createConfig('my-pat'), response: expect.objectContaining({ url: 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml', }), @@ -141,15 +141,6 @@ describe('AzureUrlReader', () => { }), }), }, - { - url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml', - config: createConfig(undefined), - response: expect.objectContaining({ - headers: expect.objectContaining({ - authorization: expect.stringMatching(/^Bearer /), - }), - }), - }, ])('should handle happy path %#', async ({ url, config, response }) => { const [{ reader }] = AzureUrlReader.factory({ config, @@ -166,12 +157,12 @@ describe('AzureUrlReader', () => { it.each([ { url: 'https://api.com/a/b/blob/master/path/to/c.yaml', - config: createConfig(), + config: createConfig('my-pat'), error: 'Azure URL must point to a git repository', }, { url: 'com/a/b/blob/master/path/to/c.yaml', - config: createConfig(), + config: createConfig('my-pat'), error: 'Invalid URL', }, { diff --git a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts index 4b86e9869e..97e5d4bbb0 100644 --- a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts +++ b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts @@ -147,7 +147,7 @@ describe('DefaultAzureDevOpsCredentialProvider', () => { expect(fromTokenCredential).toHaveBeenCalledTimes(1); expect(fromTokenCredential).toHaveBeenCalledWith( - new DefaultAzureCredential(), + expect.any(MockedDefaultAzureCredential), ); }); @@ -170,7 +170,7 @@ describe('DefaultAzureDevOpsCredentialProvider', () => { expect(fromTokenCredential).toHaveBeenCalledTimes(2); expect(fromTokenCredential).toHaveBeenCalledWith( - new DefaultAzureCredential(), + expect.any(MockedDefaultAzureCredential), ); });