diff --git a/packages/@n8n/config/src/configs/credentials.config.ts b/packages/@n8n/config/src/configs/credentials.config.ts index 9b1d8a727fd..0f1c80701c3 100644 --- a/packages/@n8n/config/src/configs/credentials.config.ts +++ b/packages/@n8n/config/src/configs/credentials.config.ts @@ -16,6 +16,10 @@ class CredentialsOverwrite { /** Authentication token for the credentials overwrite endpoint. */ @Env('CREDENTIALS_OVERWRITE_ENDPOINT_AUTH_TOKEN') endpointAuthToken: string = ''; + + /** Enable persistence for credentials overwrites. */ + @Env('CREDENTIALS_OVERWRITE_PERSISTENCE') + persistence: boolean = false; } @Config diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index b35676ecfe2..e511b9bfad5 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -106,6 +106,7 @@ describe('GlobalConfig', () => { data: '{}', endpoint: '', endpointAuthToken: '', + persistence: false, }, }, userManagement: { diff --git a/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts b/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts index cd01b3476b6..f00f4c20459 100644 --- a/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts +++ b/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts @@ -18,6 +18,7 @@ export type PubSubEventName = | 'reload-license' | 'reload-oidc-config' | 'reload-saml-config' + | 'reload-overwrite-credentials' | 'response-to-get-worker-status' | 'restart-event-bus' | 'relay-execution-lifecycle-event'; diff --git a/packages/cli/src/__tests__/credentials-overwrites.test.ts b/packages/cli/src/__tests__/credentials-overwrites.test.ts index daaa1120c6f..787e7a13b13 100644 --- a/packages/cli/src/__tests__/credentials-overwrites.test.ts +++ b/packages/cli/src/__tests__/credentials-overwrites.test.ts @@ -1,12 +1,15 @@ import type { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; +import { SettingsRepository } from '@n8n/db'; import type { NextFunction, Request, Response } from 'express'; import { mock } from 'jest-mock-extended'; -import { UnrecognizedCredentialTypeError } from 'n8n-core'; +import { Cipher, UnrecognizedCredentialTypeError } from 'n8n-core'; import type { ICredentialType } from 'n8n-workflow'; import type { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; +import type { ICredentialsOverwrite } from '@/interfaces'; describe('CredentialsOverwrites', () => { const testCredentialType = mock({ name: 'test', extends: ['parent'] }); @@ -17,7 +20,7 @@ describe('CredentialsOverwrites', () => { const logger = mock(); let credentialsOverwrites: CredentialsOverwrites; - beforeEach(() => { + beforeEach(async () => { jest.resetAllMocks(); globalConfig.credentials.overwrite.data = JSON.stringify({ @@ -34,7 +37,15 @@ describe('CredentialsOverwrites', () => { .calledWith(testCredentialType.name) .mockReturnValue([parentCredentialType.name]); - credentialsOverwrites = new CredentialsOverwrites(globalConfig, credentialTypes, logger); + credentialsOverwrites = new CredentialsOverwrites( + globalConfig, + credentialTypes, + logger, + mock(), + mock(), + ); + + await credentialsOverwrites.init(); }); describe('constructor', () => { @@ -56,6 +67,8 @@ describe('CredentialsOverwrites', () => { globalConfig, credentialTypes, logger, + mock(), + mock(), ); const middleware = localCredentialsOverwrites.getOverwriteEndpointMiddleware(); expect(middleware).toBeNull(); @@ -67,6 +80,8 @@ describe('CredentialsOverwrites', () => { globalConfig, credentialTypes, logger, + mock(), + mock(), ); const middleware = localCredentialsOverwrites.getOverwriteEndpointMiddleware(); expect(typeof middleware).toBe('function'); @@ -93,6 +108,8 @@ describe('CredentialsOverwrites', () => { globalConfig, credentialTypes, logger, + mock(), + mock(), ); middleware = localCredentialsOverwrites.getOverwriteEndpointMiddleware(); }); @@ -149,9 +166,9 @@ describe('CredentialsOverwrites', () => { }); describe('setData', () => { - it('should reset resolvedTypes when setting new data', () => { + it('should reset resolvedTypes when setting new data', async () => { const newData = { test: { token: 'test-token' } }; - credentialsOverwrites.setData(newData); + await credentialsOverwrites.setData(newData, false, false); expect(credentialsOverwrites.getAll()).toEqual(newData); }); @@ -204,7 +221,7 @@ describe('CredentialsOverwrites', () => { expect(credentialsOverwrites['resolvedTypes']).toEqual(['parent', 'test']); }); - it('should merge overwrites from parent types', () => { + it('should merge overwrites from parent types', async () => { credentialTypes.getByName.mockImplementation((credentialType) => { if (credentialType === 'childType') return mock({ extends: ['parentType1', 'parentType2'] }); @@ -223,8 +240,11 @@ describe('CredentialsOverwrites', () => { globalConfig, credentialTypes, logger, + mock(), + mock(), ); + await credentialsOverwrites.init(); const result = credentialsOverwrites.getOverwrites('childType'); expect(result).toEqual({ @@ -234,4 +254,905 @@ describe('CredentialsOverwrites', () => { }); }); }); + + describe('Database Persistence', () => { + let dbCredentialsOverwrites: CredentialsOverwrites; + let settingsRepository: jest.Mocked; + let cipher: jest.Mocked; + let publisherMock: { publishCommand: jest.Mock }; + let dbGlobalConfig: GlobalConfig; + + beforeEach(async () => { + // Mock SettingsRepository + settingsRepository = mockInstance(SettingsRepository, { + findByKey: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }); + + // Mock Cipher + cipher = mockInstance(Cipher, { + encrypt: jest.fn(), + decrypt: jest.fn(), + }); + + // Mock Publisher service - need to import the class first + const { Publisher } = await import('@/scaling/pubsub/publisher.service'); + publisherMock = { publishCommand: jest.fn() }; + mockInstance(Publisher, publisherMock); + + // Create separate config for database tests + dbGlobalConfig = mock({ + credentials: { + overwrite: { + data: JSON.stringify({ + test: { username: 'user' }, + parent: { password: 'pass' }, + }), + persistence: true, + endpointAuthToken: '', + }, + }, + }); + + // Create a new instance with mocked dependencies + dbCredentialsOverwrites = new CredentialsOverwrites( + dbGlobalConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + await dbCredentialsOverwrites.init(); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('saveOverwriteDataToDB', () => { + it('should encrypt data and save to database', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user', password: 'pass' }, + }; + const encryptedData = 'encrypted-data'; + const settingObject = { + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }; + + cipher.encrypt.mockReturnValue(encryptedData); + settingsRepository.create.mockReturnValue(settingObject); + + await dbCredentialsOverwrites.saveOverwriteDataToDB(overwriteData); + + expect(cipher.encrypt).toHaveBeenCalledWith(JSON.stringify(overwriteData)); + expect(settingsRepository.create).toHaveBeenCalledWith({ + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }); + expect(settingsRepository.save).toHaveBeenCalledWith(settingObject); + }); + + it('should call Publisher when broadcast is true', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user' }, + }; + + cipher.encrypt.mockReturnValue('encrypted'); + settingsRepository.create.mockReturnValue({ + key: 'credentialsOverwrite', + value: 'encrypted', + loadOnStartup: false, + }); + + await dbCredentialsOverwrites.saveOverwriteDataToDB(overwriteData, true); + + expect(publisherMock.publishCommand).toHaveBeenCalledWith({ + command: 'reload-overwrite-credentials', + }); + }); + + it('should skip Publisher when broadcast is false', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user' }, + }; + + cipher.encrypt.mockReturnValue('encrypted'); + settingsRepository.create.mockReturnValue({ + key: 'credentialsOverwrite', + value: 'encrypted', + loadOnStartup: false, + }); + + await dbCredentialsOverwrites.saveOverwriteDataToDB(overwriteData, false); + + expect(publisherMock.publishCommand).not.toHaveBeenCalled(); + }); + }); + + describe('loadOverwriteDataFromDB', () => { + it('should load and decrypt data without frontend reload', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user', password: 'pass' }, + }; + const encryptedData = 'encrypted-data'; + const settingData = { + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockReturnValue(JSON.stringify(overwriteData)); + + await dbCredentialsOverwrites.loadOverwriteDataFromDB(false); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith(encryptedData); + expect(dbCredentialsOverwrites.getAll()).toEqual(overwriteData); + }); + + it('should load and decrypt data with frontend reload', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user', password: 'pass' }, + }; + const encryptedData = 'encrypted-data'; + const settingData = { + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockReturnValue(JSON.stringify(overwriteData)); + + await dbCredentialsOverwrites.loadOverwriteDataFromDB(true); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith(encryptedData); + expect(dbCredentialsOverwrites.getAll()).toEqual(overwriteData); + }); + + it('should handle missing data gracefully', async () => { + settingsRepository.findByKey.mockResolvedValue(null); + + await dbCredentialsOverwrites.loadOverwriteDataFromDB(false); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).not.toHaveBeenCalled(); + // Should not throw error and existing data should remain unchanged + }); + + it('should handle decryption errors', async () => { + const settingData = { + key: 'credentialsOverwrite', + value: 'invalid-encrypted-data', + loadOnStartup: false, + }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockImplementation(() => { + throw new Error('Decryption failed'); + }); + + // Should not throw but log error + await expect(dbCredentialsOverwrites.loadOverwriteDataFromDB(false)).resolves.not.toThrow(); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith('invalid-encrypted-data'); + expect(logger.error).toHaveBeenCalledWith('Error loading overwrite credentials', { + error: expect.any(Error), + }); + }); + + it('should handle database errors', async () => { + const dbError = new Error('Database connection failed'); + settingsRepository.findByKey.mockRejectedValue(dbError); + + // Should not throw but log error + await expect(dbCredentialsOverwrites.loadOverwriteDataFromDB(false)).resolves.not.toThrow(); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Error loading overwrite credentials', { + error: dbError, + }); + }); + + it('should prevent concurrent calls using reloading flag', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user' }, + }; + const settingData = { + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }; + + // Setup mocks + settingsRepository.findByKey.mockImplementation(async () => { + // Simulate slow database operation + return await new Promise((resolve) => { + setTimeout(() => resolve(settingData), 100); + }); + }); + cipher.decrypt.mockReturnValue(JSON.stringify(overwriteData)); + + // Start first call + const firstCall = dbCredentialsOverwrites.loadOverwriteDataFromDB(false); + + // Start second call immediately (should return early due to reloading flag) + const secondCall = dbCredentialsOverwrites.loadOverwriteDataFromDB(false); + + // Wait for both calls to complete + await Promise.all([firstCall, secondCall]); + + // Database should only be called once due to reloading flag protection + expect(settingsRepository.findByKey).toHaveBeenCalledTimes(1); + expect(cipher.decrypt).toHaveBeenCalledTimes(1); + }); + + it('should handle JSON parsing errors in decrypted data', async () => { + const settingData = { + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockReturnValue('invalid-json{'); + + // Should not throw but log error + await expect(dbCredentialsOverwrites.loadOverwriteDataFromDB(false)).resolves.not.toThrow(); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith('encrypted-data'); + expect(logger.error).toHaveBeenCalledWith('Error loading overwrite credentials', { + error: expect.any(Error), + }); + }); + }); + }); + + describe('PubSub Integration', () => { + let pubsubCredentialsOverwrites: CredentialsOverwrites; + let settingsRepository: jest.Mocked; + let cipher: jest.Mocked; + let publisherMock: { publishCommand: jest.Mock }; + let pubsubGlobalConfig: GlobalConfig; + + beforeEach(async () => { + // Mock SettingsRepository + settingsRepository = mockInstance(SettingsRepository, { + findByKey: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }); + + // Mock Cipher + cipher = mockInstance(Cipher, { + encrypt: jest.fn(), + decrypt: jest.fn(), + }); + + // Mock Publisher service + const { Publisher } = await import('@/scaling/pubsub/publisher.service'); + publisherMock = { publishCommand: jest.fn() }; + mockInstance(Publisher, publisherMock); + + // Create config for PubSub tests with persistence enabled + pubsubGlobalConfig = mock({ + credentials: { + overwrite: { + data: JSON.stringify({ + test: { username: 'user' }, + }), + persistence: true, + endpointAuthToken: '', + }, + }, + }); + + // Create instance with mocked dependencies + pubsubCredentialsOverwrites = new CredentialsOverwrites( + pubsubGlobalConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + await pubsubCredentialsOverwrites.init(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('reloadOverwriteCredentials', () => { + it('should call loadOverwriteDataFromDB with reloadFrontend=true', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'newUser', password: 'newPass' }, + }; + const settingData = { + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockReturnValue(JSON.stringify(overwriteData)); + + // Mock the reloadFrontendService to avoid circular dependency issues + const mockReloadFrontendService = jest.fn(); + (pubsubCredentialsOverwrites as any).reloadFrontendService = mockReloadFrontendService; + + await pubsubCredentialsOverwrites.reloadOverwriteCredentials(); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith('encrypted-data'); + expect(pubsubCredentialsOverwrites.getAll()).toEqual(overwriteData); + expect(mockReloadFrontendService).toHaveBeenCalled(); + }); + + it('should handle database loading errors gracefully', async () => { + const dbError = new Error('Database connection failed'); + settingsRepository.findByKey.mockRejectedValue(dbError); + + await expect( + pubsubCredentialsOverwrites.reloadOverwriteCredentials(), + ).resolves.not.toThrow(); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Error loading overwrite credentials', { + error: dbError, + }); + }); + + it('should handle decryption errors gracefully', async () => { + const settingData = { + key: 'credentialsOverwrite', + value: 'invalid-encrypted-data', + loadOnStartup: false, + }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockImplementation(() => { + throw new Error('Decryption failed'); + }); + + await expect( + pubsubCredentialsOverwrites.reloadOverwriteCredentials(), + ).resolves.not.toThrow(); + + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith('invalid-encrypted-data'); + expect(logger.error).toHaveBeenCalledWith('Error loading overwrite credentials', { + error: expect.any(Error), + }); + }); + + it('should prevent concurrent reload operations', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user' }, + }; + const settingData = { + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }; + + // Setup mocks with slow database operation + settingsRepository.findByKey.mockImplementation(async () => { + return await new Promise((resolve) => { + setTimeout(() => resolve(settingData), 100); + }); + }); + cipher.decrypt.mockReturnValue(JSON.stringify(overwriteData)); + + // Mock the reloadFrontendService + const mockReloadFrontendService = jest.fn(); + (pubsubCredentialsOverwrites as any).reloadFrontendService = mockReloadFrontendService; + + // Start first reload + const firstReload = pubsubCredentialsOverwrites.reloadOverwriteCredentials(); + + // Start second reload immediately (should return early due to reloading flag) + const secondReload = pubsubCredentialsOverwrites.reloadOverwriteCredentials(); + + // Wait for both to complete + await Promise.all([firstReload, secondReload]); + + // Database should only be called once due to reloading flag protection + expect(settingsRepository.findByKey).toHaveBeenCalledTimes(1); + expect(cipher.decrypt).toHaveBeenCalledTimes(1); + }); + }); + + describe('broadcastReloadOverwriteCredentialsCommand', () => { + it('should publish command when persistence is enabled', async () => { + pubsubGlobalConfig.credentials.overwrite.persistence = true; + + // Call the private method through saveOverwriteDataToDB with broadcast=true + const overwriteData: ICredentialsOverwrite = { + test: { username: 'user' }, + }; + + cipher.encrypt.mockReturnValue('encrypted-data'); + settingsRepository.create.mockReturnValue({ + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }); + + await pubsubCredentialsOverwrites.saveOverwriteDataToDB(overwriteData, true); + + expect(publisherMock.publishCommand).toHaveBeenCalledWith({ + command: 'reload-overwrite-credentials', + }); + }); + }); + + describe('PubSub Event Decorator Integration', () => { + it('should have @OnPubSubEvent decorator configured correctly', async () => { + // Test that the method exists and can be called directly + expect(typeof pubsubCredentialsOverwrites.reloadOverwriteCredentials).toBe('function'); + + // Test that calling the method works (basic smoke test for decorator) + const settingData = { + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }; + const overwriteData = { test: { username: 'decoratorTest' } }; + + settingsRepository.findByKey.mockResolvedValue(settingData); + cipher.decrypt.mockReturnValue(JSON.stringify(overwriteData)); + + // Mock the reloadFrontendService + const mockReloadFrontendService = jest.fn(); + (pubsubCredentialsOverwrites as any).reloadFrontendService = mockReloadFrontendService; + + await expect( + pubsubCredentialsOverwrites.reloadOverwriteCredentials(), + ).resolves.not.toThrow(); + }); + }); + + describe('Integration between saveOverwriteDataToDB and PubSub broadcasting', () => { + it('should save to database and broadcast when both are enabled', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'integrationUser', password: 'integrationPass' }, + }; + const encryptedData = 'integration-encrypted-data'; + const settingObject = { + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }; + + pubsubGlobalConfig.credentials.overwrite.persistence = true; + cipher.encrypt.mockReturnValue(encryptedData); + settingsRepository.create.mockReturnValue(settingObject); + + await pubsubCredentialsOverwrites.saveOverwriteDataToDB(overwriteData, true); + + // Verify database operations + expect(cipher.encrypt).toHaveBeenCalledWith(JSON.stringify(overwriteData)); + expect(settingsRepository.create).toHaveBeenCalledWith({ + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }); + expect(settingsRepository.save).toHaveBeenCalledWith(settingObject); + + // Verify PubSub broadcast + expect(publisherMock.publishCommand).toHaveBeenCalledWith({ + command: 'reload-overwrite-credentials', + }); + }); + + it('should save to database but skip broadcast when disabled', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'noBroadcastUser' }, + }; + + cipher.encrypt.mockReturnValue('encrypted-data'); + settingsRepository.create.mockReturnValue({ + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }); + + await pubsubCredentialsOverwrites.saveOverwriteDataToDB(overwriteData, false); + + // Verify database operations still happen + expect(cipher.encrypt).toHaveBeenCalledWith(JSON.stringify(overwriteData)); + expect(settingsRepository.save).toHaveBeenCalled(); + + // Verify no PubSub broadcast + expect(publisherMock.publishCommand).not.toHaveBeenCalled(); + }); + + it('should complete database save even if broadcast fails', async () => { + const overwriteData: ICredentialsOverwrite = { + test: { username: 'broadcastFailUser' }, + }; + + pubsubGlobalConfig.credentials.overwrite.persistence = true; + cipher.encrypt.mockReturnValue('encrypted-data'); + settingsRepository.create.mockReturnValue({ + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }); + + // Make publishCommand fail + publisherMock.publishCommand.mockRejectedValue(new Error('Broadcast failed')); + + await expect( + pubsubCredentialsOverwrites.saveOverwriteDataToDB(overwriteData, true), + ).rejects.toThrow('Broadcast failed'); + + // Verify database operations completed before broadcast failure + expect(cipher.encrypt).toHaveBeenCalledWith(JSON.stringify(overwriteData)); + expect(settingsRepository.save).toHaveBeenCalled(); + expect(publisherMock.publishCommand).toHaveBeenCalledWith({ + command: 'reload-overwrite-credentials', + }); + }); + }); + }); + + describe('Frontend Integration', () => { + let frontendCredentialsOverwrites: CredentialsOverwrites; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Create instance for frontend tests + frontendCredentialsOverwrites = new CredentialsOverwrites( + globalConfig, + credentialTypes, + logger, + mock(), + mock(), + ); + + await frontendCredentialsOverwrites.init(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('reloadFrontendService via setData', () => { + beforeEach(() => { + // Mock the reloadFrontendService method directly to test through setData + jest + .spyOn(frontendCredentialsOverwrites as any, 'reloadFrontendService') + .mockResolvedValue(undefined); + }); + + it('should call reloadFrontendService when reloadFrontend is true', async () => { + const testData = { test: { username: 'frontendUser' } }; + const reloadSpy = frontendCredentialsOverwrites['reloadFrontendService'] as jest.Mock; + + await frontendCredentialsOverwrites.setData(testData, false, true); + + // Verify reloadFrontendService was called + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(frontendCredentialsOverwrites.getAll()).toEqual(testData); + }); + + it('should skip reloadFrontendService when reloadFrontend is false', async () => { + const testData = { test: { username: 'noReloadUser' } }; + const reloadSpy = frontendCredentialsOverwrites['reloadFrontendService'] as jest.Mock; + + await frontendCredentialsOverwrites.setData(testData, false, false); + + // Verify reloadFrontendService was NOT called + expect(reloadSpy).not.toHaveBeenCalled(); + expect(frontendCredentialsOverwrites.getAll()).toEqual(testData); + }); + + it('should call setData with correct parameters in init methods', async () => { + // Test both paths of setData through database loading + const settingsRepository = mockInstance(SettingsRepository, { + findByKey: jest.fn().mockResolvedValue({ + key: 'credentialsOverwrite', + value: 'encrypted-data', + loadOnStartup: false, + }), + create: jest.fn(), + save: jest.fn(), + }); + + const cipher = mockInstance(Cipher, { + encrypt: jest.fn(), + decrypt: jest.fn().mockReturnValue(JSON.stringify({ test: { username: 'dbUser' } })), + }); + + const dbInstance = new CredentialsOverwrites( + globalConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + await dbInstance.init(); // Initialize the instance first + + const setDataSpy = jest.spyOn(dbInstance, 'setData'); + + await dbInstance.loadOverwriteDataFromDB(false); + + // Verify setData was called with reloadFrontend=false + expect(setDataSpy).toHaveBeenCalledWith({ test: { username: 'dbUser' } }, false, false); + + setDataSpy.mockClear(); + + await dbInstance.loadOverwriteDataFromDB(true); + + // Verify setData was called with reloadFrontend=true + expect(setDataSpy).toHaveBeenCalledWith({ test: { username: 'dbUser' } }, false, true); + + setDataSpy.mockRestore(); + }); + }); + }); + + describe('Configuration-Based Initialization', () => { + let initCredentialsOverwrites: CredentialsOverwrites; + let settingsRepository: jest.Mocked; + let cipher: jest.Mocked; + let publisherMock: { publishCommand: jest.Mock }; + + beforeEach(async () => { + // Mock SettingsRepository + settingsRepository = mockInstance(SettingsRepository, { + findByKey: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }); + + // Mock Cipher + cipher = mockInstance(Cipher, { + encrypt: jest.fn(), + decrypt: jest.fn(), + }); + + // Mock Publisher service + const { Publisher } = await import('@/scaling/pubsub/publisher.service'); + publisherMock = { publishCommand: jest.fn() }; + mockInstance(Publisher, publisherMock); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should only load static config data when persistence is disabled', async () => { + const staticData = { test: { username: 'staticUser' } }; + const initConfig = mock({ + credentials: { + overwrite: { + data: JSON.stringify(staticData), + persistence: false, + endpointAuthToken: '', + }, + }, + }); + + initCredentialsOverwrites = new CredentialsOverwrites( + initConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + // Spy on methods to verify call patterns + const setPlainDataSpy = jest.spyOn(initCredentialsOverwrites, 'setPlainData'); + const loadFromDbSpy = jest.spyOn(initCredentialsOverwrites, 'loadOverwriteDataFromDB'); + + await initCredentialsOverwrites.init(); + + // Verify setData was called with correct parameters + expect(setPlainDataSpy).toHaveBeenCalledWith(staticData); + expect(setPlainDataSpy).toHaveBeenCalledTimes(1); + + // Verify loadOverwriteDataFromDB was NOT called + expect(loadFromDbSpy).not.toHaveBeenCalled(); + + // Verify database operations were not called + expect(settingsRepository.findByKey).not.toHaveBeenCalled(); + + setPlainDataSpy.mockRestore(); + loadFromDbSpy.mockRestore(); + }); + + it('should only load from database when persistence is enabled and no static data', async () => { + const dbData = { test: { username: 'dbUser' } }; + const initConfig = mock({ + credentials: { + overwrite: { + data: '', + persistence: true, + endpointAuthToken: '', + }, + }, + }); + + initCredentialsOverwrites = new CredentialsOverwrites( + initConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + // Setup database mocks + settingsRepository.findByKey.mockResolvedValue({ + key: 'credentialsOverwrite', + value: 'encrypted-db-data', + loadOnStartup: false, + }); + cipher.decrypt.mockReturnValue(JSON.stringify(dbData)); + + // Spy on methods + const setDataSpy = jest.spyOn(initCredentialsOverwrites, 'setData'); + const loadFromDbSpy = jest.spyOn(initCredentialsOverwrites, 'loadOverwriteDataFromDB'); + + await initCredentialsOverwrites.init(); + + // Verify setData was called once indirectly by loadOverwriteDataFromDB + expect(setDataSpy).toHaveBeenCalledWith(dbData, false, false); + expect(setDataSpy).toHaveBeenCalledTimes(1); + + // Verify loadOverwriteDataFromDB was called with correct parameter + expect(loadFromDbSpy).toHaveBeenCalledWith(false); + expect(loadFromDbSpy).toHaveBeenCalledTimes(1); + + // Verify database operations were called + expect(settingsRepository.findByKey).toHaveBeenCalledWith('credentialsOverwrite'); + expect(cipher.decrypt).toHaveBeenCalledWith('encrypted-db-data'); + + setDataSpy.mockRestore(); + loadFromDbSpy.mockRestore(); + }); + + it('should load both static data and database when both are enabled', async () => { + const staticData = { test: { username: 'staticUser' } }; + const dbData = { parent: { password: 'dbPass' } }; + const initConfig = mock({ + credentials: { + overwrite: { + data: JSON.stringify(staticData), + persistence: true, + endpointAuthToken: '', + }, + }, + }); + + initCredentialsOverwrites = new CredentialsOverwrites( + initConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + // Setup database mocks + settingsRepository.findByKey.mockResolvedValue({ + key: 'credentialsOverwrite', + value: 'encrypted-db-data', + loadOnStartup: false, + }); + cipher.decrypt.mockReturnValue(JSON.stringify(dbData)); + + // Spy on methods + const setPlainDataSpy = jest.spyOn(initCredentialsOverwrites, 'setPlainData'); + const setDataSpy = jest.spyOn(initCredentialsOverwrites, 'setData'); + const loadFromDbSpy = jest.spyOn(initCredentialsOverwrites, 'loadOverwriteDataFromDB'); + + await initCredentialsOverwrites.init(); + + // Verify setData was called twice - once directly from init(), once from loadFromDB + expect(setPlainDataSpy).toHaveBeenCalledTimes(2); + expect(setPlainDataSpy).toHaveBeenNthCalledWith(1, staticData); + expect(setPlainDataSpy).toHaveBeenNthCalledWith(2, dbData); + expect(setDataSpy).toHaveBeenCalledWith(dbData, false, false); + + expect(loadFromDbSpy).toHaveBeenCalledWith(false); + expect(loadFromDbSpy).toHaveBeenCalledTimes(1); + + // Verify correct order of operations (static setData first, then loadFromDB with its setData) + const firstSetDataCall = setPlainDataSpy.mock.invocationCallOrder[0]; + const loadDbCall = loadFromDbSpy.mock.invocationCallOrder[0]; + const secondSetDataCall = setPlainDataSpy.mock.invocationCallOrder[1]; + + expect(firstSetDataCall).toBeLessThan(loadDbCall); + expect(loadDbCall).toBeLessThan(secondSetDataCall); + + setPlainDataSpy.mockRestore(); + setDataSpy.mockRestore(); + loadFromDbSpy.mockRestore(); + }); + + it('should do nothing when neither static data nor persistence are enabled', async () => { + const initConfig = mock({ + credentials: { + overwrite: { + data: '', + persistence: false, + endpointAuthToken: '', + }, + }, + }); + + initCredentialsOverwrites = new CredentialsOverwrites( + initConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + // Spy on methods + const setDataSpy = jest.spyOn(initCredentialsOverwrites, 'setData'); + const loadFromDbSpy = jest.spyOn(initCredentialsOverwrites, 'loadOverwriteDataFromDB'); + + await initCredentialsOverwrites.init(); + + // Verify no methods were called + expect(setDataSpy).not.toHaveBeenCalled(); + expect(loadFromDbSpy).not.toHaveBeenCalled(); + + // Verify no database operations + expect(settingsRepository.findByKey).not.toHaveBeenCalled(); + + setDataSpy.mockRestore(); + loadFromDbSpy.mockRestore(); + }); + + it('should handle invalid JSON in static config data gracefully', async () => { + const initConfig = mock({ + credentials: { + overwrite: { + data: 'invalid-json{', + persistence: false, + endpointAuthToken: '', + }, + }, + }); + + initCredentialsOverwrites = new CredentialsOverwrites( + initConfig, + credentialTypes, + logger, + settingsRepository, + cipher, + ); + + // Should throw error during init due to invalid JSON + await expect(initCredentialsOverwrites.init()).rejects.toThrow( + 'The credentials-overwrite is not valid JSON.', + ); + + // Verify database operations were not attempted + expect(settingsRepository.findByKey).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 05de80d13f0..5ffd35e83ca 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -33,6 +33,7 @@ import { WaitTracker } from '@/wait-tracker'; import { WorkflowRunner } from '@/workflow-runner'; import { BaseCommand } from './base-command'; +import { CredentialsOverwrites } from '@/credentials-overwrites'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const open = require('open'); @@ -227,6 +228,8 @@ export class Start extends BaseCommand> { Container.get(WaitTracker).init(); this.logger.debug('Wait tracker init complete'); + await Container.get(CredentialsOverwrites).init(); + this.logger.debug('Credentials overwrites init complete'); await this.initBinaryDataService(); this.logger.debug('Binary data service init complete'); await this.initDataDeduplicationService(); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index a705b955973..2b7836c03c3 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -16,6 +16,7 @@ import type { WorkerServerEndpointsConfig } from '@/scaling/worker-server'; import { WorkerStatusService } from '@/scaling/worker-status.service.ee'; import { BaseCommand } from './base-command'; +import { CredentialsOverwrites } from '@/credentials-overwrites'; const flagsSchema = z.object({ concurrency: z.number().int().default(10).describe('How many jobs can run in parallel.'), @@ -88,6 +89,8 @@ export class Worker extends BaseCommand> { await this.initLicense(); this.logger.debug('License init complete'); + await Container.get(CredentialsOverwrites).init(); + this.logger.debug('Credentials overwrites init complete'); await this.initBinaryDataService(); this.logger.debug('Binary data service init complete'); await this.initDataDeduplicationService(); diff --git a/packages/cli/src/credentials-overwrites.ts b/packages/cli/src/credentials-overwrites.ts index 1101b35e7c3..fbd173212a3 100644 --- a/packages/cli/src/credentials-overwrites.ts +++ b/packages/cli/src/credentials-overwrites.ts @@ -1,12 +1,17 @@ import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; -import { Service } from '@n8n/di'; +import { SettingsRepository } from '@n8n/db'; +import { OnPubSubEvent } from '@n8n/decorators'; +import { Container, Service } from '@n8n/di'; +import { Request, Response, NextFunction } from 'express'; +import { Cipher } from 'n8n-core'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import { deepCopy, jsonParse } from 'n8n-workflow'; import { CredentialTypes } from '@/credential-types'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { Request, Response, NextFunction } from 'express'; + +const CREDENTIALS_OVERWRITE_KEY = 'credentialsOverwrite'; @Service() export class CredentialsOverwrites { @@ -18,13 +23,75 @@ export class CredentialsOverwrites { private readonly globalConfig: GlobalConfig, private readonly credentialTypes: CredentialTypes, private readonly logger: Logger, - ) { - const data = globalConfig.credentials.overwrite.data; - const overwriteData = jsonParse(data, { - errorMessage: 'The credentials-overwrite is not valid JSON.', - }); + private readonly settings: SettingsRepository, + private readonly cipher: Cipher, + ) {} - this.setData(overwriteData); + async init() { + const data = this.globalConfig.credentials.overwrite.data; + if (data) { + this.logger.debug('Loading overwrite credentials from static envvar'); + const overwriteData = jsonParse(data, { + errorMessage: 'The credentials-overwrite is not valid JSON.', + }); + + this.setPlainData(overwriteData); + } + + const persistence = this.globalConfig.credentials.overwrite.persistence; + + if (persistence) { + this.logger.debug('Loading overwrite credentials from database'); + await this.loadOverwriteDataFromDB(false); + } + } + + private reloading = false; + + @OnPubSubEvent('reload-overwrite-credentials') + async reloadOverwriteCredentials() { + await this.loadOverwriteDataFromDB(true); + } + + async loadOverwriteDataFromDB(reloadFrontend: boolean) { + if (this.reloading) return; + try { + this.reloading = true; + this.logger.debug('Loading overwrite credentials from DB'); + const data = await this.settings.findByKey(CREDENTIALS_OVERWRITE_KEY); + + if (data) { + const decryptedData = this.cipher.decrypt(data.value); + const overwriteData = jsonParse(decryptedData, { + errorMessage: 'The credentials-overwrite is not valid JSON.', + }); + + await this.setData(overwriteData, false, reloadFrontend); + } + } catch (error) { + this.logger.error('Error loading overwrite credentials', { error }); + } finally { + this.reloading = false; + } + } + + private async broadcastReloadOverwriteCredentialsCommand(): Promise { + const { Publisher } = await import('@/scaling/pubsub/publisher.service'); + await Container.get(Publisher).publishCommand({ command: 'reload-overwrite-credentials' }); + } + + async saveOverwriteDataToDB(overwriteData: ICredentialsOverwrite, broadcast: boolean = true) { + const data = this.cipher.encrypt(JSON.stringify(overwriteData)); + const setting = this.settings.create({ + key: CREDENTIALS_OVERWRITE_KEY, + value: data, + loadOnStartup: false, + }); + await this.settings.save(setting); + + if (broadcast) { + await this.broadcastReloadOverwriteCredentialsCommand(); + } } getOverwriteEndpointMiddleware() { @@ -45,7 +112,7 @@ export class CredentialsOverwrites { }; } - setData(overwriteData: ICredentialsOverwrite) { + setPlainData(overwriteData: ICredentialsOverwrite) { // If data gets reinitialized reset the resolved types cache this.resolvedTypes.length = 0; @@ -60,6 +127,28 @@ export class CredentialsOverwrites { } } + async setData( + overwriteData: ICredentialsOverwrite, + storeInDb: boolean = true, + reloadFrontend: boolean = true, + ) { + this.setPlainData(overwriteData); + if (storeInDb && this.globalConfig.credentials.overwrite.persistence) { + await this.saveOverwriteDataToDB(overwriteData, true); + } + + if (reloadFrontend) { + await this.reloadFrontendService(); + } + } + + private async reloadFrontendService() { + // FrontendService has CredentialOverwrites injected via the constructor + // to break the circular dependency we need to use the container to get the instance + const { FrontendService } = await import('./services/frontend.service'); + await Container.get(FrontendService)?.generateTypes(); + } + applyOverwrite(type: string, data: ICredentialDataDecryptedObject) { const overwrites = this.get(type); diff --git a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts index 2e772e3d6fb..15c6a20de3d 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts @@ -12,6 +12,10 @@ export type PubSubCommandMap = { // #endregion + // # region Credentials + 'reload-overwrite-credentials': never; + // #endregion + // # region SSO 'reload-oidc-config': never; diff --git a/packages/cli/src/scaling/pubsub/pubsub.types.ts b/packages/cli/src/scaling/pubsub/pubsub.types.ts index 5aec2c381db..232bc018b4d 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.types.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.types.ts @@ -42,6 +42,7 @@ export namespace PubSub { export type ReloadLicense = ToCommand<'reload-license'>; export type ReloadOIDCConfiguration = ToCommand<'reload-oidc-config'>; export type ReloadSamlConfiguration = ToCommand<'reload-saml-config'>; + export type ReloadCredentialsOverwrites = ToCommand<'reload-overwrite-credentials'>; export type RestartEventBus = ToCommand<'restart-event-bus'>; export type ReloadExternalSecretsProviders = ToCommand<'reload-external-secrets-providers'>; export type CommunityPackageInstall = ToCommand<'community-package-install'>; @@ -76,7 +77,8 @@ export namespace PubSub { | Commands.RelayExecutionLifecycleEvent | Commands.ClearTestWebhooks | Commands.ReloadOIDCConfiguration - | Commands.ReloadSamlConfiguration; + | Commands.ReloadSamlConfiguration + | Commands.ReloadCredentialsOverwrites; // ---------------------------------- // worker responses diff --git a/packages/cli/src/scaling/worker-server.ts b/packages/cli/src/scaling/worker-server.ts index 9eb96a1b205..df2db1cc5f8 100644 --- a/packages/cli/src/scaling/worker-server.ts +++ b/packages/cli/src/scaling/worker-server.ts @@ -120,9 +120,9 @@ export class WorkerServer { this.app.use(`/${endpoint}`, overwriteEndpointMiddleware); } - this.app.post(`/${endpoint}`, rawBodyReader, bodyParser, (req, res) => - this.handleOverwrites(req, res), - ); + this.app.post(`/${endpoint}`, rawBodyReader, bodyParser, async (req, res) => { + await this.handleOverwrites(req, res); + }); } if (metrics) { @@ -142,26 +142,36 @@ export class WorkerServer { : res.status(503).send({ status: 'error' }); } - private handleOverwrites( + private async handleOverwrites( req: express.Request<{}, {}, ICredentialsOverwrite>, res: express.Response, ) { - if (this.overwritesLoaded) { - ResponseHelper.sendErrorResponse(res, new CredentialsOverwritesAlreadySetError()); - return; + try { + if (this.overwritesLoaded) { + ResponseHelper.sendErrorResponse(res, new CredentialsOverwritesAlreadySetError()); + return; + } + + if (req.contentType !== 'application/json') { + ResponseHelper.sendErrorResponse(res, new NonJsonBodyError()); + return; + } + + await this.credentialsOverwrites.setData(req.body, true); + + this.overwritesLoaded = true; + + this.logger.debug('Worker loaded credentials overwrites'); + + ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200); + } catch (error) { + this.logger.error('Error handling credentials overwrites', { error }); + ResponseHelper.sendErrorResponse( + res, + new Error( + 'An error occurred while handling credentials overwrites, please check the logs for more details', + ), + ); } - - if (req.contentType !== 'application/json') { - ResponseHelper.sendErrorResponse(res, new NonJsonBodyError()); - return; - } - - this.credentialsOverwrites.setData(req.body); - - this.overwritesLoaded = true; - - this.logger.debug('Worker loaded credentials overwrites'); - - ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200); } } diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 3ff13095a5d..27c90502112 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -297,10 +297,12 @@ export class Server extends AbstractServer { this.app.use(`/${this.endpointPresetCredentials}`, overwriteEndpointMiddleware); } + const authenticationEnforced = overwriteEndpointMiddleware !== null; this.app.post( `/${this.endpointPresetCredentials}`, async (req: express.Request, res: express.Response) => { - if (!this.presetCredentialsLoaded) { + // If authentication is enforced we can allow multiple overwrites + if (!this.presetCredentialsLoaded || authenticationEnforced) { const body = req.body as ICredentialsOverwrite; if (req.contentType !== 'application/json') { @@ -313,9 +315,7 @@ export class Server extends AbstractServer { return; } - Container.get(CredentialsOverwrites).setData(body); - - await frontendService?.generateTypes(); + await Container.get(CredentialsOverwrites).setData(body, true, true); this.presetCredentialsLoaded = true; diff --git a/packages/cli/test/integration/credentials/credentials-overwrites.integration.test.ts b/packages/cli/test/integration/credentials/credentials-overwrites.integration.test.ts new file mode 100644 index 00000000000..b9cabf863d3 --- /dev/null +++ b/packages/cli/test/integration/credentials/credentials-overwrites.integration.test.ts @@ -0,0 +1,416 @@ +import { testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import { SettingsRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { Cipher } from 'n8n-core'; + +import { CredentialsOverwrites } from '@/credentials-overwrites'; +import { CredentialTypes } from '@/credential-types'; +import type { ICredentialsOverwrite } from '@/interfaces'; + +describe('CredentialsOverwrites - Integration Tests', () => { + let credentialsOverwrites: CredentialsOverwrites; + let globalConfig: GlobalConfig; + let settingsRepository: SettingsRepository; + let cipher: Cipher; + let credentialTypes: CredentialTypes; + + beforeAll(async () => { + // Initialize real database + await testDb.init(); + + // Get real instances from DI container + globalConfig = Container.get(GlobalConfig); + settingsRepository = Container.get(SettingsRepository); + cipher = Container.get(Cipher); + credentialTypes = Container.get(CredentialTypes); + + // Configure globalConfig for integration tests + (globalConfig as any).credentials = { + overwrite: { + data: '', // No static data for integration tests + persistence: true, // Enable persistence for integration tests + endpointAuthToken: 'integration-test-token', + endpoint: 'integration-credentials-overwrite', + }, + }; + + // Create instance for integration testing + credentialsOverwrites = Container.get(CredentialsOverwrites); + }); + + beforeEach(async () => { + // Clean up database before each test + await testDb.truncate(['Settings']); + }); + + afterAll(async () => { + // Clean up database after all tests + await testDb.terminate(); + }); + + describe('Integration Test Coverage', () => { + describe('Complete Credential Update Flow', () => { + it('should execute full API → Database → PubSub → Frontend chain', async () => { + const testOverwriteData: ICredentialsOverwrite = { + test: { username: 'integrationUser', password: 'integrationPass' }, + }; + + // Step 1: API call (setData) - this will save to real database + await credentialsOverwrites.setData(testOverwriteData, true, false); // Skip frontend reload for integration test + + // Step 2: Verify data was saved to database by reading it back + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + expect(savedSetting?.value).toBeTruthy(); + + // Step 3: Decrypt and verify the saved data + const decryptedData = cipher.decrypt(savedSetting!.value); + const parsedData = JSON.parse(decryptedData); + expect(parsedData).toEqual(testOverwriteData); + + // Step 4: Test that the data was set in memory + const allData = credentialsOverwrites.getAll(); + expect(allData).toEqual(testOverwriteData); + }); + + it('should maintain data consistency across sequential operations', async () => { + const data1: ICredentialsOverwrite = { type1: { key1: 'value1' } }; + const data2: ICredentialsOverwrite = { type2: { key2: 'value2' } }; + + // Execute sequential operations (avoid UNIQUE constraint issues) + await credentialsOverwrites.setData(data1, true, false); + + // Verify first operation + let savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + let decryptedData = cipher.decrypt(savedSetting!.value); + expect(JSON.parse(decryptedData)).toEqual(data1); + + await credentialsOverwrites.setData(data2, true, false); + + // Verify second operation overwrote the first + savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + decryptedData = cipher.decrypt(savedSetting!.value); + expect(JSON.parse(decryptedData)).toEqual(data2); + + // The final state should be the last write + const finalData = credentialsOverwrites.getAll(); + expect(finalData).toEqual(data2); + }); + }); + + describe('PubSub Event Flow Integration', () => { + it('should execute complete PubSub event → Database load → Frontend reload chain', async () => { + const testData: ICredentialsOverwrite = { + pubsub: { token: 'pubsubToken', secret: 'pubsubSecret' }, + }; + + // Step 1: Save data to database first + const encryptedData = cipher.encrypt(JSON.stringify(testData)); + const setting = settingsRepository.create({ + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }); + await settingsRepository.save(setting); + + // Step 2: Simulate PubSub event (this will load from database) + await credentialsOverwrites.reloadOverwriteCredentials(); + + // Step 3: Verify data was loaded from database + const loadedData = credentialsOverwrites.getAll(); + expect(loadedData).toEqual(testData); + }); + + it('should prevent race conditions between PubSub reload calls', async () => { + const testData: ICredentialsOverwrite = { race: { condition: 'test' } }; + const encryptedData = cipher.encrypt(JSON.stringify(testData)); + + // Save test data to database + const setting = settingsRepository.create({ + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }); + await settingsRepository.save(setting); + + // Fire multiple concurrent PubSub events + const promises = Array(3) + .fill(0) + .map(async () => await credentialsOverwrites.reloadOverwriteCredentials()); + + await Promise.all(promises); + + // Should complete without errors and load the data + const loadedData = credentialsOverwrites.getAll(); + expect(loadedData).toEqual(testData); + }); + }); + + describe('Mixed Configuration Scenarios', () => { + it('should handle static config + database persistence working together', async () => { + const staticData: ICredentialsOverwrite = { static: { key: 'staticValue' } }; + const dbData: ICredentialsOverwrite = { database: { key: 'dbValue' } }; + + // Configure static config + (globalConfig as any).credentials.overwrite.data = JSON.stringify(staticData); + + // Save database data + const encryptedDbData = cipher.encrypt(JSON.stringify(dbData)); + const dbSetting = settingsRepository.create({ + key: 'credentialsOverwrite', + value: encryptedDbData, + loadOnStartup: false, + }); + await settingsRepository.save(dbSetting); + + // Initialize with both static and database data + const mixedConfig = new CredentialsOverwrites( + globalConfig, + credentialTypes, + { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + settingsRepository, + cipher, + ); + + await mixedConfig.init(); + + // Should have database data (loaded after static) + const finalData = mixedConfig.getAll(); + expect(finalData).toEqual(dbData); + + // Reset config + (globalConfig as any).credentials.overwrite.data = ''; + }); + + it('should handle configuration changes at runtime', async () => { + const initialData: ICredentialsOverwrite = { initial: { key: 'initialValue' } }; + const updatedData: ICredentialsOverwrite = { updated: { key: 'updatedValue' } }; + + // Set initial data + await credentialsOverwrites.setData(initialData, true, false); + expect(credentialsOverwrites.getAll()).toEqual(initialData); + + // Update configuration at runtime + await credentialsOverwrites.setData(updatedData, true, false); + expect(credentialsOverwrites.getAll()).toEqual(updatedData); + + // Verify updated data was saved to database + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + const decryptedData = cipher.decrypt(savedSetting!.value); + expect(JSON.parse(decryptedData)).toEqual(updatedData); + }); + }); + + describe('Multi-Instance Coordination', () => { + it('should broadcast changes to multiple instances via PubSub', async () => { + const testData: ICredentialsOverwrite = { broadcast: { message: 'hello' } }; + + // Simulate saving data with broadcast enabled (default behavior) + await credentialsOverwrites.setData(testData, true, false); + + // Verify data was saved + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + + // Verify data is accessible + const loadedData = credentialsOverwrites.getAll(); + expect(loadedData).toEqual(testData); + }); + + it('should coordinate multiple instances receiving same PubSub event', async () => { + const testData: ICredentialsOverwrite = { coordination: { test: 'value' } }; + const encryptedData = cipher.encrypt(JSON.stringify(testData)); + + // Save data to database + const setting = settingsRepository.create({ + key: 'credentialsOverwrite', + value: encryptedData, + loadOnStartup: false, + }); + await settingsRepository.save(setting); + + // Simulate multiple instances receiving the same PubSub event + const instance1Promise = credentialsOverwrites.reloadOverwriteCredentials(); + const instance2Promise = credentialsOverwrites.reloadOverwriteCredentials(); + const instance3Promise = credentialsOverwrites.reloadOverwriteCredentials(); + + await Promise.all([instance1Promise, instance2Promise, instance3Promise]); + + // All instances should coordinate properly without conflicts + const loadedData = credentialsOverwrites.getAll(); + expect(loadedData).toEqual(testData); + }); + }); + + describe('Error Resilience Integration', () => { + it('should handle transient failures and recovery', async () => { + const testData: ICredentialsOverwrite = { recovery: { test: 'data' } }; + + // First attempt with failing repository + const failingRepo = { + ...settingsRepository, + create: jest.fn(() => { + throw new Error('Temporary failure'); + }), + }; + + const failingInstance = new CredentialsOverwrites( + globalConfig, + credentialTypes, + { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + failingRepo as any, + cipher, + ); + + // First call with failing repo (memory only) + await failingInstance.setData(testData, false, false); + + // Second call with working repo (should succeed) + await credentialsOverwrites.setData(testData, true, false); + + // Verify recovery worked + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + }); + }); + + describe('Backwards Compatibility Integration', () => { + it('should maintain compatibility with existing workflow patterns', async () => { + const legacyData: ICredentialsOverwrite = { + existing: { username: 'legacy', password: 'legacy' }, + }; + + // Test existing setData call with explicit parameters to avoid frontend reload + await credentialsOverwrites.setData(legacyData, true, false); + + // Verify backwards compatibility + expect(credentialsOverwrites.getAll()).toEqual(legacyData); + + // Verify data was saved to database (default behavior) + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + + // Test credential application (existing interface) + const appliedData = credentialsOverwrites.applyOverwrite('existing', { + username: '', + password: '', + }); + + expect(appliedData).toEqual({ + username: 'legacy', + password: 'legacy', + }); + }); + + it('should work with existing middleware and endpoint patterns', async () => { + // Test middleware functionality + const middleware = credentialsOverwrites.getOverwriteEndpointMiddleware(); + expect(middleware).toBeDefined(); + + // Test overwrite application + const testData: ICredentialsOverwrite = { middleware: { token: 'middlewareTest' } }; + + await credentialsOverwrites.setData(testData, true, false); + + const appliedData = credentialsOverwrites.applyOverwrite('middleware', { + token: '', + }); + + expect(appliedData).toEqual({ token: 'middlewareTest' }); + }); + }); + + describe('End-to-End Validation', () => { + it('should complete full system integration successfully', async () => { + const e2eData: ICredentialsOverwrite = { + e2eTest: { + username: 'e2eUser', + password: 'e2ePass', + token: 'e2eToken', + }, + }; + + // Step 1: Complete integration flow (skip frontend for test) + await credentialsOverwrites.setData(e2eData, true, false); + + // Step 2: Verify database persistence + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + const decryptedData = cipher.decrypt(savedSetting!.value); + expect(JSON.parse(decryptedData)).toEqual(e2eData); + + // Step 3: Test PubSub reload + const freshInstance = new CredentialsOverwrites( + globalConfig, + credentialTypes, + { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + settingsRepository, + cipher, + ); + await freshInstance.reloadOverwriteCredentials(); + expect(freshInstance.getAll()).toEqual(e2eData); + + // Step 4: Test that overwrites work correctly with new data + const appliedOverwrite = credentialsOverwrites.applyOverwrite('e2eTest', { + username: '', + password: '', + token: '', + }); + + expect(appliedOverwrite).toMatchObject({ + username: 'e2eUser', + password: 'e2ePass', + token: 'e2eToken', + }); + + // Step 5: Test middleware still works + const middleware = credentialsOverwrites.getOverwriteEndpointMiddleware(); + expect(middleware).toBeDefined(); + }); + + it('should maintain data integrity across complete integration lifecycle', async () => { + const lifecycleData: ICredentialsOverwrite = { + lifecycle: { phase1: 'init', phase2: 'process', phase3: 'complete' }, + }; + + // Phase 1: Initial save + await credentialsOverwrites.setData(lifecycleData, true, false); + + // Phase 2: Database verification + const savedSetting = await settingsRepository.findByKey('credentialsOverwrite'); + expect(savedSetting).toBeTruthy(); + + // Phase 3: Fresh instance reload + const newInstance = new CredentialsOverwrites( + globalConfig, + credentialTypes, + { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + settingsRepository, + cipher, + ); + + await newInstance.reloadOverwriteCredentials(); + + // Phase 4: Verify complete data integrity + expect(newInstance.getAll()).toEqual(lifecycleData); + + // Phase 5: Apply overwrites and verify functionality + const result = newInstance.applyOverwrite('lifecycle', { + phase1: '', + phase2: '', + phase3: '', + }); + + expect(result).toEqual({ + phase1: 'init', + phase2: 'process', + phase3: 'complete', + }); + }); + }); + }); +});