mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(core): Add resolvable fields to credential entity (#22712)
This commit is contained in:
committed by
GitHub
parent
9a608dc00c
commit
a19fefece5
@@ -42,6 +42,25 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre
|
||||
@Column({ default: false })
|
||||
isGlobal: boolean;
|
||||
|
||||
/**
|
||||
* Whether the credential can be dynamically resolved by a resolver.
|
||||
*/
|
||||
@Column({ default: false })
|
||||
isResolvable: boolean;
|
||||
|
||||
/**
|
||||
* Whether the credential resolver should allow falling back to static credentials
|
||||
* if dynamic resolution fails.
|
||||
*/
|
||||
@Column({ default: false })
|
||||
resolvableAllowFallback: boolean;
|
||||
|
||||
/**
|
||||
* ID of the dynamic credential resolver associated with this credential.
|
||||
*/
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
resolverId: string | null;
|
||||
|
||||
toJSON() {
|
||||
const { shared, ...rest } = this;
|
||||
return rest;
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const credentialsTableName = 'credentials_entity';
|
||||
const resolverTableName = 'dynamic_credential_resolver';
|
||||
const FOREIGN_KEY_NAME = 'credentials_entity_resolverId_foreign';
|
||||
|
||||
export class AddResolvableFieldsToCredentials1765459448000 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, addForeignKey, column } }: MigrationContext) {
|
||||
await addColumns(credentialsTableName, [
|
||||
column('isResolvable').bool.notNull.default(false),
|
||||
column('resolvableAllowFallback').bool.notNull.default(false),
|
||||
column('resolverId').varchar(16),
|
||||
]);
|
||||
|
||||
await addForeignKey(
|
||||
credentialsTableName,
|
||||
'resolverId',
|
||||
[resolverTableName, 'id'],
|
||||
FOREIGN_KEY_NAME,
|
||||
'SET NULL',
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
|
||||
await dropForeignKey(
|
||||
credentialsTableName,
|
||||
'resolverId',
|
||||
[resolverTableName, 'id'],
|
||||
FOREIGN_KEY_NAME,
|
||||
);
|
||||
|
||||
await dropColumns(credentialsTableName, [
|
||||
'isResolvable',
|
||||
'resolvableAllowFallback',
|
||||
'resolverId',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,7 @@ import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837
|
||||
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
|
||||
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
|
||||
import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords';
|
||||
import { AddResolvableFieldsToCredentials1765459448000 } from '../common/1765459448000-AddResolvableFieldsToCredentials';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
export const mysqlMigrations: Migration[] = [
|
||||
@@ -253,4 +254,5 @@ export const mysqlMigrations: Migration[] = [
|
||||
CreateDynamicCredentialResolverTable1764682447000,
|
||||
AddDynamicCredentialEntryTable1764689388394,
|
||||
BackfillMissingWorkflowHistoryRecords1765448186933,
|
||||
AddResolvableFieldsToCredentials1765459448000,
|
||||
];
|
||||
|
||||
@@ -124,6 +124,7 @@ import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837
|
||||
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
|
||||
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
|
||||
import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords';
|
||||
import { AddResolvableFieldsToCredentials1765459448000 } from '../common/1765459448000-AddResolvableFieldsToCredentials';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
@@ -253,4 +254,5 @@ export const postgresMigrations: Migration[] = [
|
||||
CreateDynamicCredentialResolverTable1764682447000,
|
||||
AddDynamicCredentialEntryTable1764689388394,
|
||||
BackfillMissingWorkflowHistoryRecords1765448186933,
|
||||
AddResolvableFieldsToCredentials1765459448000,
|
||||
];
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const credentialsTableName = 'credentials_entity';
|
||||
const resolverTableName = 'dynamic_credential_resolver';
|
||||
const FOREIGN_KEY_NAME = 'credentials_entity_resolverId_foreign';
|
||||
|
||||
export class AddResolvableFieldsToCredentials1764689448000 implements ReversibleMigration {
|
||||
transaction = false as const;
|
||||
|
||||
async up({ schemaBuilder: { addColumns, addForeignKey, column } }: MigrationContext) {
|
||||
await addColumns(credentialsTableName, [
|
||||
column('isResolvable').bool.notNull.default(false),
|
||||
column('resolvableAllowFallback').bool.notNull.default(false),
|
||||
column('resolverId').varchar(16),
|
||||
]);
|
||||
|
||||
await addForeignKey(
|
||||
credentialsTableName,
|
||||
'resolverId',
|
||||
[resolverTableName, 'id'],
|
||||
FOREIGN_KEY_NAME,
|
||||
'SET NULL',
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns(credentialsTableName, [
|
||||
'isResolvable',
|
||||
'resolvableAllowFallback',
|
||||
'resolverId',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddPro
|
||||
import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn';
|
||||
import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson';
|
||||
import { AddCreatorIdToProjectTable1764276827837 } from './1764276827837-AddCreatorIdToProjectTable';
|
||||
import { AddResolvableFieldsToCredentials1764689448000 } from './1764689448000-AddResolvableFieldsToCredentials';
|
||||
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
||||
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
|
||||
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
|
||||
@@ -245,6 +246,7 @@ const sqliteMigrations: Migration[] = [
|
||||
CreateDynamicCredentialResolverTable1764682447000,
|
||||
AddDynamicCredentialEntryTable1764689388394,
|
||||
BackfillMissingWorkflowHistoryRecords1765448186933,
|
||||
AddResolvableFieldsToCredentials1764689448000,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
||||
@@ -25,6 +25,9 @@ const mockCredentialsService = (
|
||||
shared: [] as SharedCredentials[],
|
||||
isManaged: false,
|
||||
isGlobal: false,
|
||||
isResolvable: false,
|
||||
resolverId: null,
|
||||
resolvableAllowFallback: false,
|
||||
id,
|
||||
// Methods present on entities via WithTimestampsAndStringId mixin
|
||||
generateId() {},
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import { CredentialsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver';
|
||||
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
|
||||
|
||||
describe('DynamicCredentialResolverRepository', () => {
|
||||
let resolverRepository: DynamicCredentialResolverRepository;
|
||||
let credentialsRepository: CredentialsRepository;
|
||||
let previousEnvVar: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
|
||||
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
|
||||
await testModules.loadModules(['dynamic-credentials']);
|
||||
await testDb.init();
|
||||
resolverRepository = Container.get(DynamicCredentialResolverRepository);
|
||||
credentialsRepository = Container.get(CredentialsRepository);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testDb.truncate(['CredentialsEntity', 'DynamicCredentialResolver']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
it('should create and find a resolver', async () => {
|
||||
const resolver = resolverRepository.create({
|
||||
name: 'Test Resolver',
|
||||
type: 'oauth2',
|
||||
config: JSON.stringify({ clientId: 'test' }),
|
||||
});
|
||||
const saved = await resolverRepository.save(resolver);
|
||||
|
||||
const found = await resolverRepository.findOne({ where: { id: saved.id } });
|
||||
|
||||
expect(found).toMatchObject({
|
||||
id: saved.id,
|
||||
name: 'Test Resolver',
|
||||
type: 'oauth2',
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationship with CredentialsEntity', () => {
|
||||
let resolver: DynamicCredentialResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
resolver = await resolverRepository.save(
|
||||
resolverRepository.create({
|
||||
name: 'Test Resolver',
|
||||
type: 'oauth2',
|
||||
config: JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should link credentials to resolver and query them', async () => {
|
||||
await credentialsRepository.save([
|
||||
credentialsRepository.create({
|
||||
name: 'Cred 1',
|
||||
type: 'oauth2',
|
||||
data: '',
|
||||
isResolvable: true,
|
||||
resolverId: resolver.id,
|
||||
}),
|
||||
credentialsRepository.create({
|
||||
name: 'Cred 2',
|
||||
type: 'oauth2',
|
||||
data: '',
|
||||
isResolvable: true,
|
||||
resolvableAllowFallback: true,
|
||||
resolverId: resolver.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const linked = await credentialsRepository.find({ where: { resolverId: resolver.id } });
|
||||
|
||||
expect(linked).toHaveLength(2);
|
||||
expect(linked[0].resolverId).toBe(resolver.id);
|
||||
expect(linked[1].resolvableAllowFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nullable resolverId', async () => {
|
||||
const credential = await credentialsRepository.save(
|
||||
credentialsRepository.create({
|
||||
name: 'Standalone',
|
||||
type: 'apiKey',
|
||||
data: '',
|
||||
isResolvable: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(credential.resolverId).toBeNull();
|
||||
});
|
||||
|
||||
it('should set resolverId to null on resolver deletion (CASCADE SET NULL)', async () => {
|
||||
const credential = await credentialsRepository.save(
|
||||
credentialsRepository.create({
|
||||
name: 'Linked',
|
||||
type: 'oauth2',
|
||||
data: '',
|
||||
isResolvable: true,
|
||||
resolverId: resolver.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await resolverRepository.delete(resolver.id);
|
||||
const orphaned = await credentialsRepository.findOneBy({ id: credential.id });
|
||||
|
||||
expect(orphaned?.resolverId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user