import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { OAuth2CredentialData } from '@n8n/client-oauth2';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb, User } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db';
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { Cipher } from 'n8n-core';
import { Credentials } from 'n8n-core';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';

import { AuthService } from '@/auth/auth.service';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import { CredentialsHelper } from '@/credentials-helper';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';
import {
	OauthService,
	OauthVersion,
	shouldSkipAuthOnOAuthCallback,
	type OAuth1CredentialData,
} from '@/oauth/oauth.service';
import type { OAuthRequest } from '@/requests';
import { UrlService } from '@/services/url.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';

jest.mock('@/workflow-execute-additional-data');
jest.mock('axios');
jest.mock('@n8n/client-oauth2');
jest.mock('pkce-challenge');

describe('OauthService', () => {
	const logger = mockInstance(Logger);
	const credentialsHelper = mockInstance(CredentialsHelper);
	const credentialsRepository = mockInstance(CredentialsRepository);
	const credentialsFinderService = mockInstance(CredentialsFinderService);
	const urlService = mockInstance(UrlService);
	const globalConfig = mockInstance(GlobalConfig);
	const externalHooks = mockInstance(ExternalHooks);
	const cipher = mock<Cipher>();
	const dynamicCredentialsProxy = mockInstance(DynamicCredentialsProxy);
	const authService = mockInstance(AuthService);
	const oauthJweServiceProxy = mockInstance(OAuthJweServiceProxy);

	let service: OauthService;

	const timestamp = 1706750625678;
	jest.useFakeTimers({ advanceTimers: true });

	beforeEach(() => {
		jest.setSystemTime(new Date(timestamp));
		jest.clearAllMocks();
		credentialsHelper.getCredentialsProperties.mockReturnValue([]);

		globalConfig.endpoints = { rest: 'rest' } as any;
		urlService.getInstanceBaseUrl.mockReturnValue('http://localhost:5678');
		jest
			.mocked(WorkflowExecuteAdditionalData.getBase)
			.mockResolvedValue(mock<IWorkflowExecuteAdditionalData>());
		externalHooks.run.mockResolvedValue(undefined);

		// Setup axios mock
		const axios = require('axios');
		axios.get = jest.fn();
		axios.post = jest.fn();

		cipher.encryptV2.mockImplementation(async (data: string | object) => {
			const plaintext = typeof data === 'string' ? data : JSON.stringify(data);
			return Buffer.from(plaintext).toString('base64');
		});
		cipher.decryptV2.mockImplementation(async (data: string) => {
			return Buffer.from(data, 'base64').toString();
		});

		service = new OauthService(
			logger,
			credentialsHelper,
			credentialsRepository,
			credentialsFinderService,
			urlService,
			globalConfig,
			externalHooks,
			cipher,
			dynamicCredentialsProxy,
			authService,
			oauthJweServiceProxy,
		);
	});

	describe('shouldSkipAuthOnOAuthCallback', () => {
		it('should return false when env var is not set', () => {
			delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK;
			expect(shouldSkipAuthOnOAuthCallback()).toBe(false);
		});

		it('should return false when env var is "false"', () => {
			process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'false';
			expect(shouldSkipAuthOnOAuthCallback()).toBe(false);
		});

		it('should return true when env var is "true"', () => {
			process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true';
			expect(shouldSkipAuthOnOAuthCallback()).toBe(true);
		});

		it('should return true when env var is "TRUE" (case insensitive)', () => {
			process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'TRUE';
			expect(shouldSkipAuthOnOAuthCallback()).toBe(true);
		});
	});

	describe('getBaseUrl', () => {
		it('should return correct URL for OAuth1', () => {
			const url = service.getBaseUrl(OauthVersion.V1);
			expect(url).toBe('http://localhost:5678/rest/oauth1-credential');
			expect(urlService.getInstanceBaseUrl).toHaveBeenCalled();
		});

		it('should return correct URL for OAuth2', () => {
			const url = service.getBaseUrl(OauthVersion.V2);
			expect(url).toBe('http://localhost:5678/rest/oauth2-credential');
		});
	});

	describe('getCredential', () => {
		it('should throw BadRequestError when credential ID is missing', async () => {
			const req = {
				query: {},
				user: mock<User>({ id: '123' }),
			} as unknown as OAuthRequest.OAuth2Credential.Auth;

			Object.defineProperty(req.query, 'id', {
				value: undefined,
				writable: true,
				enumerable: true,
			});

			const promise = service.getCredentialForUpdate(req);
			await expect(promise).rejects.toThrow(BadRequestError);
			await expect(promise).rejects.toThrow('Required credential ID is missing');
		});

		it('should throw NotFoundError when credential is not found', async () => {
			const req = mock<OAuthRequest.OAuth2Credential.Auth>({
				query: { id: 'credential-id' },
				user: mock<User>({ id: '123' }),
			});

			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);

			await expect(service.getCredentialForUpdate(req)).rejects.toThrow(NotFoundError);
			expect(logger.error).toHaveBeenCalledWith(
				'OAuth credential authorization failed because the current user does not have the correct permissions',
				{ userId: '123', credentialId: 'credential-id' },
			);
		});

		it('should return credential when found', async () => {
			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			const req = mock<OAuthRequest.OAuth2Credential.Auth>({
				query: { id: 'credential-id' },
				user: mock<User>({ id: '123' }),
			});

			credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential);

			const result = await service.getCredentialForUpdate(req);

			expect(result).toBe(mockCredential);
			expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
				'credential-id',
				req.user,
				['credential:update'],
			);
		});
	});

	describe('getAdditionalData', () => {
		it('should return workflow execute additional data', async () => {
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();
			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);

			const result = await (service as any).getAdditionalData();

			expect(result).toBe(mockAdditionalData);
			expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalled();
		});
	});

	describe('getDecryptedDataForAuthUri', () => {
		it('should call getDecryptedData with raw=false', async () => {
			const credential = mock<ICredentialsDb>({ id: '1', type: 'test' });
			const additionalData = mock<IWorkflowExecuteAdditionalData>();
			const mockDecryptedData = { clientId: 'test' };

			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);

			const result = await (service as any).getDecryptedDataForAuthUri(credential, additionalData);

			expect(result).toBe(mockDecryptedData);
			expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
				additionalData,
				credential,
				credential.type,
				'internal',
				undefined,
				false,
			);
		});
	});

	describe('getDecryptedDataForCallback', () => {
		it('should call getDecryptedData with raw=true', async () => {
			const credential = mock<ICredentialsDb>({ id: '1', type: 'test' });
			const additionalData = mock<IWorkflowExecuteAdditionalData>();
			const mockDecryptedData = { csrfSecret: 'secret' };

			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);

			const result = await (service as any).getDecryptedDataForCallback(credential, additionalData);

			expect(result).toBe(mockDecryptedData);
			expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
				additionalData,
				credential,
				credential.type,
				'internal',
				undefined,
				true,
			);
		});
	});

	describe('applyDefaultsAndOverwrites', () => {
		it('should apply defaults and overwrites', async () => {
			const credential = mock<ICredentialsDb>({ id: '1', type: 'test' });
			const decryptedData = { clientId: 'test' };
			const additionalData = mock<IWorkflowExecuteAdditionalData>();
			const mockResult = { clientId: 'test', clientSecret: 'secret' };

			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockResult);

			const result = await (service as any).applyDefaultsAndOverwrites(
				credential,
				decryptedData,
				additionalData,
			);

			expect(result).toBe(mockResult);
			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				additionalData,
				decryptedData,
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});
	});

	describe('encryptAndSaveData', () => {
		beforeEach(() => {
			jest.spyOn(Credentials.prototype, 'getData').mockResolvedValue({});
		});

		afterEach(() => {
			jest.restoreAllMocks();
		});

		it('should encrypt and save data to repository', async () => {
			const encryptedData = await cipher.encryptV2({ existing: 'data' });

			const credential = mock<ICredentialsDb>({
				id: '1',
				type: 'test',
				data: encryptedData,
			});
			const toUpdate = { clientId: 'new-id' };
			const toDelete = ['oldField'];

			await service.encryptAndSaveData(credential, toUpdate, toDelete);

			expect(credentialsRepository.update).toHaveBeenCalledWith('1', {
				id: '1',
				name: expect.anything(),
				type: 'test',
				data: expect.any(String),
				updatedAt: expect.any(Date),
			});
		});

		it('should use empty array for toDelete when not provided', async () => {
			const encryptedData = await cipher.encryptV2({ existing: 'data' });

			const credential = mock<ICredentialsDb>({
				id: '1',
				type: 'test',
				data: encryptedData,
			});
			const toUpdate = { clientId: 'new-id' };

			await service.encryptAndSaveData(credential, toUpdate);

			expect(credentialsRepository.update).toHaveBeenCalledWith('1', {
				id: '1',
				name: expect.anything(),
				type: 'test',
				data: expect.any(String),
				updatedAt: expect.any(Date),
			});
		});
	});

	describe('getCredentialWithoutUser', () => {
		it('should return credential from repository', async () => {
			const mockCredential = mock<ICredentialsDb>({ id: '1' });
			credentialsRepository.findOneBy.mockResolvedValue(mockCredential as any);

			const result = await (service as any).getCredentialWithoutUser('1');

			expect(result).toBe(mockCredential);
			expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' });
		});

		it('should return null when credential not found', async () => {
			credentialsRepository.findOneBy.mockResolvedValue(null);

			const result = await (service as any).getCredentialWithoutUser('1');

			expect(result).toBeNull();
		});
	});

	describe('createCsrfState', () => {
		it('should create CSRF state with correct structure', async () => {
			const data = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			jest.setSystemTime(new Date(timestamp));

			const [csrfSecret, base64State] = await service.createCsrfState(data);

			expect(typeof csrfSecret).toBe('string');
			expect(csrfSecret.length).toBeGreaterThan(0);
			expect(cipher.encryptV2).toHaveBeenCalled();

			// Verify base64State is a valid base64 string
			expect(typeof base64State).toBe('string');
			const base64Decoded = JSON.parse(Buffer.from(base64State, 'base64').toString());
			expect(base64Decoded.token).toBeDefined();
			expect(base64Decoded.createdAt).toBe(timestamp);
			expect(base64Decoded.data).toBeDefined();

			// Decrypt the data field to verify CSRF data
			const decryptedData = JSON.parse(await cipher.decryptV2(base64Decoded.data));
			expect(decryptedData.cid).toBe('credential-id');
			expect(decryptedData.userId).toBe('user-id');
			expect(decryptedData.origin).toBe('static-credential');
		});

		it('should include additional data in state', async () => {
			const data = {
				cid: 'credential-id',
				customField: 'custom-value',
				origin: 'static-credential' as const,
			};
			jest.setSystemTime(new Date(timestamp));

			const [, base64State] = await service.createCsrfState(data);

			expect(cipher.encryptV2).toHaveBeenCalled();

			// Verify base64State structure
			const base64Decoded = JSON.parse(Buffer.from(base64State, 'base64').toString());
			expect(base64Decoded.token).toBeDefined();
			expect(base64Decoded.createdAt).toBeDefined();
			expect(base64Decoded.data).toBeDefined();

			// Decrypt and verify the customField is in the encrypted data
			const decryptedData = JSON.parse(await cipher.decryptV2(base64Decoded.data));
			expect(decryptedData.customField).toBe('custom-value');
		});
	});

	describe('decodeCsrfState', () => {
		// Auth logic: dynamic credentials (origin === 'dynamic-credential') always skip user validation.
		// Static credentials: skip user validation only when N8N_SKIP_AUTH_ON_OAUTH_CALLBACK is true
		// (e.g. embed/iframe); otherwise req.user.id must match decryptedState.userId (BOLA prevention).
		// skipAuthOnOAuthCallback is read at module load, so the "skip for static" path is not tested here.

		it('should decode valid CSRF state', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential);
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			const [decodedState, credential] = await (service as any).decodeCsrfState(encodedState, req);

			expect(cipher.decryptV2).toHaveBeenCalledWith('encrypted-data');
			expect(decodedState).toMatchObject({
				token: 'token',
				createdAt: timestamp,
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential',
			});
			expect(credential).toBe(mockCredential);
			expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
				'credential-id',
				req.user,
				['credential:update'],
			);
		});

		it('should throw error when state format is invalid', async () => {
			const invalidState = 'not-base64-json';
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(invalidState, req)).rejects.toThrow(
				'Invalid state format',
			);
		});

		it('should throw UnexpectedError when cid is missing', async () => {
			const csrfData = {
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				UnexpectedError,
			);
		});

		it('should throw UnexpectedError when token is missing', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				UnexpectedError,
			);
		});

		it('should throw AuthError when userId does not match', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'different-user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(AuthError);
			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				'Unauthorized',
			);
		});

		it('should throw AuthError when req.user is undefined', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: undefined,
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(AuthError);
		});

		it('should succeed for dynamic-credential origin when userId matches req.user.id', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			credentialsRepository.findOneBy.mockResolvedValue(mockCredential as any);
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			const [decodedState, credential] = await (service as any).decodeCsrfState(encodedState, req);

			expect(decodedState).toMatchObject({
				token: 'token',
				createdAt: timestamp,
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential',
			});
			expect(credential).toBe(mockCredential);
			expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
		});

		it('should throw AuthError for dynamic-credential origin when userId does not match req.user.id', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'different-user-id',
				origin: 'dynamic-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(AuthError);
			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				'Unauthorized',
			);
		});

		it('should bypass user validation for dynamic-credential origin when req.user is undefined', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			credentialsRepository.findOneBy.mockResolvedValue(mockCredential as any);
			const req = mock<AuthenticatedRequest>({
				user: undefined,
			});

			const [decodedState, credential] = await (service as any).decodeCsrfState(encodedState, req);

			expect(decodedState).toMatchObject({
				token: 'token',
				createdAt: timestamp,
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential',
			});
			expect(credential).toBe(mockCredential);
			expect(cipher.decryptV2).toHaveBeenCalledWith('encrypted-data');
		});

		it('should bypass user validation for dynamic-credential origin when state has no userId (external flow)', async () => {
			const csrfData = {
				cid: 'credential-id',
				origin: 'dynamic-credential' as const,
				// no userId — state created by dynamic-credentials.controller (external/Keycloak flow)
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			credentialsRepository.findOneBy.mockResolvedValue(mockCredential as any);
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			const [decodedState, credential] = await (service as any).decodeCsrfState(encodedState, req);

			expect(decodedState).toMatchObject({
				cid: 'credential-id',
				origin: 'dynamic-credential',
			});
			expect(credential).toBe(mockCredential);
			expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
		});

		it('should require user validation for static-credential origin', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'different-user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(AuthError);
			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				'Unauthorized',
			);
		});

		it('should require user validation when origin is undefined', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'different-user-id',
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(AuthError);
			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				'Unauthorized',
			);
		});

		it('should require user validation for invalid origin values', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'different-user-id',
				origin: 'invalid-origin' as any,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			const req = mock<AuthenticatedRequest>({
				user: mock<User>({ id: 'user-id' }),
			});

			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(AuthError);
			await expect((service as any).decodeCsrfState(encodedState, req)).rejects.toThrow(
				'Unauthorized',
			);
		});
	});

	describe('buildCsrfStateData', () => {
		it('returns static-credential data when credential is not resolvable', async () => {
			const credential = mock<CredentialsEntity>({ id: 'cred-1', isResolvable: false });
			const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user: mock<User>({ id: 'user-1' }) });

			const result = await service.buildCsrfStateData(credential, req);

			expect(result).toEqual({ cid: 'cred-1', origin: 'static-credential', userId: 'user-1' });
			expect(dynamicCredentialsProxy.getSystemResolverId).not.toHaveBeenCalled();
		});

		it('returns static-credential data when credential is resolvable but no system resolver is configured', async () => {
			const credential = mock<CredentialsEntity>({ id: 'cred-1', isResolvable: true });
			const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user: mock<User>({ id: 'user-1' }) });
			dynamicCredentialsProxy.getSystemResolverId.mockReturnValueOnce(null);

			const result = await service.buildCsrfStateData(credential, req);

			expect(result).toEqual({ cid: 'cred-1', origin: 'static-credential', userId: 'user-1' });
		});

		it('returns static-credential data when credential is resolvable, resolver exists, but cookie token is missing', async () => {
			const credential = mock<CredentialsEntity>({ id: 'cred-1', isResolvable: true });
			const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user: mock<User>({ id: 'user-1' }) });
			dynamicCredentialsProxy.getSystemResolverId.mockReturnValueOnce('system-resolver');
			authService.getCookieToken.mockReturnValueOnce(undefined);

			const result = await service.buildCsrfStateData(credential, req);

			expect(result).toEqual({ cid: 'cred-1', origin: 'static-credential', userId: 'user-1' });
		});

		it('returns dynamic-credential data when credential is resolvable, resolver exists, and cookie token is present', async () => {
			const credential = mock<CredentialsEntity>({ id: 'cred-1', isResolvable: true });
			const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user: mock<User>({ id: 'user-1' }) });
			dynamicCredentialsProxy.getSystemResolverId.mockReturnValueOnce('system-resolver');
			authService.getCookieToken.mockReturnValueOnce('jwt-token');

			const result = await service.buildCsrfStateData(credential, req);

			expect(result).toEqual({
				cid: 'cred-1',
				origin: 'dynamic-credential',
				userId: 'user-1',
				credentialResolverId: 'system-resolver',
				authorizationHeader: 'Bearer jwt-token',
				authMetadata: { source: 'manual-execution' },
			});
		});
	});

	describe('verifyCsrfState', () => {
		it('should return true for valid CSRF state', () => {
			const csrfSecret = 'csrf-secret';
			const token = new (require('csrf'))();
			const stateToken = token.create(csrfSecret);

			const state = {
				token: stateToken,
				cid: 'credential-id',
				origin: 'static-credential',
				createdAt: Date.now(),
			};
			const decrypted = { csrfSecret };

			const result = (service as any).verifyCsrfState(decrypted, state);

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

		it('should return true for valid CSRF state with dynamic credential origin', () => {
			const csrfSecret = 'csrf-secret';
			const token = new (require('csrf'))();
			const stateToken = token.create(csrfSecret);

			const state = {
				token: stateToken,
				cid: 'credential-id',
				origin: 'dynamic-credential',
				createdAt: Date.now(),
			};
			const decrypted = { csrfSecret };

			const result = (service as any).verifyCsrfState(decrypted, state);

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

		it('should return false when CSRF state is expired', () => {
			const csrfSecret = 'csrf-secret';
			const token = new (require('csrf'))();
			const stateToken = token.create(csrfSecret);

			const expiredTime = Date.now() - 6 * Time.minutes.toMilliseconds;
			const state = {
				token: stateToken,
				cid: 'credential-id',
				origin: 'static-credential',
				createdAt: expiredTime,
			};
			const decrypted = { csrfSecret };

			const result = (service as any).verifyCsrfState(decrypted, state);

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

		it('should return false when csrfSecret is missing', () => {
			const token = new (require('csrf'))();
			const csrfSecret = 'csrf-secret';
			const stateToken = token.create(csrfSecret);

			const state = {
				token: stateToken,
				cid: 'credential-id',
				origin: 'static-credential',
				createdAt: Date.now(),
			};
			const decrypted = {};

			const result = (service as any).verifyCsrfState(decrypted, state);

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

		it('should return false when token verification fails', () => {
			const state = {
				token: 'invalid-token',
				cid: 'credential-id',
				origin: 'static-credential',
				createdAt: Date.now(),
			};
			const decrypted = { csrfSecret: 'csrf-secret' };

			const result = (service as any).verifyCsrfState(decrypted, state);

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

	describe('resolveCredential', () => {
		it('should resolve credential successfully', async () => {
			const token = new (require('csrf'))();
			const stateToken = token.create('csrf-secret');

			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: stateToken,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');

			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			const mockDecryptedData = { csrfSecret: 'csrf-secret' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { state: encodedState },
				user: mock<User>({ id: 'user-id' }),
			});

			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential);
			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(true);

			const result = await service.resolveCredential(req);

			expect(result[0]).toEqual(mockCredential);
			expect(result[1]).toEqual(mockDecryptedData);
			expect(result[2]).toEqual(mockOAuthCredentials);
			expect(result[3]).toMatchObject({
				token: stateToken,
				createdAt: timestamp,
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential',
			});
		});

		it('should throw NotFoundError when credential is not found', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { state: encodedState },
				user: mock<User>({ id: 'user-id' }),
			});

			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);

			await expect(service.resolveCredential(req)).rejects.toThrow(NotFoundError);
			await expect(service.resolveCredential(req)).rejects.toThrow('Credential not found');
		});

		it('should throw UnexpectedError when CSRF state is invalid', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'static-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));

			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			const mockDecryptedData = { csrfSecret: 'csrf-secret' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { state: encodedState },
				user: mock<User>({ id: 'user-id' }),
			});

			credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential);
			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(false);

			await expect(service.resolveCredential(req)).rejects.toThrow(UnexpectedError);
			await expect(service.resolveCredential(req)).rejects.toThrow(
				'The OAuth callback state is invalid!',
			);
		});

		it('should resolve dynamic credential and verify CSRF when userId matches', async () => {
			const token = new (require('csrf'))();
			const stateToken = token.create('csrf-secret');

			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential' as const,
			};
			const state = {
				token: stateToken,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');

			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			const mockDecryptedData = { csrfSecret: 'csrf-secret' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { state: encodedState },
				user: mock<User>({ id: 'user-id' }),
			});

			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			credentialsRepository.findOneBy.mockResolvedValue(mockCredential);
			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			const verifySpy = jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(true);

			const result = await service.resolveCredential(req);

			expect(result[0]).toEqual(mockCredential);
			expect(result[1]).toEqual(mockDecryptedData);
			expect(result[2]).toEqual(mockOAuthCredentials);
			expect(result[3]).toMatchObject({
				token: stateToken,
				createdAt: timestamp,
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential',
			});
			expect(verifySpy).toHaveBeenCalled();
		});

		it('should reject dynamic credential callback when userId does not match req.user.id', async () => {
			const csrfData = {
				cid: 'credential-id',
				userId: 'different-user-id',
				origin: 'dynamic-credential' as const,
			};
			const state = {
				token: 'token',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { state: encodedState },
				user: mock<User>({ id: 'user-id' }),
			});

			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));

			await expect(service.resolveCredential(req)).rejects.toThrow(AuthError);
		});

		it('should still verify CSRF for dynamic credentials even when req.user is undefined', async () => {
			const token = new (require('csrf'))();
			const stateToken = token.create('csrf-secret');

			const csrfData = {
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential' as const,
			};
			const state = {
				token: stateToken,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');

			const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
			const mockDecryptedData = { csrfSecret: 'csrf-secret' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { state: encodedState },
				user: undefined, // No user - should be bypassed for dynamic credentials
			});

			cipher.decryptV2.mockResolvedValue(JSON.stringify(csrfData));
			credentialsRepository.findOneBy.mockResolvedValue(mockCredential);
			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			const verifySpy = jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(true);

			const result = await service.resolveCredential(req);

			// Should succeed despite no user because origin is dynamic-credential
			expect(result[0]).toEqual(mockCredential);
			expect(result[1]).toEqual(mockDecryptedData);
			expect(result[2]).toEqual(mockOAuthCredentials);
			expect(result[3]).toMatchObject({
				token: stateToken,
				createdAt: timestamp,
				cid: 'credential-id',
				userId: 'user-id',
				origin: 'dynamic-credential',
			});
			// CSRF validation should still be called
			expect(verifySpy).toHaveBeenCalled();
		});
	});

	describe('saveDynamicCredential', () => {
		beforeEach(() => {
			// Mock Credentials.getData to return empty object to avoid decryption issues
			jest.spyOn(Credentials.prototype, 'getData').mockResolvedValue({});
		});

		afterEach(() => {
			jest.restoreAllMocks();
		});

		it('should save dynamic credential with correct parameters', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'credential-id',
				name: 'Test Credential',
				type: 'googleOAuth2Api',
				data: 'encrypted-data',
				isResolvable: true,
				resolverId: 'resolver-id',
			});
			const oauthTokenData = {
				access_token: 'access-token',
				refresh_token: 'refresh-token',
			};
			const authToken = 'token123'; // Controller splits 'Bearer token123' and passes just 'token123'
			const credentialResolverId = 'resolver-id';

			dynamicCredentialsProxy.storeIfNeeded.mockResolvedValue(undefined);

			await service.saveDynamicCredential(
				credential,
				oauthTokenData,
				authToken,
				credentialResolverId,
			);

			expect(dynamicCredentialsProxy.storeIfNeeded).toHaveBeenCalledWith(
				{
					id: 'credential-id',
					name: 'Test Credential',
					type: 'googleOAuth2Api',
					isResolvable: true,
					resolverId: 'resolver-id',
				},
				oauthTokenData,
				{ version: 1, identity: authToken, metadata: {} },
				expect.any(Object),
				{ credentialResolverId: 'resolver-id' },
			);
		});

		it('should remove csrfSecret from credential data', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'credential-id',
				name: 'Test Credential',
				type: 'googleOAuth2Api',
				data: 'encrypted-data',
			});
			const oauthTokenData = {
				access_token: 'access-token',
				csrfSecret: 'csrf-secret',
			};
			const authToken = 'token123'; // Controller splits 'Bearer token123' and passes just 'token123'
			const credentialResolverId = 'resolver-id';

			dynamicCredentialsProxy.storeIfNeeded.mockResolvedValue(undefined);

			await service.saveDynamicCredential(
				credential,
				oauthTokenData,
				authToken,
				credentialResolverId,
			);

			// Verify that storeIfNeeded was called with data that doesn't include csrfSecret
			const callArgs = dynamicCredentialsProxy.storeIfNeeded.mock.calls[0];
			const staticData = callArgs[3] as any;
			expect(staticData).not.toHaveProperty('csrfSecret');
		});

		it('should handle errors from dynamicCredentialsProxy', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'credential-id',
				name: 'Test Credential',
				type: 'googleOAuth2Api',
				data: 'encrypted-data',
			});
			const oauthTokenData = {
				access_token: 'access-token',
			};
			const authToken = 'token123'; // Controller splits 'Bearer token123' and passes just 'token123'
			const credentialResolverId = 'resolver-id';

			const error = new Error('Storage failed');
			dynamicCredentialsProxy.storeIfNeeded.mockRejectedValue(error);

			await expect(
				service.saveDynamicCredential(credential, oauthTokenData, authToken, credentialResolverId),
			).rejects.toThrow('Storage failed');
		});
	});

	describe('renderCallbackError', () => {
		it('should render error page with message', () => {
			const res = mock<Response>();
			const message = 'Test error message';

			service.renderCallbackError(res, message);

			expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
				error: { message },
			});
		});

		it('should render error page with message and reason', () => {
			const res = mock<Response>();
			const message = 'Test error message';
			const reason = 'Test reason';

			service.renderCallbackError(res, message, reason);

			expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
				error: { message, reason },
			});
		});
	});

	describe('getOAuthCredentials', () => {
		it('should return OAuth credentials', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'testOAuth2Api',
			});
			const mockDecryptedData = { clientId: 'client-id' };
			const mockOAuthCredentials = { clientId: 'client-id', clientSecret: 'secret' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			const result = await service.getOAuthCredentials(credential);

			expect(result).toBe(mockOAuthCredentials);
		});

		it('should delete scope for non-generic OAuth2 credentials', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'testOAuth2Api',
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'old-scope' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should not delete scope for generic OAuth2 credentials with editable scope', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'oAuth2Api',
				isManaged: false,
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'old-scope' };
			const mockOAuthCredentials = { clientId: 'client-id', scope: 'old-scope' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id', scope: 'old-scope' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it.each(['facebookGraphApiOAuth2Api', 'facebookGraphAppOAuth2Api'])(
			'should not delete scope for %s credentials',
			async (credentialType) => {
				const credential = mock<CredentialsEntity>({
					id: '1',
					type: credentialType,
					isManaged: false,
				});
				const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' };
				const mockOAuthCredentials = { clientId: 'client-id', scope: 'custom-scope' };
				const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

				jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
				credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
				credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

				await service.getOAuthCredentials(credential);

				expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
					mockAdditionalData,
					{ clientId: 'client-id', scope: 'custom-scope' },
					credentialType,
					'internal',
					undefined,
					undefined,
				);
			},
		);
		it('should not delete scope for wordpressOAuth2Api credentials', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'wordpressOAuth2Api',
				isManaged: false,
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' };
			const mockOAuthCredentials = { clientId: 'client-id', scope: 'custom-scope' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id', scope: 'custom-scope' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should not delete scope for non-OAuth2 credentials', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'testApi',
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'old-scope' };
			const mockOAuthCredentials = { clientId: 'client-id', scope: 'old-scope' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id', scope: 'old-scope' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should not delete scope when the credential inherits an editable scope property', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'customOAuth2Api',
				isManaged: false,
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' };
			const mockOAuthCredentials = { clientId: 'client-id', scope: 'custom-scope' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{ displayName: 'Scope', name: 'scope', type: 'string', default: '' },
			]);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id', scope: 'custom-scope' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should delete scope when the credential overrides the inherited scope as hidden', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'gmailOAuth2',
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'stale-scope' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{ displayName: 'Scope', name: 'scope', type: 'hidden', default: 'default-scope' },
			]);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should delete scope when getCredentialsProperties throws', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'unknownOAuth2Api',
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'stale-scope' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);
			credentialsHelper.getCredentialsProperties.mockImplementation(() => {
				throw new Error('Unknown credential type');
			});

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should delete scope for managed credentials of a generic editable-scope type', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'googleOAuth2Api',
				isManaged: true,
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});

		it('should delete scope for managed credentials that inherit an editable scope property', async () => {
			const credential = mock<CredentialsEntity>({
				id: '1',
				type: 'customOAuth2Api',
				isManaged: true,
			});
			const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' };
			const mockOAuthCredentials = { clientId: 'client-id' };
			const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();

			jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
			credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{ displayName: 'Scope', name: 'scope', type: 'string', default: '' },
			]);

			await service.getOAuthCredentials(credential);

			expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith(
				mockAdditionalData,
				{ clientId: 'client-id' },
				credential.type,
				'internal',
				undefined,
				undefined,
			);
		});
	});

	describe('generateAOauth2AuthUri', () => {
		it('should generate auth URI without dynamic client registration', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials: OAuth2CredentialData = {
				clientId: 'client_id',
				clientSecret: 'client_secret',
				authUrl: 'https://example.domain/oauth2/auth',
				accessTokenUrl: 'https://example.domain/oauth2/token',
				scope: 'openid',
				grantType: 'authorizationCode',
				authentication: 'header',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const generateAOuth2AuthUriBound = service.generateAOauth2AuthUri.bind(service);
			const authUri = await generateAOuth2AuthUriBound(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('https://example.domain/oauth2/auth');
			expect(service.encryptAndSaveData).toHaveBeenCalled();
			const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0];
			expect(callArgs[0]).toBe(credential);
			expect(callArgs[1]).toHaveProperty('csrfSecret');
			expect(typeof callArgs[1].csrfSecret).toBe('string');
			expect(callArgs[2] || []).toEqual([]);
			expect(externalHooks.run).toHaveBeenCalledWith('oauth2.authenticate', [
				expect.objectContaining({
					state: expect.any(String), // base64State
				}),
			]);

			// Reject javascript: and data: protocols in OAuth2 URLs (XSS)
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				...oauthCredentials,
				authUrl: "javascript:alert('Hacked')//",
			});
			const promiseJs = generateAOuth2AuthUriBound(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});
			await expect(promiseJs).rejects.toThrow(BadRequestError);
			await expect(promiseJs).rejects.toThrow(/OAuth url must use HTTP or HTTPS protocol/);

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				...oauthCredentials,
				accessTokenUrl: 'data:text/html,<script>alert(1)</script>',
			});
			const promiseData = generateAOuth2AuthUriBound(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});
			await expect(promiseData).rejects.toThrow(BadRequestError);
			await expect(promiseData).rejects.toThrow(/OAuth url must use HTTP or HTTPS protocol/);
		});

		it('should generate auth URI with PKCE flow', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const pkceChallenge = await import('pkce-challenge');
			jest.mocked(pkceChallenge.default).mockResolvedValue({
				code_verifier: 'code_verifier',
				code_challenge: 'code_challenge',
			});

			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid&code_challenge=code_challenge&code_challenge_method=S256',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials: OAuth2CredentialData = {
				clientId: 'client_id',
				clientSecret: 'client_secret',
				authUrl: 'https://example.domain/oauth2/auth',
				accessTokenUrl: 'https://example.domain/oauth2/token',
				scope: 'openid',
				grantType: 'pkce',
				authentication: 'header',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const authUri = await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('code_challenge=code_challenge');
			expect(service.encryptAndSaveData).toHaveBeenCalled();
			const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0];
			expect(callArgs[0]).toBe(credential);
			expect(callArgs[1]).toHaveProperty('csrfSecret');
			expect(callArgs[1]).toHaveProperty('codeVerifier', 'code_verifier');
			expect(callArgs[2] || []).toEqual([]);
		});

		it('should generate auth URI with auth query parameters', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid&custom_param=value',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials: OAuth2CredentialData = {
				clientId: 'client_id',
				clientSecret: 'client_secret',
				authUrl: 'https://example.domain/oauth2/auth',
				accessTokenUrl: 'https://example.domain/oauth2/token',
				scope: 'openid',
				grantType: 'authorizationCode',
				authentication: 'header',
				authQueryParameters: 'custom_param=value',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const authUri = await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('https://example.domain/oauth2/auth');
			expect(mockGetUri).toHaveBeenCalled();
		});

		it('should handle dynamic client registration with root-level server URL', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/oauth2/auth?client_id=registered_client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid profile',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/oauth2/auth',
					token_endpoint: 'https://example.domain/oauth2/token',
					registration_endpoint: 'https://example.domain/oauth2/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
					scopes_supported: ['openid', 'profile'],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: {
					client_id: 'registered_client_id',
					client_secret: 'registered_client_secret',
				},
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const authUri = await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('https://example.domain/oauth2/auth');
			expect(axios.get).toHaveBeenCalledWith(
				'https://example.domain/.well-known/oauth-authorization-server',
				expect.any(Object),
			);
			expect(axios.post).toHaveBeenCalledWith(
				'https://example.domain/oauth2/register',
				expect.objectContaining({
					client_name: 'n8n',
					grant_types: ['authorization_code', 'refresh_token'],
				}),
			);
			// JWE fields are only added behind both feature gates (flag + jweEnabled).
			const dcrPayload = (axios.post as jest.Mock).mock.calls[0][1];
			expect(dcrPayload).not.toHaveProperty('jwks_uri');
			expect(dcrPayload).not.toHaveProperty('id_token_encrypted_response_alg');
			expect(dcrPayload).not.toHaveProperty('id_token_encrypted_response_enc');
			expect(externalHooks.run).toHaveBeenCalledWith(
				'oauth2.dynamicClientRegistration',
				expect.any(Array),
			);
			expect(service.encryptAndSaveData).toHaveBeenCalled();
			const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0];
			expect(callArgs[0]).toBe(credential);
			expect(callArgs[1]).toHaveProperty('authUrl', 'https://example.domain/oauth2/auth');
			expect(callArgs[1]).toHaveProperty('accessTokenUrl', 'https://example.domain/oauth2/token');
			expect(callArgs[1]).toHaveProperty('clientId', 'registered_client_id');
			expect(callArgs[1]).toHaveProperty('clientSecret', 'registered_client_secret');
			expect(callArgs[1]).toHaveProperty('scope', 'openid profile');
			expect(callArgs[1]).toHaveProperty('grantType', 'pkce');
			expect(callArgs[1]).toHaveProperty('csrfSecret');
			expect(callArgs[1]).toHaveProperty('codeVerifier', 'code_verifier');
			expect(callArgs[2] || []).toEqual([]);
		});

		it('should throw BadRequestError when OAuth2 server metadata is invalid', async () => {
			const axios = require('axios');
			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: { invalid: 'metadata' },
			} as any);

			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow(BadRequestError);
			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow('Invalid OAuth2 server metadata');
		});

		it('should throw BadRequestError when client registration response is invalid', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			jest.mocked(ClientOAuth2).mockImplementation(() => ({}) as any);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/oauth2/auth',
					token_endpoint: 'https://example.domain/oauth2/token',
					registration_endpoint: 'https://example.domain/oauth2/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { invalid: 'response' },
			} as any);

			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow(BadRequestError);
			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow('Invalid client registration response');
		});

		it('should handle dynamic client registration with client_secret_post authentication', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/oauth2/auth?client_id=registered_client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/oauth2/auth',
					token_endpoint: 'https://example.domain/oauth2/token',
					registration_endpoint: 'https://example.domain/oauth2/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_post'],
					code_challenge_methods_supported: [],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: {
					client_id: 'registered_client_id',
					client_secret: 'registered_client_secret',
				},
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const authUri = await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('https://example.domain/oauth2/auth');
			expect(oauthCredentials.authentication).toBe('body');
			expect(oauthCredentials.grantType).toBe('authorizationCode');
		});

		it('should skip userId in CSRF state when skipAuthOnOAuthCallback is true', async () => {
			// This test verifies the behavior when skipAuthOnOAuthCallback is true
			// Since the skipAuthOnOAuthCallback is evaluated at module load time,
			// we need to check the actual behavior by verifying the CSRF state doesn't include userId
			// when the env var is set. However, since it's evaluated at module load, we'll test
			// that the function works correctly with or without userId
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
			const oauthCredentials: OAuth2CredentialData = {
				clientId: 'client_id',
				clientSecret: 'client_secret',
				authUrl: 'https://example.domain/oauth2/auth',
				accessTokenUrl: 'https://example.domain/oauth2/token',
				scope: 'openid',
				grantType: 'authorizationCode',
				authentication: 'header',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
			jest.spyOn(service, 'createCsrfState').mockResolvedValue(['csrf-secret', 'base64-state']);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify createCsrfState was called with cid
			expect(service.createCsrfState).toHaveBeenCalledWith(
				expect.objectContaining({
					cid: '1',
				}),
			);
		});
	});

	describe('generateAOauth2AuthUri with DCR and RFC 8414 compliance', () => {
		it('should insert .well-known between host and path per RFC 8414', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/authorize?client_id=registered_client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain/issuer1',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/issuer1/authorize',
					token_endpoint: 'https://example.domain/issuer1/token',
					registration_endpoint: 'https://example.domain/issuer1/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
					scopes_supported: ['openid', 'profile'],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: {
					client_id: 'registered_client_id',
					client_secret: 'registered_client_secret',
				},
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify RFC 8414: .well-known inserted between host and path
			expect(axios.get).toHaveBeenCalledWith(
				'https://example.domain/.well-known/oauth-authorization-server/issuer1',
				expect.any(Object),
			);
		});

		it('should handle root-level issuer URLs (no path)', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () =>
					'https://example.domain/authorize?client_id=test_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/authorize',
					token_endpoint: 'https://example.domain/token',
					registration_endpoint: 'https://example.domain/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: {
					client_id: 'test_id',
					client_secret: 'test_secret',
				},
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Root-level issuer: .well-known directly after origin
			expect(axios.get).toHaveBeenCalledWith(
				'https://example.domain/.well-known/oauth-authorization-server',
				expect.any(Object),
			);
		});

		it('should handle issuer URLs with trailing slashes', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://example.domain/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain/issuer1/',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/authorize',
					token_endpoint: 'https://example.domain/token',
					registration_endpoint: 'https://example.domain/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Should strip trailing slash: /issuer1/ becomes /issuer1
			expect(axios.get).toHaveBeenCalledWith(
				'https://example.domain/.well-known/oauth-authorization-server/issuer1',
				expect.any(Object),
			);
		});

		it('should handle multi-segment paths correctly', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://oauth.example.com/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://oauth.example.com/tenant/auth/provider',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://oauth.example.com/tenant/auth/provider/authorize',
					token_endpoint: 'https://oauth.example.com/tenant/auth/provider/token',
					registration_endpoint: 'https://oauth.example.com/tenant/auth/provider/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
				},
			} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Multi-segment path per RFC 8414
			expect(axios.get).toHaveBeenCalledWith(
				'https://oauth.example.com/.well-known/oauth-authorization-server/tenant/auth/provider',
				expect.any(Object),
			);
		});

		it('should fall back to OpenID Connect path insertion when RFC 8414 fails', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://example.domain/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain/issuer1',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Protected resource discovery fails (both calls)
			// Then RFC 8414 fails, OpenID Connect succeeds
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404 Not Found')) // protected resource path-specific
				.mockRejectedValueOnce(new Error('404 Not Found')) // protected resource root
				.mockRejectedValueOnce(new Error('404 Not Found')) // RFC 8414
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://example.domain/issuer1/authorize',
						token_endpoint: 'https://example.domain/issuer1/token',
						registration_endpoint: 'https://example.domain/issuer1/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify it tried protected resource discovery, then fell back to auth server discovery
			expect(axios.get).toHaveBeenCalledTimes(4);
			expect(axios.get).toHaveBeenNthCalledWith(
				3, // After 2 protected resource calls
				'https://example.domain/.well-known/oauth-authorization-server/issuer1',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				4, // OpenID Connect path insertion succeeds
				'https://example.domain/.well-known/openid-configuration/issuer1',
				expect.any(Object),
			);
		});

		it('should fall back to OpenID Connect path appending when first two fail', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://example.domain/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: mockGetUri,
						},
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain/issuer1',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Protected resource discovery fails, then RFC 8414 and OpenID Connect path insertion fail, path appending succeeds
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404 Not Found')) // protected resource path-specific
				.mockRejectedValueOnce(new Error('404 Not Found')) // protected resource root
				.mockRejectedValueOnce(new Error('404 Not Found')) // RFC 8414
				.mockRejectedValueOnce(new Error('404 Not Found')) // OpenID Connect path insertion
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://example.domain/issuer1/authorize',
						token_endpoint: 'https://example.domain/issuer1/token',
						registration_endpoint: 'https://example.domain/issuer1/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify all endpoints were tried (2 protected resource + 3 auth server)
			expect(axios.get).toHaveBeenCalledTimes(5);
			expect(axios.get).toHaveBeenNthCalledWith(
				3, // After 2 protected resource calls
				'https://example.domain/.well-known/oauth-authorization-server/issuer1',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				4,
				'https://example.domain/.well-known/openid-configuration/issuer1',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				5, // OpenID Connect path appending succeeds
				'https://example.domain/issuer1/.well-known/openid-configuration',
				expect.any(Object),
			);
		});

		it('should fall back to origin-only discovery when path-aware variants fail (Atlassian MCP)', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://mcp.atlassian.com/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'mcpOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://mcp.atlassian.com/v1/mcp',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // protected resource path-specific
				.mockRejectedValueOnce(new Error('404')) // protected resource root
				.mockRejectedValueOnce(new Error('404')) // RFC 8414 path insertion
				.mockRejectedValueOnce(new Error('404')) // OpenID Connect path insertion
				.mockRejectedValueOnce(new Error('401')) // OpenID Connect path appending
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://mcp.atlassian.com/authorize',
						token_endpoint: 'https://mcp.atlassian.com/token',
						registration_endpoint: 'https://mcp.atlassian.com/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any); // origin-only fallback succeeds

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(axios.get).toHaveBeenCalledTimes(6);
			expect(axios.get).toHaveBeenNthCalledWith(
				3,
				'https://mcp.atlassian.com/.well-known/oauth-authorization-server/v1/mcp',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				4,
				'https://mcp.atlassian.com/.well-known/openid-configuration/v1/mcp',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				5,
				'https://mcp.atlassian.com/v1/mcp/.well-known/openid-configuration',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				6,
				'https://mcp.atlassian.com/.well-known/oauth-authorization-server',
				expect.any(Object),
			);
		});

		it('should throw error when all discovery endpoints fail', async () => {
			const axios = require('axios');
			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain/issuer1',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// All three endpoints fail
			jest.mocked(axios.get).mockRejectedValue(new Error('404 Not Found'));

			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow(BadRequestError);

			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow('Failed to discover OAuth2 authorization server metadata');

			// Should have tried all endpoints (2 protected resource + 4 auth server per invocation)
			expect(axios.get).toHaveBeenCalledTimes(12); // 6 calls per invocation × 2 invocations
		});

		it('should discover authorization server via protected resource metadata (MCP flow)', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://auth.example.com/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://mcp.notion.com/mcp',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Protected resource discovery (path-specific fails, root succeeds)
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // path-specific protected resource
				.mockResolvedValueOnce({
					data: {
						authorization_servers: ['https://auth.example.com'],
					},
				} as any) // root protected resource ✅
				// Authorization server metadata discovery
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://auth.example.com/authorize',
						token_endpoint: 'https://auth.example.com/token',
						registration_endpoint: 'https://auth.example.com/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify protected resource discovery was attempted
			expect(axios.get).toHaveBeenNthCalledWith(
				1,
				'https://mcp.notion.com/.well-known/oauth-protected-resource/mcp',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				2,
				'https://mcp.notion.com/.well-known/oauth-protected-resource',
				expect.any(Object),
			);

			// Verify authorization server discovery used the extracted URL
			expect(axios.get).toHaveBeenNthCalledWith(
				3,
				'https://auth.example.com/.well-known/oauth-authorization-server',
				expect.any(Object),
			);
		});

		it('should fall back to direct authorization server discovery when protected resource fails', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://example.domain/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://example.domain/issuer1',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Protected resource discovery fails (both path-specific and root)
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // path-specific protected resource
				.mockRejectedValueOnce(new Error('404')) // root protected resource
				// Fall back to direct authorization server discovery
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://example.domain/issuer1/authorize',
						token_endpoint: 'https://example.domain/issuer1/token',
						registration_endpoint: 'https://example.domain/issuer1/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify protected resource discovery was attempted (and failed)
			expect(axios.get).toHaveBeenNthCalledWith(
				1,
				'https://example.domain/.well-known/oauth-protected-resource/issuer1',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				2,
				'https://example.domain/.well-known/oauth-protected-resource',
				expect.any(Object),
			);

			// Verify fallback to direct authorization server discovery
			expect(axios.get).toHaveBeenNthCalledWith(
				3,
				'https://example.domain/.well-known/oauth-authorization-server/issuer1',
				expect.any(Object),
			);
		});

		it('should handle Smithery MCP server with path-specific protected resource discovery', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://auth.smithery.ai/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'mcpOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://server.smithery.ai/@AnkitDigitalsherpa/weather_mcp',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Path-specific protected resource discovery succeeds
			jest
				.mocked(axios.get)
				.mockResolvedValueOnce({
					data: {
						authorization_servers: ['https://auth.smithery.ai/AnkitDigitalsherpa/weather_mcp'],
						resource: 'https://server.smithery.ai/@AnkitDigitalsherpa/weather_mcp',
					},
				} as any)
				// Authorization server metadata discovery
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint:
							'https://auth.smithery.ai/AnkitDigitalsherpa/weather_mcp/authorize',
						token_endpoint: 'https://auth.smithery.ai/AnkitDigitalsherpa/weather_mcp/token',
						registration_endpoint:
							'https://auth.smithery.ai/AnkitDigitalsherpa/weather_mcp/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify protected resource discovery (path-specific succeeded)
			expect(axios.get).toHaveBeenNthCalledWith(
				1,
				'https://server.smithery.ai/.well-known/oauth-protected-resource/@AnkitDigitalsherpa/weather_mcp',
				expect.any(Object),
			);

			// Verify authorization server discovery used extracted URL
			expect(axios.get).toHaveBeenNthCalledWith(
				2,
				'https://auth.smithery.ai/.well-known/oauth-authorization-server/AnkitDigitalsherpa/weather_mcp',
				expect.any(Object),
			);
		});

		it('should handle Notion MCP server with root protected resource discovery', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://mcp.notion.com/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'mcpOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://mcp.notion.com/mcp',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Path-specific fails, root protected resource discovery succeeds
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // path-specific
				.mockResolvedValueOnce({
					data: {
						resource: 'https://mcp.notion.com',
						resource_name: 'Notion MCP (Beta)',
						resource_documentation: 'https://developers.notion.com/docs/mcp',
						authorization_servers: ['https://mcp.notion.com'],
						bearer_methods_supported: ['header'],
					},
				} as any)
				// Authorization server metadata discovery (root-level)
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://mcp.notion.com/authorize',
						token_endpoint: 'https://mcp.notion.com/token',
						registration_endpoint: 'https://mcp.notion.com/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify protected resource discovery
			expect(axios.get).toHaveBeenNthCalledWith(
				1,
				'https://mcp.notion.com/.well-known/oauth-protected-resource/mcp',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				2,
				'https://mcp.notion.com/.well-known/oauth-protected-resource',
				expect.any(Object),
			);

			// Verify authorization server discovery (root-level issuer)
			expect(axios.get).toHaveBeenNthCalledWith(
				3,
				'https://mcp.notion.com/.well-known/oauth-authorization-server',
				expect.any(Object),
			);
		});

		it('should handle VEED.io with fallback to authorization server discovery', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://www.veed.io/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://www.veed.io/api/v1/oauth2',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Protected resource discovery fails (not an MCP server)
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // path-specific protected resource
				.mockRejectedValueOnce(new Error('404')) // root protected resource
				// Fallback to authorization server discovery (RFC 8414 succeeds)
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://www.veed.io/api/v1/oauth2/authorize',
						token_endpoint: 'https://www.veed.io/api/v1/oauth2/token',
						registration_endpoint: 'https://www.veed.io/api/v1/oauth2/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify protected resource discovery was attempted
			expect(axios.get).toHaveBeenNthCalledWith(
				1,
				'https://www.veed.io/.well-known/oauth-protected-resource/api/v1/oauth2',
				expect.any(Object),
			);
			expect(axios.get).toHaveBeenNthCalledWith(
				2,
				'https://www.veed.io/.well-known/oauth-protected-resource',
				expect.any(Object),
			);

			// Verify fallback to RFC 8414 authorization server discovery
			expect(axios.get).toHaveBeenNthCalledWith(
				3,
				'https://www.veed.io/.well-known/oauth-authorization-server/api/v1/oauth2',
				expect.any(Object),
			);
		});

		it('should reject malicious authorization server URL from protected resource (SSRF protection)', async () => {
			const axios = require('axios');
			const credential = mock<CredentialsEntity>({ id: '1', type: 'mcpOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://malicious-mcp.example.com/mcp',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Malicious protected resource returns javascript: protocol URL
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_servers: ['javascript:alert(1)'],
					resource: 'https://malicious-mcp.example.com/mcp',
				},
			} as any);

			try {
				await service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				});
				// Should not reach here
				expect(true).toBe(false);
			} catch (error) {
				expect(error).toBeInstanceOf(BadRequestError);
				expect((error as Error).message).toContain('OAuth url must use HTTP or HTTPS protocol');
			}
		});

		it('should succeed when server advertises only authorization_code without refresh_token', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://login.commonroom.io/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'https://login.commonroom.io',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			// Server metadata omits refresh_token from grant_types_supported (Common Room pattern)
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // protected resource path-specific
				.mockRejectedValueOnce(new Error('404')) // protected resource root
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://login.commonroom.io/authorize',
						token_endpoint: 'https://login.commonroom.io/token',
						registration_endpoint: 'https://login.commonroom.io/register',
						grant_types_supported: ['authorization_code'], // no refresh_token
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			// Should not throw "No supported grant type and authentication method found"
			await expect(
				service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).resolves.toBeDefined();

			// PKCE should be selected since S256 is supported
			const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0];
			expect(callArgs[1]).toHaveProperty('grantType', 'pkce');
		});

		it('should not produce double /.well-known/ paths when authorization server URL already contains /.well-known/', async () => {
			const axios = require('axios');
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetUri = jest.fn().mockReturnValue({
				toString: () => 'https://example.domain/authorize?client_id=test_id',
			});
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: { getUri: mockGetUri },
					}) as any,
			);

			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			// Simulate the fallback path where serverUrl itself is the MCP server URL,
			// protected resource discovery fails, and authorizationServerUrl ends up being
			// a .well-known URL (or the server URL resolves to one with a .well-known path).
			// We test by providing a serverUrl whose path starts with /.well-known/ directly.
			const oauthCredentials = {
				serverUrl: 'https://example.domain/.well-known/openid-configuration',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			// Protected resource discovery fails (both)
			jest
				.mocked(axios.get)
				.mockRejectedValueOnce(new Error('404')) // protected resource path-specific
				.mockRejectedValueOnce(new Error('404')) // protected resource root
				.mockResolvedValueOnce({
					data: {
						authorization_endpoint: 'https://example.domain/authorize',
						token_endpoint: 'https://example.domain/token',
						registration_endpoint: 'https://example.domain/register',
						grant_types_supported: ['authorization_code', 'refresh_token'],
						token_endpoint_auth_methods_supported: ['client_secret_basic'],
						code_challenge_methods_supported: ['S256'],
					},
				} as any);

			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'test_id', client_secret: 'test_secret' },
			} as any);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			// Verify the discovery URL used is the root-level one (no double /.well-known/)
			const authServerDiscoveryCall = (axios.get as jest.Mock).mock.calls[2]; // after 2 protected resource calls
			expect(authServerDiscoveryCall[0]).not.toContain(
				'/.well-known/openid-configuration/.well-known/',
			);
			expect(authServerDiscoveryCall[0]).toBe(
				'https://example.domain/.well-known/oauth-authorization-server',
			);
		});

		it('should reject malicious serverUrl before making any requests (SSRF protection)', async () => {
			const credential = mock<CredentialsEntity>({ id: '1', type: 'mcpOAuth2Api' });
			const oauthCredentials = {
				serverUrl: 'file:///etc/passwd',
				useDynamicClientRegistration: true,
			} as OAuth2CredentialData;

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			try {
				await service.generateAOauth2AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				});
				// Should not reach here
				expect(true).toBe(false);
			} catch (error) {
				expect(error).toBeInstanceOf(BadRequestError);
				expect((error as Error).message).toContain('OAuth url must use HTTP or HTTPS protocol');
			}
		});
	});

	describe('generateAOauth2AuthUri with DCR and JWE fields', () => {
		beforeEach(() => {
			const axios = require('axios');
			jest.mocked(axios.get).mockResolvedValue({
				data: {
					authorization_endpoint: 'https://example.domain/oauth2/auth',
					token_endpoint: 'https://example.domain/oauth2/token',
					registration_endpoint: 'https://example.domain/oauth2/register',
					grant_types_supported: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_methods_supported: ['client_secret_basic'],
					code_challenge_methods_supported: ['S256'],
					scopes_supported: ['openid'],
				},
			} as any);
			jest.mocked(axios.post).mockResolvedValue({
				data: { client_id: 'rid', client_secret: 'rs' },
			} as any);

			const { ClientOAuth2 } = require('@n8n/client-oauth2');
			jest.mocked(ClientOAuth2).mockImplementation(
				() =>
					({
						code: {
							getUri: jest.fn().mockReturnValue({
								toString: () => 'https://example.domain/oauth2/auth?state=state',
							}),
						},
					}) as any,
			);

			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
		});

		async function runDcr(
			jweEnabled: boolean | undefined,
			inlineJwks: boolean | undefined = undefined,
		) {
			const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				serverUrl: 'https://example.domain',
				useDynamicClientRegistration: true,
				jweEnabled,
				inlineJwks,
			} as OAuth2CredentialData);

			await service.generateAOauth2AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			const axios = require('axios');
			return (axios.post as jest.Mock).mock.calls[0][1];
		}

		it.each([
			['jweEnabled=false, inlineJwks=undefined', false, undefined],
			['jweEnabled=false, inlineJwks=true', false, true],
			['jweEnabled=undefined, inlineJwks=true', undefined, true],
		])(
			'skips the proxy entirely when the credential has not opted into JWE (%s)',
			async (_label, jweEnabled, inlineJwks) => {
				const payload = await runDcr(jweEnabled, inlineJwks);

				expect(oauthJweServiceProxy.getDcrJweFields).not.toHaveBeenCalled();
				expect(payload).not.toHaveProperty('jwks_uri');
				expect(payload).not.toHaveProperty('jwks');
				expect(payload).not.toHaveProperty('id_token_encrypted_response_alg');
				expect(payload).not.toHaveProperty('id_token_encrypted_response_enc');
			},
		);

		it('forwards inlineJwks to the proxy when the credential has opted in', async () => {
			oauthJweServiceProxy.getDcrJweFields.mockResolvedValue({});

			await runDcr(true, true);

			expect(oauthJweServiceProxy.getDcrJweFields).toHaveBeenCalledWith(true);
		});

		it('defaults inlineJwks to false when the credential leaves it unset', async () => {
			oauthJweServiceProxy.getDcrJweFields.mockResolvedValue({});

			await runDcr(true, undefined);

			expect(oauthJweServiceProxy.getDcrJweFields).toHaveBeenCalledWith(false);
		});

		it('includes jwks_uri (not jwks) when the proxy returns the URI shape', async () => {
			const fields = {
				jwks_uri: 'http://localhost:5678/rest/.well-known/jwks.json',
				id_token_encrypted_response_alg: 'RSA-OAEP-256',
			};
			oauthJweServiceProxy.getDcrJweFields.mockResolvedValue(fields);

			const payload = await runDcr(true, false);

			expect(payload).toMatchObject(fields);
			expect(payload).not.toHaveProperty('jwks');
			// We deliberately leave `enc` for the IdP to choose.
			expect(payload).not.toHaveProperty('id_token_encrypted_response_enc');
		});

		it('includes jwks (not jwks_uri) when the proxy returns the inline shape', async () => {
			const fields = {
				jwks: { keys: [{ kty: 'RSA', alg: 'RSA-OAEP-256', kid: 'kid-1', n: 'n', e: 'AQAB' }] },
				id_token_encrypted_response_alg: 'RSA-OAEP-256',
			};
			oauthJweServiceProxy.getDcrJweFields.mockResolvedValue(fields);

			const payload = await runDcr(true, true);

			expect(payload).toMatchObject(fields);
			expect(payload).not.toHaveProperty('jwks_uri');
		});

		it('propagates errors thrown by the proxy', async () => {
			oauthJweServiceProxy.getDcrJweFields.mockRejectedValue(
				new Error('OAuth JWE public key is missing an "alg" field'),
			);

			await expect(runDcr(true)).rejects.toThrow('OAuth JWE public key is missing an "alg" field');
		});
	});

	describe('generateAOauth1AuthUri', () => {
		it('should generate auth URI for OAuth1 credential', async () => {
			const axios = require('axios');
			const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
			const oauthCredentials: OAuth1CredentialData = {
				consumerKey: 'consumer_key',
				consumerSecret: 'consumer_secret',
				requestTokenUrl: 'https://example.domain/oauth/request_token',
				authUrl: 'https://example.domain/oauth/authorize',
				accessTokenUrl: 'https://example.domain/oauth/access_token',
				signatureMethod: 'HMAC-SHA1',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.request).mockResolvedValue({
				data: 'oauth_token=random-token&oauth_token_secret=random-secret',
			});
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const authUri = await service.generateAOauth1AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token');
			expect(service.encryptAndSaveData).toHaveBeenCalledWith(
				credential,
				expect.objectContaining({ csrfSecret: expect.any(String) }),
				[],
			);
			expect(externalHooks.run).toHaveBeenCalledWith('oauth1.authenticate', expect.any(Array));
		});

		it('should reject javascript: protocol in OAuth1 URL (XSS)', async () => {
			const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
			const oauthCredentials: OAuth1CredentialData = {
				consumerKey: 'consumer_key',
				consumerSecret: 'consumer_secret',
				requestTokenUrl: 'https://example.domain/oauth/request_token',
				authUrl: "javascript:alert('Hacked')//",
				accessTokenUrl: 'https://example.domain/oauth/access_token',
				signatureMethod: 'HMAC-SHA1',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);

			const promise = service.generateAOauth1AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});
			await expect(promise).rejects.toThrow(BadRequestError);
			await expect(promise).rejects.toThrow(/OAuth url must use HTTP or HTTPS protocol/);
		});

		it('should generate auth URI with different signature methods', async () => {
			const axios = require('axios');
			const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
			const oauthCredentials: OAuth1CredentialData = {
				consumerKey: 'consumer_key',
				consumerSecret: 'consumer_secret',
				requestTokenUrl: 'https://example.domain/oauth/request_token',
				authUrl: 'https://example.domain/oauth/authorize',
				accessTokenUrl: 'https://example.domain/oauth/access_token',
				signatureMethod: 'HMAC-SHA256',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.request).mockResolvedValue({
				data: 'oauth_token=random-token&oauth_token_secret=random-secret',
			});
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const authUri = await service.generateAOauth1AuthUri(credential, {
				cid: credential.id,
				origin: 'static-credential',
				userId: 'user-id',
			});

			expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token');
			expect(service.encryptAndSaveData).toHaveBeenCalled();
		});

		it('should handle request token URL errors', async () => {
			const axios = require('axios');
			const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
			const oauthCredentials: OAuth1CredentialData = {
				consumerKey: 'consumer_key',
				consumerSecret: 'consumer_secret',
				requestTokenUrl: 'https://example.domain/oauth/request_token',
				authUrl: 'https://example.domain/oauth/authorize',
				accessTokenUrl: 'https://example.domain/oauth/access_token',
				signatureMethod: 'HMAC-SHA1',
			};

			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
			jest.mocked(axios.request).mockRejectedValue(new Error('Request token failed'));

			await expect(
				service.generateAOauth1AuthUri(credential, {
					cid: credential.id,
					origin: 'static-credential',
					userId: 'user-id',
				}),
			).rejects.toThrow('Request token failed');
		});
	});

	describe('extractAccountIdentifier', () => {
		it('returns email from direct token field', () => {
			expect(
				OauthService.extractAccountIdentifier({ email: 'user@example.com', access_token: 'tok' }),
			).toBe('user@example.com');
		});

		it('returns login from direct token field (GitHub-style)', () => {
			expect(OauthService.extractAccountIdentifier({ login: 'octocat', access_token: 'tok' })).toBe(
				'octocat',
			);
		});

		it('extracts email from JWT id_token', () => {
			const payload = { email: 'user@gmail.com', sub: '123' };
			const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
			expect(OauthService.extractAccountIdentifier({ id_token: idToken })).toBe('user@gmail.com');
		});

		it('extracts preferred_username from JWT id_token when no email', () => {
			const payload = { preferred_username: 'admin@contoso.com', sub: '123' };
			const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
			expect(OauthService.extractAccountIdentifier({ id_token: idToken })).toBe(
				'admin@contoso.com',
			);
		});

		it('returns undefined for token data without identifiers', () => {
			expect(
				OauthService.extractAccountIdentifier({ access_token: 'tok', refresh_token: 'ref' }),
			).toBeUndefined();
		});

		it('handles malformed JWT gracefully', () => {
			expect(OauthService.extractAccountIdentifier({ id_token: 'not.a.jwt' })).toBeUndefined();
		});

		it('prefers direct fields over id_token', () => {
			const payload = { email: 'jwt@example.com' };
			const idToken = `h.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.s`;
			expect(
				OauthService.extractAccountIdentifier({ email: 'direct@example.com', id_token: idToken }),
			).toBe('direct@example.com');
		});
	});

	describe('refreshOAuth2CredentialById', () => {
		const credentialId = 'cred-123';
		const projectId = 'proj-456';

		function makeCredential(
			overrides: Partial<ICredentialsDb> = {},
		): ICredentialsDb & { isGlobal: boolean } {
			return {
				id: credentialId,
				isGlobal: false,
				shared: [],
				...overrides,
			} as unknown as ICredentialsDb & { isGlobal: boolean };
		}

		it('returns null when the credential is not found', async () => {
			credentialsRepository.findOne.mockResolvedValue(null);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toBeNull();
		});

		it('returns null when the credential is not accessible to the given project', async () => {
			const credential = makeCredential({
				isGlobal: false,
				shared: [{ projectId: 'other-project' }] as never,
			});
			credentialsRepository.findOne.mockResolvedValue(credential as never);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toBeNull();
		});

		it('grants access when the credential is global regardless of project', async () => {
			const credential = makeCredential({ isGlobal: true, shared: [] });
			credentialsRepository.findOne.mockResolvedValue(credential as never);
			// Returns null because there's no oauthTokenData — but the access check passed
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
			} as unknown as OAuth2CredentialData);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			// Null because oauthTokenData is missing — not because of an access denial
			expect(result).toBeNull();
			expect(service.getOAuthCredentials).toHaveBeenCalled();
		});

		it('grants access when the project is a shared member', async () => {
			const credential = makeCredential({
				isGlobal: false,
				shared: [{ projectId }] as never,
			});
			credentialsRepository.findOne.mockResolvedValue(credential as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
			} as unknown as OAuth2CredentialData);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			// Access was granted — null because there's no oauthTokenData
			expect(result).toBeNull();
			expect(service.getOAuthCredentials).toHaveBeenCalled();
		});

		it('returns null when the credential has no stored oauthTokenData', async () => {
			credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
				// oauthTokenData intentionally absent
			} as unknown as OAuth2CredentialData);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toBeNull();
		});

		it('refreshes the token with token.refresh() for authorizationCode grant and returns a Bearer header', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const refreshed = {
				data: { access_token: 'new-token', token_type: 'bearer' },
				accessToken: 'new-token',
			};
			const mockToken = { refresh: jest.fn().mockResolvedValue(refreshed), client: {} };
			jest
				.mocked(ClientOAuth2)
				.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);

			credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
				oauthTokenData: {
					access_token: 'stale',
					refresh_token: 'refresh-tok',
					token_type: 'bearer',
				},
			} as unknown as OAuth2CredentialData);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toEqual({ Authorization: 'Bearer new-token' });
			expect(mockToken.refresh).toHaveBeenCalledTimes(1);
		});

		it('persists the refreshed token data after a successful refresh', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const refreshedData = { access_token: 'new-token', token_type: 'bearer' };
			const refreshed = { data: refreshedData, accessToken: 'new-token' };
			const mockToken = { refresh: jest.fn().mockResolvedValue(refreshed), client: {} };
			const credential = makeCredential({ isGlobal: true });
			jest
				.mocked(ClientOAuth2)
				.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);

			credentialsRepository.findOne.mockResolvedValue(credential as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
				oauthTokenData: { access_token: 'stale', refresh_token: 'refresh-tok' },
			} as unknown as OAuth2CredentialData);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(service.encryptAndSaveData).toHaveBeenCalledWith(credential, {
				oauthTokenData: refreshedData,
			});
		});

		it('uses credentials.getToken() for clientCredentials grant type', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const refreshed = { data: { access_token: 'cc-token' }, accessToken: 'cc-token' };
			const getToken = jest.fn().mockResolvedValue(refreshed);
			const mockToken = { refresh: jest.fn(), client: { credentials: { getToken } } };
			jest
				.mocked(ClientOAuth2)
				.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);

			credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'clientCredentials',
				authentication: 'header',
				oauthTokenData: { access_token: 'stale' },
			} as unknown as OAuth2CredentialData);
			jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toEqual({ Authorization: 'Bearer cc-token' });
			expect(getToken).toHaveBeenCalledTimes(1);
			expect(mockToken.refresh).not.toHaveBeenCalled();
		});

		it('returns null and logs a warning when the refresh call throws', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockToken = {
				refresh: jest.fn().mockRejectedValue(new Error('network timeout')),
				client: {},
			};
			jest
				.mocked(ClientOAuth2)
				.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);

			credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
				oauthTokenData: { access_token: 'stale', refresh_token: 'refresh-tok' },
			} as unknown as OAuth2CredentialData);

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toBeNull();
			expect(logger.warn).toHaveBeenCalledWith(
				'Failed to refresh OAuth2 token for credential',
				expect.objectContaining({ credentialId, error: 'network timeout' }),
			);
		});

		it('still returns the auth header even when persisting the new token data fails', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const refreshed = { data: { access_token: 'new-token' }, accessToken: 'new-token' };
			const mockToken = { refresh: jest.fn().mockResolvedValue(refreshed), client: {} };
			jest
				.mocked(ClientOAuth2)
				.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);

			credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
			jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
				clientId: 'id',
				clientSecret: 'secret',
				accessTokenUrl: 'https://example.com/token',
				grantType: 'authorizationCode',
				authentication: 'header',
				oauthTokenData: { access_token: 'stale', refresh_token: 'refresh-tok' },
			} as unknown as OAuth2CredentialData);
			jest.spyOn(service, 'encryptAndSaveData').mockRejectedValue(new Error('db write error'));

			const result = await service.refreshOAuth2CredentialById(credentialId, projectId);

			expect(result).toEqual({ Authorization: 'Bearer new-token' });
			expect(logger.warn).toHaveBeenCalledWith(
				'Refreshed OAuth2 token but failed to persist new token data',
				expect.objectContaining({ credentialId }),
			);
		});
	});
});
