chore(core): Add support for persisting and synchronizing credential overwrites (#19919)

This commit is contained in:
Andreas Fitzek 2025-09-25 14:26:08 +02:00 committed by GitHub
parent 182a40e104
commit 1c4728aed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1494 additions and 40 deletions

View File

@ -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

View File

@ -106,6 +106,7 @@ describe('GlobalConfig', () => {
data: '{}',
endpoint: '',
endpointAuthToken: '',
persistence: false,
},
},
userManagement: {

View File

@ -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';

View File

@ -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<ICredentialType>({ name: 'test', extends: ['parent'] });
@ -17,7 +20,7 @@ describe('CredentialsOverwrites', () => {
const logger = mock<Logger>();
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<ICredentialType>({ 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<SettingsRepository>;
let cipher: jest.Mocked<Cipher>;
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<GlobalConfig>({
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<SettingsRepository>;
let cipher: jest.Mocked<Cipher>;
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<GlobalConfig>({
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<SettingsRepository>;
let cipher: jest.Mocked<Cipher>;
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<GlobalConfig>({
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<GlobalConfig>({
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<GlobalConfig>({
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<GlobalConfig>({
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<GlobalConfig>({
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();
});
});
});

View File

@ -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<z.infer<typeof flagsSchema>> {
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();

View File

@ -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<z.infer<typeof flagsSchema>> {
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();

View File

@ -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<ICredentialsOverwrite>(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<ICredentialsOverwrite>(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<ICredentialsOverwrite>(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<void> {
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);

View File

@ -12,6 +12,10 @@ export type PubSubCommandMap = {
// #endregion
// # region Credentials
'reload-overwrite-credentials': never;
// #endregion
// # region SSO
'reload-oidc-config': never;

View File

@ -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

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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',
});
});
});
});
});