mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 08:17:06 +02:00
chore(core): Add support for persisting and synchronizing credential overwrites (#19919)
This commit is contained in:
parent
182a40e104
commit
1c4728aed2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ describe('GlobalConfig', () => {
|
|||
data: '{}',
|
||||
endpoint: '',
|
||||
endpointAuthToken: '',
|
||||
persistence: false,
|
||||
},
|
||||
},
|
||||
userManagement: {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export type PubSubCommandMap = {
|
|||
|
||||
// #endregion
|
||||
|
||||
// # region Credentials
|
||||
'reload-overwrite-credentials': never;
|
||||
// #endregion
|
||||
|
||||
// # region SSO
|
||||
|
||||
'reload-oidc-config': never;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user