import type { CredentialsRepository, WorkflowRepository } from '@n8n/db';
import type {
	INode,
	IConnections,
	INodeType,
	INodeTypeDescription,
	ICredentialType,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import type { CredentialTypes } from '@/credential-types';
import type { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import type { NodeTypes } from '@/node-types';
import { WorkflowValidationService } from '@/workflows/workflow-validation.service';

describe('WorkflowValidationService', () => {
	let service: WorkflowValidationService;
	let mockWorkflowRepository: ReturnType<typeof mock<WorkflowRepository>>;
	let mockCredentialsRepository: ReturnType<typeof mock<CredentialsRepository>>;
	let mockDynamicCredentialsProxy: ReturnType<typeof mock<DynamicCredentialsProxy>>;

	beforeEach(() => {
		mockWorkflowRepository = mock<WorkflowRepository>();
		mockCredentialsRepository = mock<CredentialsRepository>();
		mockDynamicCredentialsProxy = mock<DynamicCredentialsProxy>();
		// Default to the real semantics with no system resolver seeded:
		// pass through the workflow override if any, otherwise null.
		mockDynamicCredentialsProxy.getEffectiveResolverId.mockImplementation(
			(settings) => settings?.credentialResolverId ?? null,
		);
		service = new WorkflowValidationService(
			mockWorkflowRepository,
			mockCredentialsRepository,
			mockDynamicCredentialsProxy,
			mock<CredentialTypes>(),
		);
	});

	describe('validateSubWorkflowReferences', () => {
		const createExecuteWorkflowNode = (
			name: string,
			workflowId: string | { value: string },
			options?: { disabled?: boolean; source?: string },
		): INode => ({
			name,
			type: 'n8n-nodes-base.executeWorkflow',
			id: `node-${name}`,
			typeVersion: 1,
			position: [0, 0],
			disabled: options?.disabled,
			parameters: {
				workflowId,
				...(options?.source && { source: options.source }),
			},
		});

		it('should return valid when no executeWorkflow nodes exist', async () => {
			const nodes: INode[] = [
				{
					name: 'Set',
					type: 'n8n-nodes-base.set',
					id: 'node-1',
					typeVersion: 1,
					position: [0, 0],
					parameters: {},
				},
			];

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
		});

		it('should return valid when all referenced sub-workflows are published', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Sub-workflow 1', { value: 'workflow-1' }),
				createExecuteWorkflowNode('Sub-workflow 2', { value: 'workflow-2' }),
			];

			mockWorkflowRepository.get.mockImplementation(
				async ({ id }) =>
					({
						id: id as string,
						name: `Workflow ${id}`,
						activeVersionId: 'version-id',
					}) as any,
			);

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).toHaveBeenCalledTimes(2);
		});

		it('should return invalid when a referenced sub-workflow is not published', async () => {
			const nodes: INode[] = [createExecuteWorkflowNode('Sub-workflow 1', { value: 'workflow-1' })];

			mockWorkflowRepository.get.mockResolvedValue({
				id: 'workflow-1',
				name: 'Draft Workflow',
				activeVersionId: null,
			} as any);

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Node "Sub-workflow 1" references workflow workflow-1');
			expect(result.error).toContain('("Draft Workflow")');
			expect(result.error).toContain('which is not published');
			expect(result.invalidReferences).toHaveLength(1);
			expect(result.invalidReferences?.[0]).toEqual({
				nodeName: 'Sub-workflow 1',
				workflowId: 'workflow-1',
				workflowName: 'Draft Workflow',
			});
		});

		it('should return invalid when a referenced sub-workflow does not exist', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Sub-workflow 1', { value: 'non-existent' }),
			];

			mockWorkflowRepository.get.mockResolvedValue(null);

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(false);
			expect(result.invalidReferences?.[0]).toEqual({
				nodeName: 'Sub-workflow 1',
				workflowId: 'non-existent',
				workflowName: undefined,
			});
		});

		it('should skip disabled executeWorkflow nodes', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Disabled Node', { value: 'workflow-1' }, { disabled: true }),
			];

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).not.toHaveBeenCalled();
		});

		it('should skip nodes using expressions for workflowId', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Expression Node', '={{ $json.workflowId }}'),
			];

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).not.toHaveBeenCalled();
		});

		it('should skip nodes using non-database sources', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('URL Node', { value: 'workflow-1' }, { source: 'url' }),
				createExecuteWorkflowNode(
					'Parameter Node',
					{ value: 'workflow-2' },
					{ source: 'parameter' },
				),
				createExecuteWorkflowNode(
					'LocalFile Node',
					{ value: 'workflow-3' },
					{ source: 'localFile' },
				),
			];

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).not.toHaveBeenCalled();
		});

		it('should validate nodes using database source', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Database Node', { value: 'workflow-1' }, { source: 'database' }),
			];

			mockWorkflowRepository.get.mockResolvedValue({
				id: 'workflow-1',
				name: 'Workflow',
				activeVersionId: 'version-id',
			} as any);

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).toHaveBeenCalledWith(
				{ id: 'workflow-1' },
				{ relations: [] },
			);
		});

		it('should handle old node format with string workflowId', async () => {
			const nodes: INode[] = [createExecuteWorkflowNode('Old Format Node', 'workflow-1')];

			mockWorkflowRepository.get.mockResolvedValue({
				id: 'workflow-1',
				name: 'Workflow',
				activeVersionId: 'version-id',
			} as any);

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).toHaveBeenCalledWith(
				{ id: 'workflow-1' },
				{ relations: [] },
			);
		});

		it('should collect all invalid references in a single validation', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Sub-workflow 1', { value: 'workflow-1' }),
				createExecuteWorkflowNode('Sub-workflow 2', { value: 'workflow-2' }),
				createExecuteWorkflowNode('Sub-workflow 3', { value: 'workflow-3' }),
			];

			mockWorkflowRepository.get.mockImplementation(async ({ id }) => {
				if (id === 'workflow-2') return undefined;
				return {
					id: id as string,
					name: id === 'workflow-3' ? 'Draft Workflow 3' : 'Workflow',
					activeVersionId: id === 'workflow-1' ? 'version-id' : null,
				} as any;
			});

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(false);
			expect(result.invalidReferences).toHaveLength(2);
			expect(result.error).toContain('workflow-2');
			expect(result.error).toContain('workflow-3');
		});

		it('should allow self-referencing workflows', async () => {
			const nodes: INode[] = [
				createExecuteWorkflowNode('Self Reference', { value: 'parent-workflow-id' }),
			];

			const result = await service.validateSubWorkflowReferences('parent-workflow-id', nodes);

			expect(result.isValid).toBe(true);
			expect(mockWorkflowRepository.get).not.toHaveBeenCalled();
		});
	});

	describe('validateForActivation', () => {
		let mockNodeTypes: ReturnType<typeof mock<NodeTypes>>;

		beforeEach(() => {
			mockNodeTypes = mock<NodeTypes>();
		});

		const createNode = (
			name: string,
			type: string,
			options?: {
				disabled?: boolean;
				credentials?: Record<string, { id: string; name?: string }>;
				parameters?: Record<string, unknown>;
			},
		): INode => ({
			name,
			type,
			id: `node-${name}`,
			typeVersion: 1,
			position: [0, 0],
			disabled: options?.disabled,
			credentials: options?.credentials as any,
			parameters: (options?.parameters as any) || {},
		});

		const createConnections = (connections: Array<[string, string]>): IConnections => {
			const result: IConnections = {};
			connections.forEach(([from, to]) => {
				if (!result[from]) {
					result[from] = { main: [[]] as any };
				}
				if (result[from].main?.[0]) {
					result[from].main[0].push({ node: to, type: 'main', index: 0 });
				}
			});
			return result;
		};

		const createMockNodeType = (
			credentials?: Array<{ name: string; displayName: string; required: boolean }>,
			properties?: Array<{ name: string; required?: boolean }>,
			isTrigger = false,
		): INodeType => {
			const description: INodeTypeDescription = {
				displayName: 'Test Node',
				name: 'testNode',
				group: ['transform'],
				version: 1,
				description: 'Test node',
				defaults: { name: 'Test Node' },
				inputs: ['main'],
				outputs: ['main'],
				properties: (properties || []) as any,
				credentials: credentials || [],
			};

			const nodeType: INodeType = {
				description,
			} as INodeType;

			// Add trigger method if it's a trigger node
			if (isTrigger) {
				nodeType.trigger = async function () {
					return {
						closeFunction: async () => {},
						manualTriggerFunction: async () => {},
					};
				};
			}

			return nodeType;
		};

		it('should return valid for workflow with no connected nodes', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
			};
			const connections: IConnections = {};

			mockNodeTypes.getByNameAndVersion.mockReturnValue(createMockNodeType([], [], true));

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return valid for workflow with all valid connected nodes', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				'HTTP Request': createNode('HTTP Request', 'n8n-nodes-base.httpRequest', {
					credentials: { httpAuth: { id: 'cred-1' } },
					parameters: { url: 'https://example.com' },
				}),
			};
			const connections = createConnections([['Webhook', 'HTTP Request']]);

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				if (type === 'n8n-nodes-base.httpRequest') {
					return createMockNodeType(
						[{ name: 'httpAuth', displayName: 'HTTP Auth', required: true }],
						[{ name: 'url', required: true }],
					);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return invalid when connected node is missing required credential', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				Agent: createNode('Agent', 'n8n-nodes-base.agent', {
					parameters: {},
				}),
			};
			const connections = createConnections([['Webhook', 'Agent']]);

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				if (type === 'n8n-nodes-base.agent') {
					return createMockNodeType(
						[{ name: 'openAiApi', displayName: 'OpenAI API', required: true }],
						[],
					);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Cannot publish workflow');
			expect(result.error).toContain('1 node have configuration issues');
			expect(result.error).toContain('Node "Agent"');
			expect(result.error).toContain('Missing required credential: OpenAI API');
		});

		it('should return invalid when connected node has credential without ID', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				Agent: createNode('Agent', 'n8n-nodes-base.agent', {
					credentials: { openAiApi: { id: '' } },
					parameters: {},
				}),
			};
			const connections = createConnections([['Webhook', 'Agent']]);

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				if (type === 'n8n-nodes-base.agent') {
					return createMockNodeType(
						[{ name: 'openAiApi', displayName: 'OpenAI API', required: true }],
						[],
					);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Credential not configured: OpenAI API');
		});

		it('should skip validation for disabled nodes', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				Agent: createNode('Agent', 'n8n-nodes-base.agent', {
					disabled: true,
					parameters: {},
				}),
			};
			const connections = createConnections([['Webhook', 'Agent']]);

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				if (type === 'n8n-nodes-base.agent') {
					return createMockNodeType(
						[{ name: 'openAiApi', displayName: 'OpenAI API', required: true }],
						[],
					);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should skip validation for disconnected nodes', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				Agent: createNode('Agent', 'n8n-nodes-base.agent', {
					parameters: {},
				}),
			};
			// Agent is not connected to anything
			const connections: IConnections = {};

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				if (type === 'n8n-nodes-base.agent') {
					return createMockNodeType(
						[{ name: 'openAiApi', displayName: 'OpenAI API', required: true }],
						[],
					);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should validate multiple nodes with issues', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				Agent1: createNode('Agent1', 'n8n-nodes-base.agent', {
					parameters: {},
				}),
				Agent2: createNode('Agent2', 'n8n-nodes-base.agent', {
					parameters: {},
				}),
			};
			const connections = createConnections([
				['Webhook', 'Agent1'],
				['Agent1', 'Agent2'],
			]);

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				if (type === 'n8n-nodes-base.agent') {
					return createMockNodeType(
						[{ name: 'openAiApi', displayName: 'OpenAI API', required: true }],
						[],
					);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('2 nodes have configuration issues');
			expect(result.error).toContain('Node "Agent1"');
			expect(result.error).toContain('Node "Agent2"');
		});

		it('should return invalid when node type is not found', () => {
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook'),
				Unknown: createNode('Unknown', 'n8n-nodes-base.unknownNode'),
			};
			const connections = createConnections([['Webhook', 'Unknown']]);

			mockNodeTypes.getByNameAndVersion.mockImplementation(((
				type: string,
			): INodeType | undefined => {
				if (type === 'n8n-nodes-base.webhook') {
					return createMockNodeType([], [], true);
				}
				return undefined;
			}) as any);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Node "Unknown"');
			expect(result.error).toContain('Node type not found');
		});

		it('should return invalid when workflow has no trigger node', () => {
			const nodes = {
				Set: createNode('Set', 'n8n-nodes-base.set'),
			};
			const connections: IConnections = {};

			mockNodeTypes.getByNameAndVersion.mockReturnValue(createMockNodeType([], []));

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('no trigger node');
		});

		it('should respect displayOptions when validating credentials', () => {
			// Simulates a Webhook node with authentication parameter set to 'none'
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook', {
					parameters: { authentication: 'none' },
				}),
			};
			const connections: IConnections = {};

			// Mock node type with credentials that have displayOptions
			const nodeType = createMockNodeType([], [], true);
			nodeType.description.credentials = [
				{
					name: 'httpBasicAuth',
					displayName: 'Basic Auth',
					required: true,
					displayOptions: {
						show: {
							authentication: ['basicAuth'],
						},
					},
				},
				{
					name: 'httpHeaderAuth',
					displayName: 'Header Auth',
					required: true,
					displayOptions: {
						show: {
							authentication: ['headerAuth'],
						},
					},
				},
			];

			mockNodeTypes.getByNameAndVersion.mockReturnValue(nodeType);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			// Should be valid because authentication='none', so no credentials are required
			expect(result.isValid).toBe(true);
		});

		it('should validate credentials when displayOptions match', () => {
			// Simulates a Webhook node with authentication='basicAuth' but missing credential
			const nodes = {
				Webhook: createNode('Webhook', 'n8n-nodes-base.webhook', {
					parameters: { authentication: 'basicAuth' },
				}),
			};
			const connections: IConnections = {};

			// Mock node type with credentials that have displayOptions
			const nodeType = createMockNodeType([], [], true);
			nodeType.description.credentials = [
				{
					name: 'httpBasicAuth',
					displayName: 'Basic Auth',
					required: true,
					displayOptions: {
						show: {
							authentication: ['basicAuth'],
						},
					},
				},
			];

			mockNodeTypes.getByNameAndVersion.mockReturnValue(nodeType);

			const result = service.validateForActivation(nodes, connections, mockNodeTypes);

			// Should be invalid because authentication='basicAuth' but no credential is set
			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Missing required credential: Basic Auth');
		});
	});

	describe('validateDynamicCredentials', () => {
		let mockNodeTypes: ReturnType<typeof mock<NodeTypes>>;

		const createNode = (
			name: string,
			type: string,
			options?: {
				disabled?: boolean;
				credentials?: Record<string, { id: string; name?: string }>;
				parameters?: Record<string, unknown>;
			},
		): INode => ({
			name,
			type,
			id: `node-${name}`,
			typeVersion: 1,
			position: [0, 0],
			disabled: options?.disabled,
			credentials: options?.credentials as any,
			parameters: (options?.parameters as any) || {},
		});

		const hooksParameters = {
			executionsHooksVersion: 1,
			contextEstablishmentHooks: {
				hooks: [{ hookName: 'credentials.bearerToken' }],
			},
		};

		const createTriggerNodeType = (): INodeType =>
			({
				description: {
					group: ['trigger'],
				},
				trigger: async () => ({ closeFunction: async () => {} }),
			}) as unknown as INodeType;

		// A "Send and Wait for Response" (HITL) action node — or its AI-tool variant.
		// It carries a `webhook` method for the approval callback but is NOT a trigger
		// (group is `transform`/`output`, not `trigger`).
		const createSendAndWaitNodeType = (): INodeType =>
			({
				description: {
					group: ['transform'],
				},
				webhook: async () => ({}),
			}) as unknown as INodeType;

		const SYSTEM_RESOLVER = 'system-resolver';
		const CUSTOM_RESOLVER = 'custom-resolver';

		// The effective resolver is decided at the workflow level (settings override or
		// the seeded system resolver); the credential's own resolverId is not consulted.
		const useSystemResolver = () => {
			mockDynamicCredentialsProxy.getSystemResolverId.mockReturnValue(SYSTEM_RESOLVER);
			mockDynamicCredentialsProxy.getEffectiveResolverId.mockReturnValue(SYSTEM_RESOLVER);
		};
		const useCustomResolver = () => {
			mockDynamicCredentialsProxy.getSystemResolverId.mockReturnValue(SYSTEM_RESOLVER);
			mockDynamicCredentialsProxy.getEffectiveResolverId.mockReturnValue(CUSTOM_RESOLVER);
		};
		const useNoResolver = () => {
			mockDynamicCredentialsProxy.getSystemResolverId.mockReturnValue(null);
			mockDynamicCredentialsProxy.getEffectiveResolverId.mockReturnValue(null);
		};

		beforeEach(() => {
			mockNodeTypes = mock<NodeTypes>();
			// Pin the flag off so the expected copy never depends on the ambient env.
			// Tests that need it on opt in with `withChatOAuth2(true)`.
			vi.stubEnv('N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2', 'false');
		});

		afterEach(() => {
			vi.unstubAllEnvs();
		});

		it('should return valid when no credentials are used', async () => {
			const nodes: INode[] = [createNode('Set', 'n8n-nodes-base.set')];

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
			expect(mockCredentialsRepository.find).not.toHaveBeenCalled();
		});

		it('should return valid when no credentials are resolvable', async () => {
			const nodes: INode[] = [
				createNode('Webhook', 'n8n-nodes-base.webhook'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { httpAuth: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([]);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return invalid when no resolver is configured', async () => {
			const nodes: INode[] = [
				createNode('Webhook', 'n8n-nodes-base.webhook', {
					parameters: hooksParameters,
				}),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useNoResolver();

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('end-user credentials');
			expect(result.error).toContain('"My OAuth2"');
			expect(result.error).toContain('resolver');
		});

		it('should return valid when the proxy provides a system resolver and the trigger is compatible', async () => {
			const nodes: INode[] = [
				createNode('Manual', 'n8n-nodes-base.manualTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.manualTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return valid when workflow settings provide a custom resolver and a trigger has extractor hooks', async () => {
			const nodes: INode[] = [
				createNode('Webhook', 'n8n-nodes-base.webhook', {
					parameters: hooksParameters,
				}),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.webhook') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return invalid when a custom resolver is used with a trigger without extractor hooks', async () => {
			const nodes: INode[] = [
				createNode('Schedule', 'n8n-nodes-base.scheduleTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.scheduleTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('end-user credentials');
			expect(result.error).toContain('"My OAuth2"');
			expect(result.error).toContain('identity extractor');
		});

		it('should return invalid when a custom resolver is used with a webhook trigger without hooks', async () => {
			const nodes: INode[] = [
				createNode('Webhook', 'n8n-nodes-base.webhook'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.webhook') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('end-user credentials');
			expect(result.error).toContain('identity extractor');
		});

		it('should return invalid when a system-resolved credential is used under a schedule trigger', async () => {
			const nodes: INode[] = [
				createNode('Schedule', 'n8n-nodes-base.scheduleTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.scheduleTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('end-user credentials');
			expect(result.error).toContain('"My OAuth2"');
			expect(result.error).toContain(
				'only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication',
			);
		});

		it('should return valid when a system-resolved credential is used under a manual trigger', async () => {
			const nodes: INode[] = [
				createNode('Manual', 'n8n-nodes-base.manualTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.manualTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should not treat a Send-and-Wait action node (webhook method, non-trigger group) as a trigger', async () => {
			// Regression: an MCP trigger (n8n identity) alongside a Gmail tool node using
			// "Send and Wait for Response". The tool carries a HITL `webhook`, so the old
			// method-based check mistook it for an identity-less trigger and blocked publish.
			const nodes: INode[] = [
				createNode('MCP Server Trigger', '@n8n/n8n-nodes-langchain.mcpTrigger', {
					parameters: { authentication: 'n8nOAuth2' },
				}),
				createNode('Send a message in Gmail', 'n8n-nodes-base.gmailTool', {
					credentials: { gmailOAuth2: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Gmail account' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.mcpTrigger') return createTriggerNodeType();
				if (type === 'n8n-nodes-base.gmailTool') return createSendAndWaitNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return invalid when a compatible trigger is combined with an unsupported trigger', async () => {
			const nodes: INode[] = [
				createNode('Manual', 'n8n-nodes-base.manualTrigger'),
				createNode('Schedule', 'n8n-nodes-base.scheduleTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.manualTrigger' || type === 'n8n-nodes-base.scheduleTrigger')
					return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('end-user credentials');
			expect(result.error).toContain('"My OAuth2"');
			// The compatible manual trigger doesn't mask the incompatible schedule one.
			expect(result.error).toContain('are only supported with manual and sub-workflow triggers');
		});

		it('should return valid when a system-resolved credential is used under an Execute Workflow Trigger', async () => {
			const nodes: INode[] = [
				createNode('When Executed by Another Workflow', 'n8n-nodes-base.executeWorkflowTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.executeWorkflowTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return valid when the trigger is an Execute Workflow Trigger (sub-workflow inherits identity context)', async () => {
			const nodes: INode[] = [
				createNode('When Executed by Another Workflow', 'n8n-nodes-base.executeWorkflowTrigger'),
				createNode('Google Drive', 'n8n-nodes-base.googleDrive', {
					credentials: { googleDriveOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Google Drive account 45' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.executeWorkflowTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return valid when Chat Trigger with availableInChat is the trigger', async () => {
			const nodes: INode[] = [
				createNode('Chat Trigger', '@n8n/n8n-nodes-langchain.chatTrigger', {
					parameters: { availableInChat: true },
				}),
				createNode('Google Calendar', 'n8n-nodes-base.googleCalendar', {
					credentials: { googleCalendarOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Google Calendar account 56' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.chatTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return invalid when a custom resolver is used with a Chat Trigger without availableInChat', async () => {
			const nodes: INode[] = [
				createNode('Chat Trigger', '@n8n/n8n-nodes-langchain.chatTrigger'),
				createNode('Google Calendar', 'n8n-nodes-base.googleCalendar', {
					credentials: { googleCalendarOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Google Calendar account 56' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.chatTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('identity extractor');
		});

		it('should return invalid when the system resolver is used with a Chat Trigger not available in Chat Hub', async () => {
			const nodes: INode[] = [
				createNode('Chat Trigger', '@n8n/n8n-nodes-langchain.chatTrigger'),
				createNode('Outlook', 'n8n-nodes-base.microsoftOutlook', {
					credentials: { microsoftOutlookOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Outlook account' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.chatTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('are only supported with manual and sub-workflow triggers');
		});

		it('should return valid when the system resolver is used with a Chat Trigger available in Chat Hub', async () => {
			const nodes: INode[] = [
				createNode('Chat Trigger', '@n8n/n8n-nodes-langchain.chatTrigger', {
					parameters: { availableInChat: true },
				}),
				createNode('Outlook', 'n8n-nodes-base.microsoftOutlook', {
					credentials: { microsoftOutlookOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Outlook account' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.chatTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		it('should return invalid when the system resolver is used with an MCP trigger on bearer auth', async () => {
			const nodes: INode[] = [
				createNode('MCP Server Trigger', '@n8n/n8n-nodes-langchain.mcpTrigger', {
					parameters: { authentication: 'bearerAuth' },
				}),
				createNode('Outlook', 'n8n-nodes-base.microsoftOutlook', {
					credentials: { microsoftOutlookOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Outlook account' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.mcpTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('are only supported with manual and sub-workflow triggers');
		});

		it('should return valid when the system resolver is used with an MCP trigger on n8nOAuth2', async () => {
			const nodes: INode[] = [
				createNode('MCP Server Trigger', '@n8n/n8n-nodes-langchain.mcpTrigger', {
					parameters: { authentication: 'n8nOAuth2' },
				}),
				createNode('Outlook', 'n8n-nodes-base.microsoftOutlook', {
					credentials: { microsoftOutlookOAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'Outlook account' } as any,
			]);
			useSystemResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.mcpTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
		});

		const withChatOAuth2 = (enabled: boolean) =>
			vi.stubEnv('N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2', enabled ? 'true' : 'false');

		describe('webhook trigger', () => {
			const validateWithOAuth2Webhook = async () => {
				const nodes: INode[] = [
					createNode('Webhook', 'n8n-nodes-base.webhook', {
						parameters: { authentication: 'n8nOAuth2' },
					}),
					createNode('HTTP', 'n8n-nodes-base.httpRequest', {
						credentials: { oAuth2Api: { id: 'cred-1' } },
					}),
				];

				mockCredentialsRepository.find.mockResolvedValue([
					{ id: 'cred-1', name: 'My OAuth2' } as any,
				]);
				useSystemResolver();

				mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
					if (type === 'n8n-nodes-base.webhook') return createTriggerNodeType();
					return {} as INodeType;
				}) as any);

				return await service.validateDynamicCredentials(nodes, mockNodeTypes);
			};

			it('should return valid for n8nOAuth2', async () => {
				const result = await validateWithOAuth2Webhook();

				expect(result.isValid).toBe(true);
			});
		});

		describe('form trigger', () => {
			const FORM_TRIGGER = 'n8n-nodes-base.formTrigger';

			const validateWithFormTrigger = async (authentication: string) => {
				const nodes: INode[] = [
					createNode('On form submission', FORM_TRIGGER, {
						parameters: { authentication },
					}),
					createNode('HTTP', 'n8n-nodes-base.httpRequest', {
						credentials: { oAuth2Api: { id: 'cred-1' } },
					}),
				];

				mockCredentialsRepository.find.mockResolvedValue([
					{ id: 'cred-1', name: 'My OAuth2' } as any,
				]);
				useSystemResolver();

				mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
					if (type === FORM_TRIGGER) return createTriggerNodeType();
					return {} as INodeType;
				}) as any);

				return await service.validateDynamicCredentials(nodes, mockNodeTypes);
			};

			it('should return valid for n8nUserAuth', async () => {
				const result = await validateWithFormTrigger('n8nUserAuth');

				expect(result.isValid).toBe(true);
			});

			it.each(['none', 'basicAuth'])('should reject authentication %s', async (authentication) => {
				const result = await validateWithFormTrigger(authentication);

				expect(result.isValid).toBe(false);
				expect(result.error).toBe(
					'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
				);
			});

			it('should offer the form option in the generic message', async () => {
				const nodes: INode[] = [
					createNode('Schedule', 'n8n-nodes-base.scheduleTrigger'),
					createNode('HTTP', 'n8n-nodes-base.httpRequest', {
						credentials: { oAuth2Api: { id: 'cred-1' } },
					}),
				];

				mockCredentialsRepository.find.mockResolvedValue([
					{ id: 'cred-1', name: 'My OAuth2' } as any,
				]);
				useSystemResolver();

				mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
					if (type === 'n8n-nodes-base.scheduleTrigger') return createTriggerNodeType();
					return {} as INodeType;
				}) as any);

				const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

				expect(result.isValid).toBe(false);
				expect(result.error).toContain(
					'only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication',
				);
			});
		});

		describe('chat trigger', () => {
			const CHAT_TRIGGER = '@n8n/n8n-nodes-langchain.chatTrigger';

			const validateWithChatTrigger = async (parameters: Record<string, unknown>) => {
				const nodes: INode[] = [
					createNode('When chat message received', CHAT_TRIGGER, { parameters }),
					createNode('HTTP', 'n8n-nodes-base.httpRequest', {
						credentials: { oAuth2Api: { id: 'cred-1' } },
					}),
				];

				mockCredentialsRepository.find.mockResolvedValue([
					{ id: 'cred-1', name: 'My OAuth2' } as any,
				]);
				useSystemResolver();

				mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
					if (type === CHAT_TRIGGER) return createTriggerNodeType();
					return {} as INodeType;
				}) as any);

				return await service.validateDynamicCredentials(nodes, mockNodeTypes);
			};

			// A chat trigger establishes no identity at runtime through `none`/`basicAuth`, so
			// the flag being on must not let publish accept a configuration that would only
			// fail later, mid-execution.
			it.each(['none', 'basicAuth'])(
				'should reject authentication %s even when chat OAuth2 is enabled',
				async (authentication) => {
					withChatOAuth2(true);

					const result = await validateWithChatTrigger({ authentication });

					expect(result.isValid).toBe(false);
					expect(result.error).toBe(
						'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
					);
				},
			);

			it.each([{}, { mode: 'hostedChat' }])(
				'should return valid for public n8nUserAuth in hosted-chat mode when chat OAuth2 is enabled (%o)',
				async (modeParams) => {
					withChatOAuth2(true);

					const result = await validateWithChatTrigger({
						public: true,
						authentication: 'n8nUserAuth',
						...modeParams,
					});

					expect(result.isValid).toBe(true);
				},
			);

			// With the flag off (the default), hosted-chat `n8nUserAuth` falls back to a cookie
			// check that never binds the visitor's identity — publish must not accept an
			// end-user credential it can't actually resolve at runtime.
			it('should reject public n8nUserAuth in hosted-chat mode when chat OAuth2 is disabled', async () => {
				const result = await validateWithChatTrigger({
					public: true,
					authentication: 'n8nUserAuth',
				});

				expect(result.isValid).toBe(false);
				expect(result.error).toBe(
					'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
				);
			});

			// A non-public trigger 404s on every production request and skips auth entirely
			// in test mode, so it never reaches the code that establishes identity.
			it('should reject n8nUserAuth in hosted-chat mode when not public', async () => {
				const result = await validateWithChatTrigger({ authentication: 'n8nUserAuth' });

				expect(result.isValid).toBe(false);
			});

			// Embedded/webhook-mode chat has no hosted page to run the OAuth2 handshake on, so
			// `n8nUserAuth` establishes no identity there despite being selected.
			it('should reject authentication n8nUserAuth in webhook mode', async () => {
				const result = await validateWithChatTrigger({
					public: true,
					authentication: 'n8nUserAuth',
					mode: 'webhook',
				});

				expect(result.isValid).toBe(false);
				expect(result.error).toBe(
					'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
				);
			});
		});

		it('should state the identity-extractor requirement for a custom resolver', async () => {
			const nodes: INode[] = [
				createNode('Every 5 minutes', 'n8n-nodes-base.scheduleTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useCustomResolver();

			mockNodeTypes.getByNameAndVersion.mockImplementation(((type: string) => {
				if (type === 'n8n-nodes-base.scheduleTrigger') return createTriggerNodeType();
				return {} as INodeType;
			}) as any);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.error).toBe(
				'Cannot publish workflow: end-user credentials ("My OAuth2") require a trigger with an identity extractor configured. Please configure an identity extractor on the trigger node.',
			);
		});

		it('should reject a workflow with no trigger at all', async () => {
			const nodes: INode[] = [
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			mockCredentialsRepository.find.mockResolvedValue([
				{ id: 'cred-1', name: 'My OAuth2' } as any,
			]);
			useSystemResolver();
			mockNodeTypes.getByNameAndVersion.mockReturnValue({} as INodeType);

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.error).toBe(
				'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
			);
		});

		it('should skip disabled nodes when collecting credentials', async () => {
			const nodes: INode[] = [
				createNode('Schedule', 'n8n-nodes-base.scheduleTrigger'),
				createNode('HTTP', 'n8n-nodes-base.httpRequest', {
					disabled: true,
					credentials: { oAuth2Api: { id: 'cred-1' } },
				}),
			];

			const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);

			expect(result.isValid).toBe(true);
			expect(mockCredentialsRepository.find).not.toHaveBeenCalled();
		});
	});

	describe('validateCredentialNodeRestrictions', () => {
		const buildService = (credentialTypes: CredentialTypes) =>
			new WorkflowValidationService(
				mock<WorkflowRepository>(),
				mock<CredentialsRepository>(),
				mock<DynamicCredentialsProxy>(),
				credentialTypes,
			);

		// The loader sets `supportedNodes` on the credential class to *short* names
		// (e.g. "restrictedConsumer"); the FQ list comes via `getSupportedNodes`.
		// Mocks reflect that split so tests catch a regression of the FQ-vs-short bug.
		const restrictedType = {
			name: 'restrictedApi',
			restrictToSupportedNodes: true,
			supportedNodes: ['restrictedConsumer'],
		} as ICredentialType;

		const buildCredentialTypes = (
			typeName: string,
			typeDef: ICredentialType,
			supportedNodes: string[],
		) => {
			const credentialTypes = mock<CredentialTypes>();
			credentialTypes.getByName.calledWith(typeName).mockReturnValue(typeDef);
			credentialTypes.getSupportedNodes.calledWith(typeName).mockReturnValue(supportedNodes);
			return credentialTypes;
		};

		// Helper for HTTP-Request-shaped nodes — the editor sets `authentication`
		// + `nodeCredentialType` on the parameter object to indicate which
		// credential is actively selected on the node.
		const httpRequestNodeWith = (
			activeCredentialType: string | null,
			boundCredentials: INode['credentials'],
			overrides: Partial<INode> = {},
		): INode =>
			mock<INode>({
				name: 'HTTP Request',
				type: 'n8n-nodes-base.httpRequest',
				disabled: false,
				parameters: activeCredentialType
					? { authentication: 'predefinedCredentialType', nodeCredentialType: activeCredentialType }
					: { authentication: 'none' },
				credentials: boundCredentials,
				...overrides,
			});

		it('rejects a workflow binding a restricted credential to a non-supported node', () => {
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const httpNode = httpRequestNodeWith('restrictedApi', {
				restrictedApi: { id: 'cred-1', name: 'Restricted creds' },
			});

			const result = buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]);

			expect(result.isValid).toBe(false);
			expect(result.error).toMatch(/restrictedApi/);
			expect(result.error).toMatch(/HTTP Request/);
			expect(result.error).toMatch(/n8n-nodes-base\.restrictedConsumer/);
		});

		it('accepts a workflow binding a restricted credential to a supported node', () => {
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const consumerNode = mock<INode>({
				name: 'Consumer',
				type: 'n8n-nodes-base.restrictedConsumer',
				disabled: false,
			});
			consumerNode.credentials = { restrictedApi: { id: 'cred-1', name: 'Restricted creds' } };

			expect(
				buildService(credentialTypes).validateCredentialNodeRestrictions([consumerNode]).isValid,
			).toBe(true);
		});

		it('renders "(no nodes)" when the FQ supportedNodes list is empty', () => {
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, []);

			const httpNode = httpRequestNodeWith('restrictedApi', {
				restrictedApi: { id: 'cred-1', name: 'Restricted creds' },
			});

			const result = buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]);

			expect(result.isValid).toBe(false);
			expect(result.error).toMatch(/\(no nodes\)/);
		});

		it('ignores credentials that do not opt into restriction', () => {
			const unrestricted = {
				name: 'slackApi',
				supportedNodes: ['slack'],
			} as ICredentialType;
			const credentialTypes = buildCredentialTypes('slackApi', unrestricted, [
				'n8n-nodes-base.slack',
			]);

			const httpNode = httpRequestNodeWith('slackApi', {
				slackApi: { id: 'cred-2', name: 'Slack creds' },
			});

			expect(
				buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]).isValid,
			).toBe(true);
		});

		it('validates disabled nodes too — illegal bindings must never be persisted', () => {
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const httpNode = httpRequestNodeWith(
				'restrictedApi',
				{ restrictedApi: { id: 'cred-1', name: 'Restricted creds' } },
				{ disabled: true },
			);

			const result = buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]);

			expect(result.isValid).toBe(false);
			expect(result.error).toMatch(/restrictedApi/);
			expect(result.error).toMatch(/HTTP Request/);
		});

		it('ignores a stale credential entry left over from a prior selection on HTTP Request', () => {
			// User picked restrictedApi, then switched to slackApi. The editor spreads
			// node.credentials so the old key sticks around even though only slackApi
			// is selected per parameters.nodeCredentialType. We must not flag the
			// inactive restrictedApi binding — the user sees Slack in the UI.
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const httpNode = httpRequestNodeWith('slackApi', {
				restrictedApi: { id: 'cred-r1', name: 'Stale restricted' },
				slackApi: { id: 'cred-s1', name: 'Active Slack' },
			});

			expect(
				buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]).isValid,
			).toBe(true);
		});

		it('rejects a restricted credential that IS the active selection on HTTP Request', () => {
			// Sibling of the stale-entry test: when nodeCredentialType points AT the
			// restricted type, the validator must fire — defense vs. someone POSTing
			// an illegal binding through the API.
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const httpNode = httpRequestNodeWith('restrictedApi', {
				restrictedApi: { id: 'cred-r1', name: 'Active restricted' },
				slackApi: { id: 'cred-s1', name: 'Stale Slack' },
			});

			const result = buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]);

			expect(result.isValid).toBe(false);
			expect(result.error).toMatch(/restrictedApi/);
		});

		it('ignores all entries on HTTP Request when authentication is "none"', () => {
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const httpNode = httpRequestNodeWith(null, {
				restrictedApi: { id: 'cred-r1', name: 'Leftover from earlier auth setup' },
			});

			expect(
				buildService(credentialTypes).validateCredentialNodeRestrictions([httpNode]).isValid,
			).toBe(true);
		});

		it('aggregates violations across multiple nodes', () => {
			const credentialTypes = buildCredentialTypes('restrictedApi', restrictedType, [
				'n8n-nodes-base.restrictedConsumer',
			]);

			const httpNode = httpRequestNodeWith('restrictedApi', {
				restrictedApi: { id: 'cred-1', name: 'Restricted creds' },
			});

			const slackNode = mock<INode>({
				name: 'Slack',
				type: 'n8n-nodes-base.slack',
				disabled: false,
			});
			slackNode.credentials = { restrictedApi: { id: 'cred-1', name: 'Restricted creds' } };

			const result = buildService(credentialTypes).validateCredentialNodeRestrictions([
				httpNode,
				slackNode,
			]);

			expect(result.isValid).toBe(false);
			expect(result.error).toMatch(/HTTP Request/);
			expect(result.error).toMatch(/Slack/);
		});
	});

	describe('validateTriggerNodeIds', () => {
		const triggerNode = (name: string, id?: string): INode =>
			({
				name,
				id,
				type: 'n8n-nodes-base.cron',
				typeVersion: 1,
				position: [0, 0],
				parameters: {},
			}) as INode;

		it('accepts trigger nodes that each carry a distinct id', () => {
			const result = service.validateTriggerNodeIds([
				triggerNode('Cron', 'node-1'),
				triggerNode('Webhook', 'node-2'),
			]);

			expect(result.isValid).toBe(true);
		});

		it('accepts a workflow with no trigger nodes', () => {
			const result = service.validateTriggerNodeIds([]);

			expect(result.isValid).toBe(true);
		});

		it('rejects trigger nodes that share an id, naming each node and the id', () => {
			const result = service.validateTriggerNodeIds([
				triggerNode('Cron', 'node-1'),
				triggerNode('Webhook', 'node-1'),
			]);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Cannot publish workflow');
			expect(result.error).toContain('"Cron"');
			expect(result.error).toContain('"Webhook"');
			expect(result.error).toContain('"node-1"');
		});

		it('rejects a trigger node that carries no id', () => {
			const result = service.validateTriggerNodeIds([
				triggerNode('Cron', 'node-1'),
				triggerNode('Webhook', undefined),
			]);

			expect(result.isValid).toBe(false);
			expect(result.error).toContain('Cannot publish workflow');
			expect(result.error).toContain('"Webhook"');
		});
	});
});
