import { Logger } from '@n8n/backend-common';
import type { SsrfBridge } from '@n8n/backend-network';
import { mockInstance } from '@n8n/backend-test-utils';
import type { ClientOAuth2 } from '@n8n/client-oauth2';
import { type CredentialsEntity, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Response } from 'express';
import { UserError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';
import { OauthService } from '@/oauth/oauth.service';
import type { OAuthRequest } from '@/requests';

vi.mock('axios');
vi.mock('@n8n/client-oauth2', async (importOriginal) => {
	const actual = await importOriginal<typeof import('@n8n/client-oauth2')>();
	return { ...actual, ClientOAuth2: vi.fn() };
});
vi.mock('pkce-challenge');

describe('OAuth2CredentialController', () => {
	const oauthService = mockInstance(OauthService);
	const externalHooks = mockInstance(ExternalHooks);
	const oauthJweServiceProxy = mockInstance(OAuthJweServiceProxy);
	const eventService = mockInstance(EventService);
	const credentialsOverwrites = mockInstance(CredentialsOverwrites);

	mockInstance(Logger);

	const controller = Container.get(OAuth2CredentialController);

	const timestamp = 1706750625678;
	vi.useFakeTimers({ shouldAdvanceTime: true });

	beforeEach(() => {
		vi.setSystemTime(new Date(timestamp));
		vi.clearAllMocks();
	});

	describe('getAuthUri', () => {
		it('should build CSRF state data and return a valid auth URI', async () => {
			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockCsrfData = { cid: '1', origin: 'static-credential' as const, userId: '123' };
			oauthService.getCredentialForAuthFlow.mockResolvedValueOnce(mockResolvedCredential);
			oauthService.buildCsrfStateData.mockResolvedValueOnce(mockCsrfData);
			oauthService.generateAOauth2AuthUri.mockResolvedValue('https://example.domain/oauth2/auth');

			const req = mock<OAuthRequest.OAuth2Credential.Auth>({
				user: mock<User>({ id: '123' }),
				query: { id: '1' },
			});
			const res = mock<Response>();

			const authUri = await controller.getAuthUri(req, res);

			expect(authUri).toContain('https://example.domain/oauth2/auth');
			expect(oauthService.buildCsrfStateData).toHaveBeenCalledWith(mockResolvedCredential, req);
			expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
				mockResolvedCredential,
				mockCsrfData,
				req,
				res,
			);
		});
	});

	describe('handleCallback', () => {
		const validState = Buffer.from(
			JSON.stringify({
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential',
				createdAt: timestamp,
				data: 'encrypted-data',
			}),
		).toString('base64');

		const res = mock<Response>();

		it('should render the error page when required query params are missing', async () => {
			const invalidReq = mock<OAuthRequest.OAuth2Credential.Callback>();
			invalidReq.query = { state: 'test' } as OAuthRequest.OAuth2Credential.Callback['query'];
			await controller.handleCallback(invalidReq, res);

			expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
				res,
				'Insufficient parameters for OAuth2 callback.',
				'Received following query parameters: {"state":"test"}',
			);
		});

		it('should exchange the code for a valid token, and save it to DB for static credential', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: { access_token: 'new_token', refresh_token: 'refresh_token' },
				}),
			);
			expect(res.render).toHaveBeenCalledWith('oauth-callback');
			expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', expect.any(Array));
		});

		describe('outbound network policy', () => {
			const runCallback = async () => {
				const { ClientOAuth2 } = await import('@n8n/client-oauth2');
				const mockGetToken = vi.fn().mockResolvedValue({
					data: { access_token: 'new_token', refresh_token: 'refresh_token' },
				});
				vi.mocked(ClientOAuth2).mockImplementation(function () {
					return { code: { getToken: mockGetToken } } as unknown as ClientOAuth2;
				});

				oauthService.resolveCredential.mockResolvedValueOnce([
					mock<CredentialsEntity>({ id: '1' }),
					{ csrfSecret: 'csrf-secret' },
					{
						clientId: 'client_id',
						clientSecret: 'client_secret',
						authUrl: 'https://example.domain/oauth2/auth',
						accessTokenUrl: 'https://example.domain/oauth2/token',
						scope: 'openid',
						grantType: 'authorizationCode',
						authentication: 'header',
					},
					{
						token: 'token',
						cid: '1',
						userId: '123',
						origin: 'static-credential' as const,
						createdAt: timestamp,
						data: 'encrypted-data',
					},
					{ csrfSecret: 'csrf-secret' },
				]);
				oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
				externalHooks.run.mockResolvedValue(undefined);

				const req = mock<OAuthRequest.OAuth2Credential.Callback>({
					query: { code: 'auth_code', state: validState },
					originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
				});

				await controller.handleCallback(req, res);

				return ClientOAuth2;
			};

			it('should hand the bridge to the client when the service supplies one', async () => {
				const bridge = mock<SsrfBridge>();
				oauthService.getSsrfBridge.mockReturnValue(bridge);

				const ClientOAuth2 = await runCallback();

				expect(ClientOAuth2).toHaveBeenCalledWith(expect.objectContaining({ ssrfBridge: bridge }));
				expect(res.render).toHaveBeenCalledWith('oauth-callback');
			});

			it('should build the client without a bridge when the service supplies none', async () => {
				oauthService.getSsrfBridge.mockReturnValue(undefined);

				const ClientOAuth2 = await runCallback();

				expect(ClientOAuth2).toHaveBeenCalledWith(
					expect.objectContaining({ ssrfBridge: undefined }),
				);
				// The exchange must still complete, so instances with the guard off are unaffected.
				expect(res.render).toHaveBeenCalledWith('oauth-callback');
			});

			it('should keep the bridge out of the external hook payload', async () => {
				oauthService.getSsrfBridge.mockReturnValue(mock<SsrfBridge>());

				await runCallback();

				const callbackCall = externalHooks.run.mock.calls.find(
					([hookName]) => hookName === 'oauth2.callback',
				);
				const [hookOptions] = callbackCall![1] as unknown as unknown[];
				expect(hookOptions).not.toHaveProperty('ssrfBridge');
			});
		});

		it('should build the client with a certificate when certificate authentication is selected', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as unknown as ClientOAuth2;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientCredentialType: 'certificate',
					privateKey: 'private-key-pem',
					certificate: 'certificate-pem',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'body',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(ClientOAuth2).toHaveBeenCalledWith(
				expect.objectContaining({
					clientCertificate: { privateKey: 'private-key-pem', certificate: 'certificate-pem' },
				}),
			);
			expect(res.render).toHaveBeenCalledWith('oauth-callback');
		});

		it('should not send a client secret when certificate authentication is selected but a stale secret is stored', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as unknown as ClientOAuth2;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientCredentialType: 'certificate',
					privateKey: 'private-key-pem',
					certificate: 'certificate-pem',
					// Leftover from before the credential was switched to certificate auth.
					clientSecret: 'stale-secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'body',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			// The body-auth path must not forward the stale secret alongside the client assertion.
			expect(mockGetToken).toHaveBeenCalledWith(
				expect.any(String),
				expect.objectContaining({
					body: expect.not.objectContaining({ client_secret: expect.anything() }),
				}),
			);
			// The client itself is still built for certificate auth, without a secret.
			expect(ClientOAuth2).toHaveBeenCalledWith(
				expect.objectContaining({
					clientCredentialType: 'certificate',
					clientCertificate: { privateKey: 'private-key-pem', certificate: 'certificate-pem' },
				}),
			);
		});

		it('should pass state resource to token exchange and store it for static credentials', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				resource: 'https://mcp.example.com/mcp',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{} as any,
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(ClientOAuth2).toHaveBeenCalledWith(
				expect.objectContaining({ resource: 'https://mcp.example.com/mcp' }),
			);
			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: expect.objectContaining({
						access_token: 'new_token',
						refresh_token: 'refresh_token',
						resource: 'https://mcp.example.com/mcp',
					}),
				}),
			);
		});

		it('should handle dynamic credential callback successfully', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const oauthTokenData = { access_token: 'new_token', refresh_token: 'refresh_token' };
			const mockGetToken = vi.fn().mockResolvedValue({
				data: oauthTokenData,
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1', type: 'genericOAuth2' });
			const mockDecryptedData = { csrfSecret: 'csrf-secret', existing: 'data' };
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'dynamic-credential' as const,
				credentialResolverId: 'resolver-id',
				authorizationHeader: 'Bearer token123',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				mockDecryptedData,
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);
			oauthService.saveDynamicCredential.mockResolvedValueOnce(undefined);
			credentialsOverwrites.supportsManagedAuth.mockReturnValue(true);
			credentialsOverwrites.usesManagedAuth.mockReturnValue(false);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: dynamicState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			// The controller passes decryptedDataOriginal directly, not the merged oauthTokenData
			expect(oauthService.saveDynamicCredential).toHaveBeenCalledWith(
				mockResolvedCredential,
				{
					oauthTokenData,
				},
				'token123',
				'resolver-id',
				{},
			);
			expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', {
				user: { id: '123' },
				credentialType: 'genericOAuth2',
				credentialId: '1',
				supportsManagedAuth: true,
				usesManagedAuth: false,
			});
			expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
			expect(res.render).toHaveBeenCalledWith('oauth-callback');
		});

		it('should store state resource for dynamic credentials', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'dynamic-credential' as const,
				credentialResolverId: 'resolver-id',
				authorizationHeader: 'Bearer token123',
				resource: 'https://mcp.example.com/mcp',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{} as any,
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);
			oauthService.saveDynamicCredential.mockResolvedValueOnce(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(ClientOAuth2).toHaveBeenCalledWith(
				expect.objectContaining({ resource: 'https://mcp.example.com/mcp' }),
			);
			expect(oauthService.saveDynamicCredential).toHaveBeenCalledWith(
				mockResolvedCredential,
				{
					oauthTokenData: {
						access_token: 'new_token',
						refresh_token: 'refresh_token',
						resource: 'https://mcp.example.com/mcp',
					},
				},
				'token123',
				'resolver-id',
				{},
			);
		});

		it('should render error when credentialResolverId is missing for dynamic credential', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'dynamic-credential' as const,
				authorizationHeader: 'Bearer token123',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: dynamicState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
				res,
				'Credential resolver ID is required',
			);
			expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
		});

		it('should render error when authorizationHeader is missing for dynamic credential', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'dynamic-credential' as const,
				credentialResolverId: 'resolver-id',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: dynamicState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
				res,
				'Authorization header is required',
			);
			expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
		});

		it('should render error when authorizationHeader does not start with Bearer', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'dynamic-credential' as const,
				credentialResolverId: 'resolver-id',
				authorizationHeader: 'Invalid token123',
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: dynamicState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
				res,
				'Authorization header is required',
			);
			expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
		});

		it('should handle static credential callback when origin is undefined', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			const undefinedOriginState = Buffer.from(JSON.stringify(mockState)).toString('base64');
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: undefinedOriginState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: { access_token: 'new_token', refresh_token: 'refresh_token' },
				}),
			);
			expect(res.render).toHaveBeenCalledWith('oauth-callback');
		});

		it('should handle PKCE flow', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'pkce',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(mockGetToken).toHaveBeenCalledWith(
				expect.stringContaining('code=auth_code'),
				expect.objectContaining({
					body: { code_verifier: 'code_verifier' },
				}),
			);
			expect(oauthService.encryptAndSaveData).toHaveBeenCalled();
		});

		it('should include client_id and client_secret in body for PKCE flow with body authentication', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'pkce',
					authentication: 'body',
				},
				mockState,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(mockGetToken).toHaveBeenCalledWith(
				expect.stringContaining('code=auth_code'),
				expect.objectContaining({
					body: expect.objectContaining({
						code_verifier: 'code_verifier',
						client_id: 'client_id',
						client_secret: 'client_secret',
					}),
				}),
			);
			expect(oauthService.encryptAndSaveData).toHaveBeenCalled();
		});

		it('should send code_verifier for authorization code flow when PKCE is enabled', async () => {
			const now = 1_700_000_000_000;
			vi.spyOn(Date, 'now').mockReturnValue(now);
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: {
					access_token: 'new_token',
					expires_at: 'provider-expiry',
					expires_in: 3600,
				},
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
					usePkce: true,
				},
				mockState,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(mockGetToken).toHaveBeenCalledWith(
				expect.stringContaining('code=auth_code'),
				expect.objectContaining({
					body: { code_verifier: 'code_verifier' },
				}),
			);
			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: expect.objectContaining({
						expires_at: 'provider-expiry',
						n8n_expires_at: String(now + 3_600_000),
					}),
				}),
			);
		});

		it('should send code_verifier with client authentication fields for body-authenticated authorization code flow', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					grantType: 'authorizationCode',
					authentication: 'body',
					usePkce: true,
				},
				{
					token: 'token',
					cid: '1',
					userId: '123',
					origin: 'static-credential',
					createdAt: timestamp,
					data: 'encrypted-data',
				},
				{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { code: 'auth_code', state: validState },
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(mockGetToken).toHaveBeenCalledWith(
				expect.stringContaining('code=auth_code'),
				expect.objectContaining({
					body: {
						code_verifier: 'code_verifier',
						client_id: 'client_id',
						client_secret: 'client_secret',
					},
				}),
			);
			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: expect.objectContaining({ access_token: 'new_token' }),
				}),
			);
		});

		it('should preserve the existing absolute expiry when the token response omits expires_in', async () => {
			const now = 1_700_000_000_000;
			const existingExpiresAt = String(now + 60_000);
			vi.spyOn(Date, 'now').mockReturnValue(now);
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return {
					code: { getToken: vi.fn().mockResolvedValue({ data: { access_token: 'new_token' } }) },
				} as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{
					oauthTokenData: {
						access_token: 'old_token',
						expires_in: 3600,
						n8n_expires_at: existingExpiresAt,
					},
				},
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				{
					token: 'token',
					cid: '1',
					userId: '123',
					origin: 'static-credential',
					createdAt: timestamp,
					data: 'encrypted-data',
				},
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { code: 'auth_code', state: validState },
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: expect.objectContaining({
						access_token: 'new_token',
						expires_in: 3600,
						n8n_expires_at: existingExpiresAt,
					}),
				}),
			);
		});

		it('should handle body authentication method', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'body',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(mockGetToken).toHaveBeenCalledWith(
				expect.stringContaining('code=auth_code'),
				expect.objectContaining({
					body: expect.objectContaining({
						client_id: 'client_id',
						client_secret: 'client_secret',
					}),
				}),
			);
		});

		it('should ignore a non-string state.resource (malformed state)', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token', refresh_token: 'refresh_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				resource: 12345, // non‑string value
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState as any, // force incorrect type to test robustness
				{} as any,
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: { code: 'auth_code', state: validState },
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			// resource must NOT be forwarded
			expect(ClientOAuth2).toHaveBeenCalledWith(
				expect.not.objectContaining({ resource: expect.anything() }),
			);
			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: expect.not.objectContaining({ resource: expect.anything() }),
				}),
			);
		});

		it('should handle callback with additional query parameters', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockResolvedValue({
				data: { access_token: 'new_token' },
			});
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
					extra: 'param',
				} as OAuthRequest.OAuth2Credential.Callback['query'] & { extra: string },
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state&extra=param',
			});

			await controller.handleCallback(req, res);

			expect(mockGetToken).toHaveBeenCalled();
			expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
				mockResolvedCredential,
				expect.objectContaining({
					oauthTokenData: expect.objectContaining({
						access_token: 'new_token',
						callbackQueryString: expect.objectContaining({
							extra: 'param',
						}),
					}),
				}),
			);
		});

		describe('JWE decryption', () => {
			const baseState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};

			async function setupCallback(jweEnabled: boolean, tokenResponse: Record<string, unknown>) {
				const mockGetToken = vi.fn().mockResolvedValue({ data: { ...tokenResponse } });
				const { ClientOAuth2 } = await import('@n8n/client-oauth2');
				vi.mocked(ClientOAuth2).mockImplementation(function () {
					return { code: { getToken: mockGetToken } } as any;
				});
				const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
				oauthService.resolveCredential.mockResolvedValueOnce([
					mockResolvedCredential,
					{ csrfSecret: 'csrf-secret' },
					{
						clientId: 'client_id',
						clientSecret: 'client_secret',
						authUrl: 'https://example.domain/oauth2/auth',
						accessTokenUrl: 'https://example.domain/oauth2/token',
						scope: 'openid',
						grantType: 'authorizationCode',
						authentication: 'header',
						jweEnabled,
					} as any,
					baseState,
					{ csrfSecret: 'csrf-secret' },
				]);
				oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
				externalHooks.run.mockResolvedValue(undefined);
				return { mockGetToken, mockResolvedCredential };
			}

			it('persists the decrypted response returned by the proxy', async () => {
				oauthJweServiceProxy.decryptOAuth2TokenData.mockResolvedValue({
					access_token: 'decrypted',
					refresh_token: 'r',
				});
				const { mockResolvedCredential } = await setupCallback(true, {
					access_token: 'jwe-blob',
					refresh_token: 'r',
				});

				const req = mock<OAuthRequest.OAuth2Credential.Callback>({
					query: { code: 'auth_code', state: validState },
					originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
				});

				await controller.handleCallback(req, res);

				expect(oauthJweServiceProxy.decryptOAuth2TokenData).toHaveBeenCalledWith(
					expect.objectContaining({ access_token: 'jwe-blob' }),
				);
				expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
					mockResolvedCredential,
					expect.objectContaining({
						oauthTokenData: expect.objectContaining({ access_token: 'decrypted' }),
					}),
				);
			});

			it('renders error when the proxy rejects plaintext', async () => {
				oauthJweServiceProxy.decryptOAuth2TokenData.mockRejectedValue(
					new UserError('Expected at least one JWE-encrypted token but received only plaintext'),
				);
				await setupCallback(true, { access_token: 'plaintext' });

				const req = mock<OAuthRequest.OAuth2Credential.Callback>({
					query: { code: 'auth_code', state: validState },
					originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
				});

				await controller.handleCallback(req, res);

				expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
					res,
					'Expected at least one JWE-encrypted token but received only plaintext',
					undefined,
				);
				expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
			});

			it('skips the proxy entirely when the credential opts out', async () => {
				const { mockResolvedCredential } = await setupCallback(false, {
					access_token: 'plaintext',
				});

				const req = mock<OAuthRequest.OAuth2Credential.Callback>({
					query: { code: 'auth_code', state: validState },
					originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
				});

				await controller.handleCallback(req, res);

				expect(oauthJweServiceProxy.decryptOAuth2TokenData).not.toHaveBeenCalled();
				expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
					mockResolvedCredential,
					expect.objectContaining({
						oauthTokenData: expect.objectContaining({ access_token: 'plaintext' }),
					}),
				);
			});

			it('routes the decrypted response to saveDynamicCredential when origin is dynamic-credential', async () => {
				const dynamicState = {
					token: 'token',
					cid: '1',
					userId: '123',
					origin: 'dynamic-credential' as const,
					credentialResolverId: 'resolver-1',
					authorizationHeader: 'Bearer caller-token',
					authMetadata: { tenant: 'acme' },
					createdAt: timestamp,
					data: 'encrypted-data',
				};

				const mockGetToken = vi
					.fn()
					.mockResolvedValue({ data: { access_token: 'jwe-blob', refresh_token: 'r' } });
				const { ClientOAuth2 } = await import('@n8n/client-oauth2');
				vi.mocked(ClientOAuth2).mockImplementation(function () {
					return { code: { getToken: mockGetToken } } as any;
				});
				const mockResolvedCredential = mock<CredentialsEntity>({ id: '1', type: 'genericOAuth2' });
				oauthService.resolveCredential.mockResolvedValueOnce([
					mockResolvedCredential,
					{ csrfSecret: 'csrf-secret' },
					{
						clientId: 'client_id',
						clientSecret: 'client_secret',
						authUrl: 'https://example.domain/oauth2/auth',
						accessTokenUrl: 'https://example.domain/oauth2/token',
						scope: 'openid',
						grantType: 'authorizationCode',
						authentication: 'header',
						jweEnabled: true,
					} as any,
					dynamicState,
					{ csrfSecret: 'csrf-secret' },
				]);
				oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
				externalHooks.run.mockResolvedValue(undefined);
				oauthJweServiceProxy.decryptOAuth2TokenData.mockResolvedValue({
					access_token: 'decrypted',
					refresh_token: 'r',
				});
				credentialsOverwrites.supportsManagedAuth.mockReturnValue(true);
				credentialsOverwrites.usesManagedAuth.mockReturnValue(true);

				const req = mock<OAuthRequest.OAuth2Credential.Callback>({
					query: { code: 'auth_code', state: validState },
					originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
				});

				await controller.handleCallback(req, res);

				expect(oauthJweServiceProxy.decryptOAuth2TokenData).toHaveBeenCalledWith(
					expect.objectContaining({ access_token: 'jwe-blob' }),
				);
				expect(oauthService.saveDynamicCredential).toHaveBeenCalledWith(
					mockResolvedCredential,
					expect.objectContaining({
						oauthTokenData: expect.objectContaining({ access_token: 'decrypted' }),
					}),
					'caller-token',
					'resolver-1',
					{ tenant: 'acme' },
				);
				expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', {
					user: { id: '123' },
					credentialType: 'genericOAuth2',
					credentialId: '1',
					supportsManagedAuth: true,
					usesManagedAuth: true,
				});
				expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
			});
		});

		it('should handle errors and render error page', async () => {
			const { ClientOAuth2 } = await import('@n8n/client-oauth2');
			const mockGetToken = vi.fn().mockRejectedValue(new Error('Token exchange failed'));
			vi.mocked(ClientOAuth2).mockImplementation(function () {
				return { code: { getToken: mockGetToken } } as any;
			});

			const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
			const mockState = {
				token: 'token',
				cid: '1',
				userId: '123',
				origin: 'static-credential' as const,
				createdAt: timestamp,
				data: 'encrypted-data',
			};
			oauthService.resolveCredential.mockResolvedValueOnce([
				mockResolvedCredential,
				{ csrfSecret: 'csrf-secret' },
				{
					clientId: 'client_id',
					clientSecret: 'client_secret',
					authUrl: 'https://example.domain/oauth2/auth',
					accessTokenUrl: 'https://example.domain/oauth2/token',
					scope: 'openid',
					grantType: 'authorizationCode',
					authentication: 'header',
				},
				mockState,
				{ csrfSecret: 'csrf-secret' },
			]);
			oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
			externalHooks.run.mockResolvedValue(undefined);

			const req = mock<OAuthRequest.OAuth2Credential.Callback>({
				query: {
					code: 'auth_code',
					state: validState,
				},
				originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
			});

			await controller.handleCallback(req, res);

			expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
				res,
				'Token exchange failed',
				undefined,
			);
		});
	});
});
