mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Dynamic credential resolvers API routes (#22862)
This commit is contained in:
+199
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+161
@@ -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(),
|
||||
}) {}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+195
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user