feat(core): Dynamic credential resolvers API routes (#22862)

This commit is contained in:
Guillaume Jacquart
2025-12-08 12:52:24 +01:00
committed by GitHub
parent b8d433f3bf
commit 5799cf712a
18 changed files with 1035 additions and 2 deletions
@@ -0,0 +1,199 @@
import { CreateCredentialResolverDto } from '../create-credential-resolver.dto';
describe('CreateCredentialResolverDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'basic valid resolver',
data: {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test-' },
},
},
{
name: 'resolver with complex config',
data: {
name: 'AWS Secrets Manager',
type: 'credential-resolver.aws-secrets-1.0',
config: {
region: 'us-east-1',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
},
},
},
{
name: 'resolver with empty config',
data: {
name: 'Simple Resolver',
type: 'credential-resolver.simple-1.0',
config: {},
},
},
{
name: 'resolver with minimum name length',
data: {
name: 'A',
type: 'type',
config: {},
},
},
])('should succeed validation for $name', ({ data }) => {
const result = CreateCredentialResolverDto.safeParse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe(data.name);
expect(result.data.type).toBe(data.type);
expect(result.data.config).toEqual(data.config);
}
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing name',
data: {
type: 'credential-resolver.stub-1.0',
config: {},
},
expectedErrorPath: ['name'],
},
{
name: 'missing type',
data: {
name: 'Test Resolver',
config: {},
},
expectedErrorPath: ['type'],
},
{
name: 'missing config',
data: {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
},
expectedErrorPath: ['config'],
},
{
name: 'empty name',
data: {
name: '',
type: 'credential-resolver.stub-1.0',
config: {},
},
expectedErrorPath: ['name'],
},
{
name: 'whitespace-only name',
data: {
name: ' ',
type: 'credential-resolver.stub-1.0',
config: {},
},
expectedErrorPath: ['name'],
},
{
name: 'name too long (>255 chars)',
data: {
name: 'a'.repeat(256),
type: 'credential-resolver.stub-1.0',
config: {},
},
expectedErrorPath: ['name'],
},
{
name: 'empty type',
data: {
name: 'Test Resolver',
type: '',
config: {},
},
expectedErrorPath: ['type'],
},
{
name: 'whitespace-only type',
data: {
name: 'Test Resolver',
type: ' ',
config: {},
},
expectedErrorPath: ['type'],
},
{
name: 'type too long (>255 chars)',
data: {
name: 'Test Resolver',
type: 'a'.repeat(256),
config: {},
},
expectedErrorPath: ['type'],
},
{
name: 'config as string',
data: {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: 'invalid',
},
expectedErrorPath: ['config'],
},
{
name: 'config as array',
data: {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: [],
},
expectedErrorPath: ['config'],
},
{
name: 'config as null',
data: {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: null,
},
expectedErrorPath: ['config'],
},
])('should fail validation for $name', ({ data, expectedErrorPath }) => {
const result = CreateCredentialResolverDto.safeParse(data);
expect(result.success).toBe(false);
if (expectedErrorPath && !result.success) {
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
}
});
});
describe('Trimming', () => {
test('should trim name', () => {
const result = CreateCredentialResolverDto.safeParse({
name: ' Test Resolver ',
type: 'credential-resolver.stub-1.0',
config: {},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Test Resolver');
}
});
test('should trim type', () => {
const result = CreateCredentialResolverDto.safeParse({
name: 'Test Resolver',
type: ' credential-resolver.stub-1.0 ',
config: {},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe('credential-resolver.stub-1.0');
}
});
});
});
@@ -0,0 +1,161 @@
import { UpdateCredentialResolverDto } from '../update-credential-resolver.dto';
describe('UpdateCredentialResolverDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'update only name',
data: {
name: 'Updated Resolver',
},
},
{
name: 'update only config',
data: {
config: { prefix: 'updated-' },
},
},
{
name: 'update both name and config',
data: {
name: 'Updated Resolver',
config: { prefix: 'updated-' },
},
},
{
name: 'update with empty config',
data: {
name: 'Updated Resolver',
config: {},
},
},
{
name: 'update with complex config',
data: {
config: {
region: 'eu-west-1',
timeout: 5000,
nested: {
value: true,
},
},
},
},
{
name: 'empty update (all optional)',
data: {},
},
{
name: 'minimum name length',
data: {
name: 'A',
},
},
])('should succeed validation for $name', ({ data }) => {
const result = UpdateCredentialResolverDto.safeParse(data);
expect(result.success).toBe(true);
if (result.success) {
if (data.name !== undefined) {
expect(result.data.name).toBe(data.name);
}
if (data.config !== undefined) {
expect(result.data.config).toEqual(data.config);
}
}
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'empty name',
data: {
name: '',
},
expectedErrorPath: ['name'],
},
{
name: 'whitespace-only name',
data: {
name: ' ',
},
expectedErrorPath: ['name'],
},
{
name: 'name too long (>255 chars)',
data: {
name: 'a'.repeat(256),
},
expectedErrorPath: ['name'],
},
{
name: 'config as string',
data: {
config: 'invalid',
},
expectedErrorPath: ['config'],
},
{
name: 'config as array',
data: {
config: [],
},
expectedErrorPath: ['config'],
},
{
name: 'config as null',
data: {
config: null,
},
expectedErrorPath: ['config'],
},
{
name: 'name as number',
data: {
name: 123,
},
expectedErrorPath: ['name'],
},
{
name: 'name as object',
data: {
name: { value: 'test' },
},
expectedErrorPath: ['name'],
},
])('should fail validation for $name', ({ data, expectedErrorPath }) => {
const result = UpdateCredentialResolverDto.safeParse(data);
expect(result.success).toBe(false);
if (expectedErrorPath && !result.success) {
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
}
});
});
describe('Trimming', () => {
test('should trim name when provided', () => {
const result = UpdateCredentialResolverDto.safeParse({
name: ' Updated Resolver ',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Updated Resolver');
}
});
test('should not affect config', () => {
const result = UpdateCredentialResolverDto.safeParse({
config: { key: ' value with spaces ' },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.config).toEqual({ key: ' value with spaces ' });
}
});
});
});
@@ -0,0 +1,13 @@
import { Z } from 'zod-class';
import {
credentialResolverNameSchema,
credentialResolverTypeSchema,
credentialResolverConfigSchema,
} from '../../schemas/credential-resolver.schema';
export class CreateCredentialResolverDto extends Z.class({
name: credentialResolverNameSchema,
type: credentialResolverTypeSchema,
config: credentialResolverConfigSchema,
}) {}
@@ -0,0 +1,11 @@
import { Z } from 'zod-class';
import {
credentialResolverNameSchema,
credentialResolverConfigSchema,
} from '../../schemas/credential-resolver.schema';
export class UpdateCredentialResolverDto extends Z.class({
name: credentialResolverNameSchema.optional(),
config: credentialResolverConfigSchema.optional(),
}) {}
+3
View File
@@ -12,6 +12,9 @@ export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
export { CreateCredentialResolverDto } from './credential-resolver/create-credential-resolver.dto';
export { UpdateCredentialResolverDto } from './credential-resolver/update-credential-resolver.dto';
export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto';
export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto';
export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto';
+5
View File
@@ -60,6 +60,11 @@ export type { SendWorkerStatusMessage } from './push/worker';
export type { BannerName } from './schemas/banner-name.schema';
export { ViewableMimeTypes } from './schemas/binary-data.schema';
export { passwordSchema } from './schemas/password.schema';
export {
credentialResolverSchema,
credentialResolversSchema,
type CredentialResolver,
} from './schemas/credential-resolver.schema';
export type {
ProjectType,
@@ -0,0 +1,261 @@
import {
credentialResolverIdSchema,
credentialResolverNameSchema,
credentialResolverTypeSchema,
credentialResolverConfigSchema,
credentialResolverSchema,
credentialResolversSchema,
} from '../credential-resolver.schema';
describe('credential-resolver.schema', () => {
describe('credentialResolverIdSchema', () => {
test.each([
{ name: 'valid UUID', value: '550e8400-e29b-41d4-a716-446655440000', expected: true },
{ name: 'short ID', value: 'abc123', expected: true },
{ name: 'nanoid', value: 'V1StGXR8_Z5jdHi6B-myT', expected: true },
{ name: 'ID at max length (36 chars)', value: 'a'.repeat(36), expected: true },
{ name: 'ID too long (37 chars)', value: 'a'.repeat(37), expected: false },
{ name: 'empty ID', value: '', expected: true }, // zod string allows empty by default
])('should validate $name', ({ value, expected }) => {
const result = credentialResolverIdSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('credentialResolverNameSchema', () => {
test.each([
{ name: 'valid name', value: 'Test Resolver', expected: true },
{ name: 'minimum length (1 char)', value: 'A', expected: true },
{ name: 'maximum length (255 chars)', value: 'a'.repeat(255), expected: true },
{ name: 'name with special chars', value: 'Test-Resolver_123', expected: true },
{ name: 'name with unicode', value: 'Tëst Rësölvër 🚀', expected: true },
{ name: 'empty name', value: '', expected: false },
{ name: 'whitespace-only name', value: ' ', expected: false },
{ name: 'name too long (256 chars)', value: 'a'.repeat(256), expected: false },
])('should validate $name', ({ value, expected }) => {
const result = credentialResolverNameSchema.safeParse(value);
expect(result.success).toBe(expected);
});
test('should trim whitespace', () => {
const result = credentialResolverNameSchema.safeParse(' Test Resolver ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Test Resolver');
}
});
});
describe('credentialResolverTypeSchema', () => {
test.each([
{ name: 'valid type', value: 'credential-resolver.stub-1.0', expected: true },
{ name: 'simple type', value: 'simple', expected: true },
{ name: 'type with dots', value: 'resolver.aws.v2', expected: true },
{ name: 'minimum length (1 char)', value: 'A', expected: true },
{ name: 'maximum length (255 chars)', value: 'a'.repeat(255), expected: true },
{ name: 'empty type', value: '', expected: false },
{ name: 'whitespace-only type', value: ' ', expected: false },
{ name: 'type too long (256 chars)', value: 'a'.repeat(256), expected: false },
])('should validate $name', ({ value, expected }) => {
const result = credentialResolverTypeSchema.safeParse(value);
expect(result.success).toBe(expected);
});
test('should trim whitespace', () => {
const result = credentialResolverTypeSchema.safeParse(' credential-resolver.stub-1.0 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('credential-resolver.stub-1.0');
}
});
});
describe('credentialResolverConfigSchema', () => {
test.each([
{ name: 'empty object', value: {}, expected: true },
{ name: 'simple config', value: { prefix: 'test-' }, expected: true },
{
name: 'complex config',
value: {
region: 'us-east-1',
timeout: 5000,
nested: { value: true },
},
expected: true,
},
{
name: 'config with various types',
value: {
stringValue: 'value',
numberValue: 123,
booleanValue: true,
nullValue: null,
arrayValue: [1, 2, 3],
objectValue: { key: 'value' },
},
expected: true,
},
{ name: 'string instead of object', value: 'invalid', expected: false },
{ name: 'array instead of object', value: [], expected: false },
{ name: 'null instead of object', value: null, expected: false },
{ name: 'number instead of object', value: 123, expected: false },
])('should validate $name', ({ value, expected }) => {
const result = credentialResolverConfigSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('credentialResolverSchema', () => {
const validResolver = {
id: 'resolver-123',
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: 'encrypted-config-string',
decryptedConfig: { prefix: 'test-' },
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
};
test('should validate complete resolver object', () => {
const result = credentialResolverSchema.safeParse(validResolver);
expect(result.success).toBe(true);
});
test('should allow missing optional decryptedConfig', () => {
const { decryptedConfig, ...resolverWithoutDecrypted } = validResolver;
const result = credentialResolverSchema.safeParse(resolverWithoutDecrypted);
expect(result.success).toBe(true);
});
test.each([
{
name: 'missing id',
data: { ...validResolver, id: undefined },
expectedErrorPath: ['id'],
},
{
name: 'missing name',
data: { ...validResolver, name: undefined },
expectedErrorPath: ['name'],
},
{
name: 'missing type',
data: { ...validResolver, type: undefined },
expectedErrorPath: ['type'],
},
{
name: 'missing config',
data: { ...validResolver, config: undefined },
expectedErrorPath: ['config'],
},
{
name: 'missing createdAt',
data: { ...validResolver, createdAt: undefined },
expectedErrorPath: ['createdAt'],
},
{
name: 'missing updatedAt',
data: { ...validResolver, updatedAt: undefined },
expectedErrorPath: ['updatedAt'],
},
{
name: 'invalid createdAt format',
data: { ...validResolver, createdAt: 'invalid-date' },
expectedErrorPath: ['createdAt'],
},
{
name: 'config as object instead of string',
data: { ...validResolver, config: { key: 'value' } },
expectedErrorPath: ['config'],
},
])('should fail validation for $name', ({ data, expectedErrorPath }) => {
const result = credentialResolverSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
}
});
test('should accept date strings and coerce to Date', () => {
const resolverWithStringDates = {
...validResolver,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
};
const result = credentialResolverSchema.safeParse(resolverWithStringDates);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.createdAt).toBeInstanceOf(Date);
expect(result.data.updatedAt).toBeInstanceOf(Date);
}
});
test('should accept Date objects directly', () => {
const result = credentialResolverSchema.safeParse(validResolver);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.createdAt).toBeInstanceOf(Date);
expect(result.data.updatedAt).toBeInstanceOf(Date);
}
});
});
describe('credentialResolversSchema', () => {
const validResolver1 = {
id: 'resolver-1',
name: 'Test Resolver 1',
type: 'credential-resolver.stub-1.0',
config: 'encrypted-config-1',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
};
const validResolver2 = {
id: 'resolver-2',
name: 'Test Resolver 2',
type: 'credential-resolver.stub-2.0',
config: 'encrypted-config-2',
decryptedConfig: { prefix: 'test-' },
createdAt: new Date('2024-01-03T00:00:00.000Z'),
updatedAt: new Date('2024-01-04T00:00:00.000Z'),
};
test('should validate empty array', () => {
const result = credentialResolversSchema.safeParse([]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
test('should validate array with single resolver', () => {
const result = credentialResolversSchema.safeParse([validResolver1]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(1);
}
});
test('should validate array with multiple resolvers', () => {
const result = credentialResolversSchema.safeParse([validResolver1, validResolver2]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
}
});
test('should fail if any resolver is invalid', () => {
const invalidResolver = { ...validResolver1, name: '' };
const result = credentialResolversSchema.safeParse([validResolver1, invalidResolver]);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual([1, 'name']);
}
});
test('should fail for non-array input', () => {
const result = credentialResolversSchema.safeParse(validResolver1);
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,20 @@
import { z } from 'zod';
export const credentialResolverIdSchema = z.string().max(36);
export const credentialResolverNameSchema = z.string().trim().min(1).max(255);
export const credentialResolverTypeSchema = z.string().trim().min(1).max(255);
export const credentialResolverConfigSchema = z.record(z.unknown());
export const credentialResolverSchema = z.object({
id: credentialResolverIdSchema,
name: credentialResolverNameSchema,
type: credentialResolverTypeSchema,
config: z.string(), // Encrypted config
decryptedConfig: credentialResolverConfigSchema.optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export const credentialResolversSchema = z.array(credentialResolverSchema);
export type CredentialResolver = z.infer<typeof credentialResolverSchema>;
@@ -162,6 +162,12 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"breakingChanges:*",
"apiKey:manage",
"apiKey:*",
"credentialResolver:create",
"credentialResolver:read",
"credentialResolver:update",
"credentialResolver:delete",
"credentialResolver:list",
"credentialResolver:*",
"*",
]
`;
@@ -38,6 +38,7 @@ export const RESOURCES = {
chatHubAgent: [...DEFAULT_OPERATIONS] as const,
breakingChanges: ['list'] as const,
apiKey: ['manage'] as const,
credentialResolver: [...DEFAULT_OPERATIONS] as const,
} as const;
export const API_KEY_RESOURCES = {
@@ -113,6 +113,11 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'chatHubAgent:list',
'breakingChanges:list',
'apiKey:manage',
'credentialResolver:create',
'credentialResolver:read',
'credentialResolver:update',
'credentialResolver:delete',
'credentialResolver:list',
];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
@@ -43,6 +43,7 @@ describe('permissions', () => {
chatHubAgent: {},
breakingChanges: {},
apiKey: {},
credentialResolver: {},
});
});
it('getResourcePermissions', () => {
@@ -163,6 +164,7 @@ describe('permissions', () => {
apiKey: {
manage: true,
},
credentialResolver: {},
};
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
@@ -0,0 +1,143 @@
import {
CreateCredentialResolverDto,
CredentialResolver,
credentialResolverSchema,
credentialResolversSchema,
UpdateCredentialResolverDto,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import {
Body,
Delete,
Get,
GlobalScope,
Param,
Patch,
Post,
RestController,
CredentialResolverValidationError,
} from '@n8n/decorators';
import { Response } from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { DynamicCredentialResolverNotFoundError } from './errors/credential-resolver-not-found.error';
import { DynamicCredentialResolverService } from './services/credential-resolver.service';
@RestController('/credential-resolvers')
export class CredentialResolversController {
constructor(private readonly service: DynamicCredentialResolverService) {}
@Get('/')
@GlobalScope('credentialResolver:list')
async listResolvers(
_req: AuthenticatedRequest,
_res: Response,
): Promise<Array<CredentialResolver>> {
try {
return credentialResolversSchema.parse(await this.service.findAll());
} catch (e: unknown) {
if (e instanceof Error) {
throw new InternalServerError(e.message, e);
}
throw e;
}
}
@Post('/')
@GlobalScope('credentialResolver:create')
async createResolver(
_req: AuthenticatedRequest,
_res: Response,
@Body dto: CreateCredentialResolverDto,
): Promise<CredentialResolver> {
try {
const createdResolver = await this.service.create({
name: dto.name,
type: dto.type,
config: dto.config,
});
return credentialResolverSchema.parse(createdResolver);
} catch (e: unknown) {
if (e instanceof CredentialResolverValidationError) {
throw new BadRequestError(e.message);
}
if (e instanceof Error) {
throw new InternalServerError(e.message, e);
}
throw e;
}
}
@Get('/:id')
@GlobalScope('credentialResolver:read')
async getResolver(
_req: AuthenticatedRequest,
_res: Response,
@Param('id') id: string,
): Promise<CredentialResolver> {
try {
return credentialResolverSchema.parse(await this.service.findById(id));
} catch (e: unknown) {
if (e instanceof DynamicCredentialResolverNotFoundError) {
throw new NotFoundError(e.message);
}
if (e instanceof Error) {
throw new InternalServerError(e.message, e);
}
throw e;
}
}
@Patch('/:id')
@GlobalScope('credentialResolver:update')
async updateResolver(
_req: AuthenticatedRequest,
_res: Response,
@Param('id') id: string,
@Body dto: UpdateCredentialResolverDto,
): Promise<CredentialResolver> {
try {
return credentialResolverSchema.parse(
await this.service.update(id, {
name: dto.name,
config: dto.config,
}),
);
} catch (e: unknown) {
if (e instanceof DynamicCredentialResolverNotFoundError) {
throw new NotFoundError(e.message);
}
if (e instanceof CredentialResolverValidationError) {
throw new BadRequestError(e.message);
}
if (e instanceof Error) {
throw new InternalServerError(e.message, e);
}
throw e;
}
}
@Delete('/:id')
@GlobalScope('credentialResolver:delete')
async deleteResolver(
_req: AuthenticatedRequest,
_res: Response,
@Param('id') id: string,
): Promise<{ success: true }> {
try {
await this.service.delete(id);
return { success: true };
} catch (e: unknown) {
if (e instanceof DynamicCredentialResolverNotFoundError) {
throw new NotFoundError(e.message);
}
if (e instanceof Error) {
throw new InternalServerError(e.message, e);
}
throw e;
}
}
}
@@ -5,6 +5,7 @@ import { Container } from '@n8n/di';
@BackendModule({ name: 'dynamic-credentials', licenseFlag: 'feat:externalSecrets' })
export class DynamicCredentialsModule implements ModuleInterface {
async init() {
await import('./credential-resolvers.controller');
await import('./context-establishment-hooks');
await import('./credential-resolvers');
const { DynamicCredentialResolverRegistry } = await import('./services');
@@ -58,7 +58,7 @@ export class DynamicCredentialResolverService {
const saved = await this.repository.save(resolver);
this.logger.debug(`Created credential resolver "${saved.name}" (${saved.id})`);
return saved;
return this.withDecryptedConfig(saved);
}
/**
@@ -0,0 +1,195 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { Telemetry } from '@/telemetry';
import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
import { createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
mockInstance(Telemetry);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
const testServer = utils.setupTestServer({
endpointGroups: ['credentials'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['dynamic-credentials'],
});
describe('Credential Resolvers API', () => {
let owner: User;
let member: User;
let ownerAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
let service: DynamicCredentialResolverService;
let repository: DynamicCredentialResolverRepository;
beforeAll(async () => {
owner = await createUser({ role: GLOBAL_OWNER_ROLE });
member = await createUser({ role: GLOBAL_MEMBER_ROLE });
ownerAgent = testServer.authAgentFor(owner);
memberAgent = testServer.authAgentFor(member);
service = Container.get(DynamicCredentialResolverService);
repository = Container.get(DynamicCredentialResolverRepository);
});
beforeEach(async () => {
await repository.delete({});
});
describe('GET /credential-resolvers', () => {
it('should return empty list when no resolvers exist', async () => {
const response = await ownerAgent.get('/credential-resolvers').expect(200);
expect(response.body.data).toEqual([]);
});
it('should list all resolvers', async () => {
// Create resolvers using service
await service.create({
name: 'Resolver 1',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test1-' },
});
await service.create({
name: 'Resolver 2',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test2-' },
});
const response = await ownerAgent.get('/credential-resolvers').expect(200);
expect(response.body.data).toHaveLength(2);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
name: 'Resolver 1',
type: 'credential-resolver.stub-1.0',
});
});
it('should reject access for members', async () => {
await memberAgent.get('/credential-resolvers').expect(403);
});
});
describe('POST /credential-resolvers', () => {
it('should create a resolver', async () => {
const payload = {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test-' },
};
const response = await ownerAgent.post('/credential-resolvers').send(payload).expect(200);
expect(response.body.data).toMatchObject({
id: expect.any(String),
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
});
expect(response.body.data.decryptedConfig).toEqual({ prefix: 'test-' });
// Verify it was actually created
const resolvers = await repository.find();
expect(resolvers).toHaveLength(1);
});
it('should reject unknown resolver type', async () => {
const payload = {
name: 'Test Resolver',
type: 'unknown-type',
config: {},
};
const response = await ownerAgent.post('/credential-resolvers').send(payload).expect(400);
expect(response.body.message).toContain('Unknown resolver type');
});
it('should reject access for members', async () => {
const payload = {
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test-' },
};
await memberAgent.post('/credential-resolvers').send(payload).expect(403);
});
});
describe('GET /resolvers/:id', () => {
it('should return a specific resolver', async () => {
const resolver = await service.create({
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test-' },
});
const response = await ownerAgent.get(`/credential-resolvers/${resolver.id}`).expect(200);
expect(response.body.data).toMatchObject({
id: resolver.id,
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
});
});
it('should return 404 for non-existent resolver', async () => {
await ownerAgent.get('/credential-resolvers/non-existent-id').expect(404);
});
});
describe('PATCH /resolvers/:id', () => {
it('should update resolver name', async () => {
const resolver = await service.create({
name: 'Original Name',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test-' },
});
const response = await ownerAgent
.patch(`/credential-resolvers/${resolver.id}`)
.send({ name: 'Updated Name' })
.expect(200);
expect(response.body.data.name).toBe('Updated Name');
});
it('should return 404 for non-existent resolver', async () => {
await ownerAgent
.patch('/credential-resolvers/non-existent-id')
.send({ name: 'New Name' })
.expect(404);
});
});
describe('DELETE /resolvers/:id', () => {
it('should delete a resolver', async () => {
const resolver = await service.create({
name: 'Test Resolver',
type: 'credential-resolver.stub-1.0',
config: { prefix: 'test-' },
});
const response = await ownerAgent.delete(`/credential-resolvers/${resolver.id}`).expect(200);
expect(response.body.data).toEqual({ success: true });
// Verify it was actually deleted
const remaining = await repository.find();
expect(remaining).toHaveLength(0);
});
it('should return 404 for non-existent resolver', async () => {
await ownerAgent.delete('/credential-resolvers/non-existent-id').expect(404);
});
});
});
@@ -48,7 +48,13 @@ type EndpointGroup =
| 'third-party-licenses'
| 'mcp';
type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table' | 'mcp';
type ModuleName =
| 'insights'
| 'external-secrets'
| 'community-packages'
| 'data-table'
| 'mcp'
| 'dynamic-credentials';
export interface SetupProps {
endpointGroups?: EndpointGroup[];
@@ -49,6 +49,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
chatHubAgent: {},
breakingChanges: {},
apiKey: {},
credentialResolver: {},
});
function addGlobalRole(role: Role) {