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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+949
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+420
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user