first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,282 @@
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 nock from 'nock';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service';
import { Telemetry } from '@/telemetry';
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);
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
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({});
nock.cleanAll();
// Mock OAuth metadata endpoint for resolver validation
nock('https://auth.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, {
issuer: 'https://auth.example.com',
introspection_endpoint: 'https://auth.example.com/oauth/introspect',
introspection_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
});
});
afterEach(() => {
nock.cleanAll();
});
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.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id-1',
clientSecret: 'test-client-secret-1',
validation: 'oauth2-introspection',
},
user: owner,
});
await service.create({
name: 'Resolver 2',
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id-2',
clientSecret: 'test-client-secret-2',
validation: 'oauth2-introspection',
},
user: owner,
});
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.oauth2-1.0',
});
});
it('should reject access for members', async () => {
await memberAgent.get('/credential-resolvers').expect(403);
});
});
describe('GET /credential-resolvers/types', () => {
it('should return available resolver types', async () => {
const response = await ownerAgent.get('/credential-resolvers/types').expect(200);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBeGreaterThan(0);
// Verify resolver types have required fields
const resolverType = response.body.data[0];
expect(resolverType).toMatchObject({
name: expect.any(String),
displayName: expect.any(String),
description: expect.any(String),
options: expect.any(Array),
});
});
it('should reject access for members', async () => {
await memberAgent.get('/credential-resolvers/types').expect(403);
});
});
describe('POST /credential-resolvers', () => {
it('should create a resolver', async () => {
const payload = {
name: 'Test Resolver',
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
};
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.oauth2-1.0',
});
expect(response.body.data.decryptedConfig).toMatchObject({
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
});
// 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.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
};
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.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
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.oauth2-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.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
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.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
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);
});
});
});
@@ -0,0 +1,269 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance, getPersonalProject, testDb } from '@n8n/backend-test-utils';
import type { CredentialsEntity, User } from '@n8n/db';
import { GLOBAL_OWNER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import nock from 'nock';
import * as utils from '../shared/utils';
import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service';
import { Telemetry } from '@/telemetry';
import { saveCredential } from '../shared/db/credentials';
import { DynamicCredentialsConfig } from '@/modules/dynamic-credentials.ee/dynamic-credentials.config';
import { CredentialsHelper } from '@/credentials-helper';
import { createUser } from '../shared/db/users';
import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver';
mockInstance(Telemetry);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
mockInstance(DynamicCredentialsConfig, {
endpointAuthToken: 'static-test-token',
corsOrigin: 'https://app.example.com',
corsAllowCredentials: false,
});
const testServer = utils.setupTestServer({
endpointGroups: ['credentials', 'oauth2'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['dynamic-credentials'],
});
CredentialsHelper.prototype.applyDefaultsAndOverwrites = async (_, decryptedDataOriginal) =>
decryptedDataOriginal;
const setupWorkflow = async () => {
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
const resolverService = Container.get(DynamicCredentialResolverService);
const resolver = await resolverService.create({
name: 'Test Resolver',
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
const personalProject = await getPersonalProject(owner);
const savedCredential = await saveCredential(
{
name: 'Test Dynamic Credential',
type: 'oAuth2Api',
isResolvable: true,
data: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
authUrl: 'https://test.domain/oauth2/auth',
accessTokenUrl: 'https://test.domain/oauth2/token',
grantType: 'authorizationCode',
},
},
{
project: personalProject,
role: 'credential:owner',
},
);
return { savedCredential, resolver, owner };
};
describe('Dynamic Credentials API', () => {
let savedCredential: CredentialsEntity;
let resolver: DynamicCredentialResolver;
let owner: User;
beforeAll(async () => {
// Mock OAuth metadata endpoint for resolver validation
nock.cleanAll();
nock('https://auth.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, {
issuer: 'https://auth.example.com',
introspection_endpoint: 'https://auth.example.com/oauth/introspect',
introspection_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
});
// Mock OAuth introspection endpoint for identity validation
nock('https://auth.example.com')
.persist()
.post('/oauth/introspect')
.reply(200, {
active: true,
sub: 'user-123',
exp: Math.floor(Date.now() / 1000) + 3600,
});
await testDb.truncate(['User', 'CredentialsEntity', 'DynamicCredentialResolver']);
({ savedCredential, resolver, owner } = await setupWorkflow());
});
afterAll(async () => {
nock.cleanAll();
await testDb.terminate();
testServer.httpServer.close();
});
describe('POST /credentials/:id/authorize', () => {
describe('when a static auth token is provided', () => {
it('should return the authorization URL for a credential', async () => {
const response = await testServer.authlessAgent
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('X-Authorization', 'Bearer static-test-token')
.set('Authorization', 'Bearer test-token')
.expect(200);
expect(response.body.data).toBeDefined();
expect(typeof response.body.data).toBe('string');
expect(response.body.data).toContain('https://test.domain/oauth2/auth');
});
it('should return 401 if the static auth token is invalid', async () => {
await testServer.authlessAgent
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('X-Authorization', 'Bearer invalid-token')
.set('Authorization', 'Bearer test-token')
.expect(401);
});
it('should return 401 if the static auth token is missing', async () => {
await testServer.authlessAgent
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.expect(401);
});
it('should return 401 if the static auth token is empty', async () => {
await testServer.authlessAgent
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer ')
.expect(401);
});
});
it('should return 401 if the authorization header is missing', async () => {
await testServer.authlessAgent
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('X-Authorization', 'Bearer static-test-token')
.expect(401);
});
});
describe('DELETE /credentials/:id/revoke', () => {
describe('when a static auth token is provided', () => {
it('should revoke a credential', async () => {
await testServer.authlessAgent
.delete(`/credentials/${savedCredential.id}/revoke`)
.query({ resolverId: resolver.id })
.set('X-Authorization', 'Bearer static-test-token')
.set('Authorization', 'Bearer test-token')
.expect(204);
});
it('should return 401 if the static auth token is invalid', async () => {
await testServer.authlessAgent
.delete(`/credentials/${savedCredential.id}/revoke`)
.query({ resolverId: resolver.id })
.set('X-Authorization', 'Bearer invalid-token')
.set('Authorization', 'Bearer test-token')
.expect(401);
});
it('should return 401 if the static auth token is missing', async () => {
await testServer.authlessAgent
.delete(`/credentials/${savedCredential.id}/revoke`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.expect(401);
});
it('should return 401 if the static auth token is empty', async () => {
await testServer.authlessAgent
.delete(`/credentials/${savedCredential.id}/revoke`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer ')
.expect(401);
});
});
});
describe('Cookie Authentication Bypass', () => {
describe('POST /credentials/:id/authorize', () => {
describe('when a user is authenticated via cookie', () => {
it('should allow access without static auth token', async () => {
const response = await testServer
.authAgentFor(owner)
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
// Note: NO X-Authorization header provided
.expect(200);
expect(response.body.data).toBeDefined();
expect(typeof response.body.data).toBe('string');
expect(response.body.data).toContain('https://test.domain/oauth2/auth');
});
it('should allow access even with invalid static token if cookie auth succeeds', async () => {
const response = await testServer
.authAgentFor(owner)
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer invalid-static-token') // Invalid token
.expect(200);
expect(response.body.data).toBeDefined();
expect(typeof response.body.data).toBe('string');
expect(response.body.data).toContain('https://test.domain/oauth2/auth');
});
});
});
describe('DELETE /credentials/:id/revoke', () => {
describe('when a user is authenticated via cookie', () => {
it('should allow access without static auth token', async () => {
await testServer
.authAgentFor(owner)
.delete(`/credentials/${savedCredential.id}/revoke`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
// Note: NO X-Authorization header provided
.expect(204);
});
it('should allow access even with invalid static token if cookie auth succeeds', async () => {
await testServer
.authAgentFor(owner)
.delete(`/credentials/${savedCredential.id}/revoke`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer invalid-static-token') // Invalid token
.expect(204);
});
});
});
});
});
@@ -0,0 +1,152 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance, getPersonalProject, testDb } from '@n8n/backend-test-utils';
import type { CredentialsEntity } from '@n8n/db';
import { GLOBAL_OWNER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import nock from 'nock';
import * as utils from '../shared/utils';
import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service';
import { Telemetry } from '@/telemetry';
import { saveCredential } from '../shared/db/credentials';
import { DynamicCredentialsConfig } from '@/modules/dynamic-credentials.ee/dynamic-credentials.config';
import { CredentialsHelper } from '@/credentials-helper';
import { createUser } from '../shared/db/users';
import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver';
mockInstance(Telemetry);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
mockInstance(DynamicCredentialsConfig, {
endpointAuthToken: '',
corsOrigin: 'https://app.example.com',
corsAllowCredentials: false,
});
const testServer = utils.setupTestServer({
endpointGroups: ['credentials', 'oauth2'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['dynamic-credentials'],
});
CredentialsHelper.prototype.applyDefaultsAndOverwrites = async (_, decryptedDataOriginal) =>
decryptedDataOriginal;
const setupWorkflow = async () => {
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
const resolverService = Container.get(DynamicCredentialResolverService);
const resolver = await resolverService.create({
name: 'Test Resolver',
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
const personalProject = await getPersonalProject(owner);
const savedCredential = await saveCredential(
{
name: 'Test Dynamic Credential',
type: 'oAuth2Api',
isResolvable: true,
data: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
authUrl: 'https://test.domain/oauth2/auth',
accessTokenUrl: 'https://test.domain/oauth2/token',
grantType: 'authorizationCode',
},
},
{
project: personalProject,
role: 'credential:owner',
},
);
return { savedCredential, resolver };
};
describe('Dynamic Credentials API', () => {
let savedCredential: CredentialsEntity;
let resolver: DynamicCredentialResolver;
beforeAll(async () => {
// Mock OAuth metadata endpoint for resolver validation
nock.cleanAll();
nock('https://auth.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, {
issuer: 'https://auth.example.com',
introspection_endpoint: 'https://auth.example.com/oauth/introspect',
introspection_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
});
// Mock OAuth introspection endpoint for identity validation
nock('https://auth.example.com')
.persist()
.post('/oauth/introspect')
.reply(200, {
active: true,
sub: 'user-123',
exp: Math.floor(Date.now() / 1000) + 3600,
});
await testDb.truncate(['User', 'CredentialsEntity', 'DynamicCredentialResolver']);
({ savedCredential, resolver } = await setupWorkflow());
});
afterAll(async () => {
nock.cleanAll();
await testDb.terminate();
testServer.httpServer.close();
});
describe('POST /credentials/:id/authorize', () => {
describe('when no static auth token is provided', () => {
it('should return a 500 Internal Server Error', async () => {
const response = await testServer.authlessAgent
.post(`/credentials/${savedCredential.id}/authorize`)
.query({ resolverId: resolver.id })
.set('Authorization', 'Bearer test-token')
.expect(500);
expect(response.body.message).toBe(
'Dynamic credentials configuration is invalid. Check server logs for details.',
);
});
});
});
describe('DELETE /credentials/:id/revoke', () => {
describe('when no static auth token is provided', () => {
it('should return a 500 Internal Server Error', async () => {
const response = await testServer.authlessAgent
.delete(`/credentials/${savedCredential.id}/revoke`)
.set('Authorization', 'Bearer test-token')
.query({ resolverId: resolver.id })
.expect(500);
expect(response.body.message).toBe(
'Dynamic credentials configuration is invalid. Check server logs for details.',
);
});
});
});
});
@@ -0,0 +1,255 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance, getPersonalProject, testDb } from '@n8n/backend-test-utils';
import type { CredentialsEntity, User } from '@n8n/db';
import {
GLOBAL_OWNER_ROLE,
WorkflowRepository,
SharedWorkflowRepository,
WorkflowEntity,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import nock from 'nock';
import { v4 as uuid } from 'uuid';
import type { INode } from 'n8n-workflow';
import * as utils from '../shared/utils';
import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service';
import { Telemetry } from '@/telemetry';
import { createCredentials } from '../shared/db/credentials';
import { DynamicCredentialsConfig } from '@/modules/dynamic-credentials.ee/dynamic-credentials.config';
import { createUser } from '../shared/db/users';
mockInstance(Telemetry);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
const testServer = utils.setupTestServer({
endpointGroups: ['credentials'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['dynamic-credentials'],
});
mockInstance(DynamicCredentialsConfig, {
corsOrigin: 'https://app.example.com',
corsAllowCredentials: false,
endpointAuthToken: 'static-test-token',
});
const setupWorkflow = async () => {
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
const resolverService = Container.get(DynamicCredentialResolverService);
const resolver = await resolverService.create({
name: 'Test Resolver',
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
const personalProject = await getPersonalProject(owner);
const savedCredential = await createCredentials(
{
name: 'Test Dynamic Credential',
type: 'OAuth2',
data: '',
isResolvable: true,
resolverId: resolver.id,
},
personalProject,
);
const node: INode = {
id: uuid(),
name: 'Test Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
oAuth2Api: {
id: savedCredential.id,
name: savedCredential.name,
},
},
};
const workflow = new WorkflowEntity();
workflow.name = 'Test Workflow';
workflow.nodes = [node];
workflow.active = true;
workflow.versionId = uuid();
workflow.connections = {};
const workflowRepository = Container.get(WorkflowRepository);
const savedWorkflow = await workflowRepository.save(workflow);
await Container.get(SharedWorkflowRepository).save({
workflow: savedWorkflow,
user: owner,
project: personalProject,
role: 'workflow:owner',
});
return { savedWorkflow, savedCredential, owner };
};
describe('Workflow Status API', () => {
let savedWorkflow: WorkflowEntity;
let savedCredential: CredentialsEntity;
let owner: User;
beforeAll(async () => {
// Mock OAuth metadata endpoint for resolver validation
nock.cleanAll();
nock('https://auth.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, {
issuer: 'https://auth.example.com',
introspection_endpoint: 'https://auth.example.com/oauth/introspect',
introspection_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
});
// Mock OAuth introspection endpoint for identity validation
nock('https://auth.example.com')
.persist()
.post('/oauth/introspect')
.reply(200, {
active: true,
sub: 'user-123',
exp: Math.floor(Date.now() / 1000) + 3600,
});
await testDb.truncate([
'User',
'SharedWorkflow',
'WorkflowEntity',
'CredentialsEntity',
'DynamicCredentialResolver',
]);
({ savedWorkflow, savedCredential, owner } = await setupWorkflow());
});
afterAll(async () => {
nock.cleanAll();
await testDb.terminate();
testServer.httpServer.close();
});
describe('GET /workflows/:workflowId/execution-status', () => {
describe('when a static auth token is provided', () => {
it('should return the execution status of a workflow', async () => {
const response = await testServer.authlessAgent
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer static-test-token')
.expect(200);
expect(response.body.data).toMatchObject({
workflowId: savedWorkflow.id,
readyToExecute: expect.any(Boolean),
credentials: expect.arrayContaining([
expect.objectContaining({
credentialId: savedCredential.id,
credentialName: savedCredential.name,
credentialType: savedCredential.type,
credentialStatus: expect.any(String),
}),
]),
});
});
it('should return 401 if the static auth token is invalid', async () => {
await testServer.authlessAgent
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer invalid-token')
.expect(401);
});
it('should return 401 if the static auth token is missing', async () => {
await testServer.authlessAgent
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
.expect(401);
});
it('should return 401 if the static auth token is empty', async () => {
await testServer.authlessAgent
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer ')
.expect(401);
});
});
it('should return 401 if the authorization header is missing', async () => {
await testServer.authlessAgent
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('X-Authorization', 'Bearer static-test-token')
.expect(401);
});
describe('when a user is authenticated via cookie', () => {
it('should allow access without static auth token', async () => {
const response = await testServer
.authAgentFor(owner)
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
// Note: NO X-Authorization header provided
.expect(200);
expect(response.body.data).toMatchObject({
workflowId: savedWorkflow.id,
readyToExecute: expect.any(Boolean),
credentials: expect.arrayContaining([
expect.objectContaining({
credentialId: savedCredential.id,
credentialName: savedCredential.name,
credentialType: savedCredential.type,
credentialStatus: expect.any(String),
}),
]),
});
});
it('should allow access even with invalid static token if cookie auth succeeds', async () => {
const response = await testServer
.authAgentFor(owner)
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
.set('X-Authorization', 'Bearer invalid-static-token') // Invalid token
.expect(200);
expect(response.body.data).toMatchObject({
workflowId: savedWorkflow.id,
readyToExecute: expect.any(Boolean),
credentials: expect.arrayContaining([
expect.objectContaining({
credentialId: savedCredential.id,
credentialName: savedCredential.name,
credentialType: savedCredential.type,
credentialStatus: expect.any(String),
}),
]),
});
});
});
});
});
@@ -0,0 +1,166 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance, getPersonalProject, testDb } from '@n8n/backend-test-utils';
import {
GLOBAL_OWNER_ROLE,
WorkflowRepository,
SharedWorkflowRepository,
WorkflowEntity,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { INode } from 'n8n-workflow';
import nock from 'nock';
import { v4 as uuid } from 'uuid';
import { DynamicCredentialsConfig } from '@/modules/dynamic-credentials.ee/dynamic-credentials.config';
import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service';
import { Telemetry } from '@/telemetry';
import { createCredentials } from '../shared/db/credentials';
import { createUser } from '../shared/db/users';
import * as utils from '../shared/utils';
mockInstance(Telemetry);
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(true);
Container.set(LicenseState, licenseMock);
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
mockInstance(DynamicCredentialsConfig, {
corsOrigin: 'https://app.example.com',
corsAllowCredentials: false,
endpointAuthToken: '',
});
const testServer = utils.setupTestServer({
endpointGroups: ['credentials'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['dynamic-credentials'],
});
const setupWorkflow = async () => {
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
const resolverService = Container.get(DynamicCredentialResolverService);
const resolver = await resolverService.create({
name: 'Test Resolver',
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
validation: 'oauth2-introspection',
},
user: owner,
});
const personalProject = await getPersonalProject(owner);
const savedCredential = await createCredentials(
{
name: 'Test Dynamic Credential',
type: 'oauth2',
data: '',
isResolvable: true,
resolverId: resolver.id,
},
personalProject,
);
const node: INode = {
id: uuid(),
name: 'Test Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
oAuth2Api: {
id: savedCredential.id,
name: savedCredential.name,
},
},
};
const workflow = new WorkflowEntity();
workflow.name = 'Test Workflow';
workflow.nodes = [node];
workflow.active = true;
workflow.versionId = uuid();
workflow.connections = {};
const workflowRepository = Container.get(WorkflowRepository);
const savedWorkflow = await workflowRepository.save(workflow);
await Container.get(SharedWorkflowRepository).save({
workflow: savedWorkflow,
user: owner,
project: personalProject,
role: 'workflow:owner',
});
return { savedWorkflow, savedCredential };
};
describe('Workflow Status API', () => {
let savedWorkflow: WorkflowEntity;
beforeAll(async () => {
// Mock OAuth metadata endpoint for resolver validation
nock.cleanAll();
nock('https://auth.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, {
issuer: 'https://auth.example.com',
introspection_endpoint: 'https://auth.example.com/oauth/introspect',
introspection_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
});
// Mock OAuth introspection endpoint for identity validation
nock('https://auth.example.com')
.persist()
.post('/oauth/introspect')
.reply(200, {
active: true,
sub: 'user-123',
exp: Math.floor(Date.now() / 1000) + 3600,
});
await testDb.truncate([
'User',
'SharedWorkflow',
'WorkflowEntity',
'CredentialsEntity',
'DynamicCredentialResolver',
]);
({ savedWorkflow } = await setupWorkflow());
});
afterAll(async () => {
nock.cleanAll();
await testDb.terminate();
testServer.httpServer.close();
});
describe('GET /workflows/:workflowId/execution-status', () => {
describe('when no static auth token is provided', () => {
it('should return a 500 Internal Server Error', async () => {
const response = await testServer.authlessAgent
.get(`/workflows/${savedWorkflow.id}/execution-status`)
.set('Authorization', 'Bearer test-token')
.expect(500);
expect(response.body).toMatchObject({
message: 'Dynamic credentials configuration is invalid. Check server logs for details.',
});
});
});
});
});