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,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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user