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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,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');
});
});
});
});
@@ -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 }),
]),
);
});
});
});
@@ -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);
});
});
});
@@ -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]));
}
});
});
});
@@ -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');
});
});
});