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,117 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
describe('DynamicCredentialResolverRepository', () => {
let resolverRepository: DynamicCredentialResolverRepository;
let credentialsRepository: CredentialsRepository;
let previousEnvVar: string | undefined;
beforeAll(async () => {
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
await testModules.loadModules(['dynamic-credentials']);
await testDb.init();
resolverRepository = Container.get(DynamicCredentialResolverRepository);
credentialsRepository = Container.get(CredentialsRepository);
});
afterEach(async () => {
await testDb.truncate(['CredentialsEntity', 'DynamicCredentialResolver']);
});
afterAll(async () => {
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
await testDb.terminate();
});
it('should create and find a resolver', async () => {
const resolver = resolverRepository.create({
name: 'Test Resolver',
type: 'oauth2',
config: JSON.stringify({ clientId: 'test' }),
});
const saved = await resolverRepository.save(resolver);
const found = await resolverRepository.findOne({ where: { id: saved.id } });
expect(found).toMatchObject({
id: saved.id,
name: 'Test Resolver',
type: 'oauth2',
});
});
describe('relationship with CredentialsEntity', () => {
let resolver: DynamicCredentialResolver;
beforeEach(async () => {
resolver = await resolverRepository.save(
resolverRepository.create({
name: 'Test Resolver',
type: 'oauth2',
config: JSON.stringify({}),
}),
);
});
it('should link credentials to resolver and query them', async () => {
await credentialsRepository.save([
credentialsRepository.create({
name: 'Cred 1',
type: 'oauth2',
data: '',
isResolvable: true,
resolverId: resolver.id,
}),
credentialsRepository.create({
name: 'Cred 2',
type: 'oauth2',
data: '',
isResolvable: true,
resolvableAllowFallback: true,
resolverId: resolver.id,
}),
]);
const linked = await credentialsRepository.find({ where: { resolverId: resolver.id } });
expect(linked).toHaveLength(2);
expect(linked[0].resolverId).toBe(resolver.id);
expect(linked[1].resolvableAllowFallback).toBe(true);
});
it('should handle nullable resolverId', async () => {
const credential = await credentialsRepository.save(
credentialsRepository.create({
name: 'Standalone',
type: 'apiKey',
data: '',
isResolvable: false,
}),
);
expect(credential.resolverId).toBeNull();
});
it('should set resolverId to null on resolver deletion (CASCADE SET NULL)', async () => {
const credential = await credentialsRepository.save(
credentialsRepository.create({
name: 'Linked',
type: 'oauth2',
data: '',
isResolvable: true,
resolverId: resolver.id,
}),
);
await resolverRepository.delete(resolver.id);
const orphaned = await credentialsRepository.findOneBy({ id: credential.id });
expect(orphaned?.resolverId).toBeNull();
});
});
});
@@ -0,0 +1,267 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { DynamicCredentialEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-entry-storage';
import { createDynamicCredentialResolver } from './shared/db-helpers';
import { createCredentials } from '../shared/db/credentials';
describe('DynamicCredentialEntryStorage', () => {
let storage: DynamicCredentialEntryStorage;
let previousEnvVar: string | undefined;
beforeAll(async () => {
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
await testModules.loadModules(['dynamic-credentials']);
await testDb.init();
storage = Container.get(DynamicCredentialEntryStorage);
});
afterAll(async () => {
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'DynamicCredentialEntry',
'DynamicCredentialResolver',
'CredentialsEntity',
]);
});
it('should store and retrieve credential data', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const testData = 'encrypted-credential-data';
// ACT - Store
await storage.setCredentialData(credential.id, 'test-subject', resolver.id, testData, {});
// ACT - Retrieve
const retrievedData = await storage.getCredentialData(
credential.id,
'test-subject',
resolver.id,
{},
);
// ASSERT
expect(retrievedData).toBe(testData);
});
it('should update existing credential data (upsert)', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// ACT - Insert
await storage.setCredentialData(
credential.id,
'upsert-subject',
resolver.id,
'original-data',
{},
);
// ACT - Update
await storage.setCredentialData(
credential.id,
'upsert-subject',
resolver.id,
'updated-data',
{},
);
// ACT - Retrieve
const data = await storage.getCredentialData(credential.id, 'upsert-subject', resolver.id, {});
// ASSERT
expect(data).toBe('updated-data');
});
it('should delete credential data', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const testData = 'data-to-delete';
// Store data first
await storage.setCredentialData(credential.id, 'delete-subject', resolver.id, testData, {});
// Verify it exists
const beforeDelete = await storage.getCredentialData(
credential.id,
'delete-subject',
resolver.id,
{},
);
expect(beforeDelete).toBe(testData);
// ACT - Delete
await storage.deleteCredentialData(credential.id, 'delete-subject', resolver.id, {});
// ASSERT - Verify it's gone
const afterDelete = await storage.getCredentialData(
credential.id,
'delete-subject',
resolver.id,
{},
);
expect(afterDelete).toBeNull();
});
it('should isolate entries by composite key (multiple entries do not affect each other)', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const resolver1 = await createDynamicCredentialResolver({
name: 'resolver-1',
type: 'test',
config: 'test-config-1',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'resolver-2',
type: 'test',
config: 'test-config-2',
});
// ACT - Create multiple entries with different combinations
// Same credential, different subjects
await storage.setCredentialData(
credential1.id,
'subject-A',
resolver1.id,
'data-cred1-subjA-res1',
{},
);
await storage.setCredentialData(
credential1.id,
'subject-B',
resolver1.id,
'data-cred1-subjB-res1',
{},
);
// Same credential and subject, different resolver
await storage.setCredentialData(
credential1.id,
'subject-A',
resolver2.id,
'data-cred1-subjA-res2',
{},
);
// Different credential, same subject and resolver
await storage.setCredentialData(
credential2.id,
'subject-A',
resolver1.id,
'data-cred2-subjA-res1',
{},
);
// ASSERT - Each entry should be isolated and return correct data
const data1 = await storage.getCredentialData(credential1.id, 'subject-A', resolver1.id, {});
expect(data1).toBe('data-cred1-subjA-res1');
const data2 = await storage.getCredentialData(credential1.id, 'subject-B', resolver1.id, {});
expect(data2).toBe('data-cred1-subjB-res1');
const data3 = await storage.getCredentialData(credential1.id, 'subject-A', resolver2.id, {});
expect(data3).toBe('data-cred1-subjA-res2');
const data4 = await storage.getCredentialData(credential2.id, 'subject-A', resolver1.id, {});
expect(data4).toBe('data-cred2-subjA-res1');
// ACT - Update one entry
await storage.setCredentialData(
credential1.id,
'subject-A',
resolver1.id,
'updated-data-cred1-subjA-res1',
{},
);
// ASSERT - Only the updated entry should change, others remain unchanged
const updatedData1 = await storage.getCredentialData(
credential1.id,
'subject-A',
resolver1.id,
{},
);
expect(updatedData1).toBe('updated-data-cred1-subjA-res1');
const unchangedData2 = await storage.getCredentialData(
credential1.id,
'subject-B',
resolver1.id,
{},
);
expect(unchangedData2).toBe('data-cred1-subjB-res1');
const unchangedData3 = await storage.getCredentialData(
credential1.id,
'subject-A',
resolver2.id,
{},
);
expect(unchangedData3).toBe('data-cred1-subjA-res2');
// ACT - Delete one entry
await storage.deleteCredentialData(credential1.id, 'subject-A', resolver1.id, {});
// ASSERT - Deleted entry should be gone, others remain
const deletedData = await storage.getCredentialData(
credential1.id,
'subject-A',
resolver1.id,
{},
);
expect(deletedData).toBeNull();
const stillExistingData = await storage.getCredentialData(
credential1.id,
'subject-B',
resolver1.id,
{},
);
expect(stillExistingData).toBe('data-cred1-subjB-res1');
});
});
@@ -0,0 +1,463 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { DynamicCredentialEntry } from '@/modules/dynamic-credentials.ee/database/entities/dynamic-credential-entry';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
import { DynamicCredentialEntryRepository } from '@/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-entry.repository';
import { createDynamicCredentialResolver } from './shared/db-helpers';
import { createCredentials } from '../shared/db/credentials';
describe('DynamicCredentialEntryRepository', () => {
let repository: DynamicCredentialEntryRepository;
let previousEnvVar: string | undefined;
beforeAll(async () => {
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
await testModules.loadModules(['dynamic-credentials']);
await testDb.init();
repository = Container.get(DynamicCredentialEntryRepository);
});
afterAll(async () => {
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'DynamicCredentialEntry',
'DynamicCredentialResolver',
'CredentialsEntity',
]);
});
it('should save and retrieve a dynamic credential entry', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialEntry();
entry.credentialId = credential.id;
entry.subjectId = 'subject-123';
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
// ACT
const savedEntry = await repository.save(entry);
// Retrieve it back
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
subjectId: 'subject-123',
resolverId: resolver.id,
},
});
// ASSERT
expect(savedEntry).toBeDefined();
expect(savedEntry.credentialId).toBe(credential.id);
expect(savedEntry.subjectId).toBe('subject-123');
expect(savedEntry.resolverId).toBe(resolver.id);
expect(savedEntry.data).toBe('encrypted-test-data');
expect(savedEntry.createdAt).toBeInstanceOf(Date);
expect(savedEntry.updatedAt).toBeInstanceOf(Date);
expect(foundEntry).toBeDefined();
expect(foundEntry?.data).toBe('encrypted-test-data');
});
it('should cascade delete entries when credential is deleted', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create multiple entries for the same credential
const entry1 = new DynamicCredentialEntry();
entry1.credentialId = credential.id;
entry1.subjectId = 'subject-1';
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialEntry();
entry2.credentialId = credential.id;
entry2.subjectId = 'subject-2';
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save(entry1);
await repository.save(entry2);
// Verify entries exist
const entriesBeforeDelete = await repository.find({
where: {
credentialId: credential.id,
},
});
expect(entriesBeforeDelete).toHaveLength(2);
// ACT - Delete the credential
const credentialsRepository = Container.get(CredentialsRepository);
await credentialsRepository.delete({ id: credential.id });
// ASSERT - All entries for this credential should be cascade deleted
const entriesAfterDelete = await repository.find({
where: {
credentialId: credential.id,
},
});
expect(entriesAfterDelete).toHaveLength(0);
});
it('should cascade delete entries when resolver is deleted', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create entries for multiple credentials using the same resolver
const entry1 = new DynamicCredentialEntry();
entry1.credentialId = credential1.id;
entry1.subjectId = 'subject-1';
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialEntry();
entry2.credentialId = credential2.id;
entry2.subjectId = 'subject-2';
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save(entry1);
await repository.save(entry2);
// Verify entries exist
const entriesBeforeDelete = await repository.find({
where: {
resolverId: resolver.id,
},
});
expect(entriesBeforeDelete).toHaveLength(2);
// ACT - Delete the resolver
const resolverRepository = Container.get(DynamicCredentialResolverRepository);
await resolverRepository.delete({ id: resolver.id });
// ASSERT - All entries for this resolver should be cascade deleted
const entriesAfterDelete = await repository.find({
where: {
resolverId: resolver.id,
},
});
expect(entriesAfterDelete).toHaveLength(0);
});
it('should fetch CredentialsEntity through ManyToOne relationship', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential for Relationship',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialEntry();
entry.credentialId = credential.id;
entry.subjectId = 'subject-123';
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
await repository.save(entry);
// ACT - Fetch entry with credential relationship loaded
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
subjectId: 'subject-123',
resolverId: resolver.id,
},
relations: ['credential'],
});
// ASSERT
expect(foundEntry).toBeDefined();
expect(foundEntry?.credential).toBeDefined();
expect(foundEntry?.credential.id).toBe(credential.id);
expect(foundEntry?.credential.name).toBe('Test Credential for Relationship');
expect(foundEntry?.credential.type).toBe('testType');
});
it('should fetch DynamicCredentialResolver through ManyToOne relationship', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver-for-relationship',
type: 'test-type',
config: 'test-config-data',
});
const entry = new DynamicCredentialEntry();
entry.credentialId = credential.id;
entry.subjectId = 'subject-456';
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
await repository.save(entry);
// ACT - Fetch entry with resolver relationship loaded
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
subjectId: 'subject-456',
resolverId: resolver.id,
},
relations: ['resolver'],
});
// ASSERT
expect(foundEntry).toBeDefined();
expect(foundEntry?.resolver).toBeDefined();
expect(foundEntry?.resolver.id).toBe(resolver.id);
expect(foundEntry?.resolver.name).toBe('test-resolver-for-relationship');
expect(foundEntry?.resolver.type).toBe('test-type');
});
it('should filter entries by credential type using find method', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'OAuth Credential',
type: 'oAuth2Api',
data: 'oauth-data',
});
const credential2 = await createCredentials({
name: 'API Key Credential',
type: 'apiKeyAuth',
data: 'api-key-data',
});
const credential3 = await createCredentials({
name: 'Another OAuth Credential',
type: 'oAuth2Api',
data: 'oauth-data-2',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create entries for different credential types
const entry1 = new DynamicCredentialEntry();
entry1.credentialId = credential1.id;
entry1.subjectId = 'subject-1';
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialEntry();
entry2.credentialId = credential2.id;
entry2.subjectId = 'subject-2';
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
const entry3 = new DynamicCredentialEntry();
entry3.credentialId = credential3.id;
entry3.subjectId = 'subject-3';
entry3.resolverId = resolver.id;
entry3.data = 'data-3';
await repository.save([entry1, entry2, entry3]);
// ACT - Query entries where credential type is 'oAuth2Api'
const oauthEntries = await repository.find({
where: {
credential: {
type: 'oAuth2Api',
},
},
relations: ['credential'],
});
// ASSERT
expect(oauthEntries).toHaveLength(2);
expect(oauthEntries.every((entry) => entry.credential.type === 'oAuth2Api')).toBe(true);
expect(oauthEntries.map((e) => e.subjectId).sort()).toEqual(['subject-1', 'subject-3']);
});
it('should filter entries by resolver type using find method', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const resolver1 = await createDynamicCredentialResolver({
name: 'AWS Resolver',
type: 'aws-secrets-manager',
config: 'aws-config',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'Azure Resolver',
type: 'azure-key-vault',
config: 'azure-config',
});
const resolver3 = await createDynamicCredentialResolver({
name: 'Another AWS Resolver',
type: 'aws-secrets-manager',
config: 'aws-config-2',
});
// Create entries for different resolver types
const entry1 = new DynamicCredentialEntry();
entry1.credentialId = credential.id;
entry1.subjectId = 'subject-1';
entry1.resolverId = resolver1.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialEntry();
entry2.credentialId = credential.id;
entry2.subjectId = 'subject-2';
entry2.resolverId = resolver2.id;
entry2.data = 'data-2';
const entry3 = new DynamicCredentialEntry();
entry3.credentialId = credential.id;
entry3.subjectId = 'subject-3';
entry3.resolverId = resolver3.id;
entry3.data = 'data-3';
await repository.save([entry1, entry2, entry3]);
// ACT - Query entries where resolver type is 'aws-secrets-manager'
const awsEntries = await repository.find({
where: {
resolver: {
type: 'aws-secrets-manager',
},
},
relations: ['resolver'],
});
// ASSERT
expect(awsEntries).toHaveLength(2);
expect(awsEntries.every((entry) => entry.resolver.type === 'aws-secrets-manager')).toBe(true);
expect(awsEntries.map((e) => e.subjectId).sort()).toEqual(['subject-1', 'subject-3']);
});
it('should filter entries by both credential type and resolver type using find method', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'OAuth Credential 1',
type: 'oAuth2Api',
data: 'oauth-data-1',
});
const credential2 = await createCredentials({
name: 'OAuth Credential 2',
type: 'oAuth2Api',
data: 'oauth-data-2',
});
const credential3 = await createCredentials({
name: 'API Key Credential',
type: 'apiKeyAuth',
data: 'api-key-data',
});
const resolver1 = await createDynamicCredentialResolver({
name: 'AWS Resolver',
type: 'aws-secrets-manager',
config: 'aws-config',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'Azure Resolver',
type: 'azure-key-vault',
config: 'azure-config',
});
// Create entries with various combinations
const entry1 = new DynamicCredentialEntry();
entry1.credentialId = credential1.id;
entry1.subjectId = 'subject-1';
entry1.resolverId = resolver1.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialEntry();
entry2.credentialId = credential1.id;
entry2.subjectId = 'subject-2';
entry2.resolverId = resolver2.id;
entry2.data = 'data-2';
const entry3 = new DynamicCredentialEntry();
entry3.credentialId = credential2.id;
entry3.subjectId = 'subject-3';
entry3.resolverId = resolver1.id;
entry3.data = 'data-3';
const entry4 = new DynamicCredentialEntry();
entry4.credentialId = credential3.id;
entry4.subjectId = 'subject-4';
entry4.resolverId = resolver1.id;
entry4.data = 'data-4';
await repository.save([entry1, entry2, entry3, entry4]);
// ACT - Query entries where credential type is 'oAuth2Api' AND resolver type is 'aws-secrets-manager'
const filteredEntries = await repository.find({
where: {
credential: {
type: 'oAuth2Api',
},
resolver: {
type: 'aws-secrets-manager',
},
},
relations: ['credential', 'resolver'],
});
// ASSERT - Should only return entries with both OAuth credentials and AWS resolver
expect(filteredEntries).toHaveLength(2);
expect(
filteredEntries.every(
(entry) =>
entry.credential.type === 'oAuth2Api' && entry.resolver.type === 'aws-secrets-manager',
),
).toBe(true);
expect(filteredEntries.map((e) => e.subjectId).sort()).toEqual(['subject-1', 'subject-3']);
});
});
@@ -0,0 +1,594 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { DynamicCredentialUserEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage';
import { createDynamicCredentialResolver } from './shared/db-helpers';
import { createCredentials } from '../shared/db/credentials';
import { createUser } from '../shared/db/users';
describe('DynamicCredentialUserEntryStorage', () => {
let storage: DynamicCredentialUserEntryStorage;
let previousEnvVar: string | undefined;
beforeAll(async () => {
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
await testModules.loadModules(['dynamic-credentials']);
await testDb.init();
storage = Container.get(DynamicCredentialUserEntryStorage);
});
afterAll(async () => {
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'DynamicCredentialUserEntry',
'DynamicCredentialResolver',
'CredentialsEntity',
'User',
]);
});
it('should store and retrieve credential data for a user', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const testData = 'encrypted-user-credential-data';
// ACT - Store
await storage.setCredentialData(credential.id, user.id, resolver.id, testData, {});
// ACT - Retrieve
const retrievedData = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
// ASSERT
expect(retrievedData).toBe(testData);
});
it('should return null for non-existent credential data', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'test@example.com',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// ACT - Try to retrieve non-existent data
const retrievedData = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
// ASSERT
expect(retrievedData).toBeNull();
});
it('should update existing credential data (upsert)', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'upsert@example.com',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// ACT - Insert
await storage.setCredentialData(credential.id, user.id, resolver.id, 'original-data', {});
// ACT - Update
await storage.setCredentialData(credential.id, user.id, resolver.id, 'updated-data', {});
// ACT - Retrieve
const data = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
// ASSERT
expect(data).toBe('updated-data');
});
it('should delete credential data', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'delete@example.com',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const testData = 'data-to-delete';
// Store data first
await storage.setCredentialData(credential.id, user.id, resolver.id, testData, {});
// Verify it exists
const beforeDelete = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
expect(beforeDelete).toBe(testData);
// ACT - Delete
await storage.deleteCredentialData(credential.id, user.id, resolver.id, {});
// ASSERT - Verify it's gone
const afterDelete = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
expect(afterDelete).toBeNull();
});
it('should isolate entries by composite key (multiple entries do not affect each other)', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const user1 = await createUser({
email: 'user1@example.com',
firstName: 'User',
lastName: 'One',
});
const user2 = await createUser({
email: 'user2@example.com',
firstName: 'User',
lastName: 'Two',
});
const resolver1 = await createDynamicCredentialResolver({
name: 'resolver-1',
type: 'test',
config: 'test-config-1',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'resolver-2',
type: 'test',
config: 'test-config-2',
});
// ACT - Create multiple entries with different combinations
// Same credential, different users
await storage.setCredentialData(
credential1.id,
user1.id,
resolver1.id,
'data-cred1-user1-res1',
{},
);
await storage.setCredentialData(
credential1.id,
user2.id,
resolver1.id,
'data-cred1-user2-res1',
{},
);
// Same credential and user, different resolver
await storage.setCredentialData(
credential1.id,
user1.id,
resolver2.id,
'data-cred1-user1-res2',
{},
);
// Different credential, same user and resolver
await storage.setCredentialData(
credential2.id,
user1.id,
resolver1.id,
'data-cred2-user1-res1',
{},
);
// ASSERT - Each entry should be isolated and return correct data
const data1 = await storage.getCredentialData(credential1.id, user1.id, resolver1.id, {});
expect(data1).toBe('data-cred1-user1-res1');
const data2 = await storage.getCredentialData(credential1.id, user2.id, resolver1.id, {});
expect(data2).toBe('data-cred1-user2-res1');
const data3 = await storage.getCredentialData(credential1.id, user1.id, resolver2.id, {});
expect(data3).toBe('data-cred1-user1-res2');
const data4 = await storage.getCredentialData(credential2.id, user1.id, resolver1.id, {});
expect(data4).toBe('data-cred2-user1-res1');
// ACT - Update one entry
await storage.setCredentialData(
credential1.id,
user1.id,
resolver1.id,
'updated-data-cred1-user1-res1',
{},
);
// ASSERT - Only the updated entry should change, others remain unchanged
const updatedData1 = await storage.getCredentialData(
credential1.id,
user1.id,
resolver1.id,
{},
);
expect(updatedData1).toBe('updated-data-cred1-user1-res1');
const unchangedData2 = await storage.getCredentialData(
credential1.id,
user2.id,
resolver1.id,
{},
);
expect(unchangedData2).toBe('data-cred1-user2-res1');
const unchangedData3 = await storage.getCredentialData(
credential1.id,
user1.id,
resolver2.id,
{},
);
expect(unchangedData3).toBe('data-cred1-user1-res2');
// ACT - Delete one entry
await storage.deleteCredentialData(credential1.id, user1.id, resolver1.id, {});
// ASSERT - Deleted entry should be gone, others remain
const deletedData = await storage.getCredentialData(credential1.id, user1.id, resolver1.id, {});
expect(deletedData).toBeNull();
const stillExistingData = await storage.getCredentialData(
credential1.id,
user2.id,
resolver1.id,
{},
);
expect(stillExistingData).toBe('data-cred1-user2-res1');
});
describe('CASCADE Delete - User Deletion', () => {
it('should not retrieve credential data after user is deleted', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'deletable@example.com',
firstName: 'Deletable',
lastName: 'User',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const testData = 'user-credential-data';
// Store credential data for the user
await storage.setCredentialData(credential.id, user.id, resolver.id, testData, {});
// Verify data exists before deletion
const beforeDelete = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
expect(beforeDelete).toBe(testData);
// ACT - Delete the user (CASCADE should remove credential entries)
const userRepository = Container.get(UserRepository);
await userRepository.delete({ id: user.id });
// ASSERT - Credential data should no longer be retrievable
const afterDelete = await storage.getCredentialData(credential.id, user.id, resolver.id, {});
expect(afterDelete).toBeNull();
});
it('should delete all credential entries for a user when user is deleted', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const user = await createUser({
email: 'multidelete@example.com',
});
const resolver1 = await createDynamicCredentialResolver({
name: 'resolver-1',
type: 'test',
config: 'test-config-1',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'resolver-2',
type: 'test',
config: 'test-config-2',
});
// Store multiple credential entries for the same user
await storage.setCredentialData(credential1.id, user.id, resolver1.id, 'data-1', {});
await storage.setCredentialData(credential2.id, user.id, resolver1.id, 'data-2', {});
await storage.setCredentialData(credential1.id, user.id, resolver2.id, 'data-3', {});
// Verify all data exists before deletion
const data1Before = await storage.getCredentialData(
credential1.id,
user.id,
resolver1.id,
{},
);
const data2Before = await storage.getCredentialData(
credential2.id,
user.id,
resolver1.id,
{},
);
const data3Before = await storage.getCredentialData(
credential1.id,
user.id,
resolver2.id,
{},
);
expect(data1Before).toBe('data-1');
expect(data2Before).toBe('data-2');
expect(data3Before).toBe('data-3');
// ACT - Delete the user
const userRepository = Container.get(UserRepository);
await userRepository.delete({ id: user.id });
// ASSERT - All credential entries for this user should be gone
const data1After = await storage.getCredentialData(credential1.id, user.id, resolver1.id, {});
const data2After = await storage.getCredentialData(credential2.id, user.id, resolver1.id, {});
const data3After = await storage.getCredentialData(credential1.id, user.id, resolver2.id, {});
expect(data1After).toBeNull();
expect(data2After).toBeNull();
expect(data3After).toBeNull();
});
it('should only delete credential entries for the deleted user, not other users', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Shared Credential',
type: 'testType',
data: 'test-data',
});
const user1 = await createUser({
email: 'user1-todelete@example.com',
firstName: 'User1',
});
const user2 = await createUser({
email: 'user2-tokeep@example.com',
firstName: 'User2',
});
const resolver = await createDynamicCredentialResolver({
name: 'shared-resolver',
type: 'test',
config: 'test-data',
});
// Store credential data for both users
await storage.setCredentialData(credential.id, user1.id, resolver.id, 'user1-data', {});
await storage.setCredentialData(credential.id, user2.id, resolver.id, 'user2-data', {});
// Verify both exist before deletion
const user1DataBefore = await storage.getCredentialData(
credential.id,
user1.id,
resolver.id,
{},
);
const user2DataBefore = await storage.getCredentialData(
credential.id,
user2.id,
resolver.id,
{},
);
expect(user1DataBefore).toBe('user1-data');
expect(user2DataBefore).toBe('user2-data');
// ACT - Delete user1
const userRepository = Container.get(UserRepository);
await userRepository.delete({ id: user1.id });
// ASSERT - user1's data should be gone, user2's data should remain
const user1DataAfter = await storage.getCredentialData(
credential.id,
user1.id,
resolver.id,
{},
);
const user2DataAfter = await storage.getCredentialData(
credential.id,
user2.id,
resolver.id,
{},
);
expect(user1DataAfter).toBeNull();
expect(user2DataAfter).toBe('user2-data');
});
it('should handle user deletion when user has no credential entries', async () => {
// ARRANGE
const user = await createUser({
email: 'nocreds@example.com',
firstName: 'No',
lastName: 'Credentials',
});
// ACT - Delete user with no credential entries
const userRepository = Container.get(UserRepository);
const deleteOperation = async () => await userRepository.delete({ id: user.id });
// ASSERT - Should not throw any errors
await expect(deleteOperation()).resolves.not.toThrow();
});
});
describe('deleteAllCredentialData', () => {
it('should delete all credential entries for a specific resolver', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const user1 = await createUser({ email: 'user1@example.com' });
const user2 = await createUser({ email: 'user2@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'resolver-to-delete',
type: 'test',
config: 'test-data',
});
// Store multiple entries using the same resolver
await storage.setCredentialData(credential1.id, user1.id, resolver.id, 'data-1', {});
await storage.setCredentialData(credential2.id, user1.id, resolver.id, 'data-2', {});
await storage.setCredentialData(credential1.id, user2.id, resolver.id, 'data-3', {});
// Verify entries exist
const data1Before = await storage.getCredentialData(
credential1.id,
user1.id,
resolver.id,
{},
);
const data2Before = await storage.getCredentialData(
credential2.id,
user1.id,
resolver.id,
{},
);
const data3Before = await storage.getCredentialData(
credential1.id,
user2.id,
resolver.id,
{},
);
expect(data1Before).toBe('data-1');
expect(data2Before).toBe('data-2');
expect(data3Before).toBe('data-3');
// ACT - Delete all entries for this resolver
await storage.deleteAllCredentialData({
resolverId: resolver.id,
resolverName: resolver.name,
configuration: {},
});
// ASSERT - All entries for this resolver should be gone
const data1After = await storage.getCredentialData(credential1.id, user1.id, resolver.id, {});
const data2After = await storage.getCredentialData(credential2.id, user1.id, resolver.id, {});
const data3After = await storage.getCredentialData(credential1.id, user2.id, resolver.id, {});
expect(data1After).toBeNull();
expect(data2After).toBeNull();
expect(data3After).toBeNull();
});
it('should only delete entries for the specified resolver, not other resolvers', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({ email: 'user@example.com' });
const resolver1 = await createDynamicCredentialResolver({
name: 'resolver-to-delete',
type: 'test',
config: 'test-data-1',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'resolver-to-keep',
type: 'test',
config: 'test-data-2',
});
// Store data with both resolvers
await storage.setCredentialData(credential.id, user.id, resolver1.id, 'resolver1-data', {});
await storage.setCredentialData(credential.id, user.id, resolver2.id, 'resolver2-data', {});
// Verify both exist
const resolver1DataBefore = await storage.getCredentialData(
credential.id,
user.id,
resolver1.id,
{},
);
const resolver2DataBefore = await storage.getCredentialData(
credential.id,
user.id,
resolver2.id,
{},
);
expect(resolver1DataBefore).toBe('resolver1-data');
expect(resolver2DataBefore).toBe('resolver2-data');
// ACT - Delete all entries for resolver1
await storage.deleteAllCredentialData({
resolverId: resolver1.id,
resolverName: resolver1.name,
configuration: {},
});
// ASSERT - resolver1 data should be gone, resolver2 data should remain
const resolver1DataAfter = await storage.getCredentialData(
credential.id,
user.id,
resolver1.id,
{},
);
const resolver2DataAfter = await storage.getCredentialData(
credential.id,
user.id,
resolver2.id,
{},
);
expect(resolver1DataAfter).toBeNull();
expect(resolver2DataAfter).toBe('resolver2-data');
});
});
});
@@ -0,0 +1,874 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { CredentialsRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { DynamicCredentialUserEntry } from '@/modules/dynamic-credentials.ee/database/entities/dynamic-credential-user-entry';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
import { DynamicCredentialUserEntryRepository } from '@/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-user-entry.repository';
import { createDynamicCredentialResolver } from './shared/db-helpers';
import { createCredentials } from '../shared/db/credentials';
import { createUser } from '../shared/db/users';
describe('DynamicCredentialUserEntryRepository', () => {
let repository: DynamicCredentialUserEntryRepository;
let previousEnvVar: string | undefined;
beforeAll(async () => {
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
await testModules.loadModules(['dynamic-credentials']);
await testDb.init();
repository = Container.get(DynamicCredentialUserEntryRepository);
});
afterAll(async () => {
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'DynamicCredentialUserEntry',
'DynamicCredentialResolver',
'CredentialsEntity',
'User',
]);
});
describe('CRUD Operations', () => {
it('should create and retrieve a dynamic credential user entry', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'encrypted-user-data';
// ACT
const savedEntry = await repository.save(entry);
// Retrieve it back
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
});
// ASSERT
expect(savedEntry).toBeDefined();
expect(savedEntry.credentialId).toBe(credential.id);
expect(savedEntry.userId).toBe(user.id);
expect(savedEntry.resolverId).toBe(resolver.id);
expect(savedEntry.data).toBe('encrypted-user-data');
expect(savedEntry.createdAt).toBeInstanceOf(Date);
expect(savedEntry.updatedAt).toBeInstanceOf(Date);
expect(foundEntry).toBeDefined();
expect(foundEntry?.data).toBe('encrypted-user-data');
});
it('should update an existing entry', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'test@example.com',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'original-data';
await repository.save(entry);
// ACT - Update the entry
const updatedEntry = await repository.save({
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
data: 'updated-data',
});
// ASSERT
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
});
expect(updatedEntry.data).toBe('updated-data');
expect(foundEntry?.data).toBe('updated-data');
});
it('should delete an entry', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'test@example.com',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'test-data';
await repository.save(entry);
// Verify it exists
const entryBeforeDelete = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
});
expect(entryBeforeDelete).toBeDefined();
// ACT - Delete the entry
await repository.delete({
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
});
// ASSERT
const entryAfterDelete = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
});
expect(entryAfterDelete).toBeNull();
});
it('should find multiple entries', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user1 = await createUser({ email: 'user1@example.com' });
const user2 = await createUser({ email: 'user2@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential.id;
entry1.userId = user1.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential.id;
entry2.userId = user2.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save([entry1, entry2]);
// ACT
const entries = await repository.find({
where: {
credentialId: credential.id,
},
});
// ASSERT
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.userId).sort()).toEqual([user1.id, user2.id].sort());
});
});
describe('CASCADE Delete', () => {
it('should cascade delete entries when credential is deleted', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user1 = await createUser({ email: 'user1@example.com' });
const user2 = await createUser({ email: 'user2@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create multiple entries for the same credential
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential.id;
entry1.userId = user1.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential.id;
entry2.userId = user2.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save([entry1, entry2]);
// Verify entries exist
const entriesBeforeDelete = await repository.find({
where: {
credentialId: credential.id,
},
});
expect(entriesBeforeDelete).toHaveLength(2);
// ACT - Delete the credential
const credentialsRepository = Container.get(CredentialsRepository);
await credentialsRepository.delete({ id: credential.id });
// ASSERT - All entries for this credential should be cascade deleted
const entriesAfterDelete = await repository.find({
where: {
credentialId: credential.id,
},
});
expect(entriesAfterDelete).toHaveLength(0);
});
it('should cascade delete entries when user is deleted', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const user = await createUser({ email: 'user@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create entries for multiple credentials using the same user
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential1.id;
entry1.userId = user.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential2.id;
entry2.userId = user.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save([entry1, entry2]);
// Verify entries exist
const entriesBeforeDelete = await repository.find({
where: {
userId: user.id,
},
});
expect(entriesBeforeDelete).toHaveLength(2);
// ACT - Delete the user
const userRepository = Container.get(UserRepository);
await userRepository.delete({ id: user.id });
// ASSERT - All entries for this user should be cascade deleted
const entriesAfterDelete = await repository.find({
where: {
userId: user.id,
},
});
expect(entriesAfterDelete).toHaveLength(0);
});
it('should cascade delete entries when resolver is deleted', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'Credential 1',
type: 'testType',
data: 'test-data-1',
});
const credential2 = await createCredentials({
name: 'Credential 2',
type: 'testType',
data: 'test-data-2',
});
const user = await createUser({ email: 'user@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create entries for multiple credentials using the same resolver
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential1.id;
entry1.userId = user.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential2.id;
entry2.userId = user.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save([entry1, entry2]);
// Verify entries exist
const entriesBeforeDelete = await repository.find({
where: {
resolverId: resolver.id,
},
});
expect(entriesBeforeDelete).toHaveLength(2);
// ACT - Delete the resolver
const resolverRepository = Container.get(DynamicCredentialResolverRepository);
await resolverRepository.delete({ id: resolver.id });
// ASSERT - All entries for this resolver should be cascade deleted
const entriesAfterDelete = await repository.find({
where: {
resolverId: resolver.id,
},
});
expect(entriesAfterDelete).toHaveLength(0);
});
});
describe('Relationships', () => {
it('should fetch CredentialsEntity through ManyToOne relationship', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential for Relationship',
type: 'testType',
data: 'test-data',
});
const user = await createUser({ email: 'user@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
await repository.save(entry);
// ACT - Fetch entry with credential relationship loaded
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
relations: ['credential'],
});
// ASSERT
expect(foundEntry).toBeDefined();
expect(foundEntry?.credential).toBeDefined();
expect(foundEntry?.credential.id).toBe(credential.id);
expect(foundEntry?.credential.name).toBe('Test Credential for Relationship');
expect(foundEntry?.credential.type).toBe('testType');
});
it('should fetch User through ManyToOne relationship', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'testuser@example.com',
firstName: 'Test',
lastName: 'User',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
await repository.save(entry);
// ACT - Fetch entry with user relationship loaded
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
relations: ['user'],
});
// ASSERT
expect(foundEntry).toBeDefined();
expect(foundEntry?.user).toBeDefined();
expect(foundEntry?.user.id).toBe(user.id);
expect(foundEntry?.user.email).toBe('testuser@example.com');
expect(foundEntry?.user.firstName).toBe('Test');
expect(foundEntry?.user.lastName).toBe('User');
});
it('should fetch DynamicCredentialResolver through ManyToOne relationship', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({ email: 'user@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver-for-relationship',
type: 'test-type',
config: 'test-config-data',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
await repository.save(entry);
// ACT - Fetch entry with resolver relationship loaded
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
relations: ['resolver'],
});
// ASSERT
expect(foundEntry).toBeDefined();
expect(foundEntry?.resolver).toBeDefined();
expect(foundEntry?.resolver.id).toBe(resolver.id);
expect(foundEntry?.resolver.name).toBe('test-resolver-for-relationship');
expect(foundEntry?.resolver.type).toBe('test-type');
});
it('should fetch all relationships at once', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Multi-Relation Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({
email: 'multirelation@example.com',
firstName: 'Multi',
lastName: 'Relation',
});
const resolver = await createDynamicCredentialResolver({
name: 'multi-resolver',
type: 'multi-type',
config: 'multi-config',
});
const entry = new DynamicCredentialUserEntry();
entry.credentialId = credential.id;
entry.userId = user.id;
entry.resolverId = resolver.id;
entry.data = 'encrypted-test-data';
await repository.save(entry);
// ACT - Fetch entry with all relationships loaded
const foundEntry = await repository.findOne({
where: {
credentialId: credential.id,
userId: user.id,
resolverId: resolver.id,
},
relations: ['credential', 'user', 'resolver'],
});
// ASSERT
expect(foundEntry).toBeDefined();
expect(foundEntry?.credential).toBeDefined();
expect(foundEntry?.user).toBeDefined();
expect(foundEntry?.resolver).toBeDefined();
expect(foundEntry?.credential.name).toBe('Multi-Relation Credential');
expect(foundEntry?.user.email).toBe('multirelation@example.com');
expect(foundEntry?.resolver.name).toBe('multi-resolver');
});
});
describe('Query Filtering', () => {
it('should filter entries by user', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user1 = await createUser({ email: 'user1@example.com' });
const user2 = await createUser({ email: 'user2@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential.id;
entry1.userId = user1.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential.id;
entry2.userId = user2.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save([entry1, entry2]);
// ACT - Query entries for user1
const user1Entries = await repository.find({
where: {
userId: user1.id,
},
});
// ASSERT
expect(user1Entries).toHaveLength(1);
expect(user1Entries[0].userId).toBe(user1.id);
expect(user1Entries[0].data).toBe('data-1');
});
it('should filter entries by credential type using find method', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'OAuth Credential',
type: 'oAuth2Api',
data: 'oauth-data',
});
const credential2 = await createCredentials({
name: 'API Key Credential',
type: 'apiKeyAuth',
data: 'api-key-data',
});
const credential3 = await createCredentials({
name: 'Another OAuth Credential',
type: 'oAuth2Api',
data: 'oauth-data-2',
});
const user = await createUser({ email: 'user@example.com' });
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
// Create entries for different credential types
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential1.id;
entry1.userId = user.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential2.id;
entry2.userId = user.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
const entry3 = new DynamicCredentialUserEntry();
entry3.credentialId = credential3.id;
entry3.userId = user.id;
entry3.resolverId = resolver.id;
entry3.data = 'data-3';
await repository.save([entry1, entry2, entry3]);
// ACT - Query entries where credential type is 'oAuth2Api'
const oauthEntries = await repository.find({
where: {
credential: {
type: 'oAuth2Api',
},
},
relations: ['credential'],
});
// ASSERT
expect(oauthEntries).toHaveLength(2);
expect(oauthEntries.every((entry) => entry.credential.type === 'oAuth2Api')).toBe(true);
expect(oauthEntries.map((e) => e.credentialId).sort()).toEqual(
[credential1.id, credential3.id].sort(),
);
});
it('should filter entries by resolver type using find method', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user = await createUser({ email: 'user@example.com' });
const resolver1 = await createDynamicCredentialResolver({
name: 'AWS Resolver',
type: 'aws-secrets-manager',
config: 'aws-config',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'Azure Resolver',
type: 'azure-key-vault',
config: 'azure-config',
});
const resolver3 = await createDynamicCredentialResolver({
name: 'Another AWS Resolver',
type: 'aws-secrets-manager',
config: 'aws-config-2',
});
// Create entries for different resolver types
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential.id;
entry1.userId = user.id;
entry1.resolverId = resolver1.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential.id;
entry2.userId = user.id;
entry2.resolverId = resolver2.id;
entry2.data = 'data-2';
const entry3 = new DynamicCredentialUserEntry();
entry3.credentialId = credential.id;
entry3.userId = user.id;
entry3.resolverId = resolver3.id;
entry3.data = 'data-3';
await repository.save([entry1, entry2, entry3]);
// ACT - Query entries where resolver type is 'aws-secrets-manager'
const awsEntries = await repository.find({
where: {
resolver: {
type: 'aws-secrets-manager',
},
},
relations: ['resolver'],
});
// ASSERT
expect(awsEntries).toHaveLength(2);
expect(awsEntries.every((entry) => entry.resolver.type === 'aws-secrets-manager')).toBe(true);
expect(awsEntries.map((e) => e.resolverId).sort()).toEqual(
[resolver1.id, resolver3.id].sort(),
);
});
it('should filter entries by user email using find method', async () => {
// ARRANGE
const credential = await createCredentials({
name: 'Test Credential',
type: 'testType',
data: 'test-data',
});
const user1 = await createUser({
email: 'alice@example.com',
firstName: 'Alice',
});
const user2 = await createUser({
email: 'bob@example.com',
firstName: 'Bob',
});
const resolver = await createDynamicCredentialResolver({
name: 'test-resolver',
type: 'test',
config: 'test-data',
});
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential.id;
entry1.userId = user1.id;
entry1.resolverId = resolver.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential.id;
entry2.userId = user2.id;
entry2.resolverId = resolver.id;
entry2.data = 'data-2';
await repository.save([entry1, entry2]);
// ACT - Query entries where user email is 'alice@example.com'
const aliceEntries = await repository.find({
where: {
user: {
email: 'alice@example.com',
},
},
relations: ['user'],
});
// ASSERT
expect(aliceEntries).toHaveLength(1);
expect(aliceEntries[0].user.email).toBe('alice@example.com');
expect(aliceEntries[0].user.firstName).toBe('Alice');
});
it('should filter entries by multiple criteria', async () => {
// ARRANGE
const credential1 = await createCredentials({
name: 'OAuth Credential 1',
type: 'oAuth2Api',
data: 'oauth-data-1',
});
const credential2 = await createCredentials({
name: 'OAuth Credential 2',
type: 'oAuth2Api',
data: 'oauth-data-2',
});
const credential3 = await createCredentials({
name: 'API Key Credential',
type: 'apiKeyAuth',
data: 'api-key-data',
});
const user1 = await createUser({ email: 'user1@example.com' });
const user2 = await createUser({ email: 'user2@example.com' });
const resolver1 = await createDynamicCredentialResolver({
name: 'AWS Resolver',
type: 'aws-secrets-manager',
config: 'aws-config',
});
const resolver2 = await createDynamicCredentialResolver({
name: 'Azure Resolver',
type: 'azure-key-vault',
config: 'azure-config',
});
// Create entries with various combinations
const entry1 = new DynamicCredentialUserEntry();
entry1.credentialId = credential1.id;
entry1.userId = user1.id;
entry1.resolverId = resolver1.id;
entry1.data = 'data-1';
const entry2 = new DynamicCredentialUserEntry();
entry2.credentialId = credential1.id;
entry2.userId = user1.id;
entry2.resolverId = resolver2.id;
entry2.data = 'data-2';
const entry3 = new DynamicCredentialUserEntry();
entry3.credentialId = credential2.id;
entry3.userId = user1.id;
entry3.resolverId = resolver1.id;
entry3.data = 'data-3';
const entry4 = new DynamicCredentialUserEntry();
entry4.credentialId = credential3.id;
entry4.userId = user2.id;
entry4.resolverId = resolver1.id;
entry4.data = 'data-4';
await repository.save([entry1, entry2, entry3, entry4]);
// ACT - Query entries where credential type is 'oAuth2Api', user is user1, AND resolver type is 'aws-secrets-manager'
const filteredEntries = await repository.find({
where: {
credential: {
type: 'oAuth2Api',
},
user: {
id: user1.id,
},
resolver: {
type: 'aws-secrets-manager',
},
},
relations: ['credential', 'user', 'resolver'],
});
// ASSERT - Should only return entries matching all criteria
expect(filteredEntries).toHaveLength(2);
expect(
filteredEntries.every(
(entry) =>
entry.credential.type === 'oAuth2Api' &&
entry.user.id === user1.id &&
entry.resolver.type === 'aws-secrets-manager',
),
).toBe(true);
expect(filteredEntries.map((e) => e.credentialId).sort()).toEqual(
[credential1.id, credential2.id].sort(),
);
});
});
});
@@ -0,0 +1,22 @@
import { Container } from '@n8n/di';
import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
/**
* Creates a dynamic credential resolver for testing
*/
export async function createDynamicCredentialResolver(
attributes: Partial<DynamicCredentialResolver>,
): Promise<DynamicCredentialResolver> {
const repository = Container.get(DynamicCredentialResolverRepository);
const resolver = repository.create({
name: attributes.name ?? 'test-resolver',
type: attributes.type ?? 'test-type',
config: attributes.config ?? '{}',
...attributes,
});
return await repository.save(resolver);
}