mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
chore(core): Add dynamic credential user storage (#24579)
This commit is contained in:
parent
0371bef814
commit
2b4596eb66
10 changed files with 1674 additions and 2 deletions
|
|
@ -90,7 +90,8 @@ type EntityName =
|
|||
| 'RefreshToken'
|
||||
| 'UserConsent'
|
||||
| 'DynamicCredentialEntry'
|
||||
| 'DynamicCredentialResolver';
|
||||
| 'DynamicCredentialResolver'
|
||||
| 'DynamicCredentialUserEntry';
|
||||
|
||||
/**
|
||||
* Truncate specific DB tables in a test DB.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const tableName = 'dynamic_credential_user_entry';
|
||||
|
||||
export class AddDynamicCredentialUserEntryTable1768901721000 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(tableName)
|
||||
.withColumns(
|
||||
column('credentialId').varchar(16).primary.notNull,
|
||||
column('userId').uuid.primary.notNull,
|
||||
column('resolverId').varchar(16).primary.notNull,
|
||||
column('data').text.notNull,
|
||||
)
|
||||
.withTimestamps.withForeignKey('credentialId', {
|
||||
tableName: 'credentials_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('resolverId', {
|
||||
tableName: 'dynamic_credential_resolver',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('userId', {
|
||||
tableName: 'user',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withIndexOn(['userId'])
|
||||
.withIndexOn(['resolverId']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(tableName);
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +135,7 @@ import { AddWorkflowPublishScopeToProjectRoles1766064542000 } from '../common/17
|
|||
import { AddChatMessageIndices1766068346315 } from '../common/1766068346315-AddChatMessageIndices';
|
||||
import { ExpandModelColumnLength1768402473068 } from '../common/1768402473068-ExpandModelColumnLength';
|
||||
import { AddStoredAtToExecutionEntity1768557000000 } from '../common/1768557000000-AddStoredAtToExecutionEntity';
|
||||
import { AddDynamicCredentialUserEntryTable1768901721000 } from '../common/1768901721000-AddDynamicCredentialUserEntryTable';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
|
|
@ -275,4 +276,5 @@ export const postgresMigrations: Migration[] = [
|
|||
ChangeWorkflowStatisticsFKToNoAction1767018516000,
|
||||
ExpandModelColumnLength1768402473068,
|
||||
AddStoredAtToExecutionEntity1768557000000,
|
||||
AddDynamicCredentialUserEntryTable1768901721000,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ import { AddWorkflowVersionIdToExecutionData1765892199653 } from '../common/1765
|
|||
import { AddWorkflowPublishScopeToProjectRoles1766064542000 } from '../common/1766064542000-AddWorkflowPublishScopeToProjectRoles';
|
||||
import { ExpandModelColumnLength1768402473068 } from '../common/1768402473068-ExpandModelColumnLength';
|
||||
import { AddStoredAtToExecutionEntity1768557000000 } from '../common/1768557000000-AddStoredAtToExecutionEntity';
|
||||
import { AddDynamicCredentialUserEntryTable1768901721000 } from '../common/1768901721000-AddDynamicCredentialUserEntryTable';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
|
|
@ -263,6 +264,7 @@ const sqliteMigrations: Migration[] = [
|
|||
ChangeWorkflowStatisticsFKToNoAction1767018516000,
|
||||
ExpandModelColumnLength1768402473068,
|
||||
AddStoredAtToExecutionEntity1768557000000,
|
||||
AddDynamicCredentialUserEntryTable1768901721000,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { CredentialResolverHandle } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { ICredentialEntriesStorage } from './storage-interface';
|
||||
import { DynamicCredentialUserEntry } from '../../database/entities/dynamic-credential-user-entry';
|
||||
import { DynamicCredentialUserEntryRepository } from '../../database/repositories/dynamic-credential-user-entry.repository';
|
||||
|
||||
/**
|
||||
* Storage implementation for user-specific dynamic credential entries.
|
||||
* Stores credential data linked to individual users via credential resolvers.
|
||||
*/
|
||||
@Service()
|
||||
export class DynamicCredentialUserEntryStorage implements ICredentialEntriesStorage {
|
||||
constructor(
|
||||
private readonly dynamicCredentialUserEntryRepository: DynamicCredentialUserEntryRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieves credential data for a specific user from storage.
|
||||
*
|
||||
* @param credentialId - The ID of the credential
|
||||
* @param userId - The ID of the user who owns the credential data
|
||||
* @param resolverId - The ID of the resolver that resolved this credential
|
||||
* @returns The credential data string, or null if not found
|
||||
*/
|
||||
async getCredentialData(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
resolverId: string,
|
||||
_: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const entry = await this.dynamicCredentialUserEntryRepository.findOne({
|
||||
where: {
|
||||
credentialId,
|
||||
userId,
|
||||
resolverId,
|
||||
},
|
||||
});
|
||||
|
||||
return entry?.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores or updates credential data for a specific user.
|
||||
* Creates a new entry if one doesn't exist, otherwise updates the existing entry.
|
||||
*
|
||||
* @param credentialId - The ID of the credential
|
||||
* @param userId - The ID of the user who owns the credential data
|
||||
* @param resolverId - The ID of the resolver that resolved this credential
|
||||
* @param data - The credential data to store (typically encrypted)
|
||||
*/
|
||||
async setCredentialData(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
resolverId: string,
|
||||
data: string,
|
||||
_: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
let entry = await this.dynamicCredentialUserEntryRepository.findOne({
|
||||
where: { credentialId, userId, resolverId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
entry = new DynamicCredentialUserEntry();
|
||||
entry.credentialId = credentialId;
|
||||
entry.userId = userId;
|
||||
entry.resolverId = resolverId;
|
||||
}
|
||||
|
||||
entry.data = data;
|
||||
await this.dynamicCredentialUserEntryRepository.save(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes credential data for a specific user from storage.
|
||||
*
|
||||
* @param credentialId - The ID of the credential
|
||||
* @param userId - The ID of the user who owns the credential data
|
||||
* @param resolverId - The ID of the resolver that resolved this credential
|
||||
*/
|
||||
async deleteCredentialData(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
resolverId: string,
|
||||
_: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.dynamicCredentialUserEntryRepository.delete({
|
||||
credentialId,
|
||||
userId,
|
||||
resolverId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all credential entries associated with a specific resolver.
|
||||
* Used when a resolver is removed to clean up all related user credential data.
|
||||
*
|
||||
* @param handle - The resolver handle containing the resolver ID
|
||||
*/
|
||||
async deleteAllCredentialData(handle: CredentialResolverHandle): Promise<void> {
|
||||
await this.dynamicCredentialUserEntryRepository.delete({ resolverId: handle.resolverId });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { CredentialsEntity, User, WithTimestamps } from '@n8n/db';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
||||
|
||||
import { DynamicCredentialResolver } from './credential-resolver';
|
||||
|
||||
/**
|
||||
* Stores user-specific dynamic credential data resolved by a credential resolver.
|
||||
*/
|
||||
@Entity({
|
||||
name: 'dynamic_credential_user_entry',
|
||||
})
|
||||
export class DynamicCredentialUserEntry extends WithTimestamps {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
@PrimaryColumn({
|
||||
name: 'credentialId',
|
||||
})
|
||||
credentialId: string;
|
||||
|
||||
@PrimaryColumn({
|
||||
name: 'userId',
|
||||
})
|
||||
userId: string;
|
||||
|
||||
@PrimaryColumn({
|
||||
name: 'resolverId',
|
||||
})
|
||||
resolverId: string;
|
||||
|
||||
@Column('text')
|
||||
data: string;
|
||||
|
||||
@ManyToOne(() => CredentialsEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'credentialId' })
|
||||
credential: CredentialsEntity;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => DynamicCredentialResolver, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'resolverId' })
|
||||
resolver: DynamicCredentialResolver;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { DynamicCredentialUserEntry } from '../entities/dynamic-credential-user-entry';
|
||||
|
||||
@Service()
|
||||
export class DynamicCredentialUserEntryRepository extends Repository<DynamicCredentialUserEntry> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(DynamicCredentialUserEntry, dataSource.manager);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,8 +41,11 @@ export class DynamicCredentialsModule implements ModuleInterface {
|
|||
}
|
||||
const { DynamicCredentialResolver } = await import('./database/entities/credential-resolver');
|
||||
const { DynamicCredentialEntry } = await import('./database/entities/dynamic-credential-entry');
|
||||
const { DynamicCredentialUserEntry } = await import(
|
||||
'./database/entities/dynamic-credential-user-entry'
|
||||
);
|
||||
|
||||
return [DynamicCredentialResolver, DynamicCredentialEntry];
|
||||
return [DynamicCredentialResolver, DynamicCredentialEntry, DynamicCredentialUserEntry];
|
||||
}
|
||||
|
||||
@OnShutdown()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue