first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,431 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { SecretsProviderConnectionRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
import type { IDataObject } from 'n8n-workflow';
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
import type { EventService } from '@/events/event.service';
import { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { ExternalSecretsProviderLifecycle } from '@/modules/external-secrets.ee/provider-lifecycle.service';
import { ExternalSecretsProviderRegistry } from '@/modules/external-secrets.ee/provider-registry.service';
import { ExternalSecretsRetryManager } from '@/modules/external-secrets.ee/retry-manager.service';
import { ExternalSecretsSecretsCache } from '@/modules/external-secrets.ee/secrets-cache.service';
import { ExternalSecretsSettingsStore } from '@/modules/external-secrets.ee/settings-store.service';
import type {
ExternalSecretsSettings,
SecretsProviderState,
} from '@/modules/external-secrets.ee/types';
import {
DummyProvider,
FailedProvider,
MockProviders,
TestFailProvider,
} from '../../shared/external-secrets/utils';
import { createOwner, createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
const mockProvidersInstance = new MockProviders();
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
const testServer = setupTestServer({
endpointGroups: ['externalSecrets'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['external-secrets'],
});
const connectedDate = '2023-08-01T12:32:29.000Z';
async function setExternalSecretsSettings(settings: ExternalSecretsSettings) {
const settingsStore = Container.get(ExternalSecretsSettingsStore);
await settingsStore.save(settings);
}
async function getExternalSecretsSettings(): Promise<ExternalSecretsSettings | null> {
const settingsStore = Container.get(ExternalSecretsSettingsStore);
return await settingsStore.reload();
}
const eventService = mock<EventService>();
const logger = mockLogger();
const resetManager = async () => {
Container.get(ExternalSecretsManager).shutdown();
// Get all service dependencies from Container
const config = Container.get(ExternalSecretsConfig);
const settingsStore = Container.get(ExternalSecretsSettingsStore);
const providerRegistry = Container.get(ExternalSecretsProviderRegistry);
const providerLifecycle = Container.get(ExternalSecretsProviderLifecycle);
const retryManager = Container.get(ExternalSecretsRetryManager);
const secretsCache = Container.get(ExternalSecretsSecretsCache);
const secretsProviderConnectionRepository = Container.get(SecretsProviderConnectionRepository);
const cipher = Container.get(Cipher);
Container.set(
ExternalSecretsManager,
new ExternalSecretsManager(
logger,
config,
mockProvidersInstance,
eventService,
mock(),
settingsStore,
providerRegistry,
providerLifecycle,
retryManager,
secretsCache,
secretsProviderConnectionRepository,
cipher,
),
);
await Container.get(ExternalSecretsManager).init();
};
const getDummyProviderData = ({
data,
includeProperties,
connected,
state,
connectedAt,
displayName,
}: {
data?: IDataObject;
includeProperties?: boolean;
connected?: boolean;
state?: SecretsProviderState;
connectedAt?: string | null;
displayName?: string;
} = {}) => {
const dummy: IDataObject = {
connected: connected ?? true,
connectedAt: connectedAt === undefined ? connectedDate : connectedAt,
data: data ?? {},
name: 'dummy',
displayName: displayName ?? 'Dummy Provider',
icon: 'dummy',
state: state ?? 'connected',
};
if (includeProperties) {
dummy.properties = new DummyProvider().properties;
}
return dummy;
};
beforeAll(async () => {
const owner = await createOwner();
authOwnerAgent = testServer.authAgentFor(owner);
const member = await createUser();
authMemberAgent = testServer.authAgentFor(member);
// Get all service dependencies from Container
const config = Container.get(ExternalSecretsConfig);
config.externalSecretsForProjects = false;
config.externalSecretsMultipleConnections = false;
const settingsStore = Container.get(ExternalSecretsSettingsStore);
const providerRegistry = Container.get(ExternalSecretsProviderRegistry);
const providerLifecycle = Container.get(ExternalSecretsProviderLifecycle);
const retryManager = Container.get(ExternalSecretsRetryManager);
const secretsCache = Container.get(ExternalSecretsSecretsCache);
const secretsProviderConnectionRepository = Container.get(SecretsProviderConnectionRepository);
const cipher = Container.get(Cipher);
Container.set(
ExternalSecretsManager,
new ExternalSecretsManager(
logger,
config,
mockProvidersInstance,
eventService,
mock(),
settingsStore,
providerRegistry,
providerLifecycle,
retryManager,
secretsCache,
secretsProviderConnectionRepository,
cipher,
),
);
});
beforeEach(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
});
await setExternalSecretsSettings({
dummy: {
connected: true,
connectedAt: new Date(connectedDate),
settings: {},
},
});
await resetManager();
});
afterEach(async () => {
Container.get(ExternalSecretsManager).shutdown();
});
describe('GET /external-secrets/providers', () => {
test('can retrieve providers as owner', async () => {
const resp = await authOwnerAgent.get('/external-secrets/providers');
expect(resp.body).toEqual({
data: [getDummyProviderData()],
});
});
test('can not retrieve providers as non-owner', async () => {
const resp = await authMemberAgent.get('/external-secrets/providers');
expect(resp.status).toBe(403);
});
test('does obscure passwords', async () => {
await setExternalSecretsSettings({
dummy: {
connected: true,
connectedAt: new Date(connectedDate),
settings: {
username: 'testuser',
password: 'testpass',
},
},
});
await resetManager();
const resp = await authOwnerAgent.get('/external-secrets/providers');
expect(resp.body).toEqual({
data: [
getDummyProviderData({
data: {
username: 'testuser',
password: CREDENTIAL_BLANKING_VALUE,
},
}),
],
});
});
});
describe('GET /external-secrets/providers/:provider', () => {
test('can retrieve provider as owner', async () => {
const resp = await authOwnerAgent.get('/external-secrets/providers/dummy');
expect(resp.body.data).toEqual(getDummyProviderData({ includeProperties: true }));
});
test('can not retrieve provider as non-owner', async () => {
const resp = await authMemberAgent.get('/external-secrets/providers/dummy');
expect(resp.status).toBe(403);
});
test('does obscure passwords', async () => {
await setExternalSecretsSettings({
dummy: {
connected: true,
connectedAt: new Date(connectedDate),
settings: {
username: 'testuser',
password: 'testpass',
},
},
});
await resetManager();
const resp = await authOwnerAgent.get('/external-secrets/providers/dummy');
expect(resp.body.data).toEqual(
getDummyProviderData({
data: {
username: 'testuser',
password: CREDENTIAL_BLANKING_VALUE,
},
includeProperties: true,
}),
);
});
});
describe('POST /external-secrets/providers/:provider', () => {
test('can update provider settings', async () => {
const testData = {
username: 'testuser',
other: 'testother',
};
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy').send(testData);
expect(resp.status).toBe(200);
const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy');
expect(confirmResp.body.data).toEqual(
getDummyProviderData({ data: testData, includeProperties: true }),
);
});
test('can update provider settings with blanking value', async () => {
await setExternalSecretsSettings({
dummy: {
connected: true,
connectedAt: new Date(connectedDate),
settings: {
username: 'testuser',
password: 'testpass',
},
},
});
await resetManager();
const testData = {
username: 'newuser',
password: CREDENTIAL_BLANKING_VALUE,
};
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy').send(testData);
expect(resp.status).toBe(200);
await authOwnerAgent.get('/external-secrets/providers/dummy');
expect((await getExternalSecretsSettings())?.dummy.settings).toEqual({
username: 'newuser',
password: 'testpass',
});
});
});
describe('POST /external-secrets/providers/:provider/connect', () => {
test('can change provider connected state', async () => {
const testData = {
connected: false,
};
const resp = await authOwnerAgent
.post('/external-secrets/providers/dummy/connect')
.send(testData);
expect(resp.status).toBe(200);
const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy');
expect(confirmResp.body.data).toEqual(
getDummyProviderData({
includeProperties: true,
connected: false,
state: 'initializing',
}),
);
});
});
describe('POST /external-secrets/providers/:provider/test', () => {
test('can test provider', async () => {
const testData = {
username: 'testuser',
other: 'testother',
};
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/test').send(testData);
expect(resp.status).toBe(200);
expect(resp.body.data.success).toBe(true);
expect(resp.body.data.testState).toBe('connected');
});
test('can test provider fail', async () => {
mockProvidersInstance.setProviders({
dummy: TestFailProvider,
});
await resetManager();
const testData = {
username: 'testuser',
other: 'testother',
};
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/test').send(testData);
expect(resp.status).toBe(400);
expect(resp.body.data.success).toBe(false);
expect(resp.body.data.testState).toBe('error');
});
});
describe('POST /external-secrets/providers/:provider/update', () => {
test('can update provider', async () => {
const updateSpy = jest.spyOn(
Container.get(ExternalSecretsManager).getProvider('dummy')!,
'update',
);
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update');
expect(resp.status).toBe(200);
expect(resp.body.data).toEqual({ updated: true });
expect(updateSpy).toBeCalled();
});
test('can not update errored provider', async () => {
mockProvidersInstance.setProviders({
dummy: FailedProvider,
});
await resetManager();
const updateSpy = jest.spyOn(
Container.get(ExternalSecretsManager).getProvider('dummy')!,
'update',
);
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update');
expect(resp.status).toBe(400);
expect(resp.body.data).toEqual({ updated: false });
expect(updateSpy).not.toBeCalled();
});
test('updates are reflected in list of secrets after manual sync', async () => {
// Baseline: current secrets before any change
const listBefore = await authOwnerAgent.get('/external-secrets/secrets');
expect(listBefore.status).toBe(200);
expect(listBefore.body.data).toEqual({ dummy: ['test1', 'test2'] });
// Arrange: change what the provider will publish on update()
const manager = Container.get(ExternalSecretsManager);
const provider = manager.getProvider('dummy') as unknown as DummyProvider;
provider._updateSecrets = { rotated: 'new-value' };
// Act: trigger manual update (as frontend would do)
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update');
expect(resp.status).toBe(200);
// Assert: list endpoint reflects freshly updated secrets
const listAfter = await authOwnerAgent.get('/external-secrets/secrets');
expect(listAfter.status).toBe(200);
expect(listAfter.body.data).toEqual({ dummy: ['rotated'] });
});
});
describe('GET /external-secrets/secrets', () => {
test('can get secret names as owner', async () => {
const resp = await authOwnerAgent.get('/external-secrets/secrets');
expect(resp.status).toBe(200);
expect(resp.body.data).toEqual({
dummy: ['test1', 'test2'],
});
});
test('can not get secret names as non-owner', async () => {
const resp = await authMemberAgent.get('/external-secrets/secrets');
expect(resp.status).toBe(403);
expect(resp.body.data).not.toEqual({
dummy: ['test1', 'test2'],
});
});
});
@@ -0,0 +1,175 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance, mockLogger, testDb, testModules } from '@n8n/backend-test-utils';
import { SecretsProviderConnectionRepository, SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { ExternalSecretsModule } from '@/modules/external-secrets.ee/external-secrets.module';
import { PubSubEventBus } from '@/scaling/pubsub/pubsub.eventbus';
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
import {
AnotherDummyProvider,
DummyProvider,
MockProviders,
} from '../../shared/external-secrets/utils';
const mockProvidersInstance = new MockProviders();
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
const waitForEventHandler = async (ms = 500) => {
await new Promise((resolve) => setTimeout(resolve, ms));
};
const testReloadEvent = async (
pubsubEventBus: PubSubEventBus,
{ connectsAllProviders = false }: { connectsAllProviders?: boolean } = {},
) => {
const initSpy = jest.spyOn(DummyProvider.prototype, 'init');
const connectSpy = jest.spyOn(DummyProvider.prototype, 'connect');
const disconnectSpy = jest.spyOn(DummyProvider.prototype, 'disconnect');
const updateSpy = jest.spyOn(DummyProvider.prototype, 'update');
const initAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'init');
const connectAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'connect');
const disconnectAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'disconnect');
const updateAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'update');
// Emit event and wait for async handler to complete
pubsubEventBus.emit('reload-external-secrets-providers');
await waitForEventHandler();
expect(disconnectSpy).toHaveBeenCalled();
expect(initSpy).toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalled();
expect(disconnectAnotherSpy).toHaveBeenCalled();
expect(initAnotherSpy).toHaveBeenCalled();
if (connectsAllProviders) {
expect(connectAnotherSpy).toHaveBeenCalled();
expect(updateAnotherSpy).toHaveBeenCalled();
} else {
expect(connectAnotherSpy).not.toHaveBeenCalled();
expect(updateAnotherSpy).not.toHaveBeenCalled();
}
};
describe('External Secrets Event Handling', () => {
let pubsubEventBus: PubSubEventBus;
let pubSubRegistry: PubSubRegistry;
beforeAll(async () => {
await testModules.loadModules(['external-secrets']);
await testDb.init();
Container.set(Logger, mockLogger());
// Get pub/sub infrastructure from DI container
pubsubEventBus = Container.get(PubSubEventBus);
pubSubRegistry = Container.get(PubSubRegistry);
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(async () => {
await testDb.terminate();
});
describe('using settings store', () => {
let module: ExternalSecretsModule;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
another_dummy: AnotherDummyProvider,
});
const config = Container.get(ExternalSecretsConfig);
(config as any).externalSecretsForProjects = false;
(config as any).externalSecretsMultipleConnections = false;
const settingsRepository = Container.get(SettingsRepository);
const cipher = Container.get(Cipher);
const settings = {
dummy: {
connected: true,
connectedAt: new Date(),
settings: {},
},
another_dummy: {
connected: false,
connectedAt: new Date(),
settings: {},
},
};
await settingsRepository.save({
key: 'feature.externalSecrets',
value: cipher.encrypt(settings),
loadOnStartup: false,
});
module = Container.get(ExternalSecretsModule);
await module.init();
// IMPORTANT: Initialize PubSubRegistry AFTER module.init() to wire up decorators
pubSubRegistry.init();
});
afterAll(async () => {
await module.shutdown();
});
it('should reload providers when reload-external-secrets-providers event is emitted', async () =>
await testReloadEvent(pubsubEventBus));
});
describe('using providers connections', () => {
let module: ExternalSecretsModule;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
another_dummy: AnotherDummyProvider,
});
const config = Container.get(ExternalSecretsConfig);
(config as any).externalSecretsForProjects = true;
const cipher = Container.get(Cipher);
const encryptedSettings = cipher.encrypt(JSON.stringify({}));
const connectionRepository = Container.get(SecretsProviderConnectionRepository);
await connectionRepository.save({
providerKey: 'my-vault',
type: 'dummy',
encryptedSettings,
});
await connectionRepository.save({
providerKey: 'another-vault',
type: 'another_dummy',
encryptedSettings,
});
module = Container.get(ExternalSecretsModule);
await module.init();
// IMPORTANT: Initialize PubSubRegistry AFTER module.init() to wire up decorators
pubSubRegistry.init();
});
afterAll(async () => {
await module.shutdown();
});
it('should reload providers when reload-external-secrets-providers event is emitted', async () =>
await testReloadEvent(pubsubEventBus, { connectsAllProviders: true }));
});
});
@@ -0,0 +1,204 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance, mockLogger, testDb, testModules } from '@n8n/backend-test-utils';
import { SecretsProviderConnectionRepository, SettingsRepository } from '@n8n/db';
import { ShutdownMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { ExternalSecretsModule } from '@/modules/external-secrets.ee/external-secrets.module';
import {
AnotherDummyProvider,
DummyProvider,
MockProviders,
} from '../../shared/external-secrets/utils';
const mockProvidersInstance = new MockProviders();
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
describe('ExternalSecretsModule', () => {
beforeAll(async () => {
await testModules.loadModules(['external-secrets']);
await testDb.init();
Container.set(Logger, mockLogger());
});
afterAll(async () => {
await testDb.terminate();
});
describe('decorator verification', () => {
it('should have shutdown method decorated with @OnShutdown', () => {
const shutdownMetadata = Container.get(ShutdownMetadata);
const handlers = shutdownMetadata.getHandlersByPriority().flat();
const hasShutdownHandler = handlers.some(
(handler) =>
handler.serviceClass.name === 'ExternalSecretsModule' &&
handler.methodName === 'shutdown',
);
expect(hasShutdownHandler).toBe(true);
});
});
describe('using settings store', () => {
let module: ExternalSecretsModule;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
another_dummy: AnotherDummyProvider,
});
const settingsRepository = Container.get(SettingsRepository);
const cipher = Container.get(Cipher);
const config = Container.get(ExternalSecretsConfig);
config.externalSecretsForProjects = false;
config.externalSecretsMultipleConnections = false;
module = Container.get(ExternalSecretsModule);
const settings = {
dummy: {
connected: true,
connectedAt: new Date(),
settings: {},
},
another_dummy: {
connected: false,
connectedAt: new Date(),
settings: {},
},
};
await settingsRepository.save({
key: 'feature.externalSecrets',
value: cipher.encrypt(settings),
loadOnStartup: false,
});
});
afterEach(async () => {
await module.shutdown();
});
it('should load enabled providers on init', async () => {
const initSpy = jest.spyOn(DummyProvider.prototype, 'init');
const connectSpy = jest.spyOn(DummyProvider.prototype, 'connect');
const updateSpy = jest.spyOn(DummyProvider.prototype, 'update');
const initDisabledSpy = jest.spyOn(AnotherDummyProvider.prototype, 'init');
const connectDisabledSpy = jest.spyOn(AnotherDummyProvider.prototype, 'connect');
const updateDisabledSpy = jest.spyOn(AnotherDummyProvider.prototype, 'update');
await module.init();
expect(initSpy).toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalled();
expect(initDisabledSpy).toHaveBeenCalled();
expect(connectDisabledSpy).not.toHaveBeenCalled();
expect(updateDisabledSpy).not.toHaveBeenCalled();
initSpy.mockRestore();
connectSpy.mockRestore();
updateSpy.mockRestore();
initDisabledSpy.mockRestore();
connectDisabledSpy.mockRestore();
updateDisabledSpy.mockRestore();
});
it('should disconnect providers after shutdown', async () => {
await module.init();
const disconnectSpy = jest.spyOn(DummyProvider.prototype, 'disconnect');
await module.shutdown();
expect(disconnectSpy).toHaveBeenCalled();
disconnectSpy.mockRestore();
});
});
describe('using provider connections entities', () => {
let connectionRepository: SecretsProviderConnectionRepository;
let cipher: Cipher;
let module: ExternalSecretsModule;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
another_dummy: AnotherDummyProvider,
});
connectionRepository = Container.get(SecretsProviderConnectionRepository);
cipher = Container.get(Cipher);
const config = Container.get(ExternalSecretsConfig);
config.externalSecretsForProjects = true;
module = Container.get(ExternalSecretsModule);
const encryptedSettings = cipher.encrypt(JSON.stringify({}));
await connectionRepository.save({
providerKey: 'my-vault',
type: 'dummy',
encryptedSettings,
});
await connectionRepository.save({
providerKey: 'another-vault',
type: 'another_dummy',
encryptedSettings,
});
});
afterEach(async () => {
await module.shutdown();
});
it('should load and connect all providers on init', async () => {
const initSpy = jest.spyOn(DummyProvider.prototype, 'init');
const connectSpy = jest.spyOn(DummyProvider.prototype, 'connect');
const updateSpy = jest.spyOn(DummyProvider.prototype, 'update');
const initAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'init');
const connectAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'connect');
const updateAnotherSpy = jest.spyOn(AnotherDummyProvider.prototype, 'update');
await module.init();
expect(initSpy).toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalled();
expect(initAnotherSpy).toHaveBeenCalled();
expect(connectAnotherSpy).toHaveBeenCalled();
expect(updateAnotherSpy).toHaveBeenCalled();
initSpy.mockRestore();
connectSpy.mockRestore();
updateSpy.mockRestore();
initAnotherSpy.mockRestore();
connectAnotherSpy.mockRestore();
updateAnotherSpy.mockRestore();
});
it('should disconnect providers after shutdown', async () => {
await module.init();
const disconnectSpy = jest.spyOn(DummyProvider.prototype, 'disconnect');
await module.shutdown();
expect(disconnectSpy).toHaveBeenCalled();
disconnectSpy.mockRestore();
});
});
});
@@ -0,0 +1,949 @@
import { LicenseState } from '@n8n/backend-common';
import { createTeamProject, mockInstance, testDb } from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import {
ProjectRepository,
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { MockProviders, createDummyProvider } from '../../shared/external-secrets/utils';
import { createAdmin, createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils';
const mockProvidersInstance = new MockProviders();
// Register mock providers for types used in tests
mockProvidersInstance.setProviders({
awsSecretsManager: createDummyProvider({
name: 'awsSecretsManager',
properties: [
{
name: 'region',
displayName: 'Region',
type: 'string',
default: '',
},
{
name: 'accessKeyId',
displayName: 'Access Key ID',
type: 'string',
default: '',
typeOptions: {
password: true,
},
},
{
name: 'secretAccessKey',
displayName: 'Secret Access Key',
type: 'string',
default: '',
typeOptions: {
password: true,
},
},
{
name: 'sessionToken',
displayName: 'Session Token',
type: 'string',
default: '',
typeOptions: {
password: true,
},
},
],
}),
gcpSecretsManager: createDummyProvider({ name: 'gcpSecretsManager' }),
vault: createDummyProvider({ name: 'vault' }),
});
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
mockInstance(ExternalSecretsConfig, {
externalSecretsForProjects: true,
});
describe('Secret Providers Connections API', () => {
const testServer = utils.setupTestServer({
endpointGroups: ['externalSecrets'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['external-secrets'],
});
let ownerAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
let adminAgent: SuperAgentTest;
let agents: Record<string, SuperAgentTest>;
let teamProject1: Project;
let teamProject2: Project;
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
beforeAll(async () => {
const owner = await createOwner();
ownerAgent = testServer.authAgentFor(owner);
const [member, admin] = await Promise.all([createMember(), createAdmin()]);
memberAgent = testServer.authAgentFor(member);
adminAgent = testServer.authAgentFor(admin);
agents = {
owner: ownerAgent,
admin: adminAgent,
member: memberAgent,
};
teamProject1 = await createTeamProject('Engineering');
teamProject2 = await createTeamProject('Marketing');
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
describe('Create connection', () => {
test('should create a global connection by default with encrypted settings', async () => {
const payload = {
providerKey: 'awsProd',
type: 'awsSecretsManager',
projectIds: [],
settings: {
region: 'us-east-1',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
},
};
const response = await ownerAgent
.post('/secret-providers/connections')
.send(payload)
.expect(200);
expect(response.body.data).toEqual({
id: expect.any(String),
name: 'awsProd',
type: 'awsSecretsManager',
secretsCount: 2,
state: 'connected',
secrets: [{ name: 'test1' }, { name: 'test2' }],
projects: [],
settings: expect.any(Object),
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
// Validate encrypted items in the DB
const savedConnection = await Container.get(
SecretsProviderConnectionRepository,
).findOneByOrFail({ providerKey: 'awsProd' });
expect(savedConnection.encryptedSettings).toBeTruthy();
expect(savedConnection.encryptedSettings).not.toContain('us-east-1');
expect(savedConnection.encryptedSettings).not.toContain('AKIAIOSFODNN7EXAMPLE');
// Check we can decrypt them
const cipher = Container.get(Cipher);
const decryptedSettings = JSON.parse(cipher.decrypt(savedConnection.encryptedSettings));
expect(decryptedSettings).toEqual(payload.settings);
});
test('should create a connection and assign its projects', async () => {
const payload = {
providerKey: 'awsShared',
type: 'awsSecretsManager',
projectIds: [teamProject1.id, teamProject2.id],
settings: {
region: 'us-west-2',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'secret123',
},
};
const response = await ownerAgent
.post('/secret-providers/connections')
.send(payload)
.expect(200);
expect(response.body.data.projects).toHaveLength(2);
expect(response.body.data.projects).toEqual(
expect.arrayContaining([
{ id: teamProject1.id, name: 'Engineering' },
{ id: teamProject2.id, name: 'Marketing' },
]),
);
const getResponse = await ownerAgent
.get('/secret-providers/connections/awsShared')
.expect(200);
expect(getResponse.body.data.projects).toHaveLength(2);
});
test('should reject duplicate provider key', async () => {
const payload = {
providerKey: 'duplicateTest',
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1' },
};
await ownerAgent.post('/secret-providers/connections').send(payload).expect(200);
const response = await ownerAgent
.post('/secret-providers/connections')
.send(payload)
.expect(400);
expect(response.body.message).toBe('There is already an entry with this name');
});
});
describe('Update connection', () => {
test('should update connection type', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'updateTypeTest',
type: 'awsSecretsManager',
projectIds: [teamProject1.id],
settings: { region: 'us-east-1' },
})
.expect(200);
const response = await ownerAgent
.patch('/secret-providers/connections/updateTypeTest')
.send({
type: 'gcpSecretsManager',
settings: { projectId: 'my-project' },
})
.expect(200);
expect(response.body.data.type).toBe('gcpSecretsManager');
expect(response.body.data.projects).toHaveLength(1);
const getResponse = await ownerAgent
.get('/secret-providers/connections/updateTypeTest')
.expect(200);
expect(getResponse.body.data.type).toBe('gcpSecretsManager');
});
test('should completely replace settings (not merge)', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'replaceSettingsTest',
type: 'awsSecretsManager',
projectIds: [],
settings: {
region: 'us-east-1',
accessKeyId: 'OLD_KEY',
secretAccessKey: 'OLD_SECRET',
},
})
.expect(200);
await ownerAgent
.patch('/secret-providers/connections/replaceSettingsTest')
.send({
settings: {
region: 'eu-west-1',
secretAccessKey: 'NEW_SECRET',
},
})
.expect(200);
const updated = await Container.get(SecretsProviderConnectionRepository).findOneByOrFail({
providerKey: 'replaceSettingsTest',
});
const cipher = Container.get(Cipher);
const decryptedSettings = JSON.parse(cipher.decrypt(updated.encryptedSettings));
expect(decryptedSettings).toEqual({
region: 'eu-west-1',
secretAccessKey: 'NEW_SECRET',
});
expect(decryptedSettings).not.toHaveProperty('accessKeyId');
});
test('should update project access', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'updateProjectsTest',
type: 'awsSecretsManager',
projectIds: [teamProject1.id],
settings: { region: 'us-east-1' },
})
.expect(200);
const response = await ownerAgent
.patch('/secret-providers/connections/updateProjectsTest')
.send({ projectIds: [teamProject2.id] })
.expect(200);
expect(response.body.data.projects).toEqual([{ id: teamProject2.id, name: 'Marketing' }]);
const getResponse = await ownerAgent
.get('/secret-providers/connections/updateProjectsTest')
.expect(200);
expect(getResponse.body.data.projects).toHaveLength(1);
expect(getResponse.body.data.projects[0].id).toBe(teamProject2.id);
});
test('should convert to global by passing empty projectIds', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'toGlobalTest',
type: 'awsSecretsManager',
projectIds: [teamProject1.id, teamProject2.id],
settings: { region: 'us-east-1' },
})
.expect(200);
const response = await ownerAgent
.patch('/secret-providers/connections/toGlobalTest')
.send({ projectIds: [] })
.expect(200);
expect(response.body.data.projects).toEqual([]);
const getResponse = await ownerAgent
.get('/secret-providers/connections/toGlobalTest')
.expect(200);
expect(getResponse.body.data.projects).toEqual([]);
});
test('should fail for non-existent connection', async () => {
const response = await ownerAgent
.patch('/secret-providers/connections/non-existent')
.send({ type: 'gcpSecretsManager' })
.expect(404);
expect(response.body.message).toBe('Connection with key "non-existent" not found');
});
});
describe('Delete connection', () => {
test('should delete connection and cascade project access', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'deleteTest',
type: 'awsSecretsManager',
projectIds: [teamProject1.id, teamProject2.id],
settings: { region: 'us-east-1' },
})
.expect(200);
// Save the connection ID for verification after deletion
const savedConnection = await Container.get(
SecretsProviderConnectionRepository,
).findOneByOrFail({ providerKey: 'deleteTest' });
const connectionId = savedConnection.id;
await ownerAgent.delete('/secret-providers/connections/deleteTest').expect(204);
// Verify deletion via GET
await ownerAgent.get('/secret-providers/connections/deleteTest').expect(404);
// Verify cascade delete
const remainingAccess = await Container.get(ProjectSecretsProviderAccessRepository).find({
where: { secretsProviderConnectionId: connectionId },
});
expect(remainingAccess).toHaveLength(0);
});
test('should fail for non-existent connection', async () => {
const response = await ownerAgent
.delete('/secret-providers/connections/non-existent')
.expect(404);
expect(response.body.message).toBe('Connection with key "non-existent" not found');
});
});
describe('List connections', () => {
test('should list all connections with eager-loaded projects', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'listTest1',
type: 'awsSecretsManager',
projectIds: [teamProject1.id],
settings: { region: 'us-east-1' },
})
.expect(200);
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'listTest2',
type: 'gcpSecretsManager',
projectIds: [teamProject2.id],
settings: { projectId: 'my-project' },
})
.expect(200);
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'listTest3',
type: 'vault',
projectIds: [],
settings: { vaultUrl: 'https://vault.example.com' },
})
.expect(200);
const response = await ownerAgent.get('/secret-providers/connections').expect(200);
expect(response.body.data).toHaveLength(3);
for (const connection of response.body.data) {
expect(connection).toMatchObject({
id: expect.any(String),
name: expect.any(String),
type: expect.any(String),
state: expect.any(String),
projects: expect.any(Array),
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
// LIST endpoint should NOT include settings (lightweight response)
expect(connection).not.toHaveProperty('settings');
}
const connection1 = response.body.data.find((c: { name: string }) => c.name === 'listTest1');
expect(connection1.projects).toHaveLength(1);
const connection3 = response.body.data.find((c: { name: string }) => c.name === 'listTest3');
expect(connection3.projects).toEqual([]);
});
test('should return empty array when no connections exist', async () => {
const response = await ownerAgent.get('/secret-providers/connections').expect(200);
expect(response.body.data).toEqual([]);
});
});
describe('Get single connection', () => {
test('should return full connection details', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'getTest',
type: 'awsSecretsManager',
projectIds: [teamProject1.id, teamProject2.id],
settings: {
region: 'us-east-1',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
},
})
.expect(200);
const response = await ownerAgent.get('/secret-providers/connections/getTest').expect(200);
expect(response.body.data).toMatchObject({
id: expect.any(String),
name: 'getTest',
type: 'awsSecretsManager',
state: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(response.body.data.projects).toHaveLength(2);
});
test('should fail for non-existent connection', async () => {
const response = await ownerAgent
.get('/secret-providers/connections/does-not-exist')
.expect(404);
expect(response.body.message).toBe('Connection with key "does-not-exist" not found');
});
});
describe('Security', () => {
test('should return redacted settings without exposing sensitive data', async () => {
const sensitiveSettings = {
region: 'us-east-1',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
sessionToken: 'very-secret-session-token',
};
// Create connection with sensitive settings
const createResponse = await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'securityTest',
type: 'awsSecretsManager',
projectIds: [teamProject1.id],
settings: sensitiveSettings,
})
.expect(200);
// Detail endpoint (POST) should include settings field
expect(createResponse.body.data).toHaveProperty('settings');
expect(createResponse.body.data.settings).toEqual(expect.any(Object));
// Password fields should be redacted (contain BLANK_VALUE marker)
const responseSettings = createResponse.body.data.settings;
const responseString = JSON.stringify(responseSettings);
expect(responseString).toContain('__n8n_BLANK_VALUE_');
// Actual secret values should NOT appear in response
expect(responseString).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(responseString).not.toContain('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
expect(responseString).not.toContain('very-secret-session-token');
// GET detail endpoint should also include redacted settings
const getResponse = await ownerAgent
.get('/secret-providers/connections/securityTest')
.expect(200);
expect(getResponse.body.data).toHaveProperty('settings');
const getResponseString = JSON.stringify(getResponse.body.data.settings);
expect(getResponseString).toContain('__n8n_BLANK_VALUE_');
expect(getResponseString).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(getResponseString).not.toContain('very-secret-session-token');
// LIST endpoint should NOT include settings
const listResponse = await ownerAgent.get('/secret-providers/connections').expect(200);
const securityTestConnection = listResponse.body.data.find(
(conn: { name: string }) => conn.name === 'securityTest',
);
expect(securityTestConnection).toBeDefined();
expect(securityTestConnection).not.toHaveProperty('settings');
// Verify entire list response doesn't contain secrets
const listResponseString = JSON.stringify(listResponse.body.data);
expect(listResponseString).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(listResponseString).not.toContain('very-secret-session-token');
// DELETE endpoint should return 204 with no body
await ownerAgent.delete('/secret-providers/connections/securityTest').expect(204);
});
});
describe('Access Control', () => {
async function createTestConnection(providerKey: string, projectIds: string[] = []) {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey,
type: 'awsSecretsManager',
projectIds,
settings: { region: 'us-east-1' },
})
.expect(200);
}
describe('Create connection', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to create connection', async ({ role, allowed }) => {
const providerKey = `createTest${role.charAt(0).toUpperCase() + role.slice(1)}`;
const response = await agents[role]
.post('/secret-providers/connections')
.send({
providerKey,
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1' },
})
.expect(allowed ? 200 : 403);
if (allowed) {
expect(response.body.data.name).toBe(providerKey);
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
describe('read connections', () => {
beforeEach(async () => {
await createTestConnection('readTestConnection', [teamProject1.id]);
});
describe('list all connections', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to list connections', async ({ role, allowed }) => {
const response = await agents[role]
.get('/secret-providers/connections')
.expect(allowed ? 200 : 403);
if (allowed) {
expect(Array.isArray(response.body.data)).toBe(true);
const connection = response.body.data.find(
(c: { name: string }) => c.name === 'readTestConnection',
);
expect(connection).toBeDefined();
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
describe('get single connection', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to get single connection',
async ({ role, allowed }) => {
const response = await agents[role]
.get('/secret-providers/connections/readTestConnection')
.expect(allowed ? 200 : 403);
if (allowed) {
expect(response.body.data.name).toBe('readTestConnection');
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
describe('update connections', () => {
beforeEach(async () => {
await createTestConnection('updateAuthTest');
});
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to update connection', async ({ role, allowed }) => {
const response = await agents[role]
.patch('/secret-providers/connections/updateAuthTest')
.send({ projectIds: [teamProject1.id] })
.expect(allowed ? 200 : 403);
if (allowed) {
expect(response.body.data.projects).toHaveLength(1);
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
describe('delete connections', () => {
beforeEach(async () => {
await Promise.all(
['owner', 'admin', 'member'].map(
async (role) =>
await createTestConnection(
`deleteAuth${role.charAt(0).toUpperCase() + role.slice(1)}Test`,
),
),
);
});
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to delete connection', async ({ role, allowed }) => {
const providerKey = `deleteAuth${role.charAt(0).toUpperCase() + role.slice(1)}Test`;
const response = await agents[role]
.delete(`/secret-providers/connections/${providerKey}`)
.expect(allowed ? 204 : 403);
if (allowed) {
await ownerAgent.get(`/secret-providers/connections/${providerKey}`).expect(404);
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
await ownerAgent.get(`/secret-providers/connections/${providerKey}`).expect(200);
}
});
});
});
describe('Relationships with Project entity', () => {
test('multiple providers can be associated with the same project', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'provider1',
type: 'awsSecretsManager',
projectIds: [teamProject1.id],
settings: { region: 'us-east-1' },
})
.expect(200);
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'provider2',
type: 'gcpSecretsManager',
projectIds: [teamProject1.id],
settings: { projectId: 'my-project' },
})
.expect(200);
const response = await ownerAgent.get('/secret-providers/connections').expect(200);
const provider1 = response.body.data.find((c: { name: string }) => c.name === 'provider1');
const provider2 = response.body.data.find((c: { name: string }) => c.name === 'provider2');
expect(provider1).toBeDefined();
expect(provider2).toBeDefined();
expect(provider1.projects[0].id).toBe(teamProject1.id);
expect(provider2.projects[0].id).toBe(teamProject1.id);
// Database validation
const accessForProject = await Container.get(ProjectSecretsProviderAccessRepository).find({
where: { projectId: teamProject1.id },
});
expect(accessForProject).toHaveLength(2);
});
test('should clean up project access when project is deleted', async () => {
const tempProject = await createTeamProject('Temporary');
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'orphanTest',
type: 'awsSecretsManager',
projectIds: [tempProject.id],
settings: { region: 'us-east-1' },
})
.expect(200);
await Container.get(ProjectRepository).delete(tempProject.id);
// Verify no orphaned access entries
const orphaned = await Container.get(ProjectSecretsProviderAccessRepository).find({
where: { projectId: tempProject.id },
});
expect(orphaned).toHaveLength(0);
// Connection should still exist but with empty projects
const response = await ownerAgent.get('/secret-providers/connections/orphanTest').expect(200);
expect(response.body.data.projects).toEqual([]);
});
});
describe('Reload connection secrets', () => {
beforeAll(async () => {
const { DummyProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({ awsSecretsManager: DummyProvider });
});
afterEach(async () => {
const { DummyProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({ awsSecretsManager: DummyProvider });
});
test('should successfully reload connection secrets', async () => {
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'reloadSuccess',
type: 'awsSecretsManager',
projectIds: [],
settings: { username: 'user', password: 'pass' },
})
.expect(200);
const manager = Container.get(ExternalSecretsManager);
await manager.reloadAllProviders();
const provider = manager.getProvider('reloadSuccess');
expect(provider).toBeDefined();
expect(provider?.state).toBe('connected');
const updateSpy = jest.spyOn(provider!, 'update');
const reloadResponse = await ownerAgent
.post('/secret-providers/connections/reloadSuccess/reload')
.expect(200);
expect(reloadResponse.body.data).toEqual({ success: true });
expect(updateSpy).toHaveBeenCalled();
});
test('should return 404 for non-existent connection', async () => {
const response = await ownerAgent
.post('/secret-providers/connections/non-existent/reload')
.expect(404);
expect(response.body.message).toBe('Connection with key "non-existent" not found');
});
describe('access control', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to reload connection', async ({ role, allowed }) => {
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
const providerKey = `reloadAccess${role.charAt(0).toUpperCase() + role.slice(1)}`;
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey,
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1' },
})
.expect(200);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await agents[role]
.post(`/secret-providers/connections/${providerKey}/reload`)
.expect(allowed ? 200 : 403);
if (allowed) {
expect(response.body.data).toEqual({ success: true });
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
});
describe('Test connection', () => {
beforeAll(async () => {
const { DummyProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({ awsSecretsManager: DummyProvider });
});
afterEach(async () => {
const { DummyProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({ awsSecretsManager: DummyProvider });
});
test('should successfully test connection with valid settings', async () => {
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'testSuccess',
type: 'awsSecretsManager',
projectIds: [],
settings: { username: 'user', password: 'pass' },
})
.expect(200);
const testResponse = await ownerAgent
.post('/secret-providers/connections/testSuccess/test')
.expect(200);
expect(testResponse.body.data).toMatchObject({ success: true });
});
test('should return failure when provider test fails', async () => {
const { TestFailProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({ awsSecretsManager: TestFailProvider });
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'testFail',
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1' },
})
.expect(200);
const testResponse = await ownerAgent
.post('/secret-providers/connections/testFail/test')
.expect(200);
expect(testResponse.body.data).toMatchObject({ success: false });
});
test('should return error when connection fails', async () => {
const { FailedProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({ awsSecretsManager: FailedProvider });
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey: 'testConnectionFail',
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1' },
})
.expect(200);
const testResponse = await ownerAgent
.post('/secret-providers/connections/testConnectionFail/test')
.expect(200);
expect(testResponse.body.data.success).toBe(false);
expect(testResponse.body.data.error).toBeDefined();
});
test('should return 404 for non-existent connection', async () => {
const response = await ownerAgent
.post('/secret-providers/connections/non-existent/test')
.expect(404);
expect(response.body.message).toBe('Connection with key "non-existent" not found');
});
describe('access control', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to test connection', async ({ role, allowed }) => {
const providerKey = `testAccess${role.charAt(0).toUpperCase() + role.slice(1)}`;
await ownerAgent
.post('/secret-providers/connections')
.send({
providerKey,
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1' },
})
.expect(200);
const response = await agents[role]
.post(`/secret-providers/connections/${providerKey}/test`)
.expect(allowed ? 200 : 403);
if (allowed) {
expect(response.body.data).toHaveProperty('success');
} else {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
});
});
@@ -0,0 +1,839 @@
import { LicenseState } from '@n8n/backend-common';
import { createTeamProject, mockInstance, testDb } from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import {
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
import type { Response } from 'superagent';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import {
DummyProvider,
MockProviders,
createDummyProvider,
} from '../../shared/external-secrets/utils';
import { createAdmin, createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
const mockProvidersInstance = new MockProviders();
mockProvidersInstance.setProviders({
dummy: DummyProvider,
awsSecretsManager: createDummyProvider({ name: 'awsSecretsManager' }),
gcpSecretsManager: createDummyProvider({ name: 'gcpSecretsManager' }),
});
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
mockInstance(ExternalSecretsConfig, {
externalSecretsForProjects: true,
});
describe('Secret Providers Project API', () => {
const testServer = setupTestServer({
endpointGroups: ['externalSecrets'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['external-secrets'],
});
let ownerAgent: SuperAgentTest;
let adminAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
let teamProject1: Project;
let teamProject2: Project;
let emptyProject: Project;
let connectionRepository: SecretsProviderConnectionRepository;
let projectAccessRepository: ProjectSecretsProviderAccessRepository;
beforeAll(async () => {
const [owner, admin, member] = await Promise.all([
createOwner(),
createAdmin(),
createMember(),
]);
ownerAgent = testServer.authAgentFor(owner);
adminAgent = testServer.authAgentFor(admin);
memberAgent = testServer.authAgentFor(member);
teamProject1 = await createTeamProject('Engineering');
teamProject2 = await createTeamProject('Marketing');
emptyProject = await createTeamProject('Empty');
connectionRepository = Container.get(SecretsProviderConnectionRepository);
projectAccessRepository = Container.get(ProjectSecretsProviderAccessRepository);
});
async function createProviderConnection(
providerKey: string,
projectIds: string[] = [],
): Promise<number> {
const cipher = Container.get(Cipher);
const mockSettings = { region: 'us-east-1', accessKeyId: 'test-key' };
const encryptedSettings = cipher.encrypt(mockSettings);
const connection = await connectionRepository.save(
connectionRepository.create({
providerKey,
type: 'dummy',
encryptedSettings,
isEnabled: true,
}),
);
if (projectIds.length > 0) {
const entries = projectIds.map((projectId) =>
projectAccessRepository.create({
secretsProviderConnectionId: connection.id,
projectId,
}),
);
await projectAccessRepository.save(entries);
}
return connection.id;
}
describe('GET /secret-providers/projects/:projectId/connections', () => {
describe('Authorization', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = {
owner: ownerAgent,
admin: adminAgent,
member: memberAgent,
};
});
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to list global secrets', async ({ role, allowed }) => {
const response = await agents[role]
.get(`/secret-providers/projects/${teamProject1.id}/connections`)
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
describe('with global connections only', () => {
let response: Response;
beforeAll(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
await Promise.all([
createProviderConnection('global-connection1', []),
createProviderConnection('global-connection2', []),
]);
response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections`)
.expect(200);
});
test('should return global connections', async () => {
expect(response.body.data).toHaveLength(2);
const names = response.body.data.map((c: { name: string }) => c.name);
expect(names).toEqual(expect.arrayContaining(['global-connection1', 'global-connection2']));
});
test('should return the right connection data', async () => {
const globalConnection1 = response.body.data.find(
(c: { name: string }) => c.name === 'global-connection1',
);
expect(globalConnection1).toMatchObject({
name: 'global-connection1',
type: 'dummy',
state: 'initializing',
projects: [],
});
});
});
describe('with project-specific connections only', () => {
beforeAll(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
await Promise.all([
createProviderConnection('connection1', [teamProject1.id]),
createProviderConnection('connection2', [teamProject1.id]),
createProviderConnection('connection3', [teamProject1.id]),
createProviderConnection('connection4', [teamProject2.id]),
createProviderConnection('connection5', [teamProject2.id]),
]);
});
describe('when the project has no connections', () => {
test('should return an empty array', async () => {
const response = await ownerAgent
.get(`/secret-providers/projects/${emptyProject.id}/connections`)
.expect(200);
expect(response.body.data).toEqual([]);
});
});
describe('when the project has connections', () => {
let response: Response;
beforeAll(async () => {
response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections`)
.expect(200);
});
test('should only return connections for the project', async () => {
expect(response.body.data).toHaveLength(3);
const names = response.body.data.map((c: { name: string }) => c.name);
expect(names).toEqual(
expect.arrayContaining(['connection1', 'connection2', 'connection3']),
);
});
test('should return the right connection data', async () => {
const connection1 = response.body.data.find(
(c: { name: string }) => c.name === 'connection1',
);
expect(connection1).toMatchObject({
name: 'connection1',
type: 'dummy',
state: 'initializing',
projects: [{ id: teamProject1.id, name: teamProject1.name }],
});
});
});
});
describe('with both global and project-specific connections', () => {
beforeAll(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
await Promise.all([
createProviderConnection('connection1', [teamProject1.id]),
createProviderConnection('connection2', [teamProject1.id]),
createProviderConnection('connection3', [teamProject1.id]),
createProviderConnection('connection4', [teamProject2.id]),
createProviderConnection('connection5', [teamProject2.id]),
createProviderConnection('global-connection1', []),
createProviderConnection('global-connection2', []),
]);
});
describe('when the project has no connections', () => {
test('should return global connections', async () => {
const response = await ownerAgent
.get(`/secret-providers/projects/${emptyProject.id}/connections`)
.expect(200);
expect(response.body.data).toHaveLength(2);
const names = response.body.data.map((c: { name: string }) => c.name);
expect(names).toEqual(
expect.arrayContaining(['global-connection1', 'global-connection2']),
);
});
});
describe('when the project has connections', () => {
test('should return both global and project-specific connections', async () => {
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections`)
.expect(200);
expect(response.body.data).toHaveLength(5);
const names = response.body.data.map((c: { name: string }) => c.name);
expect(names).toEqual(
expect.arrayContaining([
'connection1',
'connection2',
'connection3',
'global-connection1',
'global-connection2',
]),
);
});
});
});
describe('with no connections', () => {
test('should return an empty array', async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections`)
.expect(200);
expect(response.body.data).toEqual([]);
});
});
});
describe('POST /secret-providers/projects/:projectId/connections', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
test('should create a connection for the project', async () => {
const response = await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections`)
.send({
providerKey: 'newConn',
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1', accessKeyId: 'test-key' },
})
.expect(200);
expect(response.body.data).toMatchObject({
name: 'newConn',
type: 'awsSecretsManager',
state: 'connected',
projects: [{ id: teamProject1.id, name: teamProject1.name }],
});
// Verify it exists in the DB
const found = await connectionRepository.findOneBy({ providerKey: 'newConn' });
expect(found).not.toBeNull();
// Verify project access was created
const access = await projectAccessRepository.find({
where: { secretsProviderConnectionId: found!.id },
});
expect(access).toHaveLength(1);
expect(access[0].projectId).toBe(teamProject1.id);
});
test('should return 400 when connection with same providerKey already exists', async () => {
await createProviderConnection('existingConn', [teamProject1.id]);
await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections`)
.send({
providerKey: 'existingConn',
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1', accessKeyId: 'test-key' },
})
.expect(400);
});
test('should assign the connection only to the target project', async () => {
await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections`)
.send({
providerKey: 'proj1Only',
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1', accessKeyId: 'test-key' },
})
.expect(200);
// Connection should NOT be visible from teamProject2
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject2.id}/connections`)
.expect(200);
const names = response.body.data.map((c: { name: string }) => c.name);
expect(names).not.toContain('proj1Only');
});
describe('authorization', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to create a project connection',
async ({ role, allowed }) => {
const response = await agents[role]
.post(`/secret-providers/projects/${teamProject1.id}/connections`)
.send({
providerKey: `createAuth${role.charAt(0).toUpperCase() + role.slice(1)}`,
type: 'awsSecretsManager',
projectIds: [],
settings: { region: 'us-east-1', accessKeyId: 'test-key' },
})
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
describe('GET /secret-providers/projects/:projectId/connections/:providerKey', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
test('should return a connection belonging to the project', async () => {
await createProviderConnection('my-conn', [teamProject1.id]);
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections/my-conn`)
.expect(200);
expect(response.body.data).toMatchObject({
name: 'my-conn',
state: 'initializing',
projects: [{ id: teamProject1.id }],
});
});
test('should return 404 for a connection belonging to another project', async () => {
await createProviderConnection('other-proj-conn', [teamProject2.id]);
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections/other-proj-conn`)
.expect(404);
expect(response.body.message).toContain('not found');
});
test('should return a global connection (no project access) accessible from any project', async () => {
await createProviderConnection('global-conn', []);
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections/global-conn`)
.expect(200);
expect(response.body.data).toMatchObject({
name: 'global-conn',
state: 'initializing',
});
});
test('should return 404 for a non-existent connection', async () => {
await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/connections/does-not-exist`)
.expect(404);
});
describe('authorization', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to get a project connection',
async ({ role, allowed }) => {
await createProviderConnection('auth-get-conn', [teamProject1.id]);
const response = await agents[role]
.get(`/secret-providers/projects/${teamProject1.id}/connections/auth-get-conn`)
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
describe('PATCH /secret-providers/projects/:projectId/connections/:providerKey', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
test('should update connection type and settings', async () => {
await createProviderConnection('update-conn', [teamProject1.id]);
const response = await ownerAgent
.patch(`/secret-providers/projects/${teamProject1.id}/connections/update-conn`)
.send({ type: 'gcpSecretsManager', settings: { projectId: 'my-project' } })
.expect(200);
expect(response.body.data.type).toBe('gcpSecretsManager');
});
test('should ignore projectIds in the update body', async () => {
await createProviderConnection('no-reassign', [teamProject1.id]);
const response = await ownerAgent
.patch(`/secret-providers/projects/${teamProject1.id}/connections/no-reassign`)
.send({ projectIds: [teamProject2.id] })
.expect(200);
// Connection should still belong to teamProject1, not teamProject2
expect(response.body.data.projects).toHaveLength(1);
expect(response.body.data.projects[0].id).toBe(teamProject1.id);
});
test('should return 404 for a connection belonging to another project', async () => {
await createProviderConnection('other-update', [teamProject2.id]);
await ownerAgent
.patch(`/secret-providers/projects/${teamProject1.id}/connections/other-update`)
.send({ settings: { region: 'eu-west-1' } })
.expect(404);
});
test('should return 404 for a global connection (cannot update global from project context)', async () => {
await createProviderConnection('global-update', []);
await ownerAgent
.patch(`/secret-providers/projects/${teamProject1.id}/connections/global-update`)
.send({ settings: { region: 'eu-west-1' } })
.expect(404);
});
test('should return 404 for a non-existent connection', async () => {
await ownerAgent
.patch(`/secret-providers/projects/${teamProject1.id}/connections/missing`)
.send({ settings: { region: 'eu-west-1' } })
.expect(404);
});
describe('authorization', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to update a project connection',
async ({ role, allowed }) => {
await createProviderConnection('auth-update', [teamProject1.id]);
const response = await agents[role]
.patch(`/secret-providers/projects/${teamProject1.id}/connections/auth-update`)
.send({ settings: { region: 'eu-west-1' } })
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
describe('DELETE /secret-providers/projects/:projectId/connections/:providerKey', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
test('should delete a connection belonging to the project', async () => {
const connectionId = await createProviderConnection('del-conn', [teamProject1.id]);
await ownerAgent
.delete(`/secret-providers/projects/${teamProject1.id}/connections/del-conn`)
.expect(204);
// Verify it's actually gone from the DB
const found = await connectionRepository.findOneBy({ id: connectionId });
expect(found).toBeNull();
// Verify cascade deletes project access
const access = await projectAccessRepository.find({
where: { secretsProviderConnectionId: connectionId },
});
expect(access).toHaveLength(0);
});
test('should return 404 for a connection belonging to another project', async () => {
await createProviderConnection('otherDel', [teamProject2.id]);
await ownerAgent
.delete(`/secret-providers/projects/${teamProject1.id}/connections/otherDel`)
.expect(404);
// Verify the connection was NOT deleted
const found = await connectionRepository.findOneBy({ providerKey: 'otherDel' });
expect(found).not.toBeNull();
});
test('should return 404 for a global connection (cannot delete global from project context)', async () => {
await createProviderConnection('global-del', []);
await ownerAgent
.delete(`/secret-providers/projects/${teamProject1.id}/connections/global-del`)
.expect(404);
// Verify the connection was NOT deleted
const found = await connectionRepository.findOneBy({ providerKey: 'global-del' });
expect(found).not.toBeNull();
});
test('should return 404 for a non-existent connection', async () => {
await ownerAgent
.delete(`/secret-providers/projects/${teamProject1.id}/connections/missing`)
.expect(404);
});
describe('authorization', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to delete a project connection',
async ({ role, allowed }) => {
await createProviderConnection(`auth-del-${role}`, [teamProject1.id]);
const response = await agents[role]
.delete(`/secret-providers/projects/${teamProject1.id}/connections/auth-del-${role}`)
.expect(allowed ? 204 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
describe('POST /secret-providers/projects/:projectId/connections/:providerKey/test', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(async () => {
agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
const { DummyProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({
dummy: DummyProvider,
awsSecretsManager: DummyProvider,
});
});
afterAll(() => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
awsSecretsManager: createDummyProvider({ name: 'awsSecretsManager' }),
gcpSecretsManager: createDummyProvider({ name: 'gcpSecretsManager' }),
});
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
test('should test a connection belonging to the project', async () => {
await createProviderConnection('test-conn', [teamProject1.id]);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections/test-conn/test`)
.expect(200);
expect(response.body.data).toMatchObject({ success: true });
});
test('should test a global connection accessible from the project', async () => {
await createProviderConnection('global-test-conn', []);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections/global-test-conn/test`)
.expect(200);
expect(response.body.data).toMatchObject({ success: true });
});
test('should return 404 for a connection belonging to another project', async () => {
await createProviderConnection('other-test', [teamProject2.id]);
await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections/other-test/test`)
.expect(404);
});
test('should return 404 for a non-existent connection', async () => {
await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections/missing/test`)
.expect(404);
});
describe('authorization', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to test a project connection',
async ({ role, allowed }) => {
await createProviderConnection(`test-auth-${role}`, [teamProject1.id]);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await agents[role]
.post(
`/secret-providers/projects/${teamProject1.id}/connections/test-auth-${role}/test`,
)
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
describe('POST /secret-providers/projects/:projectId/reload', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(async () => {
agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
const { DummyProvider } = await import('../../shared/external-secrets/utils');
mockProvidersInstance.setProviders({
dummy: DummyProvider,
awsSecretsManager: DummyProvider,
});
});
afterAll(() => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
awsSecretsManager: createDummyProvider({ name: 'awsSecretsManager' }),
gcpSecretsManager: createDummyProvider({ name: 'gcpSecretsManager' }),
});
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
test('should reload all connections belonging to the project', async () => {
await createProviderConnection('reload-conn-1', [teamProject1.id]);
await createProviderConnection('reload-conn-2', [teamProject1.id]);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/reload`)
.expect(200);
expect(response.body.data).toEqual({
success: true,
providers: {
'reload-conn-1': { success: true },
'reload-conn-2': { success: true },
},
});
});
test('should succeed when project has no connections', async () => {
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await ownerAgent
.post(`/secret-providers/projects/${emptyProject.id}/reload`)
.expect(200);
expect(response.body.data).toEqual({ success: true, providers: {} });
});
test('should not reload connections that are not part of the project', async () => {
await createProviderConnection('global-conn', []);
await createProviderConnection('proj1-conn', [teamProject1.id]);
await createProviderConnection('proj2-conn', [teamProject2.id]);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
const manager = Container.get(ExternalSecretsManager);
await manager.reloadAllProviders();
const updateSpy = jest.spyOn(manager, 'updateProvider');
await ownerAgent.post(`/secret-providers/projects/${teamProject1.id}/reload`).expect(200);
expect(updateSpy).toHaveBeenCalledWith('proj1-conn');
expect(updateSpy).not.toHaveBeenCalledWith('proj2-conn');
expect(updateSpy).not.toHaveBeenCalledWith('global-conn');
updateSpy.mockRestore();
});
describe('authorization', () => {
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for $role to reload project connections',
async ({ role, allowed }) => {
await createProviderConnection(`reload-auth-${role}`, [teamProject1.id]);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await agents[role]
.post(`/secret-providers/projects/${teamProject1.id}/reload`)
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);
});
});
});
@@ -0,0 +1,307 @@
import type { SecretProviderTypeResponse, SecretsProviderType } from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import { mockInstance, mockLogger, testDb, testModules } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { Response } from 'superagent';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import {
AnotherDummyProvider,
createDummyProvider,
DummyProvider,
MockProviders,
} from '../../shared/external-secrets/utils';
import { createAdmin, createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
const mockProvidersInstance = new MockProviders();
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
mockInstance(ExternalSecretsConfig, {
externalSecretsForProjects: true,
});
describe('Secret Providers Types API', () => {
const testServer = setupTestServer({
endpointGroups: ['externalSecrets'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['external-secrets'],
});
let ownerAgent: SuperAgentTest;
let adminAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
beforeAll(async () => {
await testModules.loadModules(['external-secrets']);
await testDb.init();
Container.set(Logger, mockLogger());
const owner = await createOwner();
ownerAgent = testServer.authAgentFor(owner);
const admin = await createAdmin();
adminAgent = testServer.authAgentFor(admin);
const member = await createMember();
memberAgent = testServer.authAgentFor(member);
});
afterAll(async () => {
await testDb.terminate();
});
describe('GET /secret-providers/types', () => {
describe('Authorization', () => {
beforeAll(() => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
});
});
it('should authorize owner to list provider types', async () => {
const response = await ownerAgent.get('/secret-providers/types');
expect(response.status).toBe(200);
});
it('should authorize global admin to list provider types', async () => {
const response = await adminAgent.get('/secret-providers/types');
expect(response.status).toBe(200);
});
it('should authorize member to list provider types', async () => {
const response = await memberAgent.get('/secret-providers/types');
expect(response.status).toBe(200);
});
});
describe('with providers', () => {
const mockProvider = createDummyProvider({
name: 'mock_provider',
displayName: 'Mock Provider Custom',
properties: [
{
name: 'apiKey',
displayName: 'API Key',
type: 'string',
default: '',
required: true,
typeOptions: {
password: true,
},
},
{
name: 'region',
displayName: 'Region',
type: 'options',
default: 'us-east-1',
options: [
{ name: 'US East', value: 'us-east-1' },
{ name: 'US West', value: 'us-west-2' },
{ name: 'EU Central', value: 'eu-central-1' },
],
required: false,
},
{
name: 'projectId',
displayName: 'Project ID',
type: 'string',
default: '',
required: false,
},
{
name: 'environment',
displayName: 'Environment',
type: 'string',
default: 'production',
required: false,
},
],
});
let response: Response;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
another_dummy: AnotherDummyProvider,
mock_provider: mockProvider,
});
response = await ownerAgent.get('/secret-providers/types');
});
it('should return all available provider types', () => {
const { data } = response.body as { data: SecretProviderTypeResponse[] };
expect(response.status).toBe(200);
expect(data).toBeInstanceOf(Array);
expect(data).toHaveLength(3);
const providerNames = data.map((p) => p.type);
expect(providerNames).toEqual(
expect.arrayContaining(['dummy', 'another_dummy', 'mock_provider']),
);
});
it('should return correct provider type data', () => {
const { data } = response.body as { data: SecretProviderTypeResponse[] };
const expectedMockProvider = data.find(
(p) => p.type === ('mock_provider' as SecretsProviderType),
);
expect(expectedMockProvider).toBeDefined();
expect(expectedMockProvider?.displayName).toBe('Mock Provider Custom');
expect(expectedMockProvider?.properties).toHaveLength(4);
const propertiesNames = expectedMockProvider?.properties?.map((p) => p.name);
expect(propertiesNames).toEqual(
expect.arrayContaining(['apiKey', 'region', 'projectId', 'environment']),
);
});
});
describe('without providers', () => {
it('should return empty array when no providers are registered', async () => {
mockProvidersInstance.setProviders({});
const response = await ownerAgent.get('/secret-providers/types');
expect(response.status).toBe(200);
const { data } = response.body as { data: SecretProviderTypeResponse[] };
expect(data).toBeInstanceOf(Array);
expect(data).toHaveLength(0);
});
});
});
describe('GET /secret-providers/types/:type', () => {
describe('Authorization', () => {
beforeAll(() => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
});
});
it('should authorize owner to get specific provider type', async () => {
const response = await ownerAgent.get('/secret-providers/types/dummy');
expect(response.status).toBe(200);
});
it('should authorize global admin to get specific provider type', async () => {
const response = await adminAgent.get('/secret-providers/types/dummy');
expect(response.status).toBe(200);
});
it('should authorize member to get provider type', async () => {
const response = await memberAgent.get('/secret-providers/types/dummy');
expect(response.status).toBe(200);
});
});
describe('with provider', () => {
const mockProvider = createDummyProvider({
name: 'vault',
displayName: 'HashiCorp Vault',
properties: [
{
name: 'url',
displayName: 'Vault URL',
type: 'string',
default: '',
required: true,
placeholder: 'https://vault.example.com',
},
{
name: 'token',
displayName: 'Token',
type: 'string',
default: '',
required: true,
typeOptions: {
password: true,
},
},
{
name: 'namespace',
displayName: 'Namespace',
type: 'string',
default: '',
required: false,
},
],
});
let response: Response;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
vault: mockProvider,
});
response = await ownerAgent.get('/secret-providers/types/vault');
});
it('should return provider type', () => {
expect(response.status).toBe(200);
const { data } = response.body as { data: SecretProviderTypeResponse };
expect(data).toBeDefined();
expect(data.type).toBe('vault');
});
it('should return correct provider type data', () => {
const { data } = response.body as { data: SecretProviderTypeResponse };
expect(data.displayName).toBe('HashiCorp Vault');
expect(data.icon).toBe('vault');
expect(data.properties).toHaveLength(3);
const propertyNames = data.properties.map((p) => p.name);
expect(propertyNames).toEqual(expect.arrayContaining(['url', 'token', 'namespace']));
// Verify password field has correct typeOptions
const tokenProperty = data.properties.find((p) => p.name === 'token');
expect(tokenProperty?.typeOptions).toEqual({ password: true });
// Verify required fields
const urlProperty = data.properties.find((p) => p.name === 'url');
expect(urlProperty?.required).toBe(true);
const namespaceProperty = data.properties.find((p) => p.name === 'namespace');
expect(namespaceProperty?.required).toBe(false);
});
});
describe('without provider', () => {
let response: Response;
beforeAll(async () => {
mockProvidersInstance.setProviders({
dummy: DummyProvider,
});
response = await ownerAgent.get('/secret-providers/types/non_existent');
});
it('should return 404 for non-existent provider type', () => {
expect(response.status).toBe(404);
});
it('should return 404 with appropriate error message', () => {
expect(response.body).toEqual({
code: 404,
message: 'Provider type "non_existent" not found',
});
});
});
});
});
@@ -0,0 +1,420 @@
import { LicenseState, Logger } from '@n8n/backend-common';
import { createTeamProject, mockInstance, mockLogger, testDb } from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import {
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee';
import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { createDummyProvider, MockProviders } from '../../shared/external-secrets/utils';
import { createAdmin, createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
import { Cipher } from 'n8n-core';
import { mockCipher } from '@test/mocking';
const resetManager = async () => {
const manager = Container.get(ExternalSecretsManager);
manager.shutdown();
await manager.init();
};
const mockProvidersInstance = new MockProviders();
mockInstance(ExternalSecretsProviders, mockProvidersInstance);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
mockInstance(ExternalSecretsConfig, {
externalSecretsForProjects: true,
});
mockInstance(Cipher, mockCipher());
describe('Secret Providers Completions API', () => {
let owner: User;
let member: User;
let admin: User;
let ownerAgent: SuperAgentTest;
let adminAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
const testServer = setupTestServer({
endpointGroups: ['externalSecrets'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['external-secrets'],
});
let connectionRepository: SecretsProviderConnectionRepository;
let projectAccessRepository: ProjectSecretsProviderAccessRepository;
let projectWithConnections: Project;
let projectWithoutConnections: Project;
beforeAll(async () => {
Container.set(Logger, mockLogger());
owner = await createOwner();
member = await createMember();
admin = await createAdmin();
ownerAgent = testServer.authAgentFor(owner);
adminAgent = testServer.authAgentFor(admin);
memberAgent = testServer.authAgentFor(member);
connectionRepository = Container.get(SecretsProviderConnectionRepository);
projectAccessRepository = Container.get(ProjectSecretsProviderAccessRepository);
projectWithConnections = await createTeamProject('With Connections', owner);
projectWithoutConnections = await createTeamProject('Without Connections', owner);
});
beforeEach(async () => {
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
});
describe('GET /secret-providers/completions/secrets/global', () => {
describe('Authorization', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = {
owner: ownerAgent,
admin: adminAgent,
member: memberAgent,
};
});
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to list global secrets', async ({ role, allowed }) => {
const response = await agents[role]
.get('/secret-providers/completions/secrets/global')
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
describe('with global connections', () => {
it('should return global secrets', async () => {
const GlobalProvider1 = createDummyProvider({
name: 'global_provider_1',
secrets: { globalSecret1: 'value1', globalSecret2: 'value2' },
});
const GlobalProvider2 = createDummyProvider({
name: 'global_provider_2',
secrets: { globalSecret3: 'value3' },
});
const ProjectProvider = createDummyProvider({
name: 'project_provider',
secrets: { projectSecret1: 'value1' },
});
mockProvidersInstance.setProviders({
global_provider_1: GlobalProvider1,
global_provider_2: GlobalProvider2,
project_provider: ProjectProvider,
});
// Create enabled global connections (should be returned)
await connectionRepository.save(
connectionRepository.create({
providerKey: 'global-connection-1',
type: 'global_provider_1',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await connectionRepository.save(
connectionRepository.create({
providerKey: 'global-connection-2',
type: 'global_provider_2',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
// Create project-scoped connection (should NOT be returned)
const projectConnection = await connectionRepository.save(
connectionRepository.create({
providerKey: 'project-connection',
type: 'project_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithConnections.id,
secretsProviderConnectionId: projectConnection.id,
}),
);
await resetManager();
const response = await ownerAgent
.get('/secret-providers/completions/secrets/global')
.expect(200);
expect(response.body.data).toEqual({
'global-connection-1': ['globalSecret1', 'globalSecret2'],
'global-connection-2': ['globalSecret3'],
});
});
});
describe('without global connections', () => {
it('should return an empty object', async () => {
const ProjectProvider = createDummyProvider({
name: 'project_provider',
secrets: { projectSecret1: 'value1' },
});
mockProvidersInstance.setProviders({
project_provider: ProjectProvider,
});
// Create project-scoped connection (should NOT be returned)
const projectConnection = await connectionRepository.save(
connectionRepository.create({
providerKey: 'project-connection',
type: 'project_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithConnections.id,
secretsProviderConnectionId: projectConnection.id,
}),
);
await resetManager();
const response = await ownerAgent
.get('/secret-providers/completions/secrets/global')
.expect(200);
expect(response.body.data).toEqual({});
});
});
});
describe('GET /secret-providers/completions/secrets/project/:projectId', () => {
describe('Authorization', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';
let agents: Record<string, SuperAgentTest>;
beforeAll(() => {
agents = {
owner: ownerAgent,
admin: adminAgent,
member: memberAgent,
};
});
test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])('should allow=$allowed for $role to list project secrets', async ({ role, allowed }) => {
const response = await agents[role]
.get('/secret-providers/completions/secrets/project/123')
.expect(allowed ? 200 : 403);
if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
});
});
describe('with existing project connections', () => {
it('should return project secrets', async () => {
const ProjectProvider1 = createDummyProvider({
name: 'project_provider_1',
secrets: { secret1: 'value1', secret2: 'value2' },
});
const ProjectProvider2 = createDummyProvider({
name: 'project_provider_2',
secrets: { secret3: 'value3', secret4: 'value4' },
});
const GlobalProvider = createDummyProvider({
name: 'global_provider',
secrets: { globalSecret1: 'value1' },
});
const OtherProjectProvider = createDummyProvider({
name: 'other_project_provider',
secrets: { otherSecret1: 'value1' },
});
mockProvidersInstance.setProviders({
project_provider_1: ProjectProvider1,
project_provider_2: ProjectProvider2,
global_provider: GlobalProvider,
other_project_provider: OtherProjectProvider,
});
// Create enabled project-scoped connections for projectWithConnections (should be returned)
const connection1 = await connectionRepository.save(
connectionRepository.create({
providerKey: 'test-project-secret-connection-1',
type: 'project_provider_1',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
const connection2 = await connectionRepository.save(
connectionRepository.create({
providerKey: 'test-project-secret-connection-2',
type: 'project_provider_2',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithConnections.id,
secretsProviderConnectionId: connection1.id,
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithConnections.id,
secretsProviderConnectionId: connection2.id,
}),
);
// Create connection for another project (should NOT be returned)
const otherProjectConnection = await connectionRepository.save(
connectionRepository.create({
providerKey: 'other-project-connection',
type: 'other_project_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithoutConnections.id,
secretsProviderConnectionId: otherProjectConnection.id,
}),
);
// Create global connection (should NOT be returned)
await connectionRepository.save(
connectionRepository.create({
providerKey: 'global-connection',
type: 'global_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await resetManager();
const response = await ownerAgent
.get(`/secret-providers/completions/secrets/project/${projectWithConnections.id}`)
.expect(200);
expect(response.body.data).toEqual({
'test-project-secret-connection-1': ['secret1', 'secret2'],
'test-project-secret-connection-2': ['secret3', 'secret4'],
});
});
});
describe('with no project connections', () => {
it('should return an empty object', async () => {
const ProjectProvider = createDummyProvider({
name: 'project_provider',
secrets: { secret1: 'value1' },
});
const GlobalProvider = createDummyProvider({
name: 'global_provider',
secrets: { globalSecret1: 'value1' },
});
mockProvidersInstance.setProviders({
project_provider: ProjectProvider,
global_provider: GlobalProvider,
});
// Create connection for another project (should NOT be returned)
const otherProjectConnection = await connectionRepository.save(
connectionRepository.create({
providerKey: 'other-project-connection',
type: 'project_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithConnections.id,
secretsProviderConnectionId: otherProjectConnection.id,
}),
);
// Create global connection (should NOT be returned)
await connectionRepository.save(
connectionRepository.create({
providerKey: 'global-connection',
type: 'global_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await resetManager();
const response = await ownerAgent
.get(`/secret-providers/completions/secrets/project/${projectWithoutConnections.id}`)
.expect(200);
expect(response.body.data).toEqual({});
});
});
describe('with no project', () => {
it('should return an empty object', async () => {
const ProjectProvider = createDummyProvider({
name: 'project_provider',
secrets: { secret1: 'value1' },
});
mockProvidersInstance.setProviders({
project_provider: ProjectProvider,
});
// Create connections that should NOT be returned for non-existent project
const connection = await connectionRepository.save(
connectionRepository.create({
providerKey: 'project-connection',
type: 'project_provider',
encryptedSettings: JSON.stringify({ mocked: 'mocked-encrypted-settings' }),
}),
);
await projectAccessRepository.save(
projectAccessRepository.create({
projectId: projectWithConnections.id,
secretsProviderConnectionId: connection.id,
}),
);
await resetManager();
const nonExistentProjectId = '00000000-0000-0000-0000-000000000000';
const response = await ownerAgent
.get(`/secret-providers/completions/secrets/project/${nonExistentProjectId}`)
.expect(200);
expect(response.body.data).toEqual({});
});
});
});
});