import type { Logger } from '@n8n/backend-common';
import type {
	CredentialsRepository,
	ICredentialsDb,
	InstanceCredentialAssignmentRepository,
	SharedCredentialsRepository,
	Project,
	ProjectRepository,
	UserRepository,
	User,
	SharedCredentials,
	ListQueryDb,
	DbLockService,
} from '@n8n/db';
import { CredentialsEntity, DbLock, GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import { CREDENTIAL_ERRORS, CredentialDataError, Credentials, type ErrorReporter } from 'n8n-core';
import { OAuth2Api } from 'n8n-nodes-base/credentials/OAuth2Api.credentials';
import {
	CREDENTIAL_BLANKING_VALUE,
	CREDENTIAL_EMPTY_VALUE,
	jsonParse,
	type ICredentialDataDecryptedObject,
	type ICredentialType,
	type INodeProperties,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import type { CredentialTypes } from '@/credential-types';
import type { CredentialConnectionStatusProxy } from '@/credentials/credential-connection-status-proxy';
import type { CredentialDependencyService } from '@/credentials/credential-dependency.service';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import type { InstanceCredentialUseRegistry } from '@/credentials/instance-credential-use.registry';
import * as validation from '@/credentials/validation';
import type { CredentialsHelper } from '@/credentials-helper';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { EventService } from '@/events/event.service';
import type { ExternalHooks } from '@/external-hooks';
import type { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import {
	DCR_MANAGED_CREDENTIAL_FIELDS,
	type DcrManagedCredentialField,
} from '@/oauth/dcr-managed-fields';
import * as checkAccess from '@/permissions.ee/check-access';
import type { CredentialsTester } from '@/services/credentials-tester.service';
import type { OwnershipService } from '@/services/ownership.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { RoleService } from '@/services/role.service';

import { mockExistingCredential } from './credentials.test-data';

const ownerUser = mock<User>({ id: 'owner-id', role: GLOBAL_OWNER_ROLE });
const memberUser = mock<User>({ id: 'member-id', role: GLOBAL_MEMBER_ROLE });

describe('CredentialsService', () => {
	const credType = mock<ICredentialType>({
		extends: [],
		properties: [
			{
				name: 'clientSecret',
				type: 'string',
				typeOptions: { password: true },
				doNotInherit: false,
			},
			{
				name: 'accessToken',
				type: 'string',
				typeOptions: { password: true },
				doNotInherit: false,
			},
		],
	});

	const errorReporter = mock<ErrorReporter>();
	const credentialTypes = mock<CredentialTypes>();
	const credentialsRepository = mock<CredentialsRepository>();
	const credentialDependencyService = mock<CredentialDependencyService>();
	const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
	const ownershipService = mock<OwnershipService>();
	const logger = mock<Logger>();
	const credentialsTester = mock<CredentialsTester>();
	const externalHooks = mock<ExternalHooks>();
	const projectRepository = mock<ProjectRepository>();
	const projectService = mock<ProjectService>();
	const roleService = mock<RoleService>();
	const userRepository = mock<UserRepository>();
	const credentialsFinderService = mock<CredentialsFinderService>();
	const credentialsHelper = mock<CredentialsHelper>();
	const externalSecretsConfig = mock<ExternalSecretsConfig>();
	const externalSecretsProviderAccessCheckService = mock<SecretsProviderAccessCheckService>();
	const connectionStatusProxy = mock<CredentialConnectionStatusProxy>();
	const instanceCredentialAssignmentRepository = mock<InstanceCredentialAssignmentRepository>();
	const instanceCredentialUseRegistry = mock<InstanceCredentialUseRegistry>();
	const dbLockService = mock<DbLockService>();
	const eventService = mock<EventService>();

	const service = new CredentialsService(
		credentialsRepository,
		credentialDependencyService,
		sharedCredentialsRepository,
		ownershipService,
		logger,
		errorReporter,
		credentialsTester,
		externalHooks,
		credentialTypes,
		projectRepository,
		projectService,
		roleService,
		userRepository,
		credentialsFinderService,
		credentialsHelper,
		externalSecretsConfig,
		externalSecretsProviderAccessCheckService,
		connectionStatusProxy,
		instanceCredentialAssignmentRepository,
		instanceCredentialUseRegistry,
		dbLockService,
		eventService,
	);

	beforeEach(() => {
		vi.resetAllMocks();
		credentialDependencyService.resolveExternalSecretsStoreDependencyFilter.mockResolvedValue(
			undefined,
		);
		credentialDependencyService.syncExternalSecretProviderDependenciesForCredential.mockResolvedValue(
			undefined,
		);
		credentialDependencyService.upsertExternalSecretProviderDependenciesForCredential.mockResolvedValue(
			undefined,
		);
		ownershipService.addOwnedByAndSharedWith.mockImplementation((credential: any) => credential);
		// Mock the subquery method used by member users and admin users with onlySharedWithMe
		credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
			credentials: [],
			count: 0,
		});
		// Mock roleService for the new subquery implementation
		roleService.rolesWithScope.mockResolvedValue(['project:viewer', 'project:editor']);
		instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([]);
	});

	/**
	 * Helper function to mock the credentials repository transaction manager
	 * @param options - Configuration options
	 * @param options.credentialId - The ID to assign to saved credentials (default: 'new-cred-id')
	 * @param options.onSave - Optional callback to capture saved entities
	 */
	const mockTransactionManager = (options?: {
		credentialId?: string;
		onSave?: (entity: any) => void;
	}) => {
		const credentialId = options?.credentialId ?? 'new-cred-id';
		const onSave = options?.onSave;

		// @ts-expect-error - Mocking manager for testing
		credentialsRepository.manager = {
			transaction: vi.fn().mockImplementation(async (callback) => {
				const mockManager = {
					save: vi.fn().mockImplementation(async (entity) => {
						if (onSave) {
							onSave(entity);
						}
						return { ...entity, id: credentialId };
					}),
				};
				return await callback(mockManager);
			}),
		};
	};

	describe('clearOauthTokenData', () => {
		it('removes oauthTokenData and accountIdentifier before persisting', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'cred-1',
				name: 'My OAuth',
				type: 'gmailOAuth2',
			});

			// Raw (unredacted) decryption so the real token can be dropped.
			vi.spyOn(Credentials.prototype, 'getData').mockResolvedValue({
				clientId: 'abc',
				oauthTokenData: { access_token: 'tok' },
				accountIdentifier: 'user@example.com',
			});
			const createEncryptedDataSpy = vi
				.spyOn(service, 'createEncryptedData')
				.mockResolvedValue(mock<ICredentialsDb>());
			const updateSpy = vi.spyOn(service, 'update').mockResolvedValue(mock<CredentialsEntity>());

			await service.clearOauthTokenData(credential);

			const passedData = createEncryptedDataSpy.mock.calls[0][0].data;
			expect(passedData).not.toHaveProperty('oauthTokenData');
			expect(passedData).not.toHaveProperty('accountIdentifier');
			expect(passedData).toHaveProperty('clientId', 'abc');
			expect(updateSpy).toHaveBeenCalledWith(credential.id, expect.anything(), passedData);
		});

		it('aborts without persisting when the credential cannot be decrypted', async () => {
			const credential = mock<CredentialsEntity>({ id: 'cred-1', type: 'gmailOAuth2' });

			const decryptionError = new Error('decryption failed');
			vi.spyOn(Credentials.prototype, 'getData').mockRejectedValueOnce(decryptionError);
			const updateSpy = vi.spyOn(service, 'update');

			// Must propagate the failure rather than overwrite the credential with empty data.
			await expect(service.clearOauthTokenData(credential)).rejects.toThrow(decryptionError);
			expect(updateSpy).not.toHaveBeenCalled();
		});
	});

	describe('prepareUpdateData with dynamic client registration', () => {
		const CREDENTIAL_TYPE = 'linearMcpOAuth2Api';

		const DCR_FIELDS: Record<DcrManagedCredentialField, string | boolean> = {
			clientId: 'dcr-client-id',
			clientSecret: 'dcr-client-secret',
			authUrl: 'https://linear.app/oauth/authorize',
			accessTokenUrl: 'https://linear.app/oauth/token',
			grantType: 'authorizationCode',
			authentication: 'header',
			usePkce: true,
		};

		const oauthTokenData = { access_token: 'stale-token', refresh_token: 'refresh-token' };

		/** Merged properties of an `oAuth2Api` descendant, with DCR on or off. */
		function credentialProperties(useDynamicClientRegistration: boolean): INodeProperties[] {
			return new OAuth2Api().properties.map((property) =>
				property.name === 'useDynamicClientRegistration'
					? { ...property, default: useDynamicClientRegistration }
					: property,
			);
		}

		function storedCredential(data: ICredentialDataDecryptedObject) {
			vi.spyOn(Credentials.prototype, 'getData').mockResolvedValue(data);
			return mock<CredentialsEntity>({
				id: 'cred-1',
				name: 'Linear',
				type: CREDENTIAL_TYPE,
				usageScope: 'project',
				shared: [{ role: 'credential:owner', projectId: 'project-1' }],
			});
		}

		beforeEach(() => {
			credentialTypes.getByName.mockReturnValue(
				mock<ICredentialType>({ extends: [], properties: [] }),
			);
			credentialsRepository.create.mockImplementation(
				(data) => Object.assign(new CredentialsEntity(), data) as CredentialsEntity,
			);
			credentialsHelper.getCredentialsProperties.mockReturnValue(credentialProperties(true));
		});

		it('keeps the negotiated client fields, which the frontend never sends back', async () => {
			const credential = storedCredential({ ...DCR_FIELDS, oauthTokenData });

			const prepared = await service.prepareUpdateData(
				ownerUser,
				{ name: 'Linear', type: CREDENTIAL_TYPE, data: { scope: 'read write' } },
				credential,
			);

			expect(prepared.data).toMatchObject(DCR_FIELDS);
		});

		it('drops them along with the token when the caller clears it', async () => {
			const credential = storedCredential({ ...DCR_FIELDS, oauthTokenData });

			const prepared = await service.prepareUpdateData(
				ownerUser,
				{ name: 'Linear', type: CREDENTIAL_TYPE, data: {} },
				credential,
				{ clearOauthTokenData: true },
			);

			const preparedData = prepared.data as unknown as ICredentialDataDecryptedObject;
			expect(preparedData.oauthTokenData).toBeUndefined();
			for (const field of DCR_MANAGED_CREDENTIAL_FIELDS) {
				expect(preparedData[field]).toBeUndefined();
			}
		});

		it('lets the user clear a displayed field by returning it to its default', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue(credentialProperties(false));
			const credential = storedCredential({
				clientId: 'user-client-id',
				clientSecret: 'user-client-secret',
				// Switching this back to the default 'header' means the frontend omits it.
				authentication: 'body',
				oauthTokenData,
			});

			const prepared = await service.prepareUpdateData(
				ownerUser,
				{
					name: 'Discord',
					type: CREDENTIAL_TYPE,
					data: { clientId: 'user-client-id', clientSecret: 'user-client-secret' },
				},
				credential,
			);

			const preparedData = prepared.data as unknown as ICredentialDataDecryptedObject;
			expect(preparedData.authentication).toBeUndefined();
		});
	});

	describe('redact', () => {
		it('should redact sensitive values', () => {
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});

			const decryptedData = {
				clientId: 'abc123',
				clientSecret: 'sensitiveSecret',
				accessToken: '',
				oauthTokenData: 'super-secret',
				csrfSecret: 'super-secret',
			};

			credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				clientId: 'abc123',
				clientSecret: CREDENTIAL_BLANKING_VALUE,
				accessToken: CREDENTIAL_EMPTY_VALUE,
				oauthTokenData: CREDENTIAL_BLANKING_VALUE,
				csrfSecret: CREDENTIAL_BLANKING_VALUE,
			});
		});

		it('should redact non-string password field values without throwing', () => {
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});

			const decryptedData = {
				clientId: 'abc123',
				clientSecret: 12345, // number, not a string
				accessToken: '',
			};

			credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType);

			const redactedData = service.redact(
				decryptedData as unknown as ICredentialDataDecryptedObject,
				credential,
			);

			expect(redactedData).toEqual({
				clientId: 'abc123',
				clientSecret: CREDENTIAL_BLANKING_VALUE,
				accessToken: CREDENTIAL_EMPTY_VALUE,
			});
		});

		it('should redact null password field values without throwing', () => {
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});

			const decryptedData = {
				clientId: 'abc123',
				clientSecret: null, // null, not a string
				oauthTokenData: null,
				csrfSecret: null,
			};

			credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType);

			const redactedData = service.redact(
				decryptedData as unknown as ICredentialDataDecryptedObject,
				credential,
			);

			expect(redactedData).toEqual({
				clientId: 'abc123',
				clientSecret: CREDENTIAL_EMPTY_VALUE,
				oauthTokenData: CREDENTIAL_EMPTY_VALUE,
				csrfSecret: CREDENTIAL_EMPTY_VALUE,
			});
		});

		it('should redact sensitive values in a fixed collection with multiple values', () => {
			const fixedCollectionCredType = {
				properties: [
					{
						name: 'headers',
						type: 'fixedCollection',
						typeOptions: { multipleValues: true },
						options: [
							{
								displayName: 'Header',
								name: 'values',
								values: [
									{
										name: 'name',
										type: 'string',
									},
									{
										name: 'value',
										type: 'string',
										typeOptions: { password: true },
									},
								],
							},
						],
					},
				],
			} as unknown as ICredentialType;
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});
			const decryptedData = {
				headers: {
					values: [
						{
							name: 'Authorization',
							value: 'Bearer sensitiveSecret',
						},
						{
							name: 'Test',
							value: '123',
						},
					],
				},
			};
			credentialTypes.getByName
				.calledWith(credential.type)
				.mockReturnValueOnce(fixedCollectionCredType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				headers: {
					values: [
						{ name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE },
						{ name: 'Test', value: CREDENTIAL_BLANKING_VALUE },
					],
				},
			});
		});

		it('should redact sensitive values in a fixed collection with single value', () => {
			const fixedCollectionCredType = {
				properties: [
					{
						name: 'headers',
						type: 'fixedCollection',
						typeOptions: { multipleValues: false },
						options: [
							{
								displayName: 'Header',
								name: 'values',
								values: [
									{
										name: 'name',
										type: 'string',
									},
									{
										name: 'value',
										type: 'string',
										typeOptions: { password: true },
									},
								],
							},
						],
					},
				],
			} as unknown as ICredentialType;
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});
			const decryptedData = {
				headers: {
					values: {
						name: 'Authorization',
						value: 'Bearer sensitiveSecret',
					},
				},
			};
			credentialTypes.getByName
				.calledWith(credential.type)
				.mockReturnValueOnce(fixedCollectionCredType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				headers: {
					values: { name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE },
				},
			});
		});

		it('should redact sensitive values in a fixed collection with multiple options', () => {
			const fixedCollectionCredType = {
				properties: [
					{
						name: 'headers',
						type: 'fixedCollection',
						typeOptions: { multipleValues: true },
						options: [
							{
								displayName: 'Header',
								name: 'values1',
								values: [
									{
										name: 'name',
										type: 'string',
									},
									{
										name: 'value',
										type: 'string',
										typeOptions: { password: true },
									},
								],
							},
							{
								displayName: 'Header',
								name: 'values2',
								values: [
									{
										name: 'name',
										type: 'string',
									},
									{
										name: 'value',
										type: 'string',
										typeOptions: { password: true },
									},
								],
							},
						],
					},
				],
			} as unknown as ICredentialType;
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});
			const decryptedData = {
				headers: {
					values1: [
						{
							name: 'Authorization',
							value: 'Bearer sensitiveSecret',
						},
						{
							name: 'Test',
							value: '123',
						},
					],
					values2: [
						{
							name: 'Foo',
							value: 'Bar',
						},
						{
							name: 'Baz',
							value: 'Qux',
						},
					],
				},
			};
			credentialTypes.getByName
				.calledWith(credential.type)
				.mockReturnValueOnce(fixedCollectionCredType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				headers: {
					values1: [
						{ name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE },
						{ name: 'Test', value: CREDENTIAL_BLANKING_VALUE },
					],
					values2: [
						{ name: 'Foo', value: CREDENTIAL_BLANKING_VALUE },
						{ name: 'Baz', value: CREDENTIAL_BLANKING_VALUE },
					],
				},
			});
		});

		it('should redact sensitive values in a fixed collection with multiple options and a single value', () => {
			const fixedCollectionCredType = {
				properties: [
					{
						name: 'headers',
						type: 'fixedCollection',
						typeOptions: { multipleValues: false },
						options: [
							{
								displayName: 'Header',
								name: 'values1',
								values: [
									{
										name: 'name',
										type: 'string',
									},
									{
										name: 'value',
										type: 'string',
										typeOptions: { password: true },
									},
								],
							},
							{
								displayName: 'Header',
								name: 'values2',
								values: [
									{
										name: 'name',
										type: 'string',
									},
									{
										name: 'value',
										type: 'string',
										typeOptions: { password: true },
									},
								],
							},
						],
					},
				],
			} as unknown as ICredentialType;
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});
			const decryptedData = {
				headers: {
					values2: {
						name: 'Authorization',
						value: 'Bearer sensitiveSecret',
					},
				},
			};
			credentialTypes.getByName
				.calledWith(credential.type)
				.mockReturnValueOnce(fixedCollectionCredType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				headers: {
					values2: { name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE },
				},
			});
		});

		it('should redact json field for httpCustomAuth credential type', () => {
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'httpCustomAuth',
			});

			const decryptedData = {
				json: '{"key": "value"}',
				otherField: 'not-redacted',
			};

			const httpCustomAuthCredType = {
				properties: [
					{
						name: 'json',
						type: 'json',
						typeOptions: { redactJsonLeaves: true },
					},
					{
						name: 'otherField',
						type: 'string',
					},
				],
			} as unknown as ICredentialType;

			credentialTypes.getByName
				.calledWith(credential.type)
				.mockReturnValueOnce(httpCustomAuthCredType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				json: JSON.stringify({ key: '***' }, null, 2),
				otherField: 'not-redacted',
			});
		});

		it('should redact empty json field for httpCustomAuth credential type', () => {
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'httpCustomAuth',
			});

			const decryptedData = {
				json: '',
				otherField: 'not-redacted',
			};

			const httpCustomAuthCredType = {
				properties: [
					{
						name: 'json',
						type: 'json',
						typeOptions: { redactJsonLeaves: true },
					},
					{
						name: 'otherField',
						type: 'string',
					},
				],
			} as unknown as ICredentialType;

			credentialTypes.getByName
				.calledWith(credential.type)
				.mockReturnValueOnce(httpCustomAuthCredType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				json: CREDENTIAL_EMPTY_VALUE,
				otherField: 'not-redacted',
			});
		});

		describe('httpCustomAuth redactJsonLeaves (via service.redact)', () => {
			const makeHttpCustomAuthCredential = () =>
				mock<CredentialsEntity>({ id: '123', name: 'Test', type: 'httpCustomAuth' });

			const makeCredType = () =>
				({
					properties: [{ name: 'json', type: 'json', typeOptions: { redactJsonLeaves: true } }],
				}) as unknown as ICredentialType;

			it('should redact string leaf values', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"token": "secret", "key": "abc"}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(JSON.stringify({ token: '***', key: '***' }, null, 2));
			});

			it('should redact numeric leaf values', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"port": 8080, "timeout": 30}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(JSON.stringify({ port: '***', timeout: '***' }, null, 2));
			});

			it('should keep expression leaf values visible', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"token": "={{ $secrets.vault.replicate }}", "key": "abc"}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(
					JSON.stringify({ token: '={{ $secrets.vault.replicate }}', key: '***' }, null, 2),
				);
			});

			it('should redact boolean leaf values', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"enabled": true, "debug": false}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(JSON.stringify({ enabled: '***', debug: '***' }, null, 2));
			});

			it('should redact leaves of nested objects recursively', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{
						json: '{"headers": {"Authorization": "Bearer token", "X-Api-Key": "key"}, "url": "https://example.com"}',
					},
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(
					JSON.stringify(
						{ headers: { Authorization: '***', 'X-Api-Key': '***' }, url: '***' },
						null,
						2,
					),
				);
			});

			it('should redact leaves inside arrays', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"scopes": ["read", "write"], "tags": ["prod"]}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(
					JSON.stringify({ scopes: ['***', '***'], tags: ['***'] }, null, 2),
				);
			});

			it('should redact leaves in deeply mixed nesting (array of objects)', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"rules": [{"key": "a", "val": 1}, {"key": "b", "val": 2}]}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(
					JSON.stringify(
						{
							rules: [
								{ key: '***', val: '***' },
								{ key: '***', val: '***' },
							],
						},
						null,
						2,
					),
				);
			});

			it('should return json unchanged when it is not parseable', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact({ json: 'not-valid-json' }, makeHttpCustomAuthCredential());

				expect(result.json).toEqual('not-valid-json');
			});

			it('should redact leaves in a deeply nested object', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{ json: '{"a":{"b":{"c":{"d":"deep-secret"}}}}' },
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(JSON.stringify({ a: { b: { c: { d: '***' } } } }, null, 2));
			});

			it('should redact leaves inside an array nested inside a deep object', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact(
					{
						json: '{"auth":{"headers":[{"name":"Authorization","value":"Bearer s"},{"name":"X-Key","value":"abc"}]}}',
					},
					makeHttpCustomAuthCredential(),
				);

				expect(result.json).toEqual(
					JSON.stringify(
						{
							auth: {
								headers: [
									{ name: '***', value: '***' },
									{ name: '***', value: '***' },
								],
							},
						},
						null,
						2,
					),
				);
			});

			it('should redact null leaf values', () => {
				credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());

				const result = service.redact({ json: '{"token":null}' }, makeHttpCustomAuthCredential());

				expect(result.json).toEqual(JSON.stringify({ token: '***' }, null, 2));
			});
		});

		it('should not redact json field for non-httpCustomAuth credential types', () => {
			const credential = mock<CredentialsEntity>({
				id: '123',
				name: 'Test Credential',
				type: 'oauth2',
			});

			const decryptedData = {
				json: '{"key": "value"}',
				clientSecret: 'sensitiveSecret',
			};

			credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType);

			const redactedData = service.redact(decryptedData, credential);

			expect(redactedData).toEqual({
				json: '{"key": "value"}',
				clientSecret: CREDENTIAL_BLANKING_VALUE,
			});
		});
	});

	describe('decrypt', () => {
		const data = {
			clientId: 'abc123',
			clientSecret: 'sensitiveSecret',
			accessToken: '',
			oauthTokenData: 'super-secret',
			csrfSecret: 'super-secret',
		};
		const credentialEntity = mock<CredentialsEntity>({
			id: '123',
			name: 'Test Credential',
			type: 'oauth2',
		});
		const credentials = mock<Credentials>({ id: credentialEntity.id });

		beforeEach(() => {
			credentialTypes.getByName.calledWith(credentialEntity.type).mockReturnValueOnce(credType);
		});

		it('should redact sensitive values by default', async () => {
			// ARRANGE
			vi.spyOn(Credentials.prototype, 'getData').mockResolvedValueOnce(data);

			// ACT
			const redactedData = await service.decrypt(credentialEntity);

			// ASSERT
			expect(redactedData).toEqual({
				...data,
				clientSecret: CREDENTIAL_BLANKING_VALUE,
				accessToken: CREDENTIAL_EMPTY_VALUE,
				oauthTokenData: CREDENTIAL_BLANKING_VALUE,
				csrfSecret: CREDENTIAL_BLANKING_VALUE,
			});
		});

		it('should return sensitive values if `includeRawData` is true', async () => {
			// ARRANGE
			vi.spyOn(Credentials.prototype, 'getData').mockResolvedValueOnce(data);

			// ACT
			const redactedData = await service.decrypt(credentialEntity, true);

			// ASSERT
			expect(redactedData).toEqual(data);
		});

		it('should return return an empty object if decryption fails', async () => {
			// ARRANGE
			const decryptionError = new CredentialDataError(
				credentials,
				CREDENTIAL_ERRORS.DECRYPTION_FAILED,
			);
			vi.spyOn(Credentials.prototype, 'getData').mockRejectedValueOnce(decryptionError);

			// ACT
			const redactedData = await service.decrypt(credentialEntity, true);

			// ASSERT
			expect(redactedData).toEqual({});
			expect(credentialTypes.getByName).not.toHaveBeenCalled();
			expect(errorReporter.error).toHaveBeenCalledWith(decryptionError, {
				extra: { credentialId: credentialEntity.id },
				level: 'error',
				tags: { credentialType: credentialEntity.type },
			});
		});
	});

	describe('testById', () => {
		it('throws CredentialNotFoundError when credential does not exist', async () => {
			credentialsFinderService.findById.mockResolvedValue(null);

			await expect(service.testById(ownerUser.id, 'missing-credential')).rejects.toThrow(
				CredentialNotFoundError,
			);
			expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
		});

		it('does not expose instance credentials through public API testing', async () => {
			credentialsFinderService.findById.mockResolvedValue(
				mock<CredentialsEntity>({
					id: 'instance-credential',
					usageScope: 'instance',
				}),
			);

			await expect(service.testById(ownerUser.id, 'instance-credential')).rejects.toThrow(
				CredentialNotFoundError,
			);
			expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
		});

		it('decrypts stored credential and calls credentials tester', async () => {
			const storedCredential = mock<CredentialsEntity>({
				id: 'credential-id',
				name: 'Test Credential',
				type: 'githubApi',
				usageScope: 'project',
			});
			const decryptedData = { accessToken: 'secret-token' } as ICredentialDataDecryptedObject;
			const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;

			credentialsFinderService.findById.mockResolvedValue(storedCredential);
			credentialsTester.testCredentials.mockResolvedValue(testResult);
			vi.spyOn(service, 'decrypt').mockResolvedValue(decryptedData);

			const result = await service.testById(ownerUser.id, storedCredential.id);

			expect(credentialsFinderService.findById).toHaveBeenCalledWith(storedCredential.id);
			expect(service.decrypt).toHaveBeenCalledWith(storedCredential, true);
			expect(credentialsTester.testCredentials).toHaveBeenCalledWith(
				ownerUser.id,
				storedCredential.type,
				{
					id: storedCredential.id,
					name: storedCredential.name,
					type: storedCredential.type,
					data: decryptedData,
				},
			);
			expect(result).toEqual(testResult);
		});
	});

	describe('getOne', () => {
		const instanceCredential = mock<CredentialsEntity>({
			id: 'instance-credential',
			usageScope: 'instance',
			data: 'encrypted-data',
		});

		it('does not return instance credentials without an explicit opt-in', async () => {
			credentialsRepository.findOneBy.mockResolvedValue(instanceCredential);
			sharedCredentialsRepository.findOne.mockResolvedValue(null);

			await expect(service.getOne(ownerUser, instanceCredential.id, false)).rejects.toThrow(
				`Credential with ID "${instanceCredential.id}" could not be found.`,
			);
			expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
			expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
				where: {
					credentialsId: instanceCredential.id,
					credentials: { usageScope: 'project' },
				},
				relations: { credentials: true },
			});
		});

		it('returns instance credentials to managers when explicitly requested', async () => {
			credentialsRepository.findOneBy.mockResolvedValue(instanceCredential);

			await expect(
				service.getOne(ownerUser, instanceCredential.id, false, {
					includeInstanceCredentials: true,
				}),
			).resolves.toMatchObject({ id: instanceCredential.id, usageScope: 'instance' });
			expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
				id: instanceCredential.id,
				usageScope: 'instance',
			});
		});
	});

	describe('ensureEndUserCredentialAllowedInProject', () => {
		it('throws when the project is personal', () => {
			expect(() =>
				service.ensureEndUserCredentialAllowedInProject(
					mock<Project>({ id: 'project-id', type: 'personal' }),
				),
			).toThrow(ForbiddenError);
		});

		it('allows a team project', () => {
			expect(() =>
				service.ensureEndUserCredentialAllowedInProject(
					mock<Project>({ id: 'project-id', type: 'team' }),
				),
			).not.toThrow();
		});

		it('does nothing when no project is given', () => {
			expect(() => service.ensureEndUserCredentialAllowedInProject(undefined)).not.toThrow();
		});
	});

	describe('delete', () => {
		it('does not opt generic callers into instance credential access', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);

			await service.delete(ownerUser, 'credential-id');

			expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
				'credential-id',
				ownerUser,
				['credential:delete'],
				{},
			);
			expect(credentialsRepository.remove).not.toHaveBeenCalled();
			expect(eventService.emit).not.toHaveBeenCalled();
		});

		it('deletes instance credentials when management access is explicitly requested', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'instance-credential',
				type: 'openAiApi',
				usageScope: 'instance',
				isResolvable: false,
			});
			credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
			credentialsRepository.deleteInstanceCredentialIfUnassigned.mockResolvedValue({
				status: 'deleted',
			});

			await service.delete(ownerUser, credential.id, { includeInstanceCredentials: true });

			expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
				credential.id,
				ownerUser,
				['credential:delete'],
				{ includeInstanceCredentials: true },
			);
			expect(credentialsRepository.deleteInstanceCredentialIfUnassigned).toHaveBeenCalledWith(
				credential.id,
			);
			expect(externalHooks.run).toHaveBeenCalledWith('credentials.delete', [credential.id]);
			expect(eventService.emit).toHaveBeenCalledWith('credentials-deleted', {
				user: ownerUser,
				credentialType: credential.type,
				credentialId: credential.id,
			});
		});

		it('does not delete an instance credential bound to a feature', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'instance-credential',
				usageScope: 'instance',
				isResolvable: false,
			});
			credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
			credentialsRepository.deleteInstanceCredentialIfUnassigned.mockResolvedValue({
				status: 'assigned',
				credentialUseIds: ['instance-ai:model'],
			});

			await expect(
				service.delete(ownerUser, credential.id, { includeInstanceCredentials: true }),
			).rejects.toThrow('instance-ai:model');
			expect(externalHooks.run).toHaveBeenCalledWith('credentials.delete', [credential.id]);
			expect(eventService.emit).not.toHaveBeenCalled();
		});

		it('does not emit credentials-deleted when instance credential was already removed', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'instance-credential',
				type: 'openAiApi',
				usageScope: 'instance',
				isResolvable: false,
			});
			credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
			credentialsRepository.deleteInstanceCredentialIfUnassigned.mockResolvedValue({
				status: 'notFound',
			});

			await service.delete(ownerUser, credential.id, { includeInstanceCredentials: true });

			expect(eventService.emit).not.toHaveBeenCalled();
		});

		it('emits credentials-deleted and private-credential-deleted for resolvable project credentials', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'project-credential',
				type: 'gmailOAuth2',
				usageScope: 'project',
				isResolvable: true,
			});
			credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
			sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue(
				mock({ id: 'project-1' }),
			);
			vi.spyOn(service, 'ensureCanManageEndUserCredential').mockResolvedValue(undefined);
			credentialsRepository.remove.mockResolvedValue(credential);

			await service.delete(ownerUser, credential.id);

			expect(eventService.emit).toHaveBeenCalledWith('credentials-deleted', {
				user: ownerUser,
				credentialType: credential.type,
				credentialId: credential.id,
			});
			expect(eventService.emit).toHaveBeenCalledWith('private-credential-deleted', {
				user: ownerUser,
				credentialType: credential.type,
				credentialId: credential.id,
			});
		});

		it('should not emit "private-credential-deleted" when deleting a static credential', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'project-credential',
				type: 'gmailOAuth2',
				usageScope: 'project',
				isResolvable: false,
			});
			credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
			credentialsRepository.remove.mockResolvedValue(credential);

			await service.delete(ownerUser, credential.id);

			expect(eventService.emit).toHaveBeenCalledWith('credentials-deleted', {
				user: ownerUser,
				credentialType: credential.type,
				credentialId: credential.id,
			});
			const emittedEventNames = eventService.emit.mock.calls.map((call) => call[0]);
			expect(emittedEventNames).not.toContain('private-credential-deleted');
		});
	});

	describe('update', () => {
		const setRepositoryTransaction = (transactionManager: EntityManager) => {
			const transaction = vi.fn(
				async (run: (manager: EntityManager) => Promise<unknown>) => await run(transactionManager),
			);
			Object.defineProperty(credentialsRepository, 'manager', {
				configurable: true,
				value: { transaction },
				writable: true,
			});
			return transaction;
		};

		it('serializes instance credential validation and persistence with settings updates', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'instance-credential',
				name: 'Provider connection',
				type: 'apiKey',
			});
			const encrypted = await service.createEncryptedData({
				id: credential.id,
				name: credential.name,
				type: credential.type,
				data: { apiKey: 'secret' },
			});
			const lockTransactionManager = mock<EntityManager>();
			lockTransactionManager.findOneBy.mockResolvedValue(credential);
			const repositoryTransaction = setRepositoryTransaction(mock<EntityManager>());
			let lockHeld = false;
			let releaseLock = () => {};
			const lockAvailable = new Promise<void>((resolve) => {
				releaseLock = resolve;
			});
			dbLockService.withLock.mockImplementation(async (lockId, run) => {
				expect(lockId).toBe(DbLock.INSTANCE_AI_SETTINGS);
				expect(externalHooks.run).toHaveBeenCalledWith('credentials.update', [encrypted]);
				await lockAvailable;
				lockHeld = true;
				try {
					return await run(lockTransactionManager, {});
				} finally {
					lockHeld = false;
				}
			});
			instanceCredentialAssignmentRepository.findCredentialUseIds.mockImplementation(async () => {
				expect(lockHeld).toBe(true);
				return [];
			});

			const update = service.update(credential.id, encrypted, undefined, {
				instanceCredential: credential,
			});
			await vi.waitFor(() => expect(dbLockService.withLock).toHaveBeenCalledOnce());
			expect(lockTransactionManager.update).not.toHaveBeenCalled();
			releaseLock();
			await expect(update).resolves.toBe(credential);

			expect(repositoryTransaction).not.toHaveBeenCalled();
			expect(lockTransactionManager.update).toHaveBeenCalledWith(
				CredentialsEntity,
				credential.id,
				encrypted,
			);
		});

		it('keeps project credential updates on their existing transaction path', async () => {
			const credential = mock<CredentialsEntity>({ id: 'project-credential' });
			const transactionManager = mock<EntityManager>();
			transactionManager.findOneBy.mockResolvedValue(credential);
			const repositoryTransaction = setRepositoryTransaction(transactionManager);
			const encrypted = mock<ICredentialsDb>({
				id: credential.id,
				name: 'Project credential',
				type: 'apiKey',
				data: 'encrypted',
			});

			await expect(service.update(credential.id, encrypted)).resolves.toBe(credential);

			expect(repositoryTransaction).toHaveBeenCalledOnce();
			expect(dbLockService.withLock).not.toHaveBeenCalled();
			expect(transactionManager.update).toHaveBeenCalledWith(
				CredentialsEntity,
				credential.id,
				encrypted,
			);
		});

		it('enriches the returned credential with connectedByMe when a user is passed', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'private-credential',
				isResolvable: true,
			});
			const transactionManager = mock<EntityManager>();
			transactionManager.findOneBy.mockResolvedValue(credential);
			setRepositoryTransaction(transactionManager);
			const encrypted = mock<ICredentialsDb>({ id: credential.id });
			connectionStatusProxy.findMyConnections.mockResolvedValue(
				new Map([[credential.id, { accountIdentifier: 'me@gmail.com' }]]),
			);

			const result = await service.update(credential.id, encrypted, undefined, {
				user: ownerUser,
			});

			expect(connectionStatusProxy.findMyConnections).toHaveBeenCalledWith(ownerUser.id, [
				credential.id,
			]);
			expect(result).toMatchObject({
				connectedByMe: true,
				connectedAccountIdentifier: 'me@gmail.com',
			});
		});

		it('does not enrich the returned credential when no user is passed', async () => {
			const credential = mock<CredentialsEntity>({
				id: 'private-credential',
				isResolvable: true,
			});
			const transactionManager = mock<EntityManager>();
			transactionManager.findOneBy.mockResolvedValue(credential);
			setRepositoryTransaction(transactionManager);
			const encrypted = mock<ICredentialsDb>({ id: credential.id });

			await service.update(credential.id, encrypted);

			expect(connectionStatusProxy.findMyConnections).not.toHaveBeenCalled();
		});
	});

	describe('updateInstanceCredential', () => {
		const payload = { name: 'AI Assistant model', type: 'openAiApi', data: { apiKey: 'new-key' } };
		const preparedCredential = {
			name: payload.name,
			type: payload.type,
			data: payload.data,
		} as unknown as CredentialsEntity;
		const ctx = {};

		beforeEach(() => {
			credentialsRepository.findInstanceCredentialById.mockResolvedValue(
				mock<CredentialsEntity>({
					id: 'instance-credential',
					type: 'openAiApi',
					usageScope: 'instance',
				}),
			);
			instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([]);
		});

		it('rejects users without the instance credential management scope', async () => {
			await expect(
				service.updateInstanceCredential(memberUser, 'instance-credential', payload, ctx),
			).rejects.toThrow('You do not have permission to update provider connections');
			expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
		});

		it('rejects a credential type change', async () => {
			vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(
				mock<CredentialsEntity>({ name: payload.name, type: 'anthropicApi' }),
			);

			await expect(
				service.updateInstanceCredential(
					ownerUser,
					'instance-credential',
					{ ...payload, type: 'anthropicApi' },
					ctx,
				),
			).rejects.toThrow('Provider connection type cannot be changed');
			expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
		});

		it('runs the update hook unless the caller already ran it', async () => {
			const encrypted = await service.createEncryptedData({
				id: 'instance-credential',
				...payload,
			});
			vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
			vi.spyOn(service, 'createEncryptedData').mockResolvedValue(encrypted as never);
			credentialsRepository.updateInstanceCredential.mockResolvedValue(
				mock<CredentialsEntity>({ id: 'instance-credential' }),
			);

			await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx);
			expect(externalHooks.run).toHaveBeenCalledWith('credentials.update', [encrypted]);

			externalHooks.run.mockClear();
			await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
				skipExternalHooks: true,
			});
			expect(externalHooks.run).not.toHaveBeenCalled();
			expect(credentialsRepository.updateInstanceCredential).toHaveBeenCalledWith(
				'instance-credential',
				encrypted,
				ctx,
			);

			const hooked = { ...encrypted, name: 'Updated by hook' };
			await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
				skipExternalHooks: true,
				encryptedData: hooked as never,
			});
			expect(credentialsRepository.updateInstanceCredential).toHaveBeenLastCalledWith(
				'instance-credential',
				hooked,
				ctx,
			);
		});

		it('revalidates a pre-hooked payload before updating', async () => {
			vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
			const encrypted = await service.createEncryptedData({
				id: 'instance-credential',
				name: payload.name,
				type: payload.type,
				data: { apiKey: 'rejected-by-use' },
			});
			instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
				'instance-ai:model',
			]);
			instanceCredentialUseRegistry.get.mockReturnValue({
				id: 'instance-ai:model',
				credentialTypes: [payload.type],
				validate: ({ data }) => {
					if (data.apiKey === 'rejected-by-use') throw new Error('Invalid hooked API key');
				},
			});

			await expect(
				service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
					skipExternalHooks: true,
					encryptedData: encrypted,
				}),
			).rejects.toThrow('Invalid hooked API key');
			expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
		});

		it('rejects invalid ciphertext from a pre-run hook', async () => {
			vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
			const encrypted = await service.createEncryptedData({
				id: 'instance-credential',
				...payload,
			});
			encrypted.data = 'invalid-ciphertext';

			await expect(
				service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
					skipExternalHooks: true,
					encryptedData: encrypted,
				}),
			).rejects.toThrow('Provider connection hooks returned invalid credential data');
			expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
		});

		it.each([
			['ID', { id: 'other-credential' }],
			['type', { type: 'anthropicApi' }],
			['scope', { isGlobal: true }],
			['scope', { isManaged: true }],
			['scope', { isResolvable: true }],
			['scope', { resolvableAllowFallback: true }],
			['scope', { resolverId: 'resolver-id' }],
			['scope', { usageScope: 'project' }],
		])('rejects a hook that changes provider connection %s', async (invariant, mutation) => {
			vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
			const encrypted = await service.createEncryptedData({
				id: 'instance-credential',
				...payload,
			});
			Object.assign(encrypted, mutation);

			await expect(
				service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
					skipExternalHooks: true,
					encryptedData: encrypted,
				}),
			).rejects.toThrow(`Provider connection hooks cannot change the credential ${invariant}`);
			expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
		});
	});

	describe('createInstanceCredential hook validation', () => {
		it('revalidates the data after the create hook', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([]);
			const invalid = await service.createEncryptedData({
				id: null,
				name: 'Instance Credential',
				type: 'apiKey',
				data: { apiKey: '={{ $secrets.provider.key }}' },
			});
			externalHooks.run.mockImplementation(async (_event, hookParameters) => {
				const hookData = (hookParameters as [ICredentialsDb] | undefined)?.[0];
				if (!hookData) throw new Error('Expected credential hook data');
				hookData.data = invalid.data;
			});

			await expect(
				service.createUnmanagedCredential(
					{
						name: 'Instance Credential',
						type: 'apiKey',
						data: { apiKey: 'valid' },
						usageScope: 'instance',
					},
					ownerUser,
				),
			).rejects.toThrow('Provider connections cannot reference project-scoped external secrets');
			expect(credentialsRepository.saveInstanceCredential).not.toHaveBeenCalled();
		});
	});

	describe('runInstanceCredentialHooks', () => {
		it('returns the payload after the matching hook runs', async () => {
			const encrypted = { name: 'AI Assistant model', type: 'openAiApi', data: 'encrypted' };
			const createEncryptedDataSpy = vi
				.spyOn(service, 'createEncryptedData')
				.mockResolvedValue(encrypted as never);
			externalHooks.run.mockImplementation(async (_event, hookParameters) => {
				const hookData = (hookParameters as [ICredentialsDb] | undefined)?.[0];
				if (!hookData) throw new Error('Expected credential hook data');
				hookData.name = 'Updated by hook';
			});

			const result = await service.runInstanceCredentialHooks('create', {
				id: null,
				name: 'AI Assistant model',
				type: 'openAiApi',
				data: { apiKey: 'k' },
			});

			expect(createEncryptedDataSpy).toHaveBeenCalledWith({
				id: null,
				name: 'AI Assistant model',
				type: 'openAiApi',
				data: { apiKey: 'k' },
			});
			expect(externalHooks.run).toHaveBeenCalledWith('credentials.create', [encrypted]);
			expect(result).toBe(encrypted);
			expect(result.name).toBe('Updated by hook');
		});
	});

	describe('testWithCredentials', () => {
		it('tests an unsaved provider connection for an instance credential manager', async () => {
			const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;
			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
			credentialsTester.testCredentials.mockResolvedValue(testResult);
			const payload = {
				id: '',
				name: 'AI Assistant model',
				type: 'openAiApi',
				data: { apiKey: 'key' },
			};

			await expect(service.testWithCredentials(ownerUser, payload)).resolves.toEqual(testResult);
			expect(credentialsTester.testCredentials).toHaveBeenCalledWith(
				ownerUser.id,
				payload.type,
				payload,
			);
		});

		it('does not test an unsaved provider connection for other users', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);

			await expect(
				service.testWithCredentials(memberUser, {
					id: '',
					name: 'AI Assistant model',
					type: 'openAiApi',
					data: { apiKey: 'key' },
				}),
			).rejects.toThrow(CredentialNotFoundError);
			expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
		});

		it('throws CredentialNotFoundError when user cannot access credential', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);

			await expect(
				service.testWithCredentials(ownerUser, {
					id: 'missing-credential',
					name: 'Missing Credential',
					type: 'githubApi',
					data: {},
				}),
			).rejects.toThrow(CredentialNotFoundError);
			expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
		});

		it('prepares merged credentials and runs test', async () => {
			const storedCredential = mock<CredentialsEntity>({
				id: 'credential-id',
				name: 'Stored Credential',
				type: 'githubApi',
			});
			const decryptedData = { accessToken: 'stored-token' } as ICredentialDataDecryptedObject;
			const unredactedData = { accessToken: 'live-token' } as ICredentialDataDecryptedObject;
			const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;

			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			vi.spyOn(service, 'decrypt').mockResolvedValue(decryptedData);
			vi.spyOn(service, 'replaceCredentialContentsForSharee').mockResolvedValue(undefined);
			vi.spyOn(service, 'getCredentialTypeProperties').mockReturnValue([]);
			vi.spyOn(service, 'unredact').mockReturnValue(unredactedData);
			credentialsTester.testCredentials.mockResolvedValue(testResult);

			const payload = {
				id: 'credential-id',
				name: 'Stored Credential',
				type: 'githubApi',
				data: { accessToken: '***' },
			};

			const result = await service.testWithCredentials(ownerUser, payload);

			expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
				payload.id,
				ownerUser,
				['credential:read'],
				{ includeInstanceCredentials: true },
			);
			expect(service.decrypt).toHaveBeenCalledWith(storedCredential, true);
			expect(service.unredact).toHaveBeenCalledWith(payload.data, decryptedData, []);
			expect(credentialsTester.testCredentials).toHaveBeenCalledWith(ownerUser.id, payload.type, {
				...payload,
				data: unredactedData,
			});
			expect(result).toEqual(testResult);
		});
	});

	describe('getManyAndCount', () => {
		it('returns credentials with total count using the same path as getMany', async () => {
			const credentials = [mock<CredentialsEntity>({ id: 'cred-1', shared: [] })];
			credentialsRepository.findManyAndCount.mockResolvedValue([credentials, 1]);
			ownershipService.addOwnedByAndSharedWith.mockImplementation((c: any) => c);

			const result = await service.getManyAndCount(ownerUser, {
				listQueryOptions: { take: 50, skip: 10 },
			});

			expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith({
				take: 50,
				skip: 10,
			});
			expect(result).toEqual({ credentials, count: 1 });
		});
	});

	describe('getMany', () => {
		const regularCredential = {
			id: 'cred-1',
			name: 'Regular Credential',
			type: 'apiKey',
			isGlobal: false,
			shared: [],
		} as Partial<CredentialsEntity> as CredentialsEntity;

		const globalCredential = {
			id: 'cred-2',
			name: 'Global Credential',
			type: 'apiKey',
			isGlobal: true,
			shared: [],
		} as Partial<CredentialsEntity> as CredentialsEntity;

		beforeEach(() => {
			// Mock ownershipService to return credentials as-is
			ownershipService.addOwnedByAndSharedWith.mockImplementation((c: any) => c);
		});

		describe('returnAll = true (owner user)', () => {
			describe('with personal project filter', () => {
				it('should filter by credential:owner role when projectId is for a personal project', async () => {
					// ARRANGE
					const personalProject = { id: 'personal-proj', type: 'personal' } as any;
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					projectService.getProject.mockResolvedValue(personalProject);

					// ACT
					const result = await service.getMany(ownerUser, {
						listQueryOptions: {
							filter: { projectId: 'personal-proj' },
						},
					});

					// ASSERT
					expect(projectService.getProject).toHaveBeenCalledWith('personal-proj');
					expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
						expect.objectContaining({
							filter: expect.objectContaining({
								withRole: 'credential:owner',
								projectId: 'personal-proj',
							}),
						}),
					);
					expect(result).toHaveLength(1);
				});

				it('should not filter by role when projectId is for a team project', async () => {
					// ARRANGE
					const teamProject = { id: 'team-proj', type: 'team' } as any;
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					projectService.getProject.mockResolvedValue(teamProject);

					// ACT
					await service.getMany(ownerUser, {
						listQueryOptions: {
							filter: { projectId: 'team-proj' },
						},
					});

					// ASSERT
					expect(projectService.getProject).toHaveBeenCalledWith('team-proj');
					expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
						expect.objectContaining({
							filter: expect.not.objectContaining({
								withRole: 'credential:owner',
							}),
						}),
					);
				});

				it('should handle getProject throwing an error', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					projectService.getProject.mockRejectedValue(new Error('Project not found'));

					// ACT
					await service.getMany(ownerUser, {
						listQueryOptions: {
							filter: { projectId: 'nonexistent-proj' },
						},
					});

					// ASSERT - Should continue without filtering by role
					expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
						expect.objectContaining({
							filter: expect.not.objectContaining({
								withRole: 'credential:owner',
							}),
						}),
					);
				});
			});

			describe('with includeScopes', () => {
				it('should add scopes to credentials when includeScopes is true', async () => {
					// ARRANGE
					const projectRelations = [{ projectId: 'proj-1', role: 'project:owner' }] as any;
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					projectService.getProjectRelationsForUser.mockResolvedValue(projectRelations);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:read', 'credential:update'] }) as any,
					);

					// ACT
					const result = await service.getMany(ownerUser, {
						includeScopes: true,
					});

					// ASSERT
					expect(projectService.getProjectRelationsForUser).toHaveBeenCalledWith(ownerUser);
					expect(roleService.addScopes).toHaveBeenCalledWith(
						regularCredential,
						ownerUser,
						projectRelations,
					);
					expect(result[0]).toHaveProperty('scopes');
				});

				it('should not add scopes when includeScopes is false', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);

					// ACT
					const result = await service.getMany(ownerUser, {
						includeScopes: false,
					});

					// ASSERT
					expect(projectService.getProjectRelationsForUser).not.toHaveBeenCalled();
					expect(roleService.addScopes).not.toHaveBeenCalled();
					expect(result[0]).not.toHaveProperty('scopes');
				});
			});

			describe('with includeData', () => {
				beforeEach(() => {
					projectService.getProjectRelationsForUser.mockResolvedValue([]);
					vi.spyOn(Credentials.prototype, 'getData').mockResolvedValue({
						apiKey: 'secret-key',
						oauthTokenData: { token: 'secret-token' },
					});
					credentialTypes.getByName.mockReturnValue(credType);
				});

				it('should automatically set includeScopes to true when includeData is true', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:update'] }) as any,
					);

					// ACT
					await service.getMany(ownerUser, {
						includeData: true,
					});

					// ASSERT
					expect(projectService.getProjectRelationsForUser).toHaveBeenCalled();
				});

				it('should include decrypted data when user has credential:update scope', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:update'] }) as any,
					);

					// ACT
					const result = await service.getMany(ownerUser, {
						includeData: true,
					});

					// ASSERT
					expect(result[0]).toHaveProperty('data');
					expect(result[0].data).toBeDefined();
				});

				it('should not include decrypted data when user lacks credential:update scope', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:read'] }) as any,
					);

					// ACT
					const result = await service.getMany(ownerUser, {
						includeData: true,
					});

					// ASSERT
					expect(result[0]).toHaveProperty('data');
					expect(result[0].data).toBeUndefined();
				});

				it('should replace oauthTokenData with true when present', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:update'] }) as any,
					);
					vi.spyOn(Credentials.prototype, 'getData').mockResolvedValue({
						apiKey: 'secret-key',
						oauthTokenData: { token: 'secret-token' },
					});

					// ACT
					const result = await service.getMany(ownerUser, {
						includeData: true,
					});

					// ASSERT
					expect(result[0].data).toBeDefined();
					expect((result[0].data as any)?.oauthTokenData).toBe(true);
				});

				it('should set includeData in listQueryOptions', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:update'] }) as any,
					);

					// ACT
					await service.getMany(ownerUser, {
						includeData: true,
					});

					// ASSERT
					expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
						expect.objectContaining({
							includeData: true,
						}),
					);
				});
			});

			describe('with shared project relations', () => {
				const sharedRelation = { credentialsId: 'cred-1', projectId: 'proj-1' } as any;

				it('should fetch all relations when filtering by shared.projectId', async () => {
					// ARRANGE
					const credWithShared = { ...regularCredential, shared: [] } as any;
					credentialsRepository.findManyAndCount.mockResolvedValue([[credWithShared], 1]);
					sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
						sharedRelation,
					]);

					// ACT
					const result = await service.getMany(ownerUser, {
						listQueryOptions: {
							filter: { shared: { projectId: 'proj-1' } },
						},
					});

					// ASSERT
					expect(sharedCredentialsRepository.getAllRelationsForCredentials).toHaveBeenCalledWith([
						'cred-1',
					]);
					expect(result[0].shared).toHaveLength(1);
				});

				it('should fetch all relations when onlySharedWithMe is true', async () => {
					// ARRANGE
					const credWithShared = { ...regularCredential, shared: [] } as any;
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [credWithShared],
						count: 1,
					});
					sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
						sharedRelation,
					]);

					// ACT
					const result = await service.getMany(ownerUser, {
						onlySharedWithMe: true,
					});

					// ASSERT
					expect(sharedCredentialsRepository.getAllRelationsForCredentials).toHaveBeenCalledWith([
						'cred-1',
					]);
					expect(result[0].shared).toHaveLength(1);
				});

				it('should not fetch all relations when shared.projectId is not present', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);

					// ACT
					await service.getMany(ownerUser, {
						listQueryOptions: {
							filter: { type: 'apiKey' },
						},
					});

					// ASSERT
					expect(sharedCredentialsRepository.getAllRelationsForCredentials).not.toHaveBeenCalled();
				});
			});

			describe('with custom select (non-default select)', () => {
				it('should skip addOwnedByAndSharedWith when select is custom', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);

					// ACT
					await service.getMany(ownerUser, {
						listQueryOptions: {
							select: { id: true, name: true },
						},
					});

					// ASSERT
					expect(ownershipService.addOwnedByAndSharedWith).not.toHaveBeenCalled();
				});

				it('should skip fetching all relations when select is custom', async () => {
					// ARRANGE
					credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);

					// ACT
					await service.getMany(ownerUser, {
						listQueryOptions: {
							select: { id: true, name: true },
							filter: { shared: { projectId: 'proj-1' } },
						},
					});

					// ASSERT
					expect(sharedCredentialsRepository.getAllRelationsForCredentials).not.toHaveBeenCalled();
				});
			});
		});

		describe('returnAll = false (member user)', () => {
			beforeEach(() => {
				// Member users now use the subquery approach
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [regularCredential],
					count: 1,
				});
			});

			it('should fetch credentials using subquery approach', async () => {
				// ARRANGE - mock is set in beforeEach

				// ACT
				await service.getMany(memberUser, {});

				// ASSERT
				expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['credential:read']);
				expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:read']);
				expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalledWith(
					memberUser,
					expect.objectContaining({
						scopes: ['credential:read'],
						projectRoles: expect.any(Array),
						credentialRoles: expect.any(Array),
					}),
					expect.objectContaining({
						filters: { dependency: undefined },
					}),
				);
			});

			describe('with includeScopes', () => {
				it('should add scopes to credentials when includeScopes is true', async () => {
					// ARRANGE
					const projectRelations = [{ projectId: 'proj-1', role: 'project:editor' }] as any;
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [regularCredential],
						count: 1,
					});
					projectService.getProjectRelationsForUser.mockResolvedValue(projectRelations);
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:read'] }) as any,
					);

					// ACT
					const result = await service.getMany(memberUser, {
						includeScopes: true,
					});

					// ASSERT
					expect(projectService.getProjectRelationsForUser).toHaveBeenCalledWith(memberUser);
					expect(roleService.addScopes).toHaveBeenCalledWith(
						regularCredential,
						memberUser,
						projectRelations,
					);
					expect(result[0]).toHaveProperty('scopes');
				});
			});

			describe('with includeData', () => {
				beforeEach(() => {
					projectService.getProjectRelationsForUser.mockResolvedValue([]);
					vi.spyOn(Credentials.prototype, 'getData').mockResolvedValue({
						apiKey: 'secret-key',
					});
					credentialTypes.getByName.mockReturnValue(credType);
				});

				it('should include decrypted data when user has credential:update scope', async () => {
					// ARRANGE
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [regularCredential],
						count: 1,
					});
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:update'] }) as any,
					);

					// ACT
					const result = await service.getMany(memberUser, {
						includeData: true,
					});

					// ASSERT
					expect(result[0]).toHaveProperty('data');
					expect(result[0].data).toBeDefined();
				});

				it('should not include decrypted data when user lacks credential:update scope', async () => {
					// ARRANGE
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [regularCredential],
						count: 1,
					});
					roleService.addScopes.mockImplementation(
						(c) => ({ ...c, scopes: ['credential:read'] }) as any,
					);

					// ACT
					const result = await service.getMany(memberUser, {
						includeData: true,
					});

					// ASSERT
					expect(result[0]).toHaveProperty('data');
					expect(result[0].data).toBeUndefined();
				});
			});

			describe('with shared project relations', () => {
				const sharedRelation = { credentialsId: 'cred-1', projectId: 'proj-1' } as any;

				it('should fetch all relations when filtering by shared.projectId', async () => {
					// ARRANGE
					const credWithShared = { ...regularCredential, shared: [] } as any;
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [credWithShared],
						count: 1,
					});
					sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
						sharedRelation,
					]);

					// ACT
					const result = await service.getMany(memberUser, {
						listQueryOptions: {
							filter: { shared: { projectId: 'proj-1' } },
						},
					});

					// ASSERT
					expect(sharedCredentialsRepository.getAllRelationsForCredentials).toHaveBeenCalledWith([
						'cred-1',
					]);
					expect(result[0].shared).toHaveLength(1);
				});

				it('should fetch all relations when onlySharedWithMe is true', async () => {
					// ARRANGE
					const credWithShared = { ...regularCredential, shared: [] } as any;
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [credWithShared],
						count: 1,
					});
					sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
						sharedRelation,
					]);

					// ACT
					const result = await service.getMany(memberUser, {
						onlySharedWithMe: true,
					});

					// ASSERT
					expect(sharedCredentialsRepository.getAllRelationsForCredentials).toHaveBeenCalledWith([
						'cred-1',
					]);
					expect(result[0].shared).toHaveLength(1);
				});
			});

			describe('with custom select', () => {
				it('should skip addOwnedByAndSharedWith when select is custom', async () => {
					// ARRANGE
					credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
						credentials: [regularCredential],
						count: 1,
					});

					// ACT
					await service.getMany(memberUser, {
						listQueryOptions: {
							select: { id: true, name: true },
						},
					});

					// ASSERT
					expect(ownershipService.addOwnedByAndSharedWith).not.toHaveBeenCalled();
				});
			});
		});

		describe('with onlySharedWithMe', () => {
			it('should use subquery with onlySharedWithMe when onlySharedWithMe is true', async () => {
				// ARRANGE
				const credWithShared = { ...regularCredential, shared: [] } as any;
				const sharedRelation = { credentialsId: 'cred-1', projectId: 'proj-1' } as any;
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [credWithShared],
					count: 1,
				});
				sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
					sharedRelation,
				]);

				// ACT
				await service.getMany(memberUser, {
					onlySharedWithMe: true,
				});

				// ASSERT
				expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalledWith(
					memberUser,
					expect.objectContaining({
						onlySharedWithMe: true,
					}),
					expect.objectContaining({
						filters: { dependency: undefined },
					}),
				);
				expect(sharedCredentialsRepository.getAllRelationsForCredentials).toHaveBeenCalledWith([
					'cred-1',
				]);
			});

			it('should pass filters through when onlySharedWithMe is true', async () => {
				// ARRANGE
				const credWithShared = { ...regularCredential, shared: [] } as any;
				const sharedRelation = { credentialsId: 'cred-1', projectId: 'proj-1' } as any;
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [credWithShared],
					count: 1,
				});
				sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
					sharedRelation,
				]);

				// ACT
				await service.getMany(memberUser, {
					listQueryOptions: {
						filter: { type: 'apiKey' },
					},
					onlySharedWithMe: true,
				});

				// ASSERT
				expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalledWith(
					memberUser,
					expect.objectContaining({
						onlySharedWithMe: true,
					}),
					expect.objectContaining({
						filter: expect.objectContaining({
							type: 'apiKey',
						}),
					}),
				);
			});
		});

		describe('with includeGlobal = true', () => {
			it('should include global credentials for owner users', async () => {
				// ARRANGE
				credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([globalCredential]);

				// ACT
				const result = await service.getMany(ownerUser, {
					includeGlobal: true,
				});

				// ASSERT
				expect(credentialsRepository.findManyAndCount).toHaveBeenCalled();
				expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({
					includeData: false,
					filters: { dependency: undefined },
				});
				expect(result).toHaveLength(2);
				expect(result).toEqual(
					expect.arrayContaining([
						expect.objectContaining({ id: 'cred-1' }),
						expect.objectContaining({ id: 'cred-2' }),
					]),
				);
			});

			it('should include global credentials for member users', async () => {
				// ARRANGE
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [regularCredential],
					count: 1,
				});
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([globalCredential]);

				// ACT
				const result = await service.getMany(memberUser, {
					includeGlobal: true,
				});

				// ASSERT
				expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalled();
				expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({
					includeData: false,
					filters: { dependency: undefined },
				});
				expect(result).toHaveLength(2);
				expect(result).toEqual(
					expect.arrayContaining([
						expect.objectContaining({ id: 'cred-1' }),
						expect.objectContaining({ id: 'cred-2' }),
					]),
				);
			});

			it('should deduplicate credentials when user has project access to global credential', async () => {
				// ARRANGE - User already has access to global credential through project
				const sharedGlobalCred = {
					id: 'cred-2', // Same ID as globalCredential
					name: 'Global Credential',
					type: 'apiKey',
					isGlobal: true,
					shared: [],
				} as Partial<CredentialsEntity> as CredentialsEntity;
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [regularCredential, sharedGlobalCred],
					count: 2,
				});
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([globalCredential]);

				// ACT
				const result = await service.getMany(memberUser, {
					includeGlobal: true,
				});

				// ASSERT
				expect(result).toHaveLength(2);
				// Should have exactly one instance of cred-2, not two
				const credIds = result.map((c) => c.id);
				expect(credIds.filter((id) => id === 'cred-2')).toHaveLength(1);
			});

			it('should include data for global credentials when includeData is true', async () => {
				// ARRANGE
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [regularCredential],
					count: 1,
				});
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([globalCredential]);
				projectService.getProjectRelationsForUser.mockResolvedValue([]);
				roleService.addScopes.mockImplementation(
					(c) => ({ ...c, scopes: ['credential:read'] }) as any,
				);

				// ACT
				await service.getMany(memberUser, {
					includeGlobal: true,
					includeData: true,
				});

				// ASSERT
				expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({
					includeData: true,
					filters: { dependency: undefined },
				});
			});

			it('should forward the credential type filter to the global credentials lookup (owner user)', async () => {
				// ARRANGE
				credentialsRepository.findManyAndCount.mockResolvedValue([[], 0]);
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([]);

				// ACT
				await service.getMany(ownerUser, {
					includeGlobal: true,
					listQueryOptions: { filter: { type: 'slackOAuth2Api' } },
				});

				// ASSERT
				expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({
					includeData: false,
					type: 'slackOAuth2Api',
					filters: { dependency: undefined },
				});
			});

			it('should forward the credential type filter to the global credentials lookup (member user)', async () => {
				// ARRANGE
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [],
					count: 0,
				});
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([]);

				// ACT
				await service.getMany(memberUser, {
					includeGlobal: true,
					listQueryOptions: { filter: { type: 'slackOAuth2Api' } },
				});

				// ASSERT
				expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({
					includeData: false,
					type: 'slackOAuth2Api',
					filters: { dependency: undefined },
				});
			});

			it('should not pass a type filter when the listQueryOptions filter has no type', async () => {
				// ARRANGE
				credentialsRepository.findManyAndCount.mockResolvedValue([[], 0]);
				credentialsRepository.findAllGlobalCredentials.mockResolvedValue([]);

				// ACT
				await service.getMany(ownerUser, { includeGlobal: true });

				// ASSERT
				const lastCall =
					credentialsRepository.findAllGlobalCredentials.mock.calls.at(-1)?.[0] ?? {};
				expect(lastCall).not.toHaveProperty('type');
			});
		});

		describe('with includeGlobal = false', () => {
			it('should exclude global credentials when includeGlobal is false', async () => {
				// ARRANGE
				credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);

				// ACT
				const result = await service.getMany(ownerUser, {
					includeGlobal: false,
				});

				// ASSERT
				expect(credentialsRepository.findManyAndCount).toHaveBeenCalled();
				expect(credentialsRepository.findAllGlobalCredentials).not.toHaveBeenCalled();
				expect(result).toHaveLength(1);
				expect(result[0].id).toBe('cred-1');
			});

			it('should exclude global credentials when includeGlobal is undefined', async () => {
				// ARRANGE
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [regularCredential],
					count: 1,
				});

				// ACT
				const result = await service.getMany(memberUser, {
					includeGlobal: false,
				});

				// ASSERT
				expect(credentialsRepository.findAllGlobalCredentials).not.toHaveBeenCalled();
				expect(result).toHaveLength(1);
			});
		});

		describe('with externalSecretsStore', () => {
			it('should resolve dependency filter and pass it to repository query', async () => {
				// ARRANGE
				ownershipService.addOwnedByAndSharedWith.mockImplementation(
					(credential: any) => credential,
				);
				const credentialWithExternalSecret1 = {
					id: 'cred-with-external-secret-1',
					name: 'Credential with secret 1',
					type: 'apiKey',
					isGlobal: false,
					shared: [],
				} as Partial<CredentialsEntity> as CredentialsEntity;
				credentialDependencyService.resolveExternalSecretsStoreDependencyFilter.mockResolvedValue({
					dependencyType: 'externalSecretProvider',
					dependencyId: '123',
				});
				credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
					credentials: [credentialWithExternalSecret1],
					count: 1,
				});

				// ACT
				const result = await service.getMany(memberUser, {
					filters: { externalSecretsStore: 'myProvider' },
					listQueryOptions: { select: { id: true } } as any,
				});

				// ASSERT
				expect(result).toHaveLength(1);
				expect(result[0].id).toBe(credentialWithExternalSecret1.id);
				expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalledWith(
					memberUser,
					expect.any(Object),
					{
						select: { id: true },
						filters: {
							dependency: {
								dependencyType: 'externalSecretProvider',
								dependencyId: '123',
							},
						},
					},
				);
			});

			it('should return early when no credential dependencies match the external secrets store', async () => {
				credentialDependencyService.resolveExternalSecretsStoreDependencyFilter.mockResolvedValue(
					undefined,
				);

				const result = await service.getMany(memberUser, {
					filters: { externalSecretsStore: 'myProvider' },
				});

				expect(result).toEqual([]);
				expect(credentialsRepository.getManyAndCountWithSharingSubquery).not.toHaveBeenCalled();
			});
		});
	});

	describe('getCredentialsAUserCanUseInAWorkflow', () => {
		const user = mock<User>({ id: 'user-1' });
		const credentialCreatedAt = new Date('2024-01-01T00:00:00.000Z');
		const credentialUpdatedAt = new Date('2024-01-02T00:00:00.000Z');
		const regularCredential = {
			id: 'cred-1',
			name: 'Regular Credential',
			type: 'apiKey',
			isGlobal: false,
			isManaged: false,
			isResolvable: false,
			createdAt: credentialCreatedAt,
			updatedAt: credentialUpdatedAt,
		} as Partial<CredentialsEntity> as CredentialsEntity;
		const globalCredential = {
			id: 'cred-2',
			name: 'Global Credential',
			type: 'oauth2',
			isGlobal: true,
			isManaged: false,
			isResolvable: true,
			createdAt: credentialCreatedAt,
			updatedAt: credentialUpdatedAt,
		} as Partial<CredentialsEntity> as CredentialsEntity;

		const ownerShare = {
			credentialsId: 'cred-1',
			role: 'credential:owner',
			project: { id: 'proj-home', type: 'personal', name: 'Home Project', icon: null },
		} as SharedCredentials;

		const editorShare = {
			credentialsId: 'cred-1',
			role: 'credential:user',
			project: { id: 'proj-shared', type: 'team', name: 'Shared Project', icon: null },
		} as SharedCredentials;

		beforeEach(() => {
			projectService.getProjectRelationsForUser.mockResolvedValue([]);
			roleService.addScopes.mockImplementation(
				(c) => ({ ...c, scopes: ['credential:read'] }) as ListQueryDb.Credentials.WithScopes,
			);
			sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([]);
			connectionStatusProxy.findMyConnections.mockResolvedValue(new Map());
			ownershipService.addOwnedByAndSharedWith.mockImplementation(
				(c: ListQueryDb.Credentials.WithOwnedByAndSharedWith) => {
					c.homeProject = null;
					c.sharedWithProjects = [];
					for (const share of c.shared ?? []) {
						const slim = {
							id: share.project.id,
							type: share.project.type,
							name: share.project.name,
							icon: share.project.icon,
						};
						if (share.role === 'credential:owner') c.homeProject = slim;
						else c.sharedWithProjects.push(slim);
					}
					return c;
				},
			);
		});

		it('should return a payload matching the ICredentialsResponse shape', async () => {
			credentialsFinderService.findCredentialsForUser.mockResolvedValue([regularCredential]);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([regularCredential]);
			sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
				ownerShare,
				editorShare,
			]);

			const result = await service.getCredentialsAUserCanUseInAWorkflow(user, {
				workflowId: 'workflow-1',
			});

			expect(result).toEqual([
				{
					id: 'cred-1',
					name: 'Regular Credential',
					type: 'apiKey',
					createdAt: credentialCreatedAt.toISOString(),
					updatedAt: credentialUpdatedAt.toISOString(),
					scopes: ['credential:read'],
					isManaged: false,
					isGlobal: false,
					isResolvable: false,
					currentUserHasAccess: true,
					homeProject: { id: 'proj-home', type: 'personal', name: 'Home Project', icon: null },
					sharedWithProjects: [
						{ id: 'proj-shared', type: 'team', name: 'Shared Project', icon: null },
					],
				},
			]);
		});

		it('should emit null homeProject and empty sharedWithProjects when no shares exist', async () => {
			credentialsFinderService.findCredentialsForUser.mockResolvedValue([globalCredential]);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([]);

			const result = await service.getCredentialsAUserCanUseInAWorkflow(user, {
				workflowId: 'workflow-1',
			});

			expect(result).toHaveLength(1);
			expect(result[0].homeProject).toBeNull();
			expect(result[0].sharedWithProjects).toEqual([]);
			expect(result[0].currentUserHasAccess).toBe(true);
		});

		it('should skip the shared-relations query when intersection is empty', async () => {
			credentialsFinderService.findCredentialsForUser.mockResolvedValue([]);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([]);

			const result = await service.getCredentialsAUserCanUseInAWorkflow(user, {
				workflowId: 'workflow-1',
			});

			expect(result).toEqual([]);
			expect(sharedCredentialsRepository.getAllRelationsForCredentials).not.toHaveBeenCalled();
		});

		it('should include global credentials for workflows', async () => {
			// ARRANGE
			credentialsFinderService.findCredentialsForUser.mockResolvedValue([
				regularCredential,
				globalCredential,
			]);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([regularCredential]);

			// ACT
			const result = await service.getCredentialsAUserCanUseInAWorkflow(user, {
				workflowId: 'workflow-1',
			});

			// ASSERT
			expect(credentialsFinderService.findCredentialsForUser).toHaveBeenCalledWith(user, [
				'credential:read',
			]);
			expect(result).toHaveLength(2);
			expect(result).toEqual(
				expect.arrayContaining([
					expect.objectContaining({ id: 'cred-1', isGlobal: false }),
					expect.objectContaining({ id: 'cred-2', isGlobal: true }),
				]),
			);
		});

		it('should include global credentials for projects', async () => {
			// ARRANGE
			credentialsFinderService.findCredentialsForUser.mockResolvedValue([
				regularCredential,
				globalCredential,
			]);
			credentialsRepository.findAllCredentialsForProject.mockResolvedValue([regularCredential]);

			// ACT
			const result = await service.getCredentialsAUserCanUseInAWorkflow(user, {
				projectId: 'project-1',
			});

			// ASSERT
			expect(credentialsFinderService.findCredentialsForUser).toHaveBeenCalledWith(user, [
				'credential:read',
			]);
			expect(result).toHaveLength(2);
			expect(result).toEqual(
				expect.arrayContaining([
					expect.objectContaining({ id: 'cred-1' }),
					expect.objectContaining({ id: 'cred-2' }),
				]),
			);
		});

		it('should not duplicate credentials when user has access to global credential through project', async () => {
			// ARRANGE - Both regular and global credentials are in workflow's project
			credentialsFinderService.findCredentialsForUser.mockResolvedValue([
				regularCredential,
				globalCredential,
			]);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([
				regularCredential,
				globalCredential,
			]);

			// ACT
			const result = await service.getCredentialsAUserCanUseInAWorkflow(user, {
				workflowId: 'workflow-1',
			});

			// ASSERT
			expect(result).toHaveLength(2);
			const credIds = result.map((c) => c.id);
			expect(credIds).toEqual(['cred-1', 'cred-2']);
			// Verify no duplicates
			expect(new Set(credIds).size).toBe(2);
		});
	});

	describe('findAllCredentialIdsForWorkflow', () => {
		const workflowId = 'workflow-1';

		it('should return all personal credentials when owner has global read permissions', async () => {
			// ARRANGE
			const personalCred1 = mock<CredentialsEntity>({ id: 'cred-1' });
			const personalCred2 = mock<CredentialsEntity>({ id: 'cred-2' });
			userRepository.findPersonalOwnerForWorkflow.mockResolvedValue(ownerUser);
			credentialsRepository.findAllPersonalCredentials.mockResolvedValue([
				personalCred1,
				personalCred2,
			]);

			// ACT
			const credentials = await service.findAllCredentialIdsForWorkflow(workflowId);

			// ASSERT
			expect(userRepository.findPersonalOwnerForWorkflow).toHaveBeenCalledWith(workflowId);
			expect(credentialsRepository.findAllPersonalCredentials).toHaveBeenCalled();
			expect(credentials).toHaveLength(2);
			expect(credentials).toEqual([personalCred1, personalCred2]);
		});

		it('should return workflow credentials when owner lacks global read permissions', async () => {
			// ARRANGE
			const workflowCred1 = mock<CredentialsEntity>({ id: 'cred-1' });
			const workflowCred2 = mock<CredentialsEntity>({ id: 'cred-2' });
			userRepository.findPersonalOwnerForWorkflow.mockResolvedValue(memberUser);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([
				workflowCred1,
				workflowCred2,
			]);

			// ACT
			const credentials = await service.findAllCredentialIdsForWorkflow(workflowId);

			// ASSERT
			expect(userRepository.findPersonalOwnerForWorkflow).toHaveBeenCalledWith(workflowId);
			expect(credentialsRepository.findAllCredentialsForWorkflow).toHaveBeenCalledWith(workflowId);
			expect(credentials).toHaveLength(2);
			expect(credentials).toEqual([workflowCred1, workflowCred2]);
		});

		it('should return workflow credentials when user is not found', async () => {
			// ARRANGE
			const workflowCred = mock<CredentialsEntity>({ id: 'cred-1' });
			userRepository.findPersonalOwnerForWorkflow.mockResolvedValue(null);
			credentialsRepository.findAllCredentialsForWorkflow.mockResolvedValue([workflowCred]);

			// ACT
			const credentials = await service.findAllCredentialIdsForWorkflow(workflowId);

			// ASSERT
			expect(credentialsRepository.findAllCredentialsForWorkflow).toHaveBeenCalledWith(workflowId);
			expect(credentials).toHaveLength(1);
		});
	});

	describe('findAllCredentialIdsForProject', () => {
		const projectId = 'project-1';

		it('should return all personal credentials when project owner has global read permissions', async () => {
			// ARRANGE
			const personalCred1 = mock<CredentialsEntity>({ id: 'cred-1' });
			const personalCred2 = mock<CredentialsEntity>({ id: 'cred-2' });
			userRepository.findPersonalOwnerForProject.mockResolvedValue(ownerUser);
			credentialsRepository.findAllPersonalCredentials.mockResolvedValue([
				personalCred1,
				personalCred2,
			]);

			// ACT
			const credentials = await service.findAllCredentialIdsForProject(projectId);

			// ASSERT
			expect(userRepository.findPersonalOwnerForProject).toHaveBeenCalledWith(projectId);
			expect(credentialsRepository.findAllPersonalCredentials).toHaveBeenCalled();
			expect(credentials).toHaveLength(2);
			expect(credentials).toEqual([personalCred1, personalCred2]);
		});

		it('should return project credentials when owner lacks global read permissions', async () => {
			// ARRANGE
			const projectCred1 = mock<CredentialsEntity>({ id: 'cred-1' });
			const projectCred2 = mock<CredentialsEntity>({ id: 'cred-2' });
			userRepository.findPersonalOwnerForProject.mockResolvedValue(memberUser);
			credentialsRepository.findAllCredentialsForProject.mockResolvedValue([
				projectCred1,
				projectCred2,
			]);

			// ACT
			const credentials = await service.findAllCredentialIdsForProject(projectId);

			// ASSERT
			expect(userRepository.findPersonalOwnerForProject).toHaveBeenCalledWith(projectId);
			expect(credentialsRepository.findAllCredentialsForProject).toHaveBeenCalledWith(projectId);
			expect(credentials).toHaveLength(2);
			expect(credentials).toEqual([projectCred1, projectCred2]);
		});

		it('should return project credentials when user is not found', async () => {
			// ARRANGE
			const projectCred = mock<CredentialsEntity>({ id: 'cred-1' });
			userRepository.findPersonalOwnerForProject.mockResolvedValue(null);
			credentialsRepository.findAllCredentialsForProject.mockResolvedValue([projectCred]);

			// ACT
			const credentials = await service.findAllCredentialIdsForProject(projectId);

			// ASSERT
			expect(credentialsRepository.findAllCredentialsForProject).toHaveBeenCalledWith(projectId);
			expect(credentials).toHaveLength(1);
		});
	});

	describe('createUnmanagedCredential', () => {
		const credentialData = {
			name: 'Test Credential',
			type: 'apiKey',
			data: { apiKey: 'test-key' },
			projectId: 'project-1',
		};

		beforeEach(() => {
			// Mock the save chain
			credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);
			roleService.addScopes.mockImplementation(
				(c) =>
					({
						...c,
						scopes: ['credential:read', 'credential:update'],
					}) as any,
			);
			roleService.combineResourceScopes.mockReturnValue([
				'credential:read',
				'credential:update',
			] as any);
			sharedCredentialsRepository.findOne.mockResolvedValue({ role: 'credential:owner' } as any);
			sharedCredentialsRepository.create.mockImplementation((data) => data as any);
			sharedCredentialsRepository.find.mockResolvedValue([]);
			externalHooks.run.mockResolvedValue();
			projectService.getProjectWithScope.mockResolvedValue({
				id: 'project-1',
			} as any);
			projectService.getProjectRelationsForUser.mockResolvedValue([]);
			credentialsHelper.getCredentialsProperties.mockReturnValue([]);
			vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
		});

		it('should allow owner to create global credential', async () => {
			// ARRANGE
			const payload = { ...credentialData, isGlobal: true };
			const savedEntities: any[] = [];
			let credentialEntityInput: any;
			credentialsRepository.create.mockImplementation((data) => {
				credentialEntityInput = data;
				return data as any;
			});
			mockTransactionManager({ onSave: (entity) => savedEntities.push(entity) });

			// ACT
			await service.createUnmanagedCredential(payload, ownerUser);

			// ASSERT
			expect(credentialEntityInput.isGlobal).toBe(true);
			// First entity saved should be the credential with isGlobal
			expect(savedEntities[0]).toMatchObject({
				isGlobal: true,
			});
		});

		it('should throw error when non-owner tries to create global credential', async () => {
			// ARRANGE
			const payload = { ...credentialData, isGlobal: true };

			// ACT & ASSERT
			await expect(service.createUnmanagedCredential(payload, memberUser)).rejects.toThrow(
				'You do not have permission to create globally shared credentials',
			);
		});

		it('should create non-global credential by default', async () => {
			// ARRANGE
			const payload = { ...credentialData }; // no isGlobal field
			let savedCredential: any;
			mockTransactionManager({ onSave: (entity) => (savedCredential = entity) });

			// ACT
			await service.createUnmanagedCredential(payload, ownerUser);

			// ASSERT
			expect(savedCredential.isGlobal).toBeUndefined();
		});

		it('should allow member to create non-global credential', async () => {
			// ARRANGE
			const payload = { ...credentialData, isGlobal: false };
			let savedCredential: any;
			mockTransactionManager({ onSave: (entity) => (savedCredential = entity) });

			// ACT
			await service.createUnmanagedCredential(payload, memberUser);

			// ASSERT
			expect(savedCredential.isGlobal).toBeUndefined();
		});

		it('should allow creating credential when required field has default value and is not provided', async () => {
			// ARRANGE
			// Mock credential properties with a required field that has a default value
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'Host',
					name: 'host',
					type: 'string',
					required: true,
					default: 'https://generativelanguage.googleapis.com',
				},
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: '',
				},
			] as any);

			// Payload without 'host' field - should use default value
			const payload = {
				name: 'Google Gemini Credential',
				type: 'googlePalmApi',
				data: { apiKey: 'test-api-key' }, // host is not provided
				projectId: 'project-1',
			};

			// @ts-expect-error - Mocking manager for testing
			credentialsRepository.manager = {
				transaction: vi.fn().mockImplementation(async (callback) => {
					const mockManager = {
						save: vi.fn().mockImplementation(async (entity) => {
							return { ...entity, id: 'new-cred-id' };
						}),
					};
					return await callback(mockManager);
				}),
			};

			// ACT & ASSERT
			await expect(service.createUnmanagedCredential(payload, ownerUser)).resolves.toBeDefined();
		});

		it('should throw error when required field without default value is not provided', async () => {
			// ARRANGE
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: '', // Empty default means no valid default
					displayOptions: {},
				},
			] as any);

			const payload = {
				name: 'Test Credential',
				type: 'apiKey',
				data: {}, // apiKey is missing
				projectId: 'project-1',
			};

			// ACT & ASSERT
			await expect(service.createUnmanagedCredential(payload, ownerUser)).rejects.toThrow(
				'The field "apiKey" is mandatory for credentials of type "apiKey"',
			);
		});

		describe('external secrets', () => {
			it('should prevent use of external secret expression when required permission is missing', async () => {
				vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.myApiKey }}',
						url: 'https://api.example.com',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};

				await expect(service.createUnmanagedCredential(payload, memberUser)).rejects.toThrow(
					'Lacking permissions to reference external secrets in credentials',
				);
			});

			it('should list all unavailable external secret providers in error message', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.outsideProvider.bar }}',
						anotherApiKey: '={{ $secrets.anotherOutsideProvider.bar }}',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};
				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					false,
				);

				await expect(service.createUnmanagedCredential(payload, ownerUser)).rejects.toThrow(
					'The secret providers "outsideProvider" (used in "apiKey"), "anotherOutsideProvider" (used in "anotherApiKey") do not exist in this project',
				);
			});

			it('should throw BadRequestError when referencing external secret provider that is not available in current project', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.outsideProvider.bar }}',
						url: 'https://api.example.com',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};
				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					false,
				);

				await expect(service.createUnmanagedCredential(payload, ownerUser)).rejects.toThrow(
					'The secret provider "outsideProvider" used in "apiKey" does not exist in this project',
				);
			});

			it('should create credential that references external secret provider that is shared with current project', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.validProvider.bar }}',
						url: 'https://api.example.com',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};

				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					true,
				);

				credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);
				mockTransactionManager();

				await service.createUnmanagedCredential(payload, ownerUser);
			});

			it('should create credential when external secret uses mixed bracket notation', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: "={{ $secrets.validProvider['bar'] }}",
						url: 'https://api.example.com',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};

				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					true,
				);

				credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);
				mockTransactionManager();

				await expect(service.createUnmanagedCredential(payload, ownerUser)).resolves.toBeDefined();
				expect(
					externalSecretsProviderAccessCheckService.isProviderAvailableInProject,
				).toHaveBeenCalledWith('validProvider', 'WHwt9vP3keCUvmB5');
			});

			it('should reject project-scoped external secrets in instance credentials', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.validProvider.bar }}',
					},
					usageScope: 'instance' as const,
				};

				await expect(service.createUnmanagedCredential(payload, ownerUser)).rejects.toThrow(
					'Provider connections cannot reference project-scoped external secrets',
				);
				expect(projectRepository.getPersonalProjectForUserOrFail).not.toHaveBeenCalled();
			});

			it('should reject instance credential creation without the global scope', async () => {
				await expect(
					service.createUnmanagedCredential(
						{
							name: 'Instance Credential',
							type: 'apiKey',
							data: {},
							usageScope: 'instance',
						},
						memberUser,
					),
				).rejects.toThrow('You do not have permission to create provider connections');
			});

			it('should reject contradictory instance credential flags', async () => {
				await expect(
					service.createUnmanagedCredential(
						{
							name: 'Instance Credential',
							type: 'apiKey',
							data: {},
							usageScope: 'instance',
							isGlobal: true,
						},
						ownerUser,
					),
				).rejects.toThrow('Provider connections cannot be globally shared');
			});
		});
	});

	describe('createStubCredential', () => {
		const stubOpts = {
			name: 'Missing GitHub',
			type: 'githubApi',
			projectId: 'project-1',
		};

		beforeEach(() => {
			credentialsRepository.create.mockImplementation((data) => ({ ...data }) as CredentialsEntity);
			sharedCredentialsRepository.create.mockImplementation((data) => data as SharedCredentials);
			externalHooks.run.mockResolvedValue();
			projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
		});

		it('creates an empty stub credential without field validation', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'Access Token',
					name: 'accessToken',
					type: 'string',
					required: true,
					default: '',
					displayOptions: {},
				},
			] as never);
			const checkCredentialDataSpy = vi.spyOn(service, 'checkCredentialData');

			let credentialEntityInput: unknown;
			const savedEntities: unknown[] = [];
			credentialsRepository.create.mockImplementation((data) => {
				credentialEntityInput = data;
				return data as CredentialsEntity;
			});
			mockTransactionManager({
				credentialId: 'stub-cred-id',
				onSave: (entity) => {
					savedEntities.push(entity);
				},
			});

			const result = await service.createStubCredential(stubOpts, ownerUser);

			expect(checkCredentialDataSpy).not.toHaveBeenCalled();
			expect(credentialsHelper.getCredentialsProperties).not.toHaveBeenCalled();
			expect(credentialEntityInput).toMatchObject({
				name: 'Missing GitHub',
				type: 'githubApi',
				isManaged: false,
				isResolvable: false,
			});
			expect(savedEntities[0]).toMatchObject({
				isManaged: false,
				isResolvable: false,
			});
			expect(projectService.getProjectWithScope).toHaveBeenCalledWith(
				ownerUser,
				'project-1',
				['credential:create'],
				expect.anything(),
			);
			expect(result).toMatchObject({
				id: 'stub-cred-id',
				name: 'Missing GitHub',
				type: 'githubApi',
			});
		});

		it('mints a fresh id when none is supplied', async () => {
			const createEncryptedDataSpy = vi.spyOn(service, 'createEncryptedData');
			mockTransactionManager({ credentialId: 'stub-cred-id' });

			await service.createStubCredential(stubOpts, ownerUser);

			expect(createEncryptedDataSpy).toHaveBeenCalledWith(expect.objectContaining({ id: null }));
		});

		it('reuses a supplied id so id-based matching resolves the stub on a later import', async () => {
			const createEncryptedDataSpy = vi.spyOn(service, 'createEncryptedData');
			credentialsRepository.existsBy.mockResolvedValue(false);
			mockTransactionManager({ credentialId: 'cred-source' });

			await service.createStubCredential({ ...stubOpts, id: 'cred-source' }, ownerUser);

			expect(createEncryptedDataSpy).toHaveBeenCalledWith(
				expect.objectContaining({ id: 'cred-source' }),
			);
		});

		it('rejects a supplied id that already belongs to another credential (no upsert)', async () => {
			const createEncryptedDataSpy = vi.spyOn(service, 'createEncryptedData');
			credentialsRepository.existsBy.mockResolvedValue(true);

			await expect(
				service.createStubCredential({ ...stubOpts, id: 'cred-existing' }, ownerUser),
			).rejects.toThrow(BadRequestError);

			expect(credentialsRepository.existsBy).toHaveBeenCalledWith({ id: 'cred-existing' });
			expect(createEncryptedDataSpy).not.toHaveBeenCalled();
		});

		it('rejects when user lacks credential:create on the target project', async () => {
			projectService.getProjectWithScope.mockResolvedValue(null);
			// @ts-expect-error - Mocking manager for testing
			credentialsRepository.manager = {
				transaction: vi.fn().mockImplementation(async (callback) => {
					const mockManager = {
						existsBy: vi.fn().mockResolvedValue(true),
						save: vi.fn(),
					};
					return await callback(mockManager);
				}),
			};

			await expect(service.createStubCredential(stubOpts, memberUser)).rejects.toThrow(
				"You don't have the permissions to save the credential in this project.",
			);
		});
	});

	describe('createManagedCredential', () => {
		const credentialData = {
			name: 'Managed Credential',
			type: 'oauth2',
			oauthProvider: 'google',
			projectId: 'project-1',
			data: { someData: 'value' },
		};

		beforeEach(async () => {
			// Mock the save chain
			roleService.addScopes.mockImplementation(
				(c) =>
					({
						...c,
						scopes: ['credential:read', 'credential:update'],
					}) as any,
			);
			roleService.combineResourceScopes.mockReturnValue([
				'credential:read',
				'credential:update',
			] as any);
			sharedCredentialsRepository.findOne.mockResolvedValue({ role: 'credential:owner' } as any);
			sharedCredentialsRepository.create.mockImplementation((data) => data as any);
			sharedCredentialsRepository.find.mockResolvedValue([]);
			externalHooks.run.mockResolvedValue();
			projectService.getProjectWithScope.mockResolvedValue({
				id: 'project-1',
			} as any);
			projectService.getProjectRelationsForUser.mockResolvedValue([]);
		});

		it('should throw BadRequestError when credential is missing required properties', async () => {
			// ARRANGE
			const payload = { ...credentialData };
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'required prop',
					name: 'requiredProp',
					type: 'string',
					required: true,
					default: null,
					displayOptions: {},
				},
			]);

			// ACT
			await expect(service.createManagedCredential(payload, ownerUser)).rejects.toThrow(
				'The field "requiredProp" is mandatory for credentials of type "oauth2"',
			);
		});

		it('should create managed credential when all required properties are provided', async () => {
			// ARRANGE
			const payload = {
				...credentialData,
				data: {
					requiredProp: 'some-value',
				},
			};
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'required prop',
					name: 'requiredProp',
					type: 'string',
					required: true,
					default: null,
				},
			]);
			credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);
			mockTransactionManager({ credentialId: 'new-managed-cred-id' });

			// ACT
			const result = await service.createManagedCredential(payload, ownerUser);

			// ASSERT
			expect(result).toHaveProperty('id', 'new-managed-cred-id');
			expect(result).toHaveProperty('name', 'Managed Credential');
		});
	});

	describe('prepareUpdateData', () => {
		describe('external secrets', () => {
			beforeEach(() => {
				vi.spyOn(service, 'decrypt').mockResolvedValue({});
				vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
			});

			it('should list all unavailable external secret providers in error message', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.outsideProvider.bar }}',
						anotherApiKey: '={{ $secrets.anotherOutsideProvider.bar }}',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};
				const existingCredential = mockExistingCredential({
					name: 'Test Credential',
					type: 'apiKey',
					data: {},
					projectId: 'WHwt9vP3keCUvmB5',
				});
				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					false,
				);

				await expect(
					service.prepareUpdateData(ownerUser, payload, existingCredential),
				).rejects.toThrow(
					'The secret providers "outsideProvider" (used in "apiKey"), "anotherOutsideProvider" (used in "anotherApiKey") do not exist in this project',
				);
				expect(
					externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mock.calls,
				).toEqual([
					['outsideProvider', 'WHwt9vP3keCUvmB5'],
					['anotherOutsideProvider', 'WHwt9vP3keCUvmB5'],
				]);
			});

			it('should throw BadRequestError when referencing external secret provider that is not available in current project', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.outsideProvider.bar }}',
						url: 'https://api.example.com',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};
				const existingCredential = mockExistingCredential({
					name: 'Test Credential',
					type: 'apiKey',
					data: {},
					projectId: 'WHwt9vP3keCUvmB5',
				});
				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					false,
				);

				await expect(
					service.prepareUpdateData(ownerUser, payload, existingCredential),
				).rejects.toThrow(
					'The secret provider "outsideProvider" used in "apiKey" does not exist in this project',
				);
				expect(
					externalSecretsProviderAccessCheckService.isProviderAvailableInProject,
				).toHaveBeenCalledWith('outsideProvider', 'WHwt9vP3keCUvmB5');
			});

			it('should create credential that references external secret provider that is shared with current project', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.validProvider.bar }}',
						url: 'https://api.example.com',
					},
					projectId: 'WHwt9vP3keCUvmB5',
				};
				const existingCredential = mockExistingCredential({
					name: 'Test Credential',
					type: 'apiKey',
					data: {},
					projectId: 'WHwt9vP3keCUvmB5',
				});

				externalSecretsProviderAccessCheckService.isProviderAvailableInProject.mockResolvedValue(
					true,
				);

				credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);
				mockTransactionManager();

				await service.prepareUpdateData(ownerUser, payload, existingCredential);
			});

			it('should reject project-scoped external secrets when updating an instance credential', async () => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				const payload = {
					name: 'Test Credential',
					type: 'apiKey',
					data: {
						apiKey: '={{ $secrets.validProvider.bar }}',
					},
				};
				const existingCredential = mockExistingCredential({
					id: 'instance-credential-id',
					name: 'Test Credential',
					type: 'apiKey',
					data: { apiKey: 'old-key' },
					usageScope: 'instance',
					shared: [],
				});

				credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);

				await expect(
					service.prepareUpdateData(ownerUser, payload, existingCredential),
				).rejects.toThrow('Provider connections cannot reference project-scoped external secrets');
				expect(projectRepository.getPersonalProjectForUserOrFail).not.toHaveBeenCalled();
			});
		});

		describe('dataMerge', () => {
			beforeEach(() => {
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				credentialsRepository.create.mockImplementation((data) => ({ ...data }) as never);
				vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
			});

			it('keeps incoming data as-is when dataMerge is replace', async () => {
				vi.spyOn(service, 'decrypt').mockResolvedValue({
					apiKey: 'stored-secret',
					oauthTokenData: { access_token: 'token' },
				});
				const unredactSpy = vi.spyOn(service, 'unredact');
				const existingCredential = mockExistingCredential({
					name: 'Test Credential',
					type: 'apiKey',
					data: {},
				});

				const prepared = await service.prepareUpdateData(
					ownerUser,
					{ name: 'Test Credential', type: 'apiKey', data: { apiKey: 'new-secret' } },
					existingCredential,
					{ dataMerge: 'replace' },
				);

				expect(unredactSpy).not.toHaveBeenCalled();
				expect(prepared.data).toEqual({ apiKey: 'new-secret' });
			});

			it('merges stored data then unredacts when dataMerge is partial', async () => {
				vi.spyOn(service, 'decrypt').mockResolvedValue({
					apiKey: 'stored-secret',
					other: 'keep-me',
				});
				const unredactSpy = vi.spyOn(service, 'unredact').mockImplementation((data) => data);
				const existingCredential = mockExistingCredential({
					name: 'Test Credential',
					type: 'apiKey',
					data: {},
				});

				await service.prepareUpdateData(
					ownerUser,
					{ name: 'Test Credential', type: 'apiKey', data: { apiKey: '***' } },
					existingCredential,
					{ dataMerge: 'partial' },
				);

				expect(unredactSpy).toHaveBeenCalledWith(
					{ apiKey: '***', other: 'keep-me' },
					{ apiKey: 'stored-secret', other: 'keep-me' },
					[],
				);
			});
		});

		describe('assigned instance credentials', () => {
			const existingCredential = mockExistingCredential({
				id: 'instance-credential-id',
				name: 'AI Assistant sandbox',
				type: 'httpHeaderAuth',
				data: {},
				usageScope: 'instance',
				shared: [],
			});
			const payload = {
				name: 'AI Assistant sandbox',
				type: 'httpHeaderAuth',
				data: { name: 'Authorization', value: 'secret' },
			};

			beforeEach(() => {
				vi.spyOn(service, 'decrypt').mockResolvedValue({});
				credentialsHelper.getCredentialsProperties.mockReturnValue([]);
				credentialsRepository.create.mockImplementation((data) => ({ ...data }) as never);
			});

			it('runs the registered per-use validation with the updated data', async () => {
				const validatePrimary = vi.fn();
				const validateSecondary = vi.fn();
				const operationContext = {};
				instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
					'instance-ai:sandbox:n8n',
					'other-feature:sandbox',
				]);
				instanceCredentialUseRegistry.get.mockImplementation((credentialUseId) => ({
					id: credentialUseId,
					credentialTypes: ['httpHeaderAuth'],
					validate:
						credentialUseId === 'instance-ai:sandbox:n8n' ? validatePrimary : validateSecondary,
				}));

				await service.prepareUpdateData(ownerUser, payload, existingCredential, {
					operationContext,
				});

				const expectedCredential = {
					type: 'httpHeaderAuth',
					data: { name: 'Authorization', value: 'secret' },
				};
				expect(validatePrimary).toHaveBeenCalledWith(expectedCredential);
				expect(validateSecondary).toHaveBeenCalledWith(expectedCredential);
				expect(instanceCredentialAssignmentRepository.findCredentialUseIds).toHaveBeenCalledWith(
					existingCredential.id,
					operationContext,
				);
			});

			it('propagates per-use validation failures', async () => {
				instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
					'instance-ai:sandbox:n8n',
				]);
				instanceCredentialUseRegistry.get.mockReturnValue({
					id: 'instance-ai:sandbox:n8n',
					credentialTypes: ['httpHeaderAuth'],
					validate: () => {
						throw new Error('The credential\'s header name must be "x-api-key"');
					},
				});

				await expect(
					service.prepareUpdateData(ownerUser, payload, existingCredential),
				).rejects.toThrow(/x-api-key/);
			});

			it('skips per-use validation for unassigned instance credentials', async () => {
				await expect(
					service.prepareUpdateData(ownerUser, payload, existingCredential),
				).resolves.toBeDefined();
				expect(instanceCredentialUseRegistry.get).not.toHaveBeenCalled();
			});

			it('fails closed when an assigned use is no longer registered', async () => {
				instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
					'instance-ai:sandbox:n8n',
				]);
				instanceCredentialUseRegistry.get.mockImplementation(() => {
					throw new Error('Unknown instance credential use "instance-ai:sandbox:n8n"');
				});

				await expect(
					service.prepareUpdateData(ownerUser, payload, existingCredential),
				).rejects.toThrow('Unknown instance credential use');
			});
		});
	});

	describe('unredact (httpCustomAuth JSON shape merging)', () => {
		const s = (v: unknown) => JSON.stringify(v);
		const p = (v: unknown) => jsonParse(String(v));
		const jsonLeafProps = [
			{ name: 'json', type: 'json', typeOptions: { redactJsonLeaves: true } },
		] as unknown as INodeProperties[];

		it('should restore all *** leaves from saved JSON when all are redacted', () => {
			const saved = { token: 'real-token', key: 'real-key' };
			const result = service.unredact(
				{ json: s({ token: '***', key: '***' }) },
				{ json: s(saved) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual(saved);
		});

		it('should keep new value for changed leaf and restore *** leaf from saved', () => {
			const result = service.unredact(
				{ json: s({ token: 'new-token', key: '***' }) },
				{ json: s({ token: 'old-token', key: 'old-key' }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ token: 'new-token', key: 'old-key' });
		});

		it('should merge *** leaves recursively in nested objects', () => {
			const result = service.unredact(
				{ json: s({ headers: { Authorization: 'Bearer new', 'X-Api-Key': '***' } }) },
				{ json: s({ headers: { Authorization: 'Bearer secret', 'X-Api-Key': 'key123' } }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({
				headers: { Authorization: 'Bearer new', 'X-Api-Key': 'key123' },
			});
		});

		it('should merge *** elements in arrays by position', () => {
			const result = service.unredact(
				{ json: s({ scopes: ['***', 'new-write', '***'] }) },
				{ json: s({ scopes: ['read', 'write', 'admin'] }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ scopes: ['read', 'new-write', 'admin'] });
		});

		it('should save all leaves as-is when none are redacted', () => {
			const result = service.unredact(
				{ json: s({ token: 'completely-new', extra: 'new-field' }) },
				{ json: s({ token: 'old-token' }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ token: 'completely-new', extra: 'new-field' });
		});

		it('should leave json unchanged when it is not valid JSON', () => {
			const result = service.unredact(
				{ json: 'not-valid-json' },
				{ json: s({ token: 'saved' }) },
				jsonLeafProps,
			);
			expect(result.json).toEqual('not-valid-json');
		});

		it('should not apply JSON leaf merge when no props are given', () => {
			const result = service.unredact(
				{ json: s({ token: '***' }) },
				{ json: s({ token: 'saved' }) },
			);
			// *** is not a standard sentinel so it stays as-is
			expect(p(result.json)).toEqual({ token: '***' });
		});

		it('should not apply JSON leaf merge when props have no redactJsonLeaves field', () => {
			const result = service.unredact(
				{ json: s({ token: '***' }) },
				{ json: s({ token: 'saved' }) },
				[{ name: 'json', type: 'json' }] as INodeProperties[],
			);
			expect(p(result.json)).toEqual({ token: '***' });
		});

		it('should restore *** leaves in a deeply nested object', () => {
			const result = service.unredact(
				{ json: s({ a: { b: { c: { d: '***' } } } }) },
				{ json: s({ a: { b: { c: { d: 'deep-secret' } } } }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ a: { b: { c: { d: 'deep-secret' } } } });
		});

		it('should restore *** leaves inside arrays nested inside deep objects', () => {
			const result = service.unredact(
				{
					json: s({
						auth: {
							headers: [
								{ name: '***', value: '***' },
								{ name: '***', value: 'new-value' },
							],
						},
					}),
				},
				{
					json: s({
						auth: {
							headers: [
								{ name: 'Authorization', value: 'Bearer secret' },
								{ name: 'X-Api-Key', value: 'key123' },
							],
						},
					}),
				},
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({
				auth: {
					headers: [
						{ name: 'Authorization', value: 'Bearer secret' },
						{ name: 'X-Api-Key', value: 'new-value' },
					],
				},
			});
		});

		it('should drop a key the user removed from the JSON', () => {
			// When the user removes a key, it simply won't be in newVal — it is not re-added from saved
			const result = service.unredact(
				{ json: s({ token: '***' }) },
				{ json: s({ token: 'secret', removedKey: 'data' }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ token: 'secret' });
			expect(p(result.json)).not.toHaveProperty('removedKey');
		});

		it('should save a new key the user added to the JSON', () => {
			const result = service.unredact(
				{ json: s({ token: '***', newHeader: 'X-Custom-Value' }) },
				{ json: s({ token: 'secret' }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ token: 'secret', newHeader: 'X-Custom-Value' });
		});

		it('should use the new scalar value when the user changes a key from object to scalar', () => {
			// Type change: saved was an object, user replaced it with a plain string — new value wins
			const result = service.unredact(
				{ json: s({ auth: 'new-plain-string' }) },
				{ json: s({ auth: { token: 'secret', key: 'abc' } }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ auth: 'new-plain-string' });
		});

		it('should preserve the new array when the user changes a key from object to array', () => {
			// Type mismatch (array vs object): user made a structural change — new value is preserved as-is
			const result = service.unredact(
				{ json: s({ auth: ['new-item-1', 'new-item-2'] }) },
				{ json: s({ auth: { token: 'secret' } }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ auth: ['new-item-1', 'new-item-2'] });
		});

		it('should preserve the new object when the user changes a key from array to object (even with *** sentinels)', () => {
			// Type mismatch (object vs array): structural change — new object is preserved as-is, *** unresolved
			const result = service.unredact(
				{ json: s({ data: { key: '***' } }) },
				{ json: s({ data: ['secret1', 'secret2'] }) },
				jsonLeafProps,
			);
			expect(p(result.json)).toEqual({ data: { key: '***' } });
		});
	});

	describe('unredact (field-level sentinel restore)', () => {
		const saved = {
			privateKey: '-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----',
		};

		it('should restore the saved value for an exact blanking sentinel', () => {
			const result = service.unredact({ privateKey: CREDENTIAL_BLANKING_VALUE }, saved);
			expect(result.privateKey).toBe(saved.privateKey);
		});

		it('should restore the saved value when the sentinel was switched to expression mode', () => {
			// Toggling a redacted field to expression mode prepends `=`; the saved
			// key must still be restored instead of persisting the sentinel.
			const result = service.unredact({ privateKey: `=${CREDENTIAL_BLANKING_VALUE}` }, saved);
			expect(result.privateKey).toBe(saved.privateKey);
		});

		it('should restore the saved value for an expression-prefixed empty sentinel', () => {
			const result = service.unredact({ privateKey: `=${CREDENTIAL_EMPTY_VALUE}` }, saved);
			expect(result.privateKey).toBe(saved.privateKey);
		});

		it('should keep a genuine new value that is not a sentinel', () => {
			const result = service.unredact({ privateKey: '={{ $secrets.key }}' }, saved);
			expect(result.privateKey).toBe('={{ $secrets.key }}');
		});
	});

	describe('checkCredentialData', () => {
		const testProjectId = 'test-project-id';

		beforeEach(() => {
			vi.clearAllMocks();
			vi.spyOn(validation, 'validateExternalSecretsPermissions').mockResolvedValue();
		});

		it('should pass when all required fields are provided', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: '',
					displayOptions: {},
				},
				{
					displayName: 'Domain',
					name: 'domain',
					type: 'string',
					required: true,
					default: '',
					displayOptions: {},
				},
			]);

			const data = {
				apiKey: 'test-key',
				domain: 'example.com',
			};

			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).resolves.not.toThrow();
		});

		it('should pass when required field with valid default is not provided', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'Host',
					name: 'host',
					type: 'string',
					required: true,
					default: 'https://api.example.com',
					displayOptions: {},
				},
			]);

			const data = {};

			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).resolves.not.toThrow();
		});

		it('should pass when credential has no required fields', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'Optional Field',
					name: 'optionalField',
					type: 'string',
					required: false,
					default: '',
				},
			]);

			const data = {};

			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).resolves.not.toThrow();
		});

		it('should call validateExternalSecretsPermissions', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([]);
			const validateSpy = vi
				.spyOn(validation, 'validateExternalSecretsPermissions')
				.mockResolvedValue();

			const data = { apiKey: 'test-key' };

			await service.checkCredentialData('apiCredential', data, ownerUser, testProjectId);

			expect(validateSpy).toHaveBeenCalledWith({
				user: ownerUser,
				projectId: testProjectId,
				dataToSave: data,
			});
		});

		it('should throw BadRequestError when required field is missing and has no default', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: undefined,
					displayOptions: {},
				},
			]);

			const data = {}; // apiKey is missing

			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).rejects.toThrow('The field "apiKey" is mandatory for credentials of type "apiCredential"');
		});

		it('should throw when required field value is undefined', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: '',
					displayOptions: {},
				},
			]);

			const data = { apiKey: undefined } as unknown as ICredentialDataDecryptedObject;

			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).rejects.toThrow('The field "apiKey" is mandatory for credentials of type "apiCredential"');
		});

		it('should throw when required field value is empty string', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: '',
					displayOptions: {},
				},
			]);

			const data = { apiKey: '' };

			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).rejects.toThrow('The field "apiKey" is mandatory for credentials of type "apiCredential"');
		});

		it('should skip validation when required field has displayOptions undefined', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: undefined,
					displayOptions: undefined,
				},
			]);

			const data = {}; // apiKey is missing

			// Should not throw because displayOptions is undefined, so validation is skipped
			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).resolves.not.toThrow();
		});

		it('should skip validation when required field is hidden by displayOptions', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: undefined,
					displayOptions: {
						show: {
							authType: ['oauth2'],
						},
					},
				},
			]);

			const data = { authType: 'apiKey' }; // apiKey is missing and field is hidden

			// Should not throw because displayParameter will return false (field is hidden)
			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).resolves.not.toThrow();
		});

		it('should validate when required field is shown by displayOptions', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: undefined,
					displayOptions: {
						show: {
							authType: ['oauth2'],
						},
					},
				},
			]);

			const data = { authType: 'oauth2' }; // Field is shown but apiKey is missing

			// Should throw because field is shown but value is missing
			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).rejects.toThrow('The field "apiKey" is mandatory for credentials of type "apiCredential"');
		});

		it('should validate when displayOptions is explicitly null (edge case)', async () => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([
				{
					displayName: 'API Key',
					name: 'apiKey',
					type: 'string',
					required: true,
					default: undefined,
					// @ts-expect-error - Testing edge case where displayOptions is explicitly null
					displayOptions: null,
				},
			]);

			const data = {}; // apiKey is missing

			// null !== undefined, so the check passes and displayParameter is called
			// displayParameter treats null as falsy and returns true, so validation proceeds
			await expect(
				service.checkCredentialData('apiCredential', data, ownerUser, testProjectId),
			).rejects.toThrow('The field "apiKey" is mandatory for credentials of type "apiCredential"');
		});
	});

	describe('validateOAuthCredentialUrls', () => {
		const testProjectId = 'test-project-id';

		beforeEach(() => {
			credentialsHelper.getCredentialsProperties.mockReturnValue([]);
			vi.spyOn(validation, 'validateExternalSecretsPermissions').mockResolvedValue();
		});

		it('should reject an invalid OAuth2 URL', async () => {
			credentialTypes.getParentTypes.mockReturnValue(['oAuth2Api']);
			const data = { authUrl: 'javascript:alert(1)' };

			await expect(
				service.checkCredentialData('myOAuth2Cred', data, ownerUser, testProjectId),
			).rejects.toThrow('OAuth url must use HTTP or HTTPS protocol');
		});

		it('should accept a valid OAuth2 URL', async () => {
			credentialTypes.getParentTypes.mockReturnValue(['oAuth2Api']);
			const data = { authUrl: 'https://example.com/oauth/authorize' };

			await expect(
				service.checkCredentialData('myOAuth2Cred', data, ownerUser, testProjectId),
			).resolves.toBeUndefined();
		});

		it('should skip validation for expression-prefixed OAuth2 URLs', async () => {
			credentialTypes.getParentTypes.mockReturnValue(['oAuth2Api']);
			const data = {
				authUrl: '=https://example.com/oauth/authorize',
				accessTokenUrl: '={{ $vars.tokenUrl }}',
				serverUrl: '={{ $vars.serverUrl }}',
			};

			await expect(
				service.checkCredentialData('myOAuth2Cred', data, ownerUser, testProjectId),
			).resolves.toBeUndefined();
		});

		it('should skip validation for expression-prefixed OAuth1 URLs', async () => {
			credentialTypes.getParentTypes.mockReturnValue(['oAuth1Api']);
			const data = {
				authUrl: '={{ $vars.authUrl }}',
				requestTokenUrl: '=https://example.com/request-token',
				accessTokenUrl: '={{ $vars.tokenUrl }}',
			};

			await expect(
				service.checkCredentialData('myOAuth1Cred', data, ownerUser, testProjectId),
			).resolves.toBeUndefined();
		});
	});

	describe('probeById', () => {
		const storedCredential = mock<CredentialsEntity>({
			id: 'cred-id',
			name: 'Templated cred',
			type: 'httpTemplatedCustomAuth',
		});

		const mockDecryptedData = (data: ICredentialDataDecryptedObject) =>
			vi.spyOn(service, 'decrypt').mockResolvedValue(data);

		it('should throw when the credential is not accessible to the user', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(null);

			await expect(service.probeById(memberUser, 'cred-id')).rejects.toThrow(
				CredentialNotFoundError,
			);
			expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
				'cred-id',
				memberUser,
				['credential:read'],
			);
		});

		it('should throw when the credential has no test URL', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			mockDecryptedData({ template: '{}' });

			await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
				'The credential has no test URL to probe',
			);
			expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
		});

		it('should refuse an expression test URL instead of resolving it', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			mockDecryptedData({ testUrl: '={{ $vars.url }}' });

			await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
				'The credential has no test URL to probe',
			);
			expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
		});

		it('should refuse a test URL without a stored service origin', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			mockDecryptedData({ testUrl: 'https://api.example.com/me' });

			await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
				'The credential test URL is not bound to its service origin',
			);
			expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
		});

		it('should probe the persisted test URL with parsed accepted status codes', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			const data: ICredentialDataDecryptedObject = {
				testUrl: 'https://api.example.com/me',
				serviceOrigin: 'https://api.example.com',
				acceptedStatusCodes: '[401]',
			};
			mockDecryptedData(data);
			const verdict = {
				status: 'OK' as const,
				message: 'Connection successful!',
				outcome: 'accepted' as const,
			};
			credentialsTester.probeCredentialAuth.mockResolvedValue(verdict);

			await expect(service.probeById(ownerUser, 'cred-id')).resolves.toEqual(verdict);
			expect(credentialsTester.probeCredentialAuth).toHaveBeenCalledWith(
				ownerUser.id,
				'httpTemplatedCustomAuth',
				{ id: 'cred-id', name: 'Templated cred', type: 'httpTemplatedCustomAuth', data },
				'https://api.example.com/me',
				{ acceptedStatusCodes: [401], allowedDomains: 'api.example.com' },
			);
		});

		it('should refuse a test URL outside the stored service origin', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			mockDecryptedData({
				testUrl: 'https://status.example.net/me',
				serviceOrigin: 'https://api.example.com',
			});

			await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
				'The credential test URL is not bound to its service origin',
			);
			expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
		});

		it('should ignore malformed accepted status codes', async () => {
			credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
			mockDecryptedData({
				testUrl: 'https://api.example.com/me',
				serviceOrigin: 'https://api.example.com',
				acceptedStatusCodes: 'nope',
			});
			credentialsTester.probeCredentialAuth.mockResolvedValue({
				status: 'OK',
				message: 'Connection successful!',
				outcome: 'accepted',
			});

			await service.probeById(ownerUser, 'cred-id');
			expect(credentialsTester.probeCredentialAuth).toHaveBeenCalledWith(
				expect.anything(),
				expect.anything(),
				expect.anything(),
				expect.anything(),
				{ acceptedStatusCodes: undefined, allowedDomains: 'api.example.com' },
			);
		});
	});

	describe('isOAuthCredentialType', () => {
		it('returns true for the base OAuth1/OAuth2 types', () => {
			credentialTypes.getParentTypes.mockReturnValue([]);

			expect(service.isOAuthCredentialType('oAuth1Api')).toBe(true);
			expect(service.isOAuthCredentialType('oAuth2Api')).toBe(true);
		});

		it('returns true for a type that extends an OAuth type', () => {
			credentialTypes.getParentTypes.calledWith('gmailOAuth2').mockReturnValue(['oAuth2Api']);

			expect(service.isOAuthCredentialType('gmailOAuth2')).toBe(true);
		});

		it('returns false for a non-OAuth type', () => {
			credentialTypes.getParentTypes.calledWith('httpBasicAuth').mockReturnValue([]);

			expect(service.isOAuthCredentialType('httpBasicAuth')).toBe(false);
		});
	});
});
