From 2b4596eb66d8e68f504297e02e4e8a485c3a1e6b Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 20 Jan 2026 15:58:50 +0100 Subject: [PATCH] chore(core): Add dynamic credential user storage (#24579) --- .../@n8n/backend-test-utils/src/test-db.ts | 3 +- ...1000-AddDynamicCredentialUserEntryTable.ts | 36 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../dynamic-credential-user-entry-storage.ts | 103 +++ .../entities/dynamic-credential-user-entry.ts | 46 + ...ynamic-credential-user-entry.repository.ts | 11 + .../dynamic-credentials.module.ts | 5 +- ...amic-credential-user-entry-storage.test.ts | 594 ++++++++++++ ...c-credential-user-entry.repository.test.ts | 874 ++++++++++++++++++ 10 files changed, 1674 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1768901721000-AddDynamicCredentialUserEntryTable.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-user-entry.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-user-entry.repository.ts create mode 100644 packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry-storage.test.ts create mode 100644 packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry.repository.test.ts diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index e7d1e4b35e9..0c06d6438b1 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -90,7 +90,8 @@ type EntityName = | 'RefreshToken' | 'UserConsent' | 'DynamicCredentialEntry' - | 'DynamicCredentialResolver'; + | 'DynamicCredentialResolver' + | 'DynamicCredentialUserEntry'; /** * Truncate specific DB tables in a test DB. diff --git a/packages/@n8n/db/src/migrations/common/1768901721000-AddDynamicCredentialUserEntryTable.ts b/packages/@n8n/db/src/migrations/common/1768901721000-AddDynamicCredentialUserEntryTable.ts new file mode 100644 index 00000000000..5a7d2bf1db6 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1768901721000-AddDynamicCredentialUserEntryTable.ts @@ -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); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 17066838a5d..f82e01a9234 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index d9002183169..0e0b5da6fea 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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 }; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage.ts new file mode 100644 index 00000000000..5701cfdc2d5 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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 { + await this.dynamicCredentialUserEntryRepository.delete({ resolverId: handle.resolverId }); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-user-entry.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-user-entry.ts new file mode 100644 index 00000000000..3fb03cf28e5 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-user-entry.ts @@ -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; +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-user-entry.repository.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-user-entry.repository.ts new file mode 100644 index 00000000000..9d77f1162e1 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-user-entry.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(DynamicCredentialUserEntry, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts index a8dddf70aa0..410cc92d2f4 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts @@ -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() diff --git a/packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry-storage.test.ts b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry-storage.test.ts new file mode 100644 index 00000000000..d9bd87b24b7 --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry-storage.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry.repository.test.ts b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry.repository.test.ts new file mode 100644 index 00000000000..268bd76a47a --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-user-entry.repository.test.ts @@ -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(), + ); + }); + }); +});