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,758 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
// Test helper functions
|
||||
async function shareCredentialsToProject(
|
||||
credentials: Array<{ id: string }>,
|
||||
projectId: string,
|
||||
role: 'credential:user' | 'credential:owner',
|
||||
) {
|
||||
const sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
|
||||
await sharedCredentialsRepository.save(
|
||||
credentials.map((c) => ({
|
||||
credentialsId: c.id,
|
||||
projectId,
|
||||
role,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function expectCredentialsMatch(
|
||||
oldCredentials: Array<{ id: string; [key: string]: any }>,
|
||||
newCredentials: Array<{ id: string; [key: string]: any }>,
|
||||
) {
|
||||
// Sort by ID for consistent order-independent comparison
|
||||
const oldSorted = [...oldCredentials].sort((a, b) => a.id.localeCompare(b.id));
|
||||
const newSorted = [...newCredentials].sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// Jest's toEqual does deep recursive comparison of all fields
|
||||
expect(newSorted).toEqual(oldSorted);
|
||||
}
|
||||
|
||||
describe('CredentialsRepository', () => {
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['SharedCredentials', 'CredentialsEntity']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('getManyAndCountWithSharingSubquery', () => {
|
||||
let credentialsRepository: CredentialsRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'SharedCredentials',
|
||||
'ProjectRelation',
|
||||
'CredentialsEntity',
|
||||
'Project',
|
||||
'User',
|
||||
]);
|
||||
credentialsRepository = Container.get(CredentialsRepository);
|
||||
});
|
||||
|
||||
it('should fetch credentials using subquery for standard user with roles', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const member = await createMember();
|
||||
const teamProject = await createTeamProject('test-project');
|
||||
await linkUserToProject(member, teamProject, 'project:editor');
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Team Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Team Credential 2', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, teamProject.id, 'credential:user');
|
||||
|
||||
const sharingOptions = {
|
||||
scopes: ['credential:read'] as Scope[],
|
||||
projectRoles: ['project:editor'],
|
||||
credentialRoles: ['credential:user'],
|
||||
};
|
||||
|
||||
// ACT
|
||||
const result = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
sharingOptions,
|
||||
{},
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(result.credentials).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['Team Credential 1', 'Team Credential 2']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle personal project filtering correctly', async () => {
|
||||
// ARRANGE
|
||||
const { createOwner } = await import('../../shared/db/users');
|
||||
const { getPersonalProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const owner = await createOwner();
|
||||
const personalProject = await getPersonalProject(owner);
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Personal Credential 1', type: 'githubApi', data: '' }),
|
||||
createCredentials({ name: 'Personal Credential 2', type: 'googleApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, personalProject.id, 'credential:owner');
|
||||
|
||||
// ACT
|
||||
const result = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
owner,
|
||||
{ isPersonalProject: true, personalProjectOwnerId: owner.id },
|
||||
{ filter: { projectId: personalProject.id } },
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(result.credentials).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle onlySharedWithMe filter correctly', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { getPersonalProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const member = await createMember();
|
||||
const memberPersonalProject = await getPersonalProject(member);
|
||||
|
||||
const sharedCredential = await createCredentials({
|
||||
name: 'Shared Credential',
|
||||
type: 'slackApi',
|
||||
data: '',
|
||||
});
|
||||
await shareCredentialsToProject(
|
||||
[sharedCredential],
|
||||
memberPersonalProject.id,
|
||||
'credential:user',
|
||||
);
|
||||
|
||||
// ACT
|
||||
const result = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ onlySharedWithMe: true },
|
||||
{},
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(result.credentials).toHaveLength(1);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.credentials[0].name).toBe('Shared Credential');
|
||||
});
|
||||
|
||||
it('should apply name filter correctly with subquery approach', async () => {
|
||||
// ARRANGE
|
||||
const { createOwner } = await import('../../shared/db/users');
|
||||
const { getPersonalProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const owner = await createOwner();
|
||||
const personalProject = await getPersonalProject(owner);
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Test Credential Alpha', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Test Credential Beta', type: 'slackApi', data: '' }),
|
||||
createCredentials({ name: 'Production Credential', type: 'githubApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, personalProject.id, 'credential:owner');
|
||||
|
||||
// ACT
|
||||
const result = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
owner,
|
||||
{ isPersonalProject: true, personalProjectOwnerId: owner.id },
|
||||
{ filter: { projectId: personalProject.id, name: 'Test' } },
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(result.credentials).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['Test Credential Alpha', 'Test Credential Beta']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply type filter correctly with subquery approach', async () => {
|
||||
// ARRANGE
|
||||
const { createOwner } = await import('../../shared/db/users');
|
||||
const { getPersonalProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const owner = await createOwner();
|
||||
const personalProject = await getPersonalProject(owner);
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Google Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Google Credential 2', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Slack Credential', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, personalProject.id, 'credential:owner');
|
||||
|
||||
// ACT
|
||||
const result = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
owner,
|
||||
{ isPersonalProject: true, personalProjectOwnerId: owner.id },
|
||||
{ filter: { projectId: personalProject.id, type: 'google', data: '' } },
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(result.credentials).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['Google Credential 1', 'Google Credential 2']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pagination correctly with subquery approach', async () => {
|
||||
// ARRANGE
|
||||
const { createOwner } = await import('../../shared/db/users');
|
||||
const { getPersonalProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const owner = await createOwner();
|
||||
const personalProject = await getPersonalProject(owner);
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Credential 2', type: 'slackApi', data: '' }),
|
||||
createCredentials({ name: 'Credential 3', type: 'githubApi', data: '' }),
|
||||
createCredentials({ name: 'Credential 4', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Credential 5', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, personalProject.id, 'credential:owner');
|
||||
|
||||
const sharingOptions = { isPersonalProject: true, personalProjectOwnerId: owner.id };
|
||||
|
||||
// ACT
|
||||
const page1 = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
owner,
|
||||
sharingOptions,
|
||||
{
|
||||
filter: { projectId: personalProject.id },
|
||||
take: 2,
|
||||
skip: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const page2 = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
owner,
|
||||
sharingOptions,
|
||||
{
|
||||
filter: { projectId: personalProject.id },
|
||||
take: 2,
|
||||
skip: 2,
|
||||
},
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(page1.credentials).toHaveLength(2);
|
||||
expect(page1.count).toBe(5);
|
||||
expect(page2.credentials).toHaveLength(2);
|
||||
expect(page2.count).toBe(5);
|
||||
|
||||
// Ensure different credentials in each page
|
||||
const page1Ids = page1.credentials.map((c) => c.id);
|
||||
const page2Ids = page2.credentials.map((c) => c.id);
|
||||
expect(page1Ids).not.toEqual(expect.arrayContaining(page2Ids));
|
||||
});
|
||||
|
||||
it('should correctly filter credentials by project when credentials belong to multiple projects', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const member = await createMember();
|
||||
const projectA = await createTeamProject('Project A');
|
||||
const projectB = await createTeamProject('Project B');
|
||||
await linkUserToProject(member, projectA, 'project:editor');
|
||||
await linkUserToProject(member, projectB, 'project:editor');
|
||||
|
||||
// Create credentials and share to both projects
|
||||
const sharedCredential = await createCredentials({
|
||||
name: 'Shared Credential',
|
||||
type: 'googleApi',
|
||||
data: '',
|
||||
});
|
||||
const projectAOnlyCredential = await createCredentials({
|
||||
name: 'Project A Credential',
|
||||
type: 'slackApi',
|
||||
data: '',
|
||||
});
|
||||
const projectBOnlyCredential = await createCredentials({
|
||||
name: 'Project B Credential',
|
||||
type: 'githubApi',
|
||||
data: '',
|
||||
});
|
||||
|
||||
await shareCredentialsToProject(
|
||||
[sharedCredential, projectAOnlyCredential],
|
||||
projectA.id,
|
||||
'credential:user',
|
||||
);
|
||||
await shareCredentialsToProject(
|
||||
[sharedCredential, projectBOnlyCredential],
|
||||
projectB.id,
|
||||
'credential:user',
|
||||
);
|
||||
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
const projectRoles = ['project:editor'];
|
||||
const credentialRoles = ['credential:user'];
|
||||
|
||||
// ACT - Filter by project A using new approach
|
||||
const newResultA = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{ filter: { projectId: projectA.id } },
|
||||
);
|
||||
|
||||
// ACT - Filter by project B using new approach
|
||||
const newResultB = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{ filter: { projectId: projectB.id } },
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(newResultA.credentials).toHaveLength(2);
|
||||
expect(newResultA.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['Shared Credential', 'Project A Credential']),
|
||||
);
|
||||
|
||||
expect(newResultB.credentials).toHaveLength(2);
|
||||
expect(newResultB.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['Shared Credential', 'Project B Credential']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly isolate credentials by user - each user sees only their credentials', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
|
||||
const userA = await createMember();
|
||||
const userB = await createMember();
|
||||
const projectA = await createTeamProject('User A Project');
|
||||
const projectB = await createTeamProject('User B Project');
|
||||
await linkUserToProject(userA, projectA, 'project:editor');
|
||||
await linkUserToProject(userB, projectB, 'project:editor');
|
||||
|
||||
// Create credentials for each user
|
||||
const userACredentials = await Promise.all([
|
||||
createCredentials({ name: 'User A Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'User A Credential 2', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
const userBCredentials = await Promise.all([
|
||||
createCredentials({ name: 'User B Credential 1', type: 'githubApi', data: '' }),
|
||||
createCredentials({ name: 'User B Credential 2', type: 'googleApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(userACredentials, projectA.id, 'credential:user');
|
||||
await shareCredentialsToProject(userBCredentials, projectB.id, 'credential:user');
|
||||
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
const projectRoles = ['project:editor'];
|
||||
const credentialRoles = ['credential:user'];
|
||||
|
||||
// ACT - Query credentials for User A (new approach)
|
||||
const newResultA = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
userA,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{},
|
||||
);
|
||||
|
||||
// ACT - Query credentials for User B (new approach)
|
||||
const newResultB = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
userB,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{},
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(newResultA.credentials).toHaveLength(2);
|
||||
expect(newResultA.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['User A Credential 1', 'User A Credential 2']),
|
||||
);
|
||||
|
||||
expect(newResultB.credentials).toHaveLength(2);
|
||||
expect(newResultB.credentials.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['User B Credential 1', 'User B Credential 2']),
|
||||
);
|
||||
|
||||
// Verify no overlap
|
||||
const credentialAIds = newResultA.credentials.map((c) => c.id);
|
||||
const credentialBIds = newResultB.credentials.map((c) => c.id);
|
||||
expect(credentialAIds).not.toEqual(expect.arrayContaining(credentialBIds));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison: Old vs New Approach', () => {
|
||||
let credentialsRepository: CredentialsRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'SharedCredentials',
|
||||
'ProjectRelation',
|
||||
'CredentialsEntity',
|
||||
'Project',
|
||||
'User',
|
||||
]);
|
||||
credentialsRepository = Container.get(CredentialsRepository);
|
||||
});
|
||||
|
||||
it('should return identical results for standard user with both approaches', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
const { CredentialsFinderService } = await import('@/credentials/credentials-finder.service');
|
||||
const { RoleService } = await import('@/services/role.service');
|
||||
|
||||
const member = await createMember();
|
||||
const teamProject = await createTeamProject('test-project');
|
||||
await linkUserToProject(member, teamProject, 'project:editor');
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Credential A', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Credential B', type: 'slackApi', data: '' }),
|
||||
createCredentials({ name: 'Credential C', type: 'githubApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, teamProject.id, 'credential:user');
|
||||
|
||||
const roleService = Container.get(RoleService);
|
||||
const credentialsFinderService = Container.get(CredentialsFinderService);
|
||||
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
const projectRoles = await roleService.rolesWithScope('project', scopes);
|
||||
const credentialRoles = await roleService.rolesWithScope('credential', scopes);
|
||||
|
||||
// ACT - Old Approach (pre-fetch IDs then query)
|
||||
const credentialIds = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[member.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResult = await credentialsRepository.findManyAndCount({}, credentialIds);
|
||||
|
||||
// ACT - New Approach (subquery)
|
||||
const newResult = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{},
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(newResult.count).toBe(oldResult[1]);
|
||||
expect(newResult.credentials).toHaveLength(oldResult[0].length);
|
||||
expectCredentialsMatch(oldResult[0], newResult.credentials);
|
||||
});
|
||||
|
||||
it('should return identical results for personal project with both approaches', async () => {
|
||||
// ARRANGE
|
||||
const { createOwner } = await import('../../shared/db/users');
|
||||
const { getPersonalProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
const { CredentialsFinderService } = await import('@/credentials/credentials-finder.service');
|
||||
|
||||
const owner = await createOwner();
|
||||
const personalProject = await getPersonalProject(owner);
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Personal A', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Personal B', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, personalProject.id, 'credential:owner');
|
||||
|
||||
const credentialsFinderService = Container.get(CredentialsFinderService);
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
|
||||
// ACT - Old Approach
|
||||
const credentialIds = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[owner.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResult = await credentialsRepository.findManyAndCount(
|
||||
{ filter: { projectId: personalProject.id } },
|
||||
credentialIds,
|
||||
);
|
||||
|
||||
// ACT - New Approach
|
||||
const newResult = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
owner,
|
||||
{ isPersonalProject: true, personalProjectOwnerId: owner.id },
|
||||
{ filter: { projectId: personalProject.id } },
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(newResult.count).toBe(oldResult[1]);
|
||||
expect(newResult.credentials).toHaveLength(oldResult[0].length);
|
||||
expectCredentialsMatch(oldResult[0], newResult.credentials);
|
||||
});
|
||||
|
||||
it('should return identical results with filters and pagination', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
const { CredentialsFinderService } = await import('@/credentials/credentials-finder.service');
|
||||
const { RoleService } = await import('@/services/role.service');
|
||||
|
||||
const member = await createMember();
|
||||
const teamProject = await createTeamProject('test-project');
|
||||
await linkUserToProject(member, teamProject, 'project:editor');
|
||||
|
||||
const credentials = await Promise.all([
|
||||
createCredentials({ name: 'Alpha Test', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Beta Test', type: 'slackApi', data: '' }),
|
||||
createCredentials({ name: 'Gamma Production', type: 'githubApi', data: '' }),
|
||||
createCredentials({ name: 'Delta Test', type: 'googleApi', data: '' }),
|
||||
]);
|
||||
|
||||
await shareCredentialsToProject(credentials, teamProject.id, 'credential:user');
|
||||
|
||||
const roleService = Container.get(RoleService);
|
||||
const credentialsFinderService = Container.get(CredentialsFinderService);
|
||||
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
const projectRoles = await roleService.rolesWithScope('project', scopes);
|
||||
const credentialRoles = await roleService.rolesWithScope('credential', scopes);
|
||||
|
||||
const oldOptions = {
|
||||
filter: { projectId: teamProject.id, name: 'Test' },
|
||||
take: 2,
|
||||
skip: 0,
|
||||
};
|
||||
|
||||
const newOptions = {
|
||||
filter: { name: 'Test' },
|
||||
take: 2,
|
||||
skip: 0,
|
||||
};
|
||||
|
||||
// ACT - Old Approach
|
||||
const credentialIds = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[member.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResult = await credentialsRepository.findManyAndCount(oldOptions, credentialIds);
|
||||
|
||||
// ACT - New Approach (projectId filter already handled in subquery, so don't pass it again)
|
||||
const newResult = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
newOptions,
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(newResult.count).toBe(oldResult[1]);
|
||||
expect(newResult.credentials).toHaveLength(oldResult[0].length);
|
||||
|
||||
// Check same credentials in same order (sorting should be consistent)
|
||||
const oldIds = oldResult[0].map((c) => c.id);
|
||||
const newIds = newResult.credentials.map((c) => c.id);
|
||||
expect(newIds).toEqual(oldIds);
|
||||
});
|
||||
|
||||
it('should correctly filter credentials by project - old vs new comparison', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
const { CredentialsFinderService } = await import('@/credentials/credentials-finder.service');
|
||||
const { RoleService } = await import('@/services/role.service');
|
||||
|
||||
const member = await createMember();
|
||||
|
||||
// Create two different projects
|
||||
const projectA = await createTeamProject('project-a');
|
||||
const projectB = await createTeamProject('project-b');
|
||||
await linkUserToProject(member, projectA, 'project:editor');
|
||||
await linkUserToProject(member, projectB, 'project:editor');
|
||||
|
||||
// Create credentials in project A
|
||||
const credentialsA = await Promise.all([
|
||||
createCredentials({ name: 'Project A Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Project A Credential 2', type: 'slackApi', data: '' }),
|
||||
createCredentials({ name: 'Project A Credential 3', type: 'githubApi', data: '' }),
|
||||
]);
|
||||
await shareCredentialsToProject(credentialsA, projectA.id, 'credential:user');
|
||||
|
||||
// Create credentials in project B
|
||||
const credentialsB = await Promise.all([
|
||||
createCredentials({ name: 'Project B Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'Project B Credential 2', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
await shareCredentialsToProject(credentialsB, projectB.id, 'credential:user');
|
||||
|
||||
const roleService = Container.get(RoleService);
|
||||
const credentialsFinderService = Container.get(CredentialsFinderService);
|
||||
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
const projectRoles = await roleService.rolesWithScope('project', scopes);
|
||||
const credentialRoles = await roleService.rolesWithScope('credential', scopes);
|
||||
|
||||
// ACT - Filter by project A using old approach
|
||||
const credentialIdsA = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[member.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResultA = await credentialsRepository.findManyAndCount(
|
||||
{ filter: { projectId: projectA.id } },
|
||||
credentialIdsA,
|
||||
);
|
||||
|
||||
// ACT - Filter by project A using new approach
|
||||
const newResultA = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{ filter: { projectId: projectA.id } },
|
||||
);
|
||||
|
||||
// ACT - Filter by project B using old approach
|
||||
const credentialIdsB = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[member.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResultB = await credentialsRepository.findManyAndCount(
|
||||
{ filter: { projectId: projectB.id } },
|
||||
credentialIdsB,
|
||||
);
|
||||
|
||||
// ACT - Filter by project B using new approach
|
||||
const newResultB = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
member,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{ filter: { projectId: projectB.id } },
|
||||
);
|
||||
|
||||
// ASSERT - Project A results
|
||||
expect(newResultA.count).toBe(3);
|
||||
expect(oldResultA[1]).toBe(3);
|
||||
expect(newResultA.credentials).toHaveLength(3);
|
||||
expectCredentialsMatch(oldResultA[0], newResultA.credentials);
|
||||
|
||||
// ASSERT - Project B results
|
||||
expect(newResultB.count).toBe(2);
|
||||
expect(oldResultB[1]).toBe(2);
|
||||
expect(newResultB.credentials).toHaveLength(2);
|
||||
expectCredentialsMatch(oldResultB[0], newResultB.credentials);
|
||||
});
|
||||
|
||||
it('should correctly isolate credentials by user - old vs new comparison', async () => {
|
||||
// ARRANGE
|
||||
const { createMember } = await import('../../shared/db/users');
|
||||
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
|
||||
const { createCredentials } = await import('../../shared/db/credentials');
|
||||
const { CredentialsFinderService } = await import('@/credentials/credentials-finder.service');
|
||||
const { RoleService } = await import('@/services/role.service');
|
||||
|
||||
// Create two separate users
|
||||
const userA = await createMember();
|
||||
const userB = await createMember();
|
||||
|
||||
// Create separate projects for each user
|
||||
const projectA = await createTeamProject('user-a-project');
|
||||
const projectB = await createTeamProject('user-b-project');
|
||||
await linkUserToProject(userA, projectA, 'project:editor');
|
||||
await linkUserToProject(userB, projectB, 'project:editor');
|
||||
|
||||
// Create credentials for User A
|
||||
const credentialsUserA = await Promise.all([
|
||||
createCredentials({ name: 'User A Credential 1', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'User A Credential 2', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
await shareCredentialsToProject(credentialsUserA, projectA.id, 'credential:user');
|
||||
|
||||
// Create credentials for User B
|
||||
const credentialsUserB = await Promise.all([
|
||||
createCredentials({ name: 'User B Credential 1', type: 'githubApi', data: '' }),
|
||||
createCredentials({ name: 'User B Credential 2', type: 'googleApi', data: '' }),
|
||||
createCredentials({ name: 'User B Credential 3', type: 'slackApi', data: '' }),
|
||||
]);
|
||||
await shareCredentialsToProject(credentialsUserB, projectB.id, 'credential:user');
|
||||
|
||||
const roleService = Container.get(RoleService);
|
||||
const credentialsFinderService = Container.get(CredentialsFinderService);
|
||||
|
||||
const scopes: Scope[] = ['credential:read'];
|
||||
const projectRoles = await roleService.rolesWithScope('project', scopes);
|
||||
const credentialRoles = await roleService.rolesWithScope('credential', scopes);
|
||||
|
||||
// ACT - Query credentials for User A (old approach)
|
||||
const credentialIdsA = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[userA.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResultA = await credentialsRepository.findManyAndCount({}, credentialIdsA);
|
||||
|
||||
// ACT - Query credentials for User A (new approach)
|
||||
const newResultA = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
userA,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{},
|
||||
);
|
||||
|
||||
// ACT - Query credentials for User B (old approach)
|
||||
const credentialIdsB = await credentialsFinderService.getCredentialIdsByUserAndRole(
|
||||
[userB.id],
|
||||
{ scopes },
|
||||
);
|
||||
const oldResultB = await credentialsRepository.findManyAndCount({}, credentialIdsB);
|
||||
|
||||
// ACT - Query credentials for User B (new approach)
|
||||
const newResultB = await credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
userB,
|
||||
{ scopes, projectRoles, credentialRoles },
|
||||
{},
|
||||
);
|
||||
|
||||
// ASSERT - User A should only see their 2 credentials
|
||||
expect(newResultA.count).toBe(2);
|
||||
expect(oldResultA[1]).toBe(2);
|
||||
expect(newResultA.credentials).toHaveLength(2);
|
||||
expectCredentialsMatch(oldResultA[0], newResultA.credentials);
|
||||
|
||||
// ASSERT - User B should only see their 3 credentials
|
||||
expect(newResultB.count).toBe(3);
|
||||
expect(oldResultB[1]).toBe(3);
|
||||
expect(newResultB.credentials).toHaveLength(3);
|
||||
expectCredentialsMatch(oldResultB[0], newResultB.credentials);
|
||||
|
||||
// ASSERT - Verify no cross-contamination: User A credentials should not appear in User B results
|
||||
const userBCredentialIds = newResultB.credentials.map((c) => c.id);
|
||||
const userACredentialIds = credentialsUserA.map((c) => c.id);
|
||||
const contamination = userACredentialIds.filter((id) => userBCredentialIds.includes(id));
|
||||
expect(contamination).toHaveLength(0);
|
||||
|
||||
// ASSERT - Verify no cross-contamination: User B credentials should not appear in User A results
|
||||
const userAResultIds = newResultA.credentials.map((c) => c.id);
|
||||
const userBCredentialIdsOriginal = credentialsUserB.map((c) => c.id);
|
||||
const reverseContamination = userBCredentialIdsOriginal.filter((id) =>
|
||||
userAResultIds.includes(id),
|
||||
);
|
||||
expect(reverseContamination).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createWorkflow, testDb } from '@n8n/backend-test-utils';
|
||||
import { ExecutionDataRepository, ExecutionRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { stringify } from 'flatted';
|
||||
import type { IRunExecutionData, IRunExecutionDataAll } from 'n8n-workflow';
|
||||
|
||||
describe('ExecutionRepository', () => {
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'ExecutionEntity']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('run execution data migration', () => {
|
||||
it('should automatically migrate IRunExecutionDataV0 to V1 when reading', async () => {
|
||||
const executionRepo = Container.get(ExecutionRepository);
|
||||
const executionDataRepo = Container.get(ExecutionDataRepository);
|
||||
const workflow = await createWorkflow({ settings: { executionOrder: 'v1' } });
|
||||
|
||||
// Create V0 data with string destinationNode
|
||||
const v0Data: IRunExecutionDataAll = {
|
||||
version: 0,
|
||||
startData: { destinationNode: 'TestNode' },
|
||||
resultData: { runData: {} },
|
||||
};
|
||||
|
||||
// Insert execution with V0 data directly into the database
|
||||
const { identifiers } = await executionRepo.insert({
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'success',
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const executionId = identifiers[0].id as string;
|
||||
await executionDataRepo.insert({
|
||||
executionId,
|
||||
workflowData: { id: workflow.id, connections: {}, nodes: [], name: workflow.name },
|
||||
data: stringify(v0Data),
|
||||
});
|
||||
|
||||
// Read the execution back
|
||||
const execution = await executionRepo.findSingleExecution(executionId, {
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
});
|
||||
|
||||
// Verify that the data was migrated to V1
|
||||
const data = execution?.data as IRunExecutionData;
|
||||
expect(data.version).toBe(1);
|
||||
expect(data.startData?.destinationNode).toEqual({
|
||||
nodeName: 'TestNode',
|
||||
mode: 'inclusive',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('findByStopExecutionsFilter', () => {
|
||||
it('should find executions by status', async () => {
|
||||
const executionRepo = Container.get(ExecutionRepository);
|
||||
const workflow = await createWorkflow();
|
||||
|
||||
// Insert executions with different statuses
|
||||
await executionRepo.insert([
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'running',
|
||||
finished: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'success',
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'error',
|
||||
finished: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Find executions with status 'running' and 'error'
|
||||
const executions = await executionRepo.findByStopExecutionsFilter({
|
||||
status: ['running', 'error'],
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(executions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should find executions by startedAfter and startedBefore', async () => {
|
||||
const executionRepo = Container.get(ExecutionRepository);
|
||||
const workflow = await createWorkflow();
|
||||
|
||||
// Insert executions with different start times
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago
|
||||
const futureDate = new Date(now.getTime() + 1000 * 60 * 60); // 1 hour later
|
||||
|
||||
await executionRepo.insert([
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: pastDate,
|
||||
status: 'running',
|
||||
finished: false,
|
||||
createdAt: pastDate,
|
||||
},
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: now,
|
||||
status: 'success',
|
||||
finished: true,
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
mode: 'manual',
|
||||
startedAt: futureDate,
|
||||
status: 'error',
|
||||
finished: false,
|
||||
createdAt: futureDate,
|
||||
},
|
||||
]);
|
||||
|
||||
// Find executions started between pastDate and now
|
||||
const executions = await executionRepo.findByStopExecutionsFilter({
|
||||
startedAfter: new Date(pastDate.getTime() + 1).toISOString(),
|
||||
startedBefore: new Date(futureDate.getTime() - 1).toISOString(),
|
||||
status: ['running', 'success', 'error'],
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(executions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should find executions for all workflows when workflowId is "all"', async () => {
|
||||
const executionRepo = Container.get(ExecutionRepository);
|
||||
const workflow1 = await createWorkflow();
|
||||
const workflow2 = await createWorkflow();
|
||||
|
||||
// Insert executions for different workflows
|
||||
await executionRepo.insert([
|
||||
{
|
||||
workflowId: workflow1.id,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'running',
|
||||
finished: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
workflowId: workflow2.id,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'success',
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Find executions for all workflows
|
||||
const executions = await executionRepo.findByStopExecutionsFilter({
|
||||
status: ['running', 'success'],
|
||||
workflowId: 'all',
|
||||
});
|
||||
|
||||
expect(executions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createTeamProject, testDb } from '@n8n/backend-test-utils';
|
||||
import { AuthIdentity, ProjectRepository, UserRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { EntityNotFoundError } from '@n8n/typeorm';
|
||||
|
||||
import { createMember, createOwner } from '../../shared/db/users';
|
||||
|
||||
describe('ProjectRepository', () => {
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User', 'WorkflowEntity', 'Project']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('getPersonalProjectForUser', () => {
|
||||
it('returns the personal project', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const owner = await createOwner();
|
||||
const ownerPersonalProject = await Container.get(ProjectRepository).findOneByOrFail({
|
||||
projectRelations: { userId: owner.id },
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUser(
|
||||
owner.id,
|
||||
);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
if (!personalProject) {
|
||||
fail('Expected personalProject to be defined.');
|
||||
}
|
||||
expect(personalProject).toBeDefined();
|
||||
expect(personalProject.id).toBe(ownerPersonalProject.id);
|
||||
});
|
||||
|
||||
it('does not return non personal projects', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const owner = await createOwner();
|
||||
await Container.get(ProjectRepository).delete({});
|
||||
await createTeamProject(undefined, owner);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUser(
|
||||
owner.id,
|
||||
);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(personalProject).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPersonalProjectForUserOrFail', () => {
|
||||
it('returns the personal project', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const owner = await createOwner();
|
||||
const ownerPersonalProject = await Container.get(ProjectRepository).findOneByOrFail({
|
||||
projectRelations: { userId: owner.id },
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const personalProject = await Container.get(
|
||||
ProjectRepository,
|
||||
).getPersonalProjectForUserOrFail(owner.id);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
if (!personalProject) {
|
||||
fail('Expected personalProject to be defined.');
|
||||
}
|
||||
expect(personalProject).toBeDefined();
|
||||
expect(personalProject.id).toBe(ownerPersonalProject.id);
|
||||
});
|
||||
|
||||
it('does not return non personal projects', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const owner = await createOwner();
|
||||
await Container.get(ProjectRepository).delete({});
|
||||
await createTeamProject(undefined, owner);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const promise = Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
await expect(promise).rejects.toThrowError(EntityNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update personal project name', () => {
|
||||
// TypeORM enters an infinite loop if you create entities with circular
|
||||
// references and pass this to the `Repository.create` function.
|
||||
//
|
||||
// This actually happened in combination with SAML.
|
||||
// `samlHelpers.updateUserFromSamlAttributes` and
|
||||
// `samlHelpers.createUserFromSamlAttributes` would create a User and an
|
||||
// AuthIdentity and assign them to one another. Then it would call
|
||||
// `UserRepository.save(user)`. This would then call the UserSubscriber in
|
||||
// `database/entities/Project.ts` which would pass the circular User into
|
||||
// `UserRepository.create` and cause the infinite loop.
|
||||
//
|
||||
// This test simulates that behavior and makes sure the UserSubscriber
|
||||
// checks if the entity is already a user and does not pass it into
|
||||
// `UserRepository.create` in that case.
|
||||
test('do not pass a User instance with circular references into `UserRepository.create`', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const user = await createMember();
|
||||
|
||||
const authIdentity = new AuthIdentity();
|
||||
authIdentity.providerId = user.email;
|
||||
authIdentity.providerType = 'saml';
|
||||
authIdentity.user = user;
|
||||
|
||||
user.firstName = `updated ${user.firstName}`;
|
||||
user.authIdentities = [];
|
||||
user.authIdentities.push(authIdentity);
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await expect(Container.get(UserRepository).save(user)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,783 @@
|
||||
import { testDb, linkUserToProject, createTeamProject } from '@n8n/backend-test-utils';
|
||||
import { AuthRolesService, RoleRepository, ScopeRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import {
|
||||
createRole,
|
||||
createSystemRole,
|
||||
createCustomRoleWithScopes,
|
||||
createTestScopes,
|
||||
} from '../../shared/db/roles';
|
||||
import { createUser } from '../../shared/db/users';
|
||||
|
||||
describe('RoleRepository', () => {
|
||||
let roleRepository: RoleRepository;
|
||||
let scopeRepository: ScopeRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
roleRepository = Container.get(RoleRepository);
|
||||
scopeRepository = Container.get(ScopeRepository);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Truncate in the correct order to respect foreign key constraints
|
||||
// user table references role via roleSlug
|
||||
// ProjectRelation references role
|
||||
await testDb.truncate(['User', 'ProjectRelation', 'Project', 'Role', 'Scope']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('findAll()', () => {
|
||||
it('should return empty array when no roles exist', async () => {
|
||||
//
|
||||
// ARRANGE & ACT
|
||||
//
|
||||
const roles = await roleRepository.findAll();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all roles when roles exist', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const role1 = await createRole({ slug: 'test-role-1', displayName: 'Role 1' });
|
||||
const role2 = await createRole({ slug: 'test-role-2', displayName: 'Role 2' });
|
||||
const role3 = await createSystemRole({ slug: 'system-role-1', displayName: 'System Role' });
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleRepository.findAll();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toHaveLength(3);
|
||||
expect(roles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: role1.slug, displayName: role1.displayName }),
|
||||
expect.objectContaining({ slug: role2.slug, displayName: role2.displayName }),
|
||||
expect.objectContaining({ slug: role3.slug, displayName: role3.displayName }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return roles with their eager-loaded scopes', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope } = await createTestScopes();
|
||||
await createCustomRoleWithScopes([readScope, writeScope], {
|
||||
slug: 'test-role-with-scopes',
|
||||
displayName: 'Role With Scopes',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleRepository.findAll();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toHaveLength(1);
|
||||
expect(roles[0].scopes).toHaveLength(2);
|
||||
expect(roles[0].scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBySlug()', () => {
|
||||
it('should return null when role does not exist', async () => {
|
||||
//
|
||||
// ARRANGE & ACT
|
||||
//
|
||||
const role = await roleRepository.findBySlug('non-existent-role');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(role).toBeNull();
|
||||
});
|
||||
|
||||
it('should return role when it exists', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const createdRole = await createRole({
|
||||
slug: 'test-find-role',
|
||||
displayName: 'Test Find Role',
|
||||
description: 'A role for testing findBySlug',
|
||||
roleType: 'project',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const foundRole = await roleRepository.findBySlug('test-find-role');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(foundRole).not.toBeNull();
|
||||
expect(foundRole!.slug).toBe(createdRole.slug);
|
||||
expect(foundRole!.displayName).toBe(createdRole.displayName);
|
||||
expect(foundRole!.description).toBe(createdRole.description);
|
||||
expect(foundRole!.roleType).toBe(createdRole.roleType);
|
||||
});
|
||||
|
||||
it('should return role with eager-loaded scopes', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope, adminScope } = await createTestScopes();
|
||||
await createCustomRoleWithScopes([readScope, writeScope, adminScope], {
|
||||
slug: 'test-role-with-all-scopes',
|
||||
displayName: 'Role With All Scopes',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const foundRole = await roleRepository.findBySlug('test-role-with-all-scopes');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(foundRole).not.toBeNull();
|
||||
expect(foundRole!.scopes).toHaveLength(3);
|
||||
expect(foundRole!.scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
expect.objectContaining({ slug: adminScope.slug }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find system roles correctly', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const systemRole = await createSystemRole({
|
||||
slug: 'system-test-role',
|
||||
displayName: 'System Test Role',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const foundRole = await roleRepository.findBySlug('system-test-role');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(foundRole).not.toBeNull();
|
||||
expect(foundRole!.systemRole).toBe(true);
|
||||
expect(foundRole!.slug).toBe(systemRole.slug);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBySlug()', () => {
|
||||
it('should successfully remove existing role', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createRole({
|
||||
slug: 'role-to-delete',
|
||||
displayName: 'Role To Delete',
|
||||
});
|
||||
|
||||
// Verify role exists
|
||||
let foundRole = await roleRepository.findBySlug('role-to-delete');
|
||||
expect(foundRole).not.toBeNull();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
await roleRepository.removeBySlug('role-to-delete');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
foundRole = await roleRepository.findBySlug('role-to-delete');
|
||||
expect(foundRole).toBeNull();
|
||||
|
||||
// Verify it's removed from database
|
||||
const allRoles = await roleRepository.findAll();
|
||||
expect(allRoles.find((r) => r.slug === 'role-to-delete')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when trying to remove non-existent role', async () => {
|
||||
//
|
||||
// ARRANGE & ACT & ASSERT
|
||||
//
|
||||
await expect(roleRepository.removeBySlug('non-existent-role')).rejects.toThrow(
|
||||
'Failed to delete role "non-existent-role"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove role with associated scopes (many-to-many relationship)', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope } = await createTestScopes();
|
||||
await createCustomRoleWithScopes([readScope, writeScope], {
|
||||
slug: 'role-with-scopes-to-delete',
|
||||
displayName: 'Role With Scopes To Delete',
|
||||
});
|
||||
|
||||
// Verify role and scopes exist
|
||||
let foundRole = await roleRepository.findBySlug('role-with-scopes-to-delete');
|
||||
expect(foundRole).not.toBeNull();
|
||||
expect(foundRole!.scopes).toHaveLength(2);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
await roleRepository.removeBySlug('role-with-scopes-to-delete');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
foundRole = await roleRepository.findBySlug('role-with-scopes-to-delete');
|
||||
expect(foundRole).toBeNull();
|
||||
|
||||
// Verify scopes still exist (should not cascade delete)
|
||||
const foundScopes = await scopeRepository.findByList([readScope.slug, writeScope.slug]);
|
||||
expect(foundScopes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRole()', () => {
|
||||
describe('transaction handling', () => {
|
||||
it('should use transactions', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createRole({
|
||||
slug: 'role-for-transaction-test',
|
||||
displayName: 'Original Name',
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
// Spy on transaction method to verify it's called
|
||||
const transactionSpy = jest.spyOn(roleRepository.manager, 'transaction');
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-transaction-test', {
|
||||
displayName: 'Updated Name',
|
||||
description: 'Updated Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(transactionSpy).toHaveBeenCalled();
|
||||
expect(updatedRole.displayName).toBe('Updated Name');
|
||||
expect(updatedRole.description).toBe('Updated Description');
|
||||
|
||||
transactionSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful updates', () => {
|
||||
it('should update role displayName', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createRole({
|
||||
slug: 'role-for-name-update',
|
||||
displayName: 'Original Name',
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-name-update', {
|
||||
displayName: 'New Display Name',
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.displayName).toBe('New Display Name');
|
||||
expect(updatedRole.description).toBe('Original Description'); // Should remain unchanged
|
||||
expect(updatedRole.slug).toBe('role-for-name-update'); // Should remain unchanged
|
||||
|
||||
// Verify in database
|
||||
const foundRole = await roleRepository.findBySlug('role-for-name-update');
|
||||
expect(foundRole!.displayName).toBe('New Display Name');
|
||||
});
|
||||
|
||||
it('should update role description', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createRole({
|
||||
slug: 'role-for-desc-update',
|
||||
displayName: 'Test Role',
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-desc-update', {
|
||||
description: 'New Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.description).toBe('New Description');
|
||||
expect(updatedRole.displayName).toBe('Test Role'); // Should remain unchanged
|
||||
|
||||
// Verify in database
|
||||
const foundRole = await roleRepository.findBySlug('role-for-desc-update');
|
||||
expect(foundRole!.description).toBe('New Description');
|
||||
});
|
||||
|
||||
it('should update role scopes', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
||||
await createCustomRoleWithScopes([readScope, writeScope], {
|
||||
slug: 'role-for-scope-update',
|
||||
displayName: 'Role For Scope Update',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-scope-update', {
|
||||
scopes: [deleteScope, adminScope],
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.scopes).toHaveLength(2);
|
||||
expect(updatedRole.scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: deleteScope.slug }),
|
||||
expect.objectContaining({ slug: adminScope.slug }),
|
||||
]),
|
||||
);
|
||||
|
||||
// Verify in database
|
||||
const foundRole = await roleRepository.findBySlug('role-for-scope-update');
|
||||
expect(foundRole!.scopes).toHaveLength(2);
|
||||
expect(foundRole!.scopes.map((s) => s.slug)).toEqual(
|
||||
expect.arrayContaining([deleteScope.slug, adminScope.slug]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update multiple fields simultaneously', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope } = await createTestScopes();
|
||||
await createRole({
|
||||
slug: 'role-for-multi-update',
|
||||
displayName: 'Original Name',
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-multi-update', {
|
||||
displayName: 'Updated Name',
|
||||
description: 'Updated Description',
|
||||
scopes: [readScope],
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.displayName).toBe('Updated Name');
|
||||
expect(updatedRole.description).toBe('Updated Description');
|
||||
expect(updatedRole.scopes).toHaveLength(1);
|
||||
expect(updatedRole.scopes[0].slug).toBe(readScope.slug);
|
||||
|
||||
// Verify in database
|
||||
const foundRole = await roleRepository.findBySlug('role-for-multi-update');
|
||||
expect(foundRole!.displayName).toBe('Updated Name');
|
||||
expect(foundRole!.description).toBe('Updated Description');
|
||||
expect(foundRole!.scopes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should set scopes to empty array', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope } = await createTestScopes();
|
||||
await createCustomRoleWithScopes([readScope, writeScope], {
|
||||
slug: 'role-for-empty-scopes',
|
||||
displayName: 'Role With Scopes',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-empty-scopes', {
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.scopes).toEqual([]);
|
||||
|
||||
// Verify in database
|
||||
const foundRole = await roleRepository.findBySlug('role-for-empty-scopes');
|
||||
expect(foundRole!.scopes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system role protection', () => {
|
||||
it('should throw error when trying to update system role', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createSystemRole({
|
||||
slug: 'system-role-protected',
|
||||
displayName: 'Protected System Role',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await expect(
|
||||
roleRepository.updateRole('system-role-protected', {
|
||||
displayName: 'Attempt To Change System Role',
|
||||
}),
|
||||
).rejects.toThrow('Cannot update system roles');
|
||||
});
|
||||
|
||||
it('should not modify system role in database when update fails', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createSystemRole({
|
||||
slug: 'system-role-immutable',
|
||||
displayName: 'Immutable System Role',
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
try {
|
||||
await roleRepository.updateRole('system-role-immutable', {
|
||||
displayName: 'Malicious Change',
|
||||
description: 'Malicious Description',
|
||||
});
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
const foundRole = await roleRepository.findBySlug('system-role-immutable');
|
||||
expect(foundRole!.displayName).toBe('Immutable System Role');
|
||||
expect(foundRole!.description).toBe('Original Description');
|
||||
expect(foundRole!.systemRole).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error scenarios', () => {
|
||||
it('should throw error when role does not exist', async () => {
|
||||
//
|
||||
// ARRANGE & ACT & ASSERT
|
||||
//
|
||||
await expect(
|
||||
roleRepository.updateRole('non-existent-role', {
|
||||
displayName: 'New Name',
|
||||
}),
|
||||
).rejects.toThrow('Role not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null description update', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createRole({
|
||||
slug: 'role-for-null-desc',
|
||||
displayName: 'Role With Description',
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-null-desc', {
|
||||
description: null,
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.description).toBeNull();
|
||||
|
||||
// Verify in database
|
||||
const foundRole = await roleRepository.findBySlug('role-for-null-desc');
|
||||
expect(foundRole!.description).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle update with no changes', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createRole({
|
||||
slug: 'role-for-no-change',
|
||||
displayName: 'Unchanged Role',
|
||||
description: 'Unchanged Description',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-no-change', {});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.displayName).toBe('Unchanged Role');
|
||||
expect(updatedRole.description).toBe('Unchanged Description');
|
||||
});
|
||||
|
||||
it('should handle undefined scope update (no change to scopes)', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope } = await createTestScopes();
|
||||
await createCustomRoleWithScopes([readScope], {
|
||||
slug: 'role-for-undefined-scopes',
|
||||
displayName: 'Role With Scope',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const updatedRole = await roleRepository.updateRole('role-for-undefined-scopes', {
|
||||
displayName: 'Updated Name',
|
||||
scopes: undefined,
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(updatedRole.displayName).toBe('Updated Name');
|
||||
|
||||
// When scopes is undefined, it should not modify scopes, and the returned role should have scopes loaded
|
||||
// However, the updateRole method may not have eager loaded scopes, so let's verify with a fresh fetch
|
||||
const foundRole = await roleRepository.findBySlug('role-for-undefined-scopes');
|
||||
expect(foundRole!.scopes).toHaveLength(1);
|
||||
expect(foundRole!.scopes[0].slug).toBe(readScope.slug);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('countUsersWithRole()', () => {
|
||||
beforeEach(async () => {
|
||||
// make sure to initalize the default roles for user creation
|
||||
await Container.get(AuthRolesService).init();
|
||||
});
|
||||
|
||||
describe('global roles', () => {
|
||||
it('should return 0 when no users have the global role', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const globalRole = await createRole({
|
||||
slug: 'global-empty-role',
|
||||
displayName: 'Global Empty Role',
|
||||
roleType: 'global',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const count = await roleRepository.countUsersWithRole(globalRole);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct count when multiple users have the global role', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const globalRole = await createRole({
|
||||
slug: 'global-multi-role',
|
||||
displayName: 'Global Multi Role',
|
||||
roleType: 'global',
|
||||
});
|
||||
|
||||
await createUser({ role: globalRole });
|
||||
await createUser({ role: globalRole });
|
||||
await createUser({ role: globalRole });
|
||||
|
||||
// Create user with different role to ensure isolation
|
||||
const otherRole = await createRole({
|
||||
slug: 'other-global-role',
|
||||
displayName: 'Other Global Role',
|
||||
roleType: 'global',
|
||||
});
|
||||
await createUser({ role: otherRole });
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const count = await roleRepository.countUsersWithRole(globalRole);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project roles', () => {
|
||||
it('should return 0 when no project relations exist for the project role', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const projectRole = await createRole({
|
||||
slug: 'project-empty-role',
|
||||
displayName: 'Project Empty Role',
|
||||
roleType: 'project',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const count = await roleRepository.countUsersWithRole(projectRole);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct count when multiple users have the project role', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const projectRole = await createRole({
|
||||
slug: 'project-multi-role',
|
||||
displayName: 'Project Multi Role',
|
||||
roleType: 'project',
|
||||
});
|
||||
|
||||
// Create users and projects
|
||||
const user1 = await createUser();
|
||||
const user2 = await createUser();
|
||||
const user3 = await createUser();
|
||||
const project1 = await createTeamProject('Test Project 1');
|
||||
const project2 = await createTeamProject('Test Project 2');
|
||||
|
||||
// Link users to projects with the target role
|
||||
await linkUserToProject(user1, project1, projectRole.slug);
|
||||
await linkUserToProject(user2, project1, projectRole.slug);
|
||||
await linkUserToProject(user3, project2, projectRole.slug);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const count = await roleRepository.countUsersWithRole(projectRole);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it('should only count users with the specific project role slug', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const targetRole = await createRole({
|
||||
slug: 'project-target-role',
|
||||
displayName: 'Project Target Role',
|
||||
roleType: 'project',
|
||||
});
|
||||
|
||||
const otherRole = await createRole({
|
||||
slug: 'project-other-role',
|
||||
displayName: 'Project Other Role',
|
||||
roleType: 'project',
|
||||
});
|
||||
|
||||
const user1 = await createUser();
|
||||
const user2 = await createUser();
|
||||
const user3 = await createUser();
|
||||
const project = await createTeamProject('Test Project');
|
||||
|
||||
// Link users with different roles
|
||||
await linkUserToProject(user1, project, targetRole.slug as any);
|
||||
await linkUserToProject(user2, project, targetRole.slug as any);
|
||||
await linkUserToProject(user3, project, otherRole.slug as any);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const count = await roleRepository.countUsersWithRole(targetRole);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle project roles when query returns null count', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const projectRole = await createRole({
|
||||
slug: 'project-null-count-role',
|
||||
displayName: 'Project Null Count Role',
|
||||
roleType: 'project',
|
||||
});
|
||||
|
||||
// Create a project role but don't link any users to it
|
||||
// This ensures the query returns a row but with null/0 count
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const count = await roleRepository.countUsersWithRole(projectRole);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { type Scope, ScopeRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope as ScopeType } from '@n8n/permissions';
|
||||
|
||||
import { createScope, createScopes, createTestScopes } from '../../shared/db/roles';
|
||||
|
||||
describe('ScopeRepository', () => {
|
||||
let scopeRepository: ScopeRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
scopeRepository = Container.get(ScopeRepository);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Truncate in the correct order to respect foreign key constraints
|
||||
// user table references role via roleSlug
|
||||
// project_relation references role
|
||||
// role_scope references scope, so truncate it first
|
||||
await testDb.truncate(['User', 'ProjectRelation', 'Role', 'Scope']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('findByList()', () => {
|
||||
describe('successful queries', () => {
|
||||
it('should return empty array when given empty slug array', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createTestScopes(); // Create some scopes but don't query for them
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when no scopes exist', async () => {
|
||||
//
|
||||
// ARRANGE & ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList(['non-existent:scope']);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single scope when one slug matches', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([readScope.slug]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: readScope.slug,
|
||||
displayName: readScope.displayName,
|
||||
description: readScope.description,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return multiple scopes when multiple slugs match', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([
|
||||
readScope.slug,
|
||||
writeScope.slug,
|
||||
deleteScope.slug,
|
||||
]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(3);
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
expect.objectContaining({ slug: deleteScope.slug }),
|
||||
]),
|
||||
);
|
||||
|
||||
// Verify adminScope is NOT included
|
||||
expect(scopes.find((s) => s.slug === adminScope.slug)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return all existing scopes when all slugs match', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([
|
||||
readScope.slug,
|
||||
writeScope.slug,
|
||||
deleteScope.slug,
|
||||
adminScope.slug,
|
||||
]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(4);
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
expect.objectContaining({ slug: deleteScope.slug }),
|
||||
expect.objectContaining({ slug: adminScope.slug }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial matches', () => {
|
||||
it('should return only existing scopes when some slugs do not exist', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([
|
||||
readScope.slug,
|
||||
'non-existent:scope:1' as ScopeType,
|
||||
writeScope.slug,
|
||||
'non-existent:scope:2' as ScopeType,
|
||||
]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(2);
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when none of the slugs exist', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
await createTestScopes(); // Create scopes but don't query for them
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([
|
||||
'non-existent:scope:1' as ScopeType,
|
||||
'non-existent:scope:2' as ScopeType,
|
||||
'non-existent:scope:3' as ScopeType,
|
||||
]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate handling', () => {
|
||||
it('should return each scope only once when slug array contains duplicates', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([
|
||||
readScope.slug,
|
||||
writeScope.slug,
|
||||
readScope.slug, // Duplicate
|
||||
writeScope.slug, // Duplicate
|
||||
readScope.slug, // Another duplicate
|
||||
]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(2);
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
]),
|
||||
);
|
||||
|
||||
// Verify no duplicates in result
|
||||
const slugs = scopes.map((s) => s.slug);
|
||||
const uniqueSlugs = [...new Set(slugs)];
|
||||
expect(slugs).toEqual(uniqueSlugs);
|
||||
});
|
||||
|
||||
it('should handle mix of valid, invalid, and duplicate slugs', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([
|
||||
readScope.slug,
|
||||
'invalid:scope:1' as ScopeType,
|
||||
readScope.slug, // Duplicate valid
|
||||
'invalid:scope:2' as ScopeType,
|
||||
'invalid:scope:1' as ScopeType, // Duplicate invalid
|
||||
readScope.slug, // Another duplicate valid
|
||||
]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toEqual(expect.objectContaining({ slug: readScope.slug }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('large datasets', () => {
|
||||
it('should handle querying for many scopes efficiently', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const createdScopes = await createScopes(50, { description: 'Bulk test scope' });
|
||||
const slugsToQuery = createdScopes.slice(0, 25).map((s) => s.slug);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const startTime = Date.now();
|
||||
const scopes = await scopeRepository.findByList(slugsToQuery);
|
||||
const endTime = Date.now();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(25);
|
||||
expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
|
||||
|
||||
// Verify all requested scopes are returned
|
||||
const returnedSlugs = scopes.map((s) => s.slug).sort();
|
||||
const expectedSlugs = slugsToQuery.sort();
|
||||
expect(returnedSlugs).toEqual(expectedSlugs);
|
||||
});
|
||||
|
||||
it('should maintain data integrity with complex scope structures', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const complexScopes = await Promise.all([
|
||||
createScope({
|
||||
slug: 'complex:scope:with:colons' as ScopeType,
|
||||
displayName: 'Complex Scope With Colons',
|
||||
description: 'A scope with multiple colons in the slug',
|
||||
}),
|
||||
createScope({
|
||||
slug: 'scope-with-dashes' as ScopeType,
|
||||
displayName: 'Scope With Dashes',
|
||||
description: 'A scope with dashes',
|
||||
}),
|
||||
createScope({
|
||||
slug: 'scope_with_underscores' as ScopeType,
|
||||
displayName: 'Scope With Underscores',
|
||||
description: 'A scope with underscores',
|
||||
}),
|
||||
]);
|
||||
|
||||
const slugsToQuery = complexScopes.map((s) => s.slug);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList(slugsToQuery);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(3);
|
||||
|
||||
for (const originalScope of complexScopes) {
|
||||
const foundScope = scopes.find((s) => s.slug === originalScope.slug);
|
||||
expect(foundScope).toBeDefined();
|
||||
expect(foundScope!.displayName).toBe(originalScope.displayName);
|
||||
expect(foundScope!.description).toBe(originalScope.description);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and validation', () => {
|
||||
it('should handle null and undefined values gracefully', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const scope = await createScope({
|
||||
slug: 'scope-with-nulls' as ScopeType,
|
||||
displayName: null,
|
||||
description: null,
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes = await scopeRepository.findByList([scope.slug]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0].slug).toBe(scope.slug);
|
||||
expect(scopes[0].displayName).toBeNull();
|
||||
expect(scopes[0].description).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve order consistency across multiple queries', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
||||
const slugsToQuery = [adminScope.slug, readScope.slug, deleteScope.slug, writeScope.slug];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopes1 = await scopeRepository.findByList(slugsToQuery);
|
||||
const scopes2 = await scopeRepository.findByList(slugsToQuery);
|
||||
const scopes3 = await scopeRepository.findByList(slugsToQuery);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopes1).toHaveLength(4);
|
||||
expect(scopes2).toHaveLength(4);
|
||||
expect(scopes3).toHaveLength(4);
|
||||
|
||||
// All queries should return the same scopes (though order may vary due to SQL implementation)
|
||||
const getSortedSlugs = (scopeList: Scope[]) => scopeList.map((s) => s.slug).sort();
|
||||
|
||||
expect(getSortedSlugs(scopes1)).toEqual(getSortedSlugs(scopes2));
|
||||
expect(getSortedSlugs(scopes2)).toEqual(getSortedSlugs(scopes3));
|
||||
});
|
||||
|
||||
it('should verify database state remains consistent after queries', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const { readScope, writeScope } = await createTestScopes();
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const scopesBefore = await scopeRepository.find();
|
||||
await scopeRepository.findByList([readScope.slug, writeScope.slug]);
|
||||
const scopesAfter = await scopeRepository.find();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(scopesBefore).toHaveLength(4); // readScope, writeScope, deleteScope, adminScope
|
||||
expect(scopesAfter).toHaveLength(4);
|
||||
expect(scopesBefore.map((s) => s.slug).sort()).toEqual(
|
||||
scopesAfter.map((s) => s.slug).sort(),
|
||||
);
|
||||
|
||||
// Verify specific scopes are unchanged
|
||||
const readScopeBefore = scopesBefore.find((s) => s.slug === readScope.slug);
|
||||
const readScopeAfter = scopesAfter.find((s) => s.slug === readScope.slug);
|
||||
expect(readScopeBefore).toEqual(readScopeAfter);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByListOrFail()', () => {
|
||||
describe('success path', () => {
|
||||
it('should return empty array when given empty slug array', async () => {
|
||||
await createTestScopes();
|
||||
|
||||
const scopes = await scopeRepository.findByListOrFail([]);
|
||||
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single scope when one slug matches', async () => {
|
||||
const { readScope } = await createTestScopes();
|
||||
|
||||
const scopes = await scopeRepository.findByListOrFail([readScope.slug]);
|
||||
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: readScope.slug,
|
||||
displayName: readScope.displayName,
|
||||
description: readScope.description,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return multiple scopes when multiple slugs match', async () => {
|
||||
const { readScope, writeScope, deleteScope } = await createTestScopes();
|
||||
|
||||
const scopes = await scopeRepository.findByListOrFail([
|
||||
readScope.slug,
|
||||
writeScope.slug,
|
||||
deleteScope.slug,
|
||||
]);
|
||||
|
||||
expect(scopes).toHaveLength(3);
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ slug: readScope.slug }),
|
||||
expect.objectContaining({ slug: writeScope.slug }),
|
||||
expect.objectContaining({ slug: deleteScope.slug }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error path (invalid scopes)', () => {
|
||||
it('should throw when one slug does not exist', async () => {
|
||||
await createTestScopes();
|
||||
|
||||
await expect(
|
||||
scopeRepository.findByListOrFail(['non-existent:scope' as ScopeType]),
|
||||
).rejects.toThrow('The following scopes are invalid: non-existent:scope');
|
||||
});
|
||||
|
||||
it('should throw when some slugs do not exist and list invalid scopes in message', async () => {
|
||||
const { readScope } = await createTestScopes();
|
||||
|
||||
await expect(
|
||||
scopeRepository.findByListOrFail([
|
||||
readScope.slug,
|
||||
'non-existent:1' as ScopeType,
|
||||
'non-existent:2' as ScopeType,
|
||||
]),
|
||||
).rejects.toThrow('The following scopes are invalid: non-existent:1, non-existent:2');
|
||||
});
|
||||
|
||||
it('should throw when all slugs do not exist', async () => {
|
||||
await createTestScopes();
|
||||
|
||||
await expect(
|
||||
scopeRepository.findByListOrFail([
|
||||
'non-existent:1' as ScopeType,
|
||||
'non-existent:2' as ScopeType,
|
||||
]),
|
||||
).rejects.toThrow('The following scopes are invalid: non-existent:1, non-existent:2');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { createTeamProject, 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';
|
||||
|
||||
describe('SecretsProviderConnectionRepository', () => {
|
||||
let connectionRepository: SecretsProviderConnectionRepository;
|
||||
let projectAccessRepository: ProjectSecretsProviderAccessRepository;
|
||||
|
||||
let project1: Project;
|
||||
let project2: Project;
|
||||
|
||||
beforeAll(async () => {
|
||||
const licenseMock = mock<LicenseState>();
|
||||
licenseMock.isLicensed.mockReturnValue(true);
|
||||
Container.set(LicenseState, licenseMock);
|
||||
|
||||
await testDb.init();
|
||||
|
||||
connectionRepository = Container.get(SecretsProviderConnectionRepository);
|
||||
projectAccessRepository = Container.get(ProjectSecretsProviderAccessRepository);
|
||||
|
||||
project1 = await createTeamProject('Project 1');
|
||||
project2 = await createTeamProject('Project 2');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
async function createConnection(providerKey: string, type: string, projectIds: string[] = []) {
|
||||
const cipher = Container.get(Cipher);
|
||||
const encryptedSettings = cipher.encrypt({});
|
||||
|
||||
const connection = await connectionRepository.save(
|
||||
connectionRepository.create({
|
||||
providerKey,
|
||||
type,
|
||||
encryptedSettings,
|
||||
isEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
await projectAccessRepository.save(
|
||||
projectIds.map((projectId) =>
|
||||
projectAccessRepository.create({
|
||||
secretsProviderConnectionId: connection.id,
|
||||
projectId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
describe('findGlobalConnections', () => {
|
||||
it('returns only connections without project access', async () => {
|
||||
await Promise.all([
|
||||
createConnection('global1', 'awsSecretsManager'),
|
||||
createConnection('global2', 'hashicorpVault'),
|
||||
createConnection('project1', 'awsSecretsManager', [project1.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections();
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'global1',
|
||||
'global2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by provider keys when provided', async () => {
|
||||
await Promise.all([
|
||||
createConnection('globalAws', 'awsSecretsManager'),
|
||||
createConnection('globalVault', 'hashicorpVault'),
|
||||
createConnection('globalGcp', 'gcpSecretsManager'),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections({
|
||||
providerKeys: ['globalAws', 'globalVault'],
|
||||
});
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'globalAws',
|
||||
'globalVault',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no global connections exist', async () => {
|
||||
await createConnection('projectOnly', 'awsSecretsManager', [project1.id]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections();
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when providerKeys filter is an empty array', async () => {
|
||||
await Promise.all([
|
||||
createConnection('globalAws', 'awsSecretsManager'),
|
||||
createConnection('globalVault', 'hashicorpVault'),
|
||||
createConnection('globalGcp', 'gcpSecretsManager'),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections({
|
||||
providerKeys: [],
|
||||
});
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProjectId', () => {
|
||||
it('returns only connections assigned to the project', async () => {
|
||||
await Promise.all([
|
||||
createConnection('global', 'awsSecretsManager'),
|
||||
createConnection('proj1A', 'awsSecretsManager', [project1.id]),
|
||||
createConnection('proj1B', 'hashicorpVault', [project1.id]),
|
||||
createConnection('proj2A', 'gcpSecretsManager', [project2.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id);
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'proj1A',
|
||||
'proj1B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by provider keys when provided', async () => {
|
||||
await Promise.all([
|
||||
createConnection('projAws', 'awsSecretsManager', [project1.id]),
|
||||
createConnection('projVault', 'hashicorpVault', [project1.id]),
|
||||
createConnection('projGcp', 'gcpSecretsManager', [project1.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id, {
|
||||
providerKeys: ['projAws', 'projGcp'],
|
||||
});
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'projAws',
|
||||
'projGcp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no connections exist for project', async () => {
|
||||
await createConnection('otherProject', 'awsSecretsManager', [project2.id]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id);
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when providerKeys filter is an empty array', async () => {
|
||||
await Promise.all([
|
||||
createConnection('projAws', 'awsSecretsManager', [project1.id]),
|
||||
createConnection('projVault', 'hashicorpVault', [project1.id]),
|
||||
createConnection('projGcp', 'gcpSecretsManager', [project1.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id, {
|
||||
providerKeys: [],
|
||||
});
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { SettingsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
|
||||
describe('SettingsRepository', () => {
|
||||
let settingsRepository: SettingsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
settingsRepository = Container.get(SettingsRepository);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['Settings']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('findByKey()', () => {
|
||||
it('should return null when key does not exist', async () => {
|
||||
const result = await settingsRepository.findByKey('non.existent.key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the setting when key exists', async () => {
|
||||
await settingsRepository.save({
|
||||
key: 'test.find.by.key',
|
||||
value: 'found-value',
|
||||
loadOnStartup: true,
|
||||
});
|
||||
|
||||
const result = await settingsRepository.findByKey('test.find.by.key');
|
||||
|
||||
expect(result).toMatchObject({ key: 'test.find.by.key', value: 'found-value' });
|
||||
});
|
||||
|
||||
it('should use the provided EntityManager instead of the default one', async () => {
|
||||
await settingsRepository.save({
|
||||
key: 'test.find.by.key.em',
|
||||
value: 'em-value',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
|
||||
const dataSource = Container.get(DataSource);
|
||||
const em = dataSource.manager;
|
||||
|
||||
const result = await settingsRepository.findByKey('test.find.by.key.em', em);
|
||||
|
||||
expect(result).toMatchObject({ key: 'test.find.by.key.em', value: 'em-value' });
|
||||
});
|
||||
|
||||
it('should work inside a transaction', async () => {
|
||||
const dataSource = Container.get(DataSource);
|
||||
|
||||
await dataSource.manager.transaction(async (trx) => {
|
||||
await trx.save(
|
||||
settingsRepository.create({
|
||||
key: 'test.trx.key',
|
||||
value: 'trx-value',
|
||||
loadOnStartup: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should find the row using the same transaction
|
||||
const result = await settingsRepository.findByKey('test.trx.key', trx);
|
||||
expect(result).toMatchObject({ key: 'test.trx.key', value: 'trx-value' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByKeys()', () => {
|
||||
it('should return empty array when given empty keys array', async () => {
|
||||
await settingsRepository.save({
|
||||
key: 'test:key:1',
|
||||
value: 'value1',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
|
||||
const settings = await settingsRepository.findByKeys([]);
|
||||
|
||||
expect(settings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when no settings exist for given keys', async () => {
|
||||
const settings = await settingsRepository.findByKeys(['non.existent.key']);
|
||||
|
||||
expect(settings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single setting when one key matches', async () => {
|
||||
const saved = await settingsRepository.save({
|
||||
key: 'test:key:single',
|
||||
value: 'single-value',
|
||||
loadOnStartup: true,
|
||||
});
|
||||
|
||||
const settings = await settingsRepository.findByKeys(['test:key:single']);
|
||||
|
||||
expect(settings).toHaveLength(1);
|
||||
expect(settings[0]).toMatchObject({
|
||||
key: saved.key,
|
||||
value: saved.value,
|
||||
loadOnStartup: saved.loadOnStartup,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple settings when multiple keys match', async () => {
|
||||
const a = await settingsRepository.save({
|
||||
key: 'test:key:a',
|
||||
value: 'valueA',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
const b = await settingsRepository.save({
|
||||
key: 'test:key:b',
|
||||
value: 'valueB',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
const c = await settingsRepository.save({
|
||||
key: 'test:key:c',
|
||||
value: 'valueC',
|
||||
loadOnStartup: true,
|
||||
});
|
||||
|
||||
const settings = await settingsRepository.findByKeys([
|
||||
'test:key:a',
|
||||
'test:key:b',
|
||||
'test:key:c',
|
||||
]);
|
||||
|
||||
expect(settings).toHaveLength(3);
|
||||
expect(settings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: a.key, value: a.value }),
|
||||
expect.objectContaining({ key: b.key, value: b.value }),
|
||||
expect.objectContaining({ key: c.key, value: c.value }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return only existing settings when some keys do not exist', async () => {
|
||||
const a = await settingsRepository.save({
|
||||
key: 'test:key:exists',
|
||||
value: 'exists-value',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
|
||||
const settings = await settingsRepository.findByKeys([
|
||||
'test:key:exists',
|
||||
'non.existent.key.1',
|
||||
'non.existent.key.2',
|
||||
]);
|
||||
|
||||
expect(settings).toHaveLength(1);
|
||||
expect(settings[0]).toMatchObject({ key: a.key, value: a.value });
|
||||
});
|
||||
|
||||
it('should return each setting only once when keys array contains duplicates', async () => {
|
||||
const a = await settingsRepository.save({
|
||||
key: 'test:key:dup:a',
|
||||
value: 'dupA',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
const b = await settingsRepository.save({
|
||||
key: 'test:key:dup:b',
|
||||
value: 'dupB',
|
||||
loadOnStartup: false,
|
||||
});
|
||||
|
||||
const settings = await settingsRepository.findByKeys([
|
||||
'test:key:dup:a',
|
||||
'test:key:dup:b',
|
||||
'test:key:dup:a',
|
||||
'test:key:dup:b',
|
||||
]);
|
||||
|
||||
expect(settings).toHaveLength(2);
|
||||
expect(settings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: a.key }),
|
||||
expect.objectContaining({ key: b.key }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
|
||||
import { WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
describe('WorkflowDependencyRepository', () => {
|
||||
let workflowDependencyRepository: WorkflowDependencyRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
workflowDependencyRepository = Container.get(WorkflowDependencyRepository);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Truncate in correct order to respect foreign key constraints
|
||||
await testDb.truncate(['WorkflowDependency', 'SharedWorkflow', 'WorkflowEntity']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('updateDependenciesForWorkflow()', () => {
|
||||
it('should insert new dependencies for a workflow with no existing dependencies', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({ versionId: 'v1' });
|
||||
const dependencies = new WorkflowDependencies(workflow.id, 1);
|
||||
dependencies.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-123',
|
||||
dependencyInfo: { name: 'Test Credential' },
|
||||
});
|
||||
dependencies.add({
|
||||
dependencyType: 'nodeType',
|
||||
dependencyKey: 'n8n-nodes-base.httpRequest',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = await workflowDependencyRepository.updateDependenciesForWorkflow(
|
||||
workflow.id,
|
||||
dependencies,
|
||||
);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toBe(true);
|
||||
const savedDependencies = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
order: { dependencyType: 'ASC' },
|
||||
});
|
||||
expect(savedDependencies).toHaveLength(2);
|
||||
expect(savedDependencies[0]).toMatchObject({
|
||||
workflowId: workflow.id,
|
||||
workflowVersionId: 1,
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-123',
|
||||
dependencyInfo: { name: 'Test Credential' },
|
||||
indexVersionId: 1,
|
||||
});
|
||||
expect(savedDependencies[1]).toMatchObject({
|
||||
workflowId: workflow.id,
|
||||
workflowVersionId: 1,
|
||||
dependencyType: 'nodeType',
|
||||
dependencyKey: 'n8n-nodes-base.httpRequest',
|
||||
dependencyInfo: null,
|
||||
indexVersionId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace existing dependencies with newer version', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({ versionId: 'v1' });
|
||||
|
||||
// Insert initial dependencies with version 1
|
||||
const initialDeps = new WorkflowDependencies(workflow.id, 1);
|
||||
initialDeps.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-old',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
await workflowDependencyRepository.updateDependenciesForWorkflow(workflow.id, initialDeps);
|
||||
|
||||
// Create new dependencies with version 2
|
||||
const updatedDeps = new WorkflowDependencies(workflow.id, 2);
|
||||
updatedDeps.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-new',
|
||||
dependencyInfo: { updated: true },
|
||||
});
|
||||
updatedDeps.add({
|
||||
dependencyType: 'webhookPath',
|
||||
dependencyKey: '/webhook/test',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = await workflowDependencyRepository.updateDependenciesForWorkflow(
|
||||
workflow.id,
|
||||
updatedDeps,
|
||||
);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toBe(true);
|
||||
const savedDependencies = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
order: { dependencyType: 'ASC' },
|
||||
});
|
||||
expect(savedDependencies).toHaveLength(2);
|
||||
expect(savedDependencies[0].dependencyKey).toBe('cred-new');
|
||||
expect(savedDependencies[0].workflowVersionId).toBe(2);
|
||||
expect(savedDependencies[1].dependencyType).toBe('webhookPath');
|
||||
expect(savedDependencies[1].workflowVersionId).toBe(2);
|
||||
});
|
||||
|
||||
it('should not update when incoming version is older than existing version', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({ versionId: 'v2' });
|
||||
|
||||
// Insert dependencies with version 2
|
||||
const newerDeps = new WorkflowDependencies(workflow.id, 2);
|
||||
newerDeps.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-new',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
await workflowDependencyRepository.updateDependenciesForWorkflow(workflow.id, newerDeps);
|
||||
|
||||
// Try to update with older version 1
|
||||
const olderDeps = new WorkflowDependencies(workflow.id, 1);
|
||||
olderDeps.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-old',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = await workflowDependencyRepository.updateDependenciesForWorkflow(
|
||||
workflow.id,
|
||||
olderDeps,
|
||||
);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toBe(false);
|
||||
const savedDependencies = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
expect(savedDependencies).toHaveLength(1);
|
||||
expect(savedDependencies[0].dependencyKey).toBe('cred-new');
|
||||
expect(savedDependencies[0].workflowVersionId).toBe(2);
|
||||
});
|
||||
|
||||
it('should prevent races between concurrent updates', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({ versionId: '2' });
|
||||
|
||||
const depsVersion1 = new WorkflowDependencies(workflow.id, 1);
|
||||
depsVersion1.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-1',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
|
||||
const depsVersion2 = new WorkflowDependencies(workflow.id, 2);
|
||||
depsVersion2.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-2',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
// Run the two updates concurrently. Due to the versioning logic,
|
||||
// the second update should always be applied. If there's a race,
|
||||
// this test may intermittently fail.
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
await Promise.all([
|
||||
workflowDependencyRepository.updateDependenciesForWorkflow(workflow.id, depsVersion1),
|
||||
workflowDependencyRepository.updateDependenciesForWorkflow(workflow.id, depsVersion2),
|
||||
]);
|
||||
|
||||
const savedDependencies = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
expect(savedDependencies).toHaveLength(1);
|
||||
expect(savedDependencies[0].workflowVersionId).toBe(2);
|
||||
expect(savedDependencies[0].dependencyKey).toBe('cred-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDependenciesForWorkflow()', () => {
|
||||
it('should remove all dependencies for a workflow', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({ versionId: 'v1' });
|
||||
const dependencies = new WorkflowDependencies(workflow.id, 1);
|
||||
dependencies.add({
|
||||
dependencyType: 'credentialId',
|
||||
dependencyKey: 'cred-1',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
dependencies.add({
|
||||
dependencyType: 'nodeType',
|
||||
dependencyKey: 'node-1',
|
||||
dependencyInfo: null,
|
||||
});
|
||||
await workflowDependencyRepository.updateDependenciesForWorkflow(workflow.id, dependencies);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = await workflowDependencyRepository.removeDependenciesForWorkflow(workflow.id);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toBe(true);
|
||||
const remainingDeps = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
expect(remainingDeps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return false when no dependencies exist to remove', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({ versionId: 'v1' });
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = await workflowDependencyRepository.removeDependenciesForWorkflow(workflow.id);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
createWorkflow,
|
||||
createWorkflowHistory,
|
||||
createWorkflowWithHistory,
|
||||
testDb,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { WorkflowHistoryRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { RULES, type INode } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
describe('WorkflowHistoryRepository', () => {
|
||||
const testNode1 = {
|
||||
id: uuid(),
|
||||
name: 'testNode1',
|
||||
parameters: {},
|
||||
type: 'aNodeType',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
} satisfies INode;
|
||||
|
||||
const alwaysMergeRule = () => true;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowPublishHistory', 'WorkflowHistory', 'WorkflowEntity', 'User']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('pruneHistory', () => {
|
||||
it('should prune superseded version', async () => {
|
||||
const id1 = uuid();
|
||||
const id2 = uuid();
|
||||
|
||||
const workflow = await createWorkflowWithHistory({
|
||||
versionId: id1,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'a' } }],
|
||||
});
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'ab' } }],
|
||||
});
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abc' } }],
|
||||
});
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: id2,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const repository = Container.get(WorkflowHistoryRepository);
|
||||
|
||||
const tenMinsAgo = new Date();
|
||||
tenMinsAgo.setMinutes(tenMinsAgo.getMinutes() - 10);
|
||||
|
||||
const aMinAgo = new Date();
|
||||
aMinAgo.setMinutes(aMinAgo.getMinutes() - 1);
|
||||
|
||||
const nextMin = new Date();
|
||||
nextMin.setMinutes(nextMin.getMinutes() + 1);
|
||||
|
||||
const inTenMins = new Date();
|
||||
inTenMins.setMinutes(inTenMins.getMinutes() + 10);
|
||||
|
||||
{
|
||||
// Don't touch workflows younger than range
|
||||
const { deleted, seen } = await repository.pruneHistory(workflow.id, tenMinsAgo, aMinAgo, [
|
||||
RULES.mergeAdditiveChanges,
|
||||
]);
|
||||
expect(deleted).toBe(0);
|
||||
expect(seen).toBe(0);
|
||||
|
||||
const history = await repository.find();
|
||||
expect(history.length).toBe(4);
|
||||
}
|
||||
|
||||
{
|
||||
// Don't touch workflows older
|
||||
const { deleted, seen } = await repository.pruneHistory(workflow.id, nextMin, inTenMins, [
|
||||
RULES.mergeAdditiveChanges,
|
||||
]);
|
||||
expect(deleted).toBe(0);
|
||||
expect(seen).toBe(0);
|
||||
|
||||
const history = await repository.find();
|
||||
expect(history.length).toBe(4);
|
||||
}
|
||||
|
||||
{
|
||||
const { deleted, seen } = await repository.pruneHistory(workflow.id, aMinAgo, nextMin, [
|
||||
RULES.mergeAdditiveChanges,
|
||||
]);
|
||||
expect(seen).toBe(4);
|
||||
expect(deleted).toBe(3);
|
||||
|
||||
const history = await repository.find();
|
||||
expect(history.length).toBe(1);
|
||||
expect(history).toEqual([expect.objectContaining({ versionId: id2 })]);
|
||||
}
|
||||
});
|
||||
it('should not prune non-additive version', async () => {
|
||||
const id1 = uuid();
|
||||
const id2 = uuid();
|
||||
|
||||
const workflow = await createWorkflowWithHistory({
|
||||
versionId: id1,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abcde' } }],
|
||||
});
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'ab' } }],
|
||||
});
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abc' } }],
|
||||
});
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: id2,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const repository = Container.get(WorkflowHistoryRepository);
|
||||
|
||||
const tenMinsAgo = new Date();
|
||||
tenMinsAgo.setMinutes(tenMinsAgo.getMinutes() - 10);
|
||||
|
||||
const aMinAgo = new Date();
|
||||
aMinAgo.setMinutes(aMinAgo.getMinutes() - 1);
|
||||
|
||||
const nextMin = new Date();
|
||||
nextMin.setMinutes(nextMin.getMinutes() + 1);
|
||||
|
||||
const inTenMins = new Date();
|
||||
inTenMins.setMinutes(inTenMins.getMinutes() + 10);
|
||||
|
||||
{
|
||||
const { deleted, seen } = await repository.pruneHistory(workflow.id, aMinAgo, nextMin, [
|
||||
RULES.mergeAdditiveChanges,
|
||||
]);
|
||||
expect(seen).toBe(4);
|
||||
expect(deleted).toBe(2);
|
||||
|
||||
const history = await repository.find();
|
||||
expect(history.length).toBe(2);
|
||||
expect(history).toEqual([
|
||||
expect.objectContaining({ versionId: id1 }),
|
||||
expect.objectContaining({ versionId: id2 }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should never prune previously active or named versions', async () => {
|
||||
// ARRANGE
|
||||
const id1 = uuid();
|
||||
const id2 = uuid();
|
||||
const id3 = uuid();
|
||||
const id4 = uuid();
|
||||
const id5 = uuid();
|
||||
|
||||
const workflow = await createWorkflowWithHistory({
|
||||
versionId: id1,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'a' } }],
|
||||
});
|
||||
await createWorkflowHistory(
|
||||
{
|
||||
...workflow,
|
||||
versionId: id2,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'ab' } }],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{ name: 'aVersionName' },
|
||||
);
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: id3,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abc' } }],
|
||||
});
|
||||
await createWorkflowHistory(
|
||||
{
|
||||
...workflow,
|
||||
versionId: id4,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
|
||||
},
|
||||
undefined,
|
||||
{ event: 'activated' },
|
||||
);
|
||||
await createWorkflowHistory({
|
||||
...workflow,
|
||||
versionId: id5,
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abcde' } }],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const repository = Container.get(WorkflowHistoryRepository);
|
||||
|
||||
const aDayAgo = new Date();
|
||||
aDayAgo.setDate(aDayAgo.getDate() - 1);
|
||||
|
||||
const nextDay = new Date();
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const { deleted, seen } = await repository.pruneHistory(workflow.id, aDayAgo, nextDay, [
|
||||
alwaysMergeRule,
|
||||
]);
|
||||
|
||||
// ASSERT
|
||||
expect(seen).toBe(5);
|
||||
expect(deleted).toBe(2);
|
||||
|
||||
const history = await repository.find();
|
||||
expect(history).toEqual([
|
||||
expect.objectContaining({ versionId: id2 }),
|
||||
expect.objectContaining({ versionId: id4 }),
|
||||
expect.objectContaining({ versionId: id5 }),
|
||||
]);
|
||||
|
||||
const redo = await repository.pruneHistory(workflow.id, aDayAgo, nextDay, [alwaysMergeRule]);
|
||||
|
||||
// ASSERT
|
||||
expect(redo.deleted).toBe(0);
|
||||
expect(redo.seen).toBe(3);
|
||||
});
|
||||
});
|
||||
describe('getWorkflowIdsInRange', () => {
|
||||
it('should return versions in range', async () => {
|
||||
const now = Date.now();
|
||||
const twoSecondsAhead = new Date(now + 2 * 1000);
|
||||
const fourSecondsAhead = new Date(now + 4 * 1000);
|
||||
const sixSecondsAhead = new Date(now + 6 * 1000);
|
||||
|
||||
const workflowA = await createWorkflow({
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'a' } }],
|
||||
});
|
||||
|
||||
// Create workflow history for the initial version
|
||||
await createWorkflowHistory(workflowA, undefined, undefined, { createdAt: new Date(now) });
|
||||
|
||||
await createWorkflowHistory(
|
||||
{
|
||||
...workflowA,
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{ createdAt: twoSecondsAhead },
|
||||
);
|
||||
|
||||
const workflowB = await createWorkflow({
|
||||
versionId: uuid(),
|
||||
nodes: [{ ...testNode1, parameters: { a: 'a' } }],
|
||||
});
|
||||
await createWorkflowHistory(workflowB, undefined, undefined, { createdAt: fourSecondsAhead });
|
||||
|
||||
// ACT
|
||||
const repository = Container.get(WorkflowHistoryRepository);
|
||||
{
|
||||
const ids = await repository.getWorkflowIdsInRange(sixSecondsAhead, sixSecondsAhead);
|
||||
expect(ids).toEqual([]);
|
||||
}
|
||||
{
|
||||
const ids = await repository.getWorkflowIdsInRange(fourSecondsAhead, sixSecondsAhead);
|
||||
expect(ids).toEqual([workflowB.id]);
|
||||
}
|
||||
{
|
||||
const ids = await repository.getWorkflowIdsInRange(twoSecondsAhead, sixSecondsAhead);
|
||||
expect(ids).toEqual(expect.arrayContaining([workflowA.id, workflowB.id]));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
createWorkflow,
|
||||
createWorkflowHistory,
|
||||
createWorkflowWithHistory,
|
||||
testDb,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import {
|
||||
UserRepository,
|
||||
WorkflowHistoryRepository,
|
||||
WorkflowPublishHistoryRepository,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { createUser } from '../../shared/db/users';
|
||||
|
||||
describe('WorkflowPublishHistoryRepository', () => {
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowPublishHistory', 'WorkflowHistory', 'WorkflowEntity', 'User']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('addRecord', () => {
|
||||
it('should create a publish history record with all fields', async () => {
|
||||
const id1 = uuid();
|
||||
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const user = await createUser();
|
||||
const workflow = await createWorkflowWithHistory({ versionId: id1 });
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
|
||||
userId: user.id,
|
||||
});
|
||||
expect(record?.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should create a record with null userId', async () => {
|
||||
const id1 = uuid();
|
||||
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const workflow = await createWorkflowWithHistory({ versionId: id1 });
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create multiple records for same workflow', async () => {
|
||||
const id1 = uuid();
|
||||
const id2 = uuid();
|
||||
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const workflow = await createWorkflow();
|
||||
await createWorkflowHistory({ ...workflow, versionId: id1 });
|
||||
await createWorkflowHistory({ ...workflow, versionId: id2 });
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: id1,
|
||||
event: 'activated',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: id1,
|
||||
event: 'deactivated',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: id2,
|
||||
event: 'activated',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const records = await repository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0]).toMatchObject({
|
||||
versionId: id1,
|
||||
event: 'activated',
|
||||
});
|
||||
expect(records[1]).toMatchObject({
|
||||
versionId: id1,
|
||||
event: 'deactivated',
|
||||
});
|
||||
expect(records[2]).toMatchObject({
|
||||
versionId: id2,
|
||||
event: 'activated',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Foreign key constraints', () => {
|
||||
it('should cascade delete when workflow is deleted', async () => {
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const workflowRepository = Container.get(WorkflowRepository);
|
||||
const workflow = await createWorkflowWithHistory();
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await workflowRepository.delete(workflow.id);
|
||||
|
||||
const records = await repository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
|
||||
expect(records).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should cascade delete when workflow history version is deleted', async () => {
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const workflow = await createWorkflowWithHistory();
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await Container.get(WorkflowHistoryRepository).delete({ versionId: workflow.versionId });
|
||||
|
||||
const records = await repository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
|
||||
expect(records).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should set userId to null when user is deleted', async () => {
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const user = await createUser();
|
||||
const workflow = await createWorkflowWithHistory();
|
||||
|
||||
await repository.addRecord({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await Container.get(UserRepository).delete(user.id);
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
|
||||
expect(record).toBeDefined();
|
||||
expect(record?.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { DbConnectionOptions, DbLock, DbLockService } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { OperationalError, sleep } from 'n8n-workflow';
|
||||
|
||||
let dbLockService: DbLockService;
|
||||
let isPostgres: boolean;
|
||||
|
||||
// Separate DataSource with its own connection for holding locks during
|
||||
// contention tests. The main DataSource may have poolSize=1 in CI
|
||||
// (set by setup-testcontainers.js), so we need an independent connection
|
||||
// to hold a lock while the service tries to acquire it on the main pool.
|
||||
let holdLockDs: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
dbLockService = Container.get(DbLockService);
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
isPostgres = globalConfig.database.type === 'postgresdb';
|
||||
|
||||
if (isPostgres) {
|
||||
holdLockDs = new DataSource({
|
||||
type: 'postgres',
|
||||
...Container.get(DbConnectionOptions).getPostgresOverrides(),
|
||||
schema: globalConfig.database.postgresdb.schema,
|
||||
});
|
||||
await holdLockDs.initialize();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (holdLockDs?.isInitialized) {
|
||||
await holdLockDs.destroy();
|
||||
}
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('DbLockService', () => {
|
||||
describe('withLock', () => {
|
||||
it('should execute the callback inside a transaction', async () => {
|
||||
const result = await dbLockService.withLock(DbLock.TEST, async (tx) => {
|
||||
expect(tx).toBeDefined();
|
||||
expect(tx.queryRunner).toBeDefined();
|
||||
return 'done';
|
||||
});
|
||||
|
||||
expect(result).toBe('done');
|
||||
});
|
||||
|
||||
it('should return the value from the callback', async () => {
|
||||
const result = await dbLockService.withLock(DbLock.TEST, async () => 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should roll back the transaction when the callback throws', async () => {
|
||||
await expect(
|
||||
dbLockService.withLock(DbLock.TEST, async () => {
|
||||
throw new Error('rollback me');
|
||||
}),
|
||||
).rejects.toThrow('rollback me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryWithLock', () => {
|
||||
it('should execute the callback when no contention', async () => {
|
||||
const result = await dbLockService.tryWithLock(DbLock.TEST, async (tx) => {
|
||||
expect(tx).toBeDefined();
|
||||
return 'acquired';
|
||||
});
|
||||
|
||||
expect(result).toBe('acquired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisory lock serialization (Postgres)', () => {
|
||||
it('should serialize concurrent withLock calls', async () => {
|
||||
if (!isPostgres) return;
|
||||
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
let lockAcquired!: () => void;
|
||||
const lockAcquiredPromise = new Promise<void>((resolve) => {
|
||||
lockAcquired = resolve;
|
||||
});
|
||||
|
||||
// First call: hold lock on the separate connection
|
||||
const first = holdLockDs.manager.transaction(async (tx) => {
|
||||
await tx.query('SELECT pg_advisory_xact_lock($1)', [DbLock.TEST]);
|
||||
executionOrder.push('first:start');
|
||||
lockAcquired();
|
||||
await sleep(300);
|
||||
executionOrder.push('first:end');
|
||||
return 'first';
|
||||
});
|
||||
|
||||
await lockAcquiredPromise;
|
||||
|
||||
// Second call via the service: should block until first releases the lock
|
||||
const second = dbLockService.withLock(DbLock.TEST, async () => {
|
||||
executionOrder.push('second:start');
|
||||
return 'second';
|
||||
});
|
||||
|
||||
const results = await Promise.all([first, second]);
|
||||
|
||||
expect(results).toEqual(['first', 'second']);
|
||||
// The second call should only start after the first call ends
|
||||
expect(executionOrder).toEqual(['first:start', 'first:end', 'second:start']);
|
||||
});
|
||||
|
||||
it('should throw OperationalError when withLock times out', async () => {
|
||||
if (!isPostgres) return;
|
||||
|
||||
let lockAcquired!: () => void;
|
||||
const lockAcquiredPromise = new Promise<void>((resolve) => {
|
||||
lockAcquired = resolve;
|
||||
});
|
||||
|
||||
// Hold the lock on the separate connection
|
||||
const holdLockPromise = holdLockDs.manager.transaction(async (tx) => {
|
||||
await tx.query('SELECT pg_advisory_xact_lock($1)', [DbLock.TEST]);
|
||||
lockAcquired();
|
||||
await sleep(2000);
|
||||
});
|
||||
|
||||
await lockAcquiredPromise;
|
||||
|
||||
// Try to acquire on the main connection with a short timeout — should fail
|
||||
await expect(
|
||||
dbLockService.withLock(DbLock.TEST, async () => 'should not reach', {
|
||||
timeoutMs: 200,
|
||||
}),
|
||||
).rejects.toThrow(OperationalError);
|
||||
|
||||
await holdLockPromise;
|
||||
});
|
||||
|
||||
it('should throw OperationalError when tryWithLock cannot acquire', async () => {
|
||||
if (!isPostgres) return;
|
||||
|
||||
let lockAcquired!: () => void;
|
||||
const lockAcquiredPromise = new Promise<void>((resolve) => {
|
||||
lockAcquired = resolve;
|
||||
});
|
||||
|
||||
// Hold the lock on the separate connection
|
||||
const holdLockPromise = holdLockDs.manager.transaction(async (tx) => {
|
||||
await tx.query('SELECT pg_advisory_xact_lock($1)', [DbLock.TEST]);
|
||||
lockAcquired();
|
||||
await sleep(2000);
|
||||
});
|
||||
|
||||
await lockAcquiredPromise;
|
||||
|
||||
// tryWithLock on the main connection should fail immediately
|
||||
const error = await dbLockService
|
||||
.tryWithLock(DbLock.TEST, async () => 'should not reach')
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(OperationalError);
|
||||
expect((error as OperationalError).message).toMatch(/already held by another process/);
|
||||
|
||||
await holdLockPromise;
|
||||
});
|
||||
|
||||
it('tryWithLock should succeed when lock is not held', async () => {
|
||||
if (!isPostgres) return;
|
||||
|
||||
const result = await dbLockService.tryWithLock(DbLock.TEST, async () => 'free');
|
||||
expect(result).toBe('free');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user