mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
refactor(API): Route public API credentials through the shared credentials service (#36048)
This commit is contained in:
@@ -16,6 +16,8 @@ export { generateHostInstanceId } from './utils/generators';
|
||||
export { isStringArray } from './utils/is-string-array';
|
||||
export { isUniqueConstraintError } from './utils/is-unique-constraint-error';
|
||||
export { isValidEmail } from './utils/is-valid-email';
|
||||
export { parseListQuerySortBy } from './utils/list-query-sort';
|
||||
export type { ListQuerySort, ListQuerySortDirection } from './utils/list-query-sort';
|
||||
export { separate } from './utils/separate';
|
||||
export { sql } from './utils/sql';
|
||||
export { idStringifier, lowerCaser, objectRetriever, sqlite } from './utils/transformers';
|
||||
|
||||
@@ -156,6 +156,37 @@ describe('CredentialsRepository', () => {
|
||||
expect(callArg).toBeDefined();
|
||||
expect(callArg!.where).toEqual(expect.objectContaining({ id: In(['id1', 'id2']) }));
|
||||
});
|
||||
|
||||
it('should apply sortBy as TypeORM order', async () => {
|
||||
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
|
||||
|
||||
await credentialsRepository.findManyAndCount({
|
||||
take: 10,
|
||||
skip: 0,
|
||||
sortBy: 'createdAt:desc',
|
||||
});
|
||||
|
||||
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
|
||||
expect(callArg?.order).toEqual({ createdAt: 'DESC' });
|
||||
});
|
||||
|
||||
it('should default sort direction to ASC when omitted', async () => {
|
||||
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
|
||||
|
||||
await credentialsRepository.findManyAndCount({ sortBy: 'name' });
|
||||
|
||||
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
|
||||
expect(callArg?.order).toEqual({ name: 'ASC' });
|
||||
});
|
||||
|
||||
it('should ignore unknown sortBy columns', async () => {
|
||||
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
|
||||
|
||||
await credentialsRepository.findManyAndCount({ sortBy: 'data:desc' });
|
||||
|
||||
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
|
||||
expect(callArg?.order).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllGlobalCredentials', () => {
|
||||
|
||||
@@ -14,6 +14,14 @@ import { SharedCredentialsRepository } from './shared-credentials.repository';
|
||||
import type { ICredentialsDb, ListQuery } from '../entities/types-db';
|
||||
import type { OperationContext } from '../services/transaction';
|
||||
import { TransactionRunner } from '../services/transaction';
|
||||
import { parseListQuerySortBy } from '../utils/list-query-sort';
|
||||
|
||||
const SORTABLE_COLUMNS = new Set(['id', 'name', 'createdAt', 'updatedAt']);
|
||||
|
||||
type CredentialsListQueryOptions = ListQuery.Options & {
|
||||
includeData?: boolean;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
@@ -116,31 +124,8 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
}
|
||||
}
|
||||
|
||||
async findMany(
|
||||
listQueryOptions?: ListQuery.Options & {
|
||||
includeData?: boolean;
|
||||
user?: User;
|
||||
/** When provided, sets sort order for the query. */
|
||||
order?: FindManyOptions<CredentialsEntity>['order'];
|
||||
},
|
||||
credentialIds?: string[],
|
||||
) {
|
||||
const findManyOptions = this.toFindManyOptions(listQueryOptions);
|
||||
|
||||
if (credentialIds) {
|
||||
findManyOptions.where = { ...findManyOptions.where, id: In(credentialIds) };
|
||||
}
|
||||
|
||||
return await this.find(this.onlyProjectCredentials(findManyOptions));
|
||||
}
|
||||
|
||||
async findManyAndCount(
|
||||
listQueryOptions?: ListQuery.Options & {
|
||||
includeData?: boolean;
|
||||
user?: User;
|
||||
/** When provided, sets sort order for the query. */
|
||||
order?: FindManyOptions<CredentialsEntity>['order'];
|
||||
},
|
||||
listQueryOptions?: CredentialsListQueryOptions,
|
||||
credentialIds?: string[],
|
||||
): Promise<[CredentialsEntity[], number]> {
|
||||
const findManyOptions = this.toFindManyOptions(listQueryOptions);
|
||||
@@ -159,12 +144,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
return findManyOptions;
|
||||
}
|
||||
|
||||
private toFindManyOptions(
|
||||
listQueryOptions?: ListQuery.Options & {
|
||||
includeData?: boolean;
|
||||
order?: FindManyOptions<CredentialsEntity>['order'];
|
||||
},
|
||||
) {
|
||||
private toFindManyOptions(listQueryOptions?: CredentialsListQueryOptions) {
|
||||
const findManyOptions: FindManyOptions<CredentialsEntity> = {};
|
||||
|
||||
type Select = Array<keyof CredentialsEntity>;
|
||||
@@ -189,7 +169,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
} as FindManyOptions<CredentialsEntity>;
|
||||
}
|
||||
|
||||
const { filter, select, take, skip, order } = listQueryOptions;
|
||||
const { filter, select, take, skip, sortBy } = listQueryOptions;
|
||||
|
||||
if (typeof filter?.name === 'string' && filter?.name !== '') {
|
||||
filter.name = Like(`%${filter.name}%`);
|
||||
@@ -215,8 +195,11 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
findManyOptions.relations = defaultRelations;
|
||||
}
|
||||
|
||||
if (order !== undefined) {
|
||||
findManyOptions.order = order;
|
||||
if (sortBy) {
|
||||
const { column, direction } = parseListQuerySortBy(sortBy);
|
||||
if (SORTABLE_COLUMNS.has(column)) {
|
||||
findManyOptions.order = { [column]: direction };
|
||||
}
|
||||
}
|
||||
|
||||
if (listQueryOptions.includeData) {
|
||||
@@ -230,9 +213,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
return findManyOptions;
|
||||
}
|
||||
|
||||
private handleSharedFilters(
|
||||
listQueryOptions?: ListQuery.Options & { includeData?: boolean },
|
||||
): void {
|
||||
private handleSharedFilters(listQueryOptions?: CredentialsListQueryOptions): void {
|
||||
if (!listQueryOptions?.filter) return;
|
||||
|
||||
const { filter } = listQueryOptions;
|
||||
@@ -418,9 +399,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
personalProjectOwnerId?: string;
|
||||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: ListQuery.Options & {
|
||||
includeData?: boolean;
|
||||
order?: FindManyOptions<CredentialsEntity>['order'];
|
||||
options: CredentialsListQueryOptions & {
|
||||
filters?: {
|
||||
dependency?: CredentialDependencyFilter;
|
||||
};
|
||||
@@ -458,9 +437,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
personalProjectOwnerId?: string;
|
||||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: ListQuery.Options & {
|
||||
includeData?: boolean;
|
||||
order?: FindManyOptions<CredentialsEntity>['order'];
|
||||
options: CredentialsListQueryOptions & {
|
||||
filters?: {
|
||||
dependency?: CredentialDependencyFilter;
|
||||
};
|
||||
@@ -549,11 +526,11 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
.leftJoinAndSelect('project.projectRelations', 'projectRelations');
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (options.order) {
|
||||
Object.entries(options.order).forEach(([key, direction]) => {
|
||||
qb.addOrderBy(`credential.${key}`, direction as 'ASC' | 'DESC');
|
||||
});
|
||||
if (options.sortBy) {
|
||||
const { column, direction } = parseListQuerySortBy(options.sortBy);
|
||||
if (SORTABLE_COLUMNS.has(column)) {
|
||||
qb.addOrderBy(`credential.${column}`, direction);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PROJECT_ROOT } from 'n8n-workflow';
|
||||
|
||||
import { Folder, FolderTagMapping, TagEntity } from '../entities';
|
||||
import type { FolderWithWorkflowAndSubFolderCountAndPath, ListQuery } from '../entities/types-db';
|
||||
import { parseListQuerySortBy } from '../utils/list-query-sort';
|
||||
|
||||
@Service()
|
||||
export class FolderRepository extends Repository<Folder> {
|
||||
@@ -228,13 +229,8 @@ export class FolderRepository extends Repository<Folder> {
|
||||
return;
|
||||
}
|
||||
|
||||
const [field, order] = this.parseSortingParams(sortBy);
|
||||
this.applySortingByField(query, field, order);
|
||||
}
|
||||
|
||||
private parseSortingParams(sortBy: string): [string, 'DESC' | 'ASC'] {
|
||||
const [field, order] = sortBy.split(':');
|
||||
return [field, order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'];
|
||||
const { column, direction } = parseListQuerySortBy(sortBy);
|
||||
this.applySortingByField(query, column, direction);
|
||||
}
|
||||
|
||||
private applySortingByField(
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
import { type OperationContext, TransactionRunner } from '../services/transaction';
|
||||
import { applyWorkflowBooleanSettingFilter } from '../utils/apply-workflow-boolean-setting-filter';
|
||||
import { isStringArray } from '../utils/is-string-array';
|
||||
import { parseListQuerySortBy } from '../utils/list-query-sort';
|
||||
import { TimedQuery } from '../utils/timed-query';
|
||||
|
||||
type ResourceType = 'folder' | 'workflow';
|
||||
@@ -369,7 +370,7 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||
// For union, we need to have the same columns, so add NULL as description for folders
|
||||
const columnNames = [...Object.keys(workflowQueryParameters.select ?? {}), 'resource'];
|
||||
|
||||
const [sortByColumn, sortByDirection] = this.parseSortingParams(
|
||||
const { column: sortByColumn, direction: sortByDirection } = parseListQuerySortBy(
|
||||
options.sortBy ?? 'updatedAt:asc',
|
||||
);
|
||||
|
||||
@@ -735,7 +736,7 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||
// For union, we need to have the same columns, so add NULL as description for folders
|
||||
const columnNames = [...Object.keys(workflowQueryParameters.select ?? {}), 'resource'];
|
||||
|
||||
const [sortByColumn, sortByDirection] = this.parseSortingParams(
|
||||
const { column: sortByColumn, direction: sortByDirection } = parseListQuerySortBy(
|
||||
options.sortBy ?? 'updatedAt:asc',
|
||||
);
|
||||
|
||||
@@ -1438,15 +1439,10 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||
return;
|
||||
}
|
||||
|
||||
const [column, direction] = this.parseSortingParams(sortBy);
|
||||
const { column, direction } = parseListQuerySortBy(sortBy);
|
||||
this.applySortingByColumn(qb, column, direction);
|
||||
}
|
||||
|
||||
private parseSortingParams(sortBy: string): [string, 'ASC' | 'DESC'] {
|
||||
const [column, order] = sortBy.split(':');
|
||||
return [column, order.toUpperCase() as 'ASC' | 'DESC'];
|
||||
}
|
||||
|
||||
private applySortingByColumn(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
column: string,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { parseListQuerySortBy } from '../list-query-sort';
|
||||
|
||||
describe('parseListQuerySortBy', () => {
|
||||
it.each([
|
||||
['createdAt:desc', { column: 'createdAt', direction: 'DESC' }],
|
||||
['createdAt:DESC', { column: 'createdAt', direction: 'DESC' }],
|
||||
['name:asc', { column: 'name', direction: 'ASC' }],
|
||||
['name:ASC', { column: 'name', direction: 'ASC' }],
|
||||
['updatedAt', { column: 'updatedAt', direction: 'ASC' }],
|
||||
['name:foo', { column: 'name', direction: 'ASC' }],
|
||||
] as const)('parses %s', (sortBy, expected) => {
|
||||
expect(parseListQuerySortBy(sortBy)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export type ListQuerySortDirection = 'ASC' | 'DESC';
|
||||
|
||||
export type ListQuerySort = {
|
||||
column: string;
|
||||
direction: ListQuerySortDirection;
|
||||
};
|
||||
|
||||
export function parseListQuerySortBy(sortBy: string): ListQuerySort {
|
||||
const [column, order] = sortBy.split(':');
|
||||
return {
|
||||
column,
|
||||
direction: order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC',
|
||||
};
|
||||
}
|
||||
@@ -117,8 +117,6 @@ export default defineConfig(
|
||||
// migration to the `@PublicApiController` + service pattern (API-70). NEVER add to this
|
||||
// list — a new violation must fail CI. Entries are removed as each file migrates.
|
||||
files: [
|
||||
'./src/public-api/v1/handlers/credentials/credentials.handler.ts',
|
||||
'./src/public-api/v1/handlers/credentials/credentials.service.ts',
|
||||
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
|
||||
'./src/public-api/v1/handlers/data-tables/data-tables.service.ts',
|
||||
'./src/public-api/v1/handlers/discover/discover.handler.ts',
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('CredentialsController', () => {
|
||||
mock(), // instanceCredentialAssignmentRepository
|
||||
mock(), // instanceCredentialUseRegistry
|
||||
mock(), // dbLockService
|
||||
mock(), // eventService
|
||||
);
|
||||
|
||||
// Spy on methods that need to be mocked in tests
|
||||
@@ -1034,14 +1035,14 @@ describe('CredentialsController', () => {
|
||||
describe('deleteCredentials', () => {
|
||||
const credentialId = 'cred-del-1';
|
||||
|
||||
it('should emit "private-credential-deleted" when deleting a resolvable credential', async () => {
|
||||
it('should delete an accessible credential via CredentialsService', async () => {
|
||||
const privateCredential = mock<CredentialsEntity>({
|
||||
id: credentialId,
|
||||
type: 'gmailOAuth2',
|
||||
isResolvable: true,
|
||||
});
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential);
|
||||
vi.spyOn(credentialsService, 'delete').mockResolvedValue(undefined);
|
||||
const deleteSpy = vi.spyOn(credentialsService, 'delete').mockResolvedValue(undefined);
|
||||
|
||||
const deleteReq = {
|
||||
user: { id: 'u1' },
|
||||
@@ -1050,32 +1051,10 @@ describe('CredentialsController', () => {
|
||||
|
||||
await credentialsController.deleteCredentials(deleteReq);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('private-credential-deleted', {
|
||||
user: deleteReq.user,
|
||||
credentialType: privateCredential.type,
|
||||
credentialId: privateCredential.id,
|
||||
expect(deleteSpy).toHaveBeenCalledWith(deleteReq.user, credentialId, {
|
||||
includeInstanceCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit "private-credential-deleted" when deleting a static credential', async () => {
|
||||
const staticCredential = mock<CredentialsEntity>({
|
||||
id: credentialId,
|
||||
type: 'gmailOAuth2',
|
||||
isResolvable: false,
|
||||
});
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(staticCredential);
|
||||
vi.spyOn(credentialsService, 'delete').mockResolvedValue(undefined);
|
||||
|
||||
const deleteReq = {
|
||||
user: { id: 'u1' },
|
||||
params: { credentialId },
|
||||
} as unknown as CredentialRequest.Delete;
|
||||
|
||||
await credentialsController.deleteCredentials(deleteReq);
|
||||
|
||||
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
|
||||
expect(emittedEventNames).not.toContain('private-credential-deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnectOauthToken', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import * as validation from '@/credentials/validation';
|
||||
import type { CredentialsHelper } from '@/credentials-helper';
|
||||
import { CredentialNotFoundError } from '@/errors/credential-not-found.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';
|
||||
@@ -94,6 +95,7 @@ describe('CredentialsService', () => {
|
||||
const instanceCredentialAssignmentRepository = mock<InstanceCredentialAssignmentRepository>();
|
||||
const instanceCredentialUseRegistry = mock<InstanceCredentialUseRegistry>();
|
||||
const dbLockService = mock<DbLockService>();
|
||||
const eventService = mock<EventService>();
|
||||
|
||||
const service = new CredentialsService(
|
||||
credentialsRepository,
|
||||
@@ -117,6 +119,7 @@ describe('CredentialsService', () => {
|
||||
instanceCredentialAssignmentRepository,
|
||||
instanceCredentialUseRegistry,
|
||||
dbLockService,
|
||||
eventService,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1002,7 +1005,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
describe('testById', () => {
|
||||
it('throws CredentialNotFoundError when credential does not exist', async () => {
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(null);
|
||||
credentialsFinderService.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.testById(ownerUser.id, 'missing-credential')).rejects.toThrow(
|
||||
CredentialNotFoundError,
|
||||
@@ -1011,7 +1014,7 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
|
||||
it('does not expose instance credentials through public API testing', async () => {
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(
|
||||
credentialsFinderService.findById.mockResolvedValue(
|
||||
mock<CredentialsEntity>({
|
||||
id: 'instance-credential',
|
||||
usageScope: 'instance',
|
||||
@@ -1034,13 +1037,13 @@ describe('CredentialsService', () => {
|
||||
const decryptedData = { accessToken: 'secret-token' } as ICredentialDataDecryptedObject;
|
||||
const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;
|
||||
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(storedCredential);
|
||||
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.findCredentialById).toHaveBeenCalledWith(storedCredential.id);
|
||||
expect(credentialsFinderService.findById).toHaveBeenCalledWith(storedCredential.id);
|
||||
expect(service.decrypt).toHaveBeenCalledWith(storedCredential, true);
|
||||
expect(credentialsTester.testCredentials).toHaveBeenCalledWith(
|
||||
ownerUser.id,
|
||||
@@ -1130,11 +1133,13 @@ describe('CredentialsService', () => {
|
||||
{},
|
||||
);
|
||||
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,
|
||||
});
|
||||
@@ -1155,6 +1160,11 @@ describe('CredentialsService', () => {
|
||||
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 () => {
|
||||
@@ -1173,6 +1183,73 @@ describe('CredentialsService', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1609,6 +1686,24 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -1636,7 +1731,7 @@ describe('CredentialsService', () => {
|
||||
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.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
projectService.getProject.mockResolvedValue(personalProject);
|
||||
|
||||
// ACT
|
||||
@@ -1648,7 +1743,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
// ASSERT
|
||||
expect(projectService.getProject).toHaveBeenCalledWith('personal-proj');
|
||||
expect(credentialsRepository.findMany).toHaveBeenCalledWith(
|
||||
expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
withRole: 'credential:owner',
|
||||
@@ -1662,7 +1757,7 @@ describe('CredentialsService', () => {
|
||||
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.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
projectService.getProject.mockResolvedValue(teamProject);
|
||||
|
||||
// ACT
|
||||
@@ -1674,7 +1769,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
// ASSERT
|
||||
expect(projectService.getProject).toHaveBeenCalledWith('team-proj');
|
||||
expect(credentialsRepository.findMany).toHaveBeenCalledWith(
|
||||
expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.not.objectContaining({
|
||||
withRole: 'credential:owner',
|
||||
@@ -1685,7 +1780,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should handle getProject throwing an error', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
projectService.getProject.mockRejectedValue(new Error('Project not found'));
|
||||
|
||||
// ACT
|
||||
@@ -1696,7 +1791,7 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
|
||||
// ASSERT - Should continue without filtering by role
|
||||
expect(credentialsRepository.findMany).toHaveBeenCalledWith(
|
||||
expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.not.objectContaining({
|
||||
withRole: 'credential:owner',
|
||||
@@ -1710,7 +1805,7 @@ describe('CredentialsService', () => {
|
||||
it('should add scopes to credentials when includeScopes is true', async () => {
|
||||
// ARRANGE
|
||||
const projectRelations = [{ projectId: 'proj-1', role: 'project:owner' }] as any;
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
projectService.getProjectRelationsForUser.mockResolvedValue(projectRelations);
|
||||
roleService.addScopes.mockImplementation(
|
||||
(c) => ({ ...c, scopes: ['credential:read', 'credential:update'] }) as any,
|
||||
@@ -1733,7 +1828,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should not add scopes when includeScopes is false', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
|
||||
// ACT
|
||||
const result = await service.getMany(ownerUser, {
|
||||
@@ -1759,7 +1854,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should automatically set includeScopes to true when includeData is true', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
roleService.addScopes.mockImplementation(
|
||||
(c) => ({ ...c, scopes: ['credential:update'] }) as any,
|
||||
);
|
||||
@@ -1775,7 +1870,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should include decrypted data when user has credential:update scope', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
roleService.addScopes.mockImplementation(
|
||||
(c) => ({ ...c, scopes: ['credential:update'] }) as any,
|
||||
);
|
||||
@@ -1792,7 +1887,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should not include decrypted data when user lacks credential:update scope', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
roleService.addScopes.mockImplementation(
|
||||
(c) => ({ ...c, scopes: ['credential:read'] }) as any,
|
||||
);
|
||||
@@ -1809,7 +1904,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should replace oauthTokenData with true when present', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
roleService.addScopes.mockImplementation(
|
||||
(c) => ({ ...c, scopes: ['credential:update'] }) as any,
|
||||
);
|
||||
@@ -1830,7 +1925,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should set includeData in listQueryOptions', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
roleService.addScopes.mockImplementation(
|
||||
(c) => ({ ...c, scopes: ['credential:update'] }) as any,
|
||||
);
|
||||
@@ -1841,7 +1936,7 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsRepository.findMany).toHaveBeenCalledWith(
|
||||
expect(credentialsRepository.findManyAndCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeData: true,
|
||||
}),
|
||||
@@ -1855,7 +1950,7 @@ describe('CredentialsService', () => {
|
||||
it('should fetch all relations when filtering by shared.projectId', async () => {
|
||||
// ARRANGE
|
||||
const credWithShared = { ...regularCredential, shared: [] } as any;
|
||||
credentialsRepository.findMany.mockResolvedValue([credWithShared]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[credWithShared], 1]);
|
||||
sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([
|
||||
sharedRelation,
|
||||
]);
|
||||
@@ -1899,7 +1994,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should not fetch all relations when shared.projectId is not present', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
|
||||
// ACT
|
||||
await service.getMany(ownerUser, {
|
||||
@@ -1916,7 +2011,7 @@ describe('CredentialsService', () => {
|
||||
describe('with custom select (non-default select)', () => {
|
||||
it('should skip addOwnedByAndSharedWith when select is custom', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
|
||||
// ACT
|
||||
await service.getMany(ownerUser, {
|
||||
@@ -1931,7 +2026,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should skip fetching all relations when select is custom', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
|
||||
// ACT
|
||||
await service.getMany(ownerUser, {
|
||||
@@ -2201,7 +2296,7 @@ describe('CredentialsService', () => {
|
||||
describe('with includeGlobal = true', () => {
|
||||
it('should include global credentials for owner users', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
credentialsRepository.findAllGlobalCredentials.mockResolvedValue([globalCredential]);
|
||||
|
||||
// ACT
|
||||
@@ -2210,7 +2305,7 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsRepository.findMany).toHaveBeenCalled();
|
||||
expect(credentialsRepository.findManyAndCount).toHaveBeenCalled();
|
||||
expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({
|
||||
includeData: false,
|
||||
filters: { dependency: undefined },
|
||||
@@ -2306,7 +2401,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should forward the credential type filter to the global credentials lookup (owner user)', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[], 0]);
|
||||
credentialsRepository.findAllGlobalCredentials.mockResolvedValue([]);
|
||||
|
||||
// ACT
|
||||
@@ -2347,7 +2442,7 @@ describe('CredentialsService', () => {
|
||||
|
||||
it('should not pass a type filter when the listQueryOptions filter has no type', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[], 0]);
|
||||
credentialsRepository.findAllGlobalCredentials.mockResolvedValue([]);
|
||||
|
||||
// ACT
|
||||
@@ -2363,7 +2458,7 @@ describe('CredentialsService', () => {
|
||||
describe('with includeGlobal = false', () => {
|
||||
it('should exclude global credentials when includeGlobal is false', async () => {
|
||||
// ARRANGE
|
||||
credentialsRepository.findMany.mockResolvedValue([regularCredential]);
|
||||
credentialsRepository.findManyAndCount.mockResolvedValue([[regularCredential], 1]);
|
||||
|
||||
// ACT
|
||||
const result = await service.getMany(ownerUser, {
|
||||
@@ -2371,7 +2466,7 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsRepository.findMany).toHaveBeenCalled();
|
||||
expect(credentialsRepository.findManyAndCount).toHaveBeenCalled();
|
||||
expect(credentialsRepository.findAllGlobalCredentials).not.toHaveBeenCalled();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('cred-1');
|
||||
@@ -3380,6 +3475,63 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
@@ -52,15 +52,19 @@ export class CredentialsFinderService {
|
||||
});
|
||||
}
|
||||
|
||||
async findCredentialById(
|
||||
async findById(
|
||||
credentialId: string,
|
||||
options: { includeInstanceCredentials?: boolean } = {},
|
||||
options: {
|
||||
includeInstanceCredentials?: boolean;
|
||||
includeSharedProject?: boolean;
|
||||
} = {},
|
||||
): Promise<CredentialsEntity | null> {
|
||||
return await this.credentialsRepository.findOne({
|
||||
where: {
|
||||
id: credentialId,
|
||||
usageScope: options.includeInstanceCredentials ? In(['project', 'instance']) : 'project',
|
||||
},
|
||||
relations: options.includeSharedProject ? { shared: { project: true } } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -462,20 +462,6 @@ export class CredentialsController {
|
||||
includeInstanceCredentials: true,
|
||||
});
|
||||
|
||||
this.eventService.emit('credentials-deleted', {
|
||||
user: req.user,
|
||||
credentialType: credential.type,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
|
||||
if (credential.isResolvable) {
|
||||
this.eventService.emit('private-credential-deleted', {
|
||||
user: req.user,
|
||||
credentialType: credential.type,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export class EnterpriseCredentialsService {
|
||||
}
|
||||
|
||||
async getOne(credentialId: string) {
|
||||
return await this.credentialsFinderService.findCredentialById(credentialId);
|
||||
return await this.credentialsFinderService.findById(credentialId);
|
||||
}
|
||||
|
||||
async getOneForUser(user: User, credentialId: string, includeDecryptedData: boolean) {
|
||||
|
||||
@@ -56,14 +56,15 @@ 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 { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { validateEntity } from '@/generic-helpers';
|
||||
import { getChangedSharedFields } from '@/modules/dynamic-credentials.ee/services/shared-fields';
|
||||
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
|
||||
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
|
||||
import { DCR_MANAGED_CREDENTIAL_FIELDS } from '@/oauth/dcr-managed-fields';
|
||||
import { validateOAuthUrl } from '@/oauth/validate-oauth-url';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
import { getChangedSharedFields } from '@/modules/dynamic-credentials.ee/services/shared-fields';
|
||||
import type { CredentialRequest, ListQuery } from '@/requests';
|
||||
import { CredentialsTester } from '@/services/credentials-tester.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
@@ -97,6 +98,24 @@ type PrepareUpdateDataOptions = {
|
||||
*/
|
||||
clearOauthTokenData?: boolean;
|
||||
operationContext?: OperationContext;
|
||||
/**
|
||||
* How to combine incoming `data` with the stored decrypted blob:
|
||||
* - `unredact` (default): replace `***` redacted values from stored data
|
||||
* - `replace`: use incoming data as-is
|
||||
* - `partial`: merge stored + incoming, then unredact
|
||||
*/
|
||||
dataMerge?: 'unredact' | 'replace' | 'partial';
|
||||
};
|
||||
|
||||
type GetManyOptions = {
|
||||
listQueryOptions?: ListQuery.Options;
|
||||
includeScopes?: boolean;
|
||||
includeData?: boolean;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
filters?: {
|
||||
externalSecretsStore?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UpdateOptions = {
|
||||
@@ -142,6 +161,11 @@ type GetManyCredentialsOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
type CredentialsWithCount<T = CredentialsEntity> = {
|
||||
credentials: T[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
type WorkflowCredentialResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -190,6 +214,7 @@ export class CredentialsService {
|
||||
private readonly instanceCredentialAssignmentRepository: InstanceCredentialAssignmentRepository,
|
||||
private readonly instanceCredentialUseRegistry: InstanceCredentialUseRegistry,
|
||||
private readonly dbLockService: DbLockService,
|
||||
private readonly eventService: EventService,
|
||||
) {}
|
||||
|
||||
async countConnectedUsers(credentialId: string): Promise<number> {
|
||||
@@ -256,31 +281,23 @@ export class CredentialsService {
|
||||
|
||||
async getMany(
|
||||
user: User,
|
||||
options: {
|
||||
listQueryOptions?: ListQuery.Options;
|
||||
includeScopes?: boolean;
|
||||
includeData: true;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
filters?: {
|
||||
externalSecretsStore?: string;
|
||||
};
|
||||
},
|
||||
options: GetManyOptions & { includeData: true },
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
|
||||
async getMany(user: User, options?: GetManyOptions): Promise<CredentialsEntity[]>;
|
||||
async getMany(
|
||||
user: User,
|
||||
options?: {
|
||||
listQueryOptions?: ListQuery.Options;
|
||||
includeScopes?: boolean;
|
||||
includeData?: boolean;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
filters?: {
|
||||
externalSecretsStore?: string;
|
||||
};
|
||||
},
|
||||
): Promise<CredentialsEntity[]>;
|
||||
async getMany(
|
||||
options: GetManyOptions = {},
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> | CredentialsEntity[]> {
|
||||
const { credentials } = await this.getManyAndCount(user, options);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async getManyAndCount(
|
||||
user: User,
|
||||
options: GetManyOptions & { includeData: true },
|
||||
): Promise<CredentialsWithCount<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
|
||||
async getManyAndCount(user: User, options?: GetManyOptions): Promise<CredentialsWithCount>;
|
||||
async getManyAndCount(
|
||||
user: User,
|
||||
{
|
||||
listQueryOptions = {},
|
||||
@@ -289,17 +306,10 @@ export class CredentialsService {
|
||||
onlySharedWithMe = false,
|
||||
includeGlobal = false,
|
||||
filters = {},
|
||||
}: {
|
||||
listQueryOptions?: ListQuery.Options;
|
||||
includeScopes?: boolean;
|
||||
includeData?: boolean;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
filters?: {
|
||||
externalSecretsStore?: string;
|
||||
};
|
||||
} = {},
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> | CredentialsEntity[]> {
|
||||
}: GetManyOptions = {},
|
||||
): Promise<
|
||||
CredentialsWithCount<ICredentialsDecrypted<ICredentialDataDecryptedObject> | CredentialsEntity>
|
||||
> {
|
||||
const { externalSecretsStore } = filters;
|
||||
const returnAll = hasGlobalScope(user, 'credential:list');
|
||||
const isDefaultSelect = !listQueryOptions.select;
|
||||
@@ -310,7 +320,7 @@ export class CredentialsService {
|
||||
: undefined;
|
||||
|
||||
if (externalSecretsStore && !dependencyFilter) {
|
||||
return [];
|
||||
return { credentials: [], count: 0 };
|
||||
}
|
||||
|
||||
// Auto-enable includeScopes when includeData is requested
|
||||
@@ -318,27 +328,19 @@ export class CredentialsService {
|
||||
includeScopes = true;
|
||||
}
|
||||
|
||||
let credentials: CredentialsEntity[];
|
||||
const fetchOptions: GetManyCredentialsOptions = {
|
||||
listQueryOptions,
|
||||
includeGlobal,
|
||||
includeData,
|
||||
onlySharedWithMe,
|
||||
filters: { dependency: dependencyFilter },
|
||||
};
|
||||
|
||||
if (returnAll) {
|
||||
credentials = await this.getManyForAdminUser(user, {
|
||||
listQueryOptions,
|
||||
includeGlobal,
|
||||
includeData,
|
||||
onlySharedWithMe,
|
||||
filters: { dependency: dependencyFilter },
|
||||
});
|
||||
} else {
|
||||
credentials = await this.getManyForMemberUser(user, {
|
||||
listQueryOptions,
|
||||
includeGlobal,
|
||||
includeData,
|
||||
onlySharedWithMe,
|
||||
filters: { dependency: dependencyFilter },
|
||||
});
|
||||
}
|
||||
const { credentials, count } = returnAll
|
||||
? await this.getManyForAdminUser(user, fetchOptions)
|
||||
: await this.getManyForMemberUser(user, fetchOptions);
|
||||
|
||||
return await this.enrichCredentials(
|
||||
const enriched = await this.enrichCredentials(
|
||||
credentials,
|
||||
user,
|
||||
isDefaultSelect,
|
||||
@@ -347,6 +349,8 @@ export class CredentialsService {
|
||||
listQueryOptions,
|
||||
onlySharedWithMe,
|
||||
);
|
||||
|
||||
return { credentials: enriched, count };
|
||||
}
|
||||
|
||||
private async getManyForAdminUser(
|
||||
@@ -358,7 +362,7 @@ export class CredentialsService {
|
||||
onlySharedWithMe,
|
||||
filters,
|
||||
}: GetManyCredentialsOptions,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
): Promise<CredentialsWithCount> {
|
||||
const { dependency: dependencyFilter } = filters ?? {};
|
||||
const typeFilter = this.extractTypeFilter(listQueryOptions);
|
||||
|
||||
@@ -367,47 +371,50 @@ export class CredentialsService {
|
||||
const sharingOptions = {
|
||||
...(onlySharedWithMe ? { onlySharedWithMe: true } : {}),
|
||||
};
|
||||
const { credentials } = await this.credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
{
|
||||
const { credentials, count } =
|
||||
await this.credentialsRepository.getManyAndCountWithSharingSubquery(user, sharingOptions, {
|
||||
...listQueryOptions,
|
||||
...(includeData ? { includeData: true } : {}),
|
||||
filters: {
|
||||
dependency: dependencyFilter,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (includeGlobal) {
|
||||
return await this.addGlobalCredentials(
|
||||
credentials,
|
||||
includeData,
|
||||
dependencyFilter,
|
||||
typeFilter,
|
||||
);
|
||||
return {
|
||||
credentials: await this.addGlobalCredentials(
|
||||
credentials,
|
||||
includeData,
|
||||
dependencyFilter,
|
||||
typeFilter,
|
||||
),
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
return credentials;
|
||||
return { credentials, count };
|
||||
}
|
||||
|
||||
await this.applyPersonalProjectFilter(listQueryOptions);
|
||||
|
||||
let credentials = await this.credentialsRepository.findMany({
|
||||
const [credentials, count] = await this.credentialsRepository.findManyAndCount({
|
||||
...listQueryOptions,
|
||||
...(includeData ? { includeData: true } : {}),
|
||||
});
|
||||
|
||||
if (includeGlobal) {
|
||||
credentials = await this.addGlobalCredentials(
|
||||
credentials,
|
||||
includeData,
|
||||
dependencyFilter,
|
||||
typeFilter,
|
||||
);
|
||||
return {
|
||||
credentials: await this.addGlobalCredentials(
|
||||
credentials,
|
||||
includeData,
|
||||
dependencyFilter,
|
||||
typeFilter,
|
||||
),
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
return credentials;
|
||||
return { credentials, count };
|
||||
}
|
||||
|
||||
private async getManyForMemberUser(
|
||||
@@ -419,7 +426,7 @@ export class CredentialsService {
|
||||
onlySharedWithMe,
|
||||
filters,
|
||||
}: GetManyCredentialsOptions,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
): Promise<CredentialsWithCount> {
|
||||
const { dependency: dependencyFilter } = filters ?? {};
|
||||
const typeFilter = this.extractTypeFilter(listQueryOptions);
|
||||
|
||||
@@ -432,7 +439,7 @@ export class CredentialsService {
|
||||
id: listQueryOptions.filter.projectId as string,
|
||||
});
|
||||
if (!project) {
|
||||
return [];
|
||||
return { credentials: [], count: 0 };
|
||||
}
|
||||
isPersonalProject = project.type === 'personal';
|
||||
personalProjectOwnerId = project.creatorId;
|
||||
@@ -451,7 +458,7 @@ export class CredentialsService {
|
||||
if (isPersonalProject && personalProjectOwnerId) {
|
||||
// Prevent users from accessing another user's personal project credentials
|
||||
if (personalProjectOwnerId !== user.id && !hasGlobalScope(user, 'credential:read')) {
|
||||
return [];
|
||||
return { credentials: [], count: 0 };
|
||||
}
|
||||
sharingOptions.isPersonalProject = true;
|
||||
sharingOptions.personalProjectOwnerId = personalProjectOwnerId;
|
||||
@@ -468,29 +475,28 @@ export class CredentialsService {
|
||||
sharingOptions.credentialRoles = credentialRoles;
|
||||
}
|
||||
|
||||
// Use the new subquery-based repository method
|
||||
const { credentials } = await this.credentialsRepository.getManyAndCountWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
{
|
||||
const { credentials, count } =
|
||||
await this.credentialsRepository.getManyAndCountWithSharingSubquery(user, sharingOptions, {
|
||||
...listQueryOptions,
|
||||
...(includeData ? { includeData: true } : {}),
|
||||
filters: {
|
||||
dependency: dependencyFilter,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (includeGlobal) {
|
||||
return await this.addGlobalCredentials(
|
||||
credentials,
|
||||
includeData,
|
||||
dependencyFilter,
|
||||
typeFilter,
|
||||
);
|
||||
return {
|
||||
credentials: await this.addGlobalCredentials(
|
||||
credentials,
|
||||
includeData,
|
||||
dependencyFilter,
|
||||
typeFilter,
|
||||
),
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
return credentials;
|
||||
return { credentials, count };
|
||||
}
|
||||
|
||||
private async applyPersonalProjectFilter(listQueryOptions: ListQuery.Options): Promise<void> {
|
||||
@@ -516,24 +522,6 @@ export class CredentialsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
isDefaultSelect: boolean,
|
||||
includeScopes: boolean,
|
||||
includeData: true,
|
||||
listQueryOptions: ListQuery.Options,
|
||||
onlySharedWithMe: boolean,
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
|
||||
private async enrichCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
isDefaultSelect: boolean,
|
||||
includeScopes: boolean,
|
||||
includeData: boolean,
|
||||
listQueryOptions: ListQuery.Options,
|
||||
onlySharedWithMe: boolean,
|
||||
): Promise<CredentialsEntity[]>;
|
||||
private async enrichCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
@@ -756,6 +744,24 @@ export class CredentialsService {
|
||||
});
|
||||
}
|
||||
|
||||
private applyDataMerge(
|
||||
incomingData: ICredentialDataDecryptedObject,
|
||||
decryptedData: ICredentialDataDecryptedObject,
|
||||
credentialType: string,
|
||||
dataMerge: NonNullable<PrepareUpdateDataOptions['dataMerge']>,
|
||||
): ICredentialDataDecryptedObject {
|
||||
if (dataMerge === 'replace') {
|
||||
return incomingData;
|
||||
}
|
||||
const dataToUnredact =
|
||||
dataMerge === 'partial' ? { ...decryptedData, ...incomingData } : incomingData;
|
||||
return this.unredact(
|
||||
dataToUnredact,
|
||||
decryptedData,
|
||||
this.getCredentialTypeProperties(credentialType),
|
||||
);
|
||||
}
|
||||
|
||||
async prepareUpdateData(
|
||||
user: User,
|
||||
data: CredentialRequest.CredentialProperties,
|
||||
@@ -763,13 +769,15 @@ export class CredentialsService {
|
||||
options?: PrepareUpdateDataOptions,
|
||||
): Promise<CredentialsEntity> {
|
||||
const decryptedData = await this.decrypt(existingCredential, true);
|
||||
const dataMerge = options?.dataMerge ?? 'unredact';
|
||||
|
||||
const mergedData = deepCopy(data);
|
||||
if (mergedData.data) {
|
||||
mergedData.data = this.unredact(
|
||||
mergedData.data = this.applyDataMerge(
|
||||
mergedData.data,
|
||||
decryptedData,
|
||||
this.getCredentialTypeProperties(existingCredential.type),
|
||||
existingCredential.type,
|
||||
dataMerge,
|
||||
);
|
||||
}
|
||||
if (existingCredential.usageScope === 'instance') {
|
||||
@@ -810,15 +818,13 @@ export class CredentialsService {
|
||||
|
||||
await validateEntity(updateData);
|
||||
|
||||
// Do not overwrite the oauth data else data like the access or refresh token would get lost
|
||||
// every time anybody changes anything on the credentials even if it is just the name.
|
||||
// Exception: when toggling to private (Static→Private), the shared token must be cleared.
|
||||
if (decryptedData.oauthTokenData && !options?.clearOauthTokenData) {
|
||||
// @ts-expect-error data is typed as encrypted string
|
||||
updateData.data.oauthTokenData = decryptedData.oauthTokenData;
|
||||
}
|
||||
|
||||
if (!options?.clearOauthTokenData) {
|
||||
// Keep oauth / DCR fields unless the caller replaces the blob or clears the token
|
||||
// (e.g. Static→Private toggle).
|
||||
if (dataMerge !== 'replace' && !options?.clearOauthTokenData) {
|
||||
if (decryptedData.oauthTokenData) {
|
||||
// @ts-expect-error data is typed as encrypted string
|
||||
updateData.data.oauthTokenData = decryptedData.oauthTokenData;
|
||||
}
|
||||
this.restoreHiddenDcrFields(
|
||||
existingCredential.type,
|
||||
updateData.data as unknown as ICredentialDataDecryptedObject,
|
||||
@@ -826,10 +832,12 @@ export class CredentialsService {
|
||||
);
|
||||
}
|
||||
|
||||
this.validateOAuthCredentialUrls(
|
||||
updateData.type,
|
||||
updateData.data as unknown as ICredentialDataDecryptedObject,
|
||||
);
|
||||
if (updateData.data) {
|
||||
this.validateOAuthCredentialUrls(
|
||||
updateData.type,
|
||||
updateData.data as unknown as ICredentialDataDecryptedObject,
|
||||
);
|
||||
}
|
||||
return updateData;
|
||||
}
|
||||
|
||||
@@ -1162,6 +1170,10 @@ export class CredentialsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async findCredentialOwningProject(credentialId: string) {
|
||||
return await this.sharedCredentialsRepository.findCredentialOwningProject(credentialId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a credential.
|
||||
*
|
||||
@@ -1200,10 +1212,30 @@ export class CredentialsService {
|
||||
`This credential is assigned to credential use "${result.credentialUseIds.join(', ')}" and cannot be deleted`,
|
||||
);
|
||||
}
|
||||
if (result.status === 'deleted') {
|
||||
this.emitCredentialDeleted(user, credential);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.credentialsRepository.remove(credential);
|
||||
this.emitCredentialDeleted(user, credential);
|
||||
}
|
||||
|
||||
private emitCredentialDeleted(user: User, credential: CredentialsEntity) {
|
||||
this.eventService.emit('credentials-deleted', {
|
||||
user,
|
||||
credentialType: credential.type,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
|
||||
if (credential.isResolvable) {
|
||||
this.eventService.emit('private-credential-deleted', {
|
||||
user,
|
||||
credentialType: credential.type,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async test(userId: User['id'], credentials: ICredentialsDecrypted) {
|
||||
@@ -1211,7 +1243,7 @@ export class CredentialsService {
|
||||
}
|
||||
|
||||
async testById(userId: User['id'], credentialId: string) {
|
||||
const storedCredential = await this.credentialsFinderService.findCredentialById(credentialId);
|
||||
const storedCredential = await this.credentialsFinderService.findById(credentialId);
|
||||
|
||||
// Dynamic-credential flows only; admins test instance credentials via testWithCredentials
|
||||
if (!storedCredential || storedCredential.usageScope !== 'project') {
|
||||
|
||||
@@ -12,58 +12,6 @@ describe('CredentialsRepository', () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('findMany', () => {
|
||||
const credentialsId = 'cred_123';
|
||||
const credential = mock<CredentialsEntity>({ id: credentialsId });
|
||||
|
||||
test('return `data` property if `includeData:true` and select is using the record syntax', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findMany({ includeData: true, select: { id: true } });
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('return `data` property if `includeData:true` and select is using the array syntax', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findMany({
|
||||
includeData: true,
|
||||
//TODO: fix this
|
||||
// The function's type does not support this but this is what it
|
||||
// actually gets from the service because the middlewares are typed
|
||||
// loosely.
|
||||
select: ['id'] as never,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('should include isGlobal in default select', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ACT
|
||||
await repository.findMany();
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.objectContaining({
|
||||
select: expect.arrayContaining(['isGlobal']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findStartingWith', () => {
|
||||
it('only searches project credential names', async () => {
|
||||
entityManager.find.mockResolvedValueOnce([]);
|
||||
|
||||
+3
-3
@@ -93,7 +93,7 @@ describe('AgentsBuilderSettingsService', () => {
|
||||
id: 'cred-1',
|
||||
type: opts.credentialType ?? 'anthropicApi',
|
||||
});
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(credential);
|
||||
credentialsFinderService.findById.mockResolvedValue(credential);
|
||||
credentialsService.decrypt.mockReturnValue({
|
||||
apiKey: opts.apiKey ?? 'sk-test',
|
||||
...(opts.url ? { url: opts.url } : {}),
|
||||
@@ -272,7 +272,7 @@ describe('AgentsBuilderSettingsService', () => {
|
||||
modelName: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||
});
|
||||
aiService.isProxyEnabled.mockReturnValue(false);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(
|
||||
credentialsFinderService.findById.mockResolvedValue(
|
||||
mock<CredentialsEntity>({ id: 'cred-1', type: 'aws' }),
|
||||
);
|
||||
credentialsService.decrypt.mockReturnValue({
|
||||
@@ -320,7 +320,7 @@ describe('AgentsBuilderSettingsService', () => {
|
||||
credentialId: 'cred-deleted',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
});
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(null);
|
||||
credentialsFinderService.findById.mockResolvedValue(null);
|
||||
aiService.isProxyEnabled.mockReturnValue(false);
|
||||
process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env';
|
||||
|
||||
|
||||
+7
-7
@@ -53,7 +53,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'openAiApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
apiKey: 'sk-key',
|
||||
url: 'https://openai-compatible.example/v1',
|
||||
@@ -89,7 +89,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'openAiApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
apiKey: 'sk-key',
|
||||
url: 'https://openai-compatible.example/v1',
|
||||
@@ -115,7 +115,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'openAiApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
apiKey: 'sk-key',
|
||||
url: 'https://api.openai.com/v1',
|
||||
@@ -136,7 +136,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'openAiApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
apiKey: 'sk-key',
|
||||
url: 'https://openai-compatible.example/v1',
|
||||
@@ -154,7 +154,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'openAiApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
apiKey: 'sk-key',
|
||||
url: 'https://openai-compatible.example/v1',
|
||||
@@ -218,7 +218,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'anthropicApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({ apiKey: 'sk-key', url: 'https://proxy.local' });
|
||||
listModelsForProvider.mockResolvedValue([
|
||||
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
@@ -242,7 +242,7 @@ describe('BuilderModelLiveLookupService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'anthropicApi'),
|
||||
);
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsFinderService.findById.mockResolvedValue(mock<CredentialsEntity>());
|
||||
credentialsService.decrypt.mockResolvedValue({ apiKey: 'sk-key' });
|
||||
// An empty list from a chat provider is far more likely a broken request
|
||||
// or drifted response shape than a real zero-model account — callers must
|
||||
|
||||
@@ -207,9 +207,7 @@ export class AgentsBuilderSettingsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const credential = await this.credentialsFinderService.findCredentialById(
|
||||
settings.credentialId,
|
||||
);
|
||||
const credential = await this.credentialsFinderService.findById(settings.credentialId);
|
||||
if (!credential) return null;
|
||||
|
||||
const data = await this.credentialsService.decrypt(credential, true);
|
||||
|
||||
@@ -77,7 +77,7 @@ export class BuilderModelLiveLookupService {
|
||||
throw new Error(`Credential ${credentialId} not found or not accessible`);
|
||||
}
|
||||
|
||||
const credential = await this.credentialsFinderService.findCredentialById(credentialId);
|
||||
const credential = await this.credentialsFinderService.findById(credentialId);
|
||||
if (!credential) {
|
||||
throw new Error(`Credential ${credentialId} not found or not accessible`);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Project, withTransaction } from '@n8n/db';
|
||||
import { parseListQuerySortBy, Project, withTransaction } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, EntityManager, In, Repository, SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import {
|
||||
@@ -262,13 +262,8 @@ export class DataTableRepository extends Repository<DataTable> {
|
||||
return;
|
||||
}
|
||||
|
||||
const [field, order] = this.parseSortingParams(sortBy);
|
||||
this.applySortingByField(query, field, order);
|
||||
}
|
||||
|
||||
private parseSortingParams(sortBy: string): [string, 'DESC' | 'ASC'] {
|
||||
const [field, order] = sortBy.split(':');
|
||||
return [field, order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'];
|
||||
const { column, direction } = parseListQuerySortBy(sortBy);
|
||||
this.applySortingByField(query, column, direction);
|
||||
}
|
||||
|
||||
private applySortingByField(
|
||||
|
||||
@@ -310,7 +310,7 @@ export class DynamicCredentialsController {
|
||||
res: Response,
|
||||
@Param('credentialId') credentialId: string,
|
||||
): Promise<void> {
|
||||
const credential = await this.credentialsFinderService.findCredentialById(credentialId);
|
||||
const credential = await this.credentialsFinderService.findById(credentialId);
|
||||
|
||||
if (!credential) {
|
||||
throw new NotFoundError('Credential not found');
|
||||
|
||||
+2
-7
@@ -1,6 +1,6 @@
|
||||
import { isValidTimeZone } from '@n8n/api-types';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { sql } from '@n8n/db';
|
||||
import { parseListQuerySortBy, sql } from '@n8n/db';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { DataSource, LessThanOrEqual, Repository } from '@n8n/typeorm';
|
||||
@@ -347,11 +347,6 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
return summaryParser.parse(rawRows);
|
||||
}
|
||||
|
||||
private parseSortingParams(sortBy: string): [string, 'ASC' | 'DESC'] {
|
||||
const [column, order] = sortBy.split(':');
|
||||
return [column, order.toUpperCase() as 'ASC' | 'DESC'];
|
||||
}
|
||||
|
||||
private async countInsightsByWorkflowGroups(
|
||||
rawRowsQuery: SelectQueryBuilder<InsightsByPeriod>,
|
||||
): Promise<number> {
|
||||
@@ -382,7 +377,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
endDate: Date;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const [sortField, sortOrder] = this.parseSortingParams(sortBy);
|
||||
const { column: sortField, direction: sortOrder } = parseListQuerySortBy(sortBy);
|
||||
const sumOfExecutions = sql`SUM(CASE WHEN insights.type IN (${TypeToNumber.success.toString()}, ${TypeToNumber.failure.toString()}) THEN value ELSE 0 END)`;
|
||||
|
||||
const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate, timeZone });
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('OauthService', () => {
|
||||
user: mock<User>({ id: '123' }),
|
||||
});
|
||||
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(
|
||||
credentialsFinderService.findById.mockResolvedValue(
|
||||
mock<CredentialsEntity>({ id: 'credential-id', isResolvable: false }),
|
||||
);
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential);
|
||||
@@ -327,7 +327,7 @@ describe('OauthService', () => {
|
||||
user: mock<User>({ id: '123' }),
|
||||
});
|
||||
|
||||
credentialsFinderService.findCredentialById.mockResolvedValue(mockCredential);
|
||||
credentialsFinderService.findById.mockResolvedValue(mockCredential);
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential);
|
||||
|
||||
const result = await service.getCredentialForAuthFlow(req);
|
||||
|
||||
@@ -284,12 +284,9 @@ export class OauthService {
|
||||
// Private credentials are connected per-user, so executing users can authorize
|
||||
// their own account without edit rights. Shared/static credentials store the
|
||||
// token on the shared credential itself, so connecting them still requires edit.
|
||||
const existingCredential = await this.credentialsFinderService.findCredentialById(
|
||||
credentialId,
|
||||
{
|
||||
includeInstanceCredentials: true,
|
||||
},
|
||||
);
|
||||
const existingCredential = await this.credentialsFinderService.findById(credentialId, {
|
||||
includeInstanceCredentials: true,
|
||||
});
|
||||
const requiredScope = existingCredential?.isResolvable
|
||||
? 'credential:connect'
|
||||
: 'credential:update';
|
||||
|
||||
-760
@@ -1,760 +0,0 @@
|
||||
import type { CredentialsEntity, Project, SharedCredentials, User } from '@n8n/db';
|
||||
import {
|
||||
CredentialsRepository,
|
||||
GLOBAL_OWNER_ROLE,
|
||||
GLOBAL_MEMBER_ROLE,
|
||||
SharedCredentialsRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { validate, type Schema } from 'jsonschema';
|
||||
import { Cipher, CipherAes256GCM, CipherAes256CBC, EncryptionKeyProxy } from 'n8n-core';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import type { GenericValue, IDataObject, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
buildSharedForCredential,
|
||||
saveCredential,
|
||||
toJsonSchema,
|
||||
updateCredential,
|
||||
} from '../credentials.service';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
|
||||
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
|
||||
import * as checkAccess from '@/permissions.ee/check-access';
|
||||
import type { IDependency } from '@/public-api/types';
|
||||
|
||||
// Set up real Cipher with mocked InstanceSettings for encryption
|
||||
const cipher = new Cipher(
|
||||
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
|
||||
new CipherAes256GCM(),
|
||||
new CipherAes256CBC(),
|
||||
new EncryptionKeyProxy(),
|
||||
);
|
||||
Container.set(Cipher, cipher);
|
||||
|
||||
describe('CredentialsService', () => {
|
||||
let mockExternalSecretsConfig: ExternalSecretsConfig;
|
||||
const canAccessProviderFromProjectMock = vi.fn();
|
||||
const mockSecretsProviderAccessCheckService = mock<SecretsProviderAccessCheckService>({
|
||||
isProviderAvailableInProject: canAccessProviderFromProjectMock,
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockExternalSecretsConfig = new ExternalSecretsConfig();
|
||||
Container.set(ExternalSecretsConfig, mockExternalSecretsConfig);
|
||||
Container.set(SecretsProviderAccessCheckService, mockSecretsProviderAccessCheckService);
|
||||
|
||||
canAccessProviderFromProjectMock.mockResolvedValue(true);
|
||||
});
|
||||
describe('buildSharedForCredential', () => {
|
||||
it('returns one shared entry when credential is shared with one project', () => {
|
||||
const createdAt = new Date('2024-01-01T00:00:00.000Z');
|
||||
const updatedAt = new Date('2024-01-02T00:00:00.000Z');
|
||||
const credential = {
|
||||
shared: [
|
||||
{
|
||||
role: 'credential:owner',
|
||||
createdAt,
|
||||
updatedAt,
|
||||
project: { id: 'proj-1', name: 'My Project' },
|
||||
},
|
||||
],
|
||||
} as unknown as CredentialsEntity;
|
||||
expect(buildSharedForCredential(credential)).toEqual([
|
||||
{
|
||||
id: 'proj-1',
|
||||
name: 'My Project',
|
||||
role: 'credential:owner',
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns multiple shared entries and skips shared entries without project', () => {
|
||||
const createdAt1 = new Date('2024-01-01T00:00:00.000Z');
|
||||
const updatedAt1 = new Date('2024-01-02T00:00:00.000Z');
|
||||
const createdAt2 = new Date('2024-02-01T00:00:00.000Z');
|
||||
const updatedAt2 = new Date('2024-02-02T00:00:00.000Z');
|
||||
const credential = {
|
||||
shared: [
|
||||
{
|
||||
role: 'credential:owner',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: updatedAt1,
|
||||
project: { id: 'proj-1', name: 'Project One' },
|
||||
},
|
||||
{ role: 'credential:user', createdAt: createdAt2, updatedAt: updatedAt2, project: null },
|
||||
{
|
||||
role: 'credential:user',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: updatedAt2,
|
||||
project: { id: 'proj-2', name: 'Project Two' },
|
||||
},
|
||||
],
|
||||
} as unknown as CredentialsEntity;
|
||||
expect(buildSharedForCredential(credential)).toEqual([
|
||||
{
|
||||
id: 'proj-1',
|
||||
name: 'Project One',
|
||||
role: 'credential:owner',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: updatedAt1,
|
||||
},
|
||||
{
|
||||
id: 'proj-2',
|
||||
name: 'Project Two',
|
||||
role: 'credential:user',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: updatedAt2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCredential', () => {
|
||||
it('forces public API credentials to project usage scope', async () => {
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
|
||||
const eventService = mock<EventService>();
|
||||
Container.set(CredentialsService, credentialsService);
|
||||
Container.set(SharedCredentialsRepository, sharedCredentialsRepository);
|
||||
Container.set(EventService, eventService);
|
||||
credentialsService.createUnmanagedCredential.mockResolvedValue({
|
||||
id: 'credential-id',
|
||||
name: 'Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
isGlobal: false,
|
||||
isResolvable: false,
|
||||
resolvableAllowFallback: false,
|
||||
resolverId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
scopes: [],
|
||||
} as never);
|
||||
sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue(undefined);
|
||||
const payload = {
|
||||
type: 'testApi',
|
||||
name: 'Credential',
|
||||
data: { apiKey: 'secret' },
|
||||
usageScope: 'instance',
|
||||
};
|
||||
|
||||
await saveCredential(payload, mock<User>());
|
||||
|
||||
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(
|
||||
{
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
data: payload.data,
|
||||
projectId: undefined,
|
||||
isResolvable: undefined,
|
||||
usageScope: 'project',
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJsonSchema', () => {
|
||||
it('should create separate conditionals for different values of the same dependant field', () => {
|
||||
// This test simulates the JWT auth credential scenario where
|
||||
// multiple properties depend on the same field (keyType) but with different values
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'keyType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'passphrase', name: 'Passphrase' },
|
||||
{ value: 'pemKey', name: 'PEM Key' },
|
||||
],
|
||||
displayName: 'Key Type',
|
||||
default: 'passphrase',
|
||||
},
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Secret',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
keyType: ['passphrase'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'privateKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Private Key',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
keyType: ['pemKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publicKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Public Key',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
keyType: ['pemKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
const props = schema.properties as IDataObject;
|
||||
expect(props).toBeDefined();
|
||||
expect(props.keyType).toEqual({
|
||||
type: 'string',
|
||||
enum: ['passphrase', 'pemKey'],
|
||||
});
|
||||
|
||||
// All conditional fields should not be globally required
|
||||
expect(schema.required).not.toContain('secret');
|
||||
expect(schema.required).not.toContain('privateKey');
|
||||
expect(schema.required).not.toContain('publicKey');
|
||||
|
||||
// Should have 2 separate conditionals (one for each keyType value)
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(Array.isArray(allOf)).toBe(true);
|
||||
expect(allOf?.length).toBe(2);
|
||||
|
||||
// Find conditional for passphrase
|
||||
const passphraseCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.keyType?.enum?.[0] === 'passphrase',
|
||||
) as IDependency;
|
||||
expect(passphraseCondition).toBeDefined();
|
||||
expect(passphraseCondition.then?.allOf).toHaveLength(1);
|
||||
expect(passphraseCondition.then?.allOf[0].required).toContain('secret');
|
||||
expect(passphraseCondition.then?.allOf[0].required).not.toContain('privateKey');
|
||||
expect(passphraseCondition.then?.allOf[0].required).not.toContain('publicKey');
|
||||
|
||||
// Find conditional for pemKey
|
||||
const pemKeyCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.keyType?.enum?.[0] === 'pemKey',
|
||||
) as IDependency;
|
||||
expect(pemKeyCondition).toBeDefined();
|
||||
expect(pemKeyCondition.then?.allOf).toHaveLength(2);
|
||||
expect(
|
||||
pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('privateKey')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('publicKey')),
|
||||
).toBe(true);
|
||||
expect(pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('secret'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle properties with no displayOptions as globally required', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{ name: 'apiKey', type: 'string', required: true, displayName: 'API Key', default: '' },
|
||||
{ name: 'domain', type: 'string', required: true, displayName: 'Domain', default: '' },
|
||||
{
|
||||
name: 'optionalField',
|
||||
type: 'string',
|
||||
required: false,
|
||||
displayName: 'Optional',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['apiKey', 'domain']));
|
||||
expect(schema.required).not.toContain('optionalField');
|
||||
expect(schema.allOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mix of required and conditional properties', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{ name: 'apiKey', type: 'string', required: true, displayName: 'API Key', default: '' },
|
||||
{
|
||||
name: 'authType',
|
||||
type: 'options',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'basic', name: 'Basic' },
|
||||
{ value: 'oauth2', name: 'OAuth2' },
|
||||
],
|
||||
displayName: 'Auth Type',
|
||||
default: 'basic',
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Username',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authType: ['basic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Password',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authType: ['basic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'clientId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Client ID',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authType: ['oauth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
// apiKey and authType should be globally required
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['apiKey', 'authType']));
|
||||
// Conditional fields should not be globally required
|
||||
expect(schema.required).not.toContain('username');
|
||||
expect(schema.required).not.toContain('password');
|
||||
expect(schema.required).not.toContain('clientId');
|
||||
|
||||
// Should have 2 conditionals
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(allOf?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle properties with multiple options depending on same field', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'create', name: 'Create' },
|
||||
{ value: 'update', name: 'Update' },
|
||||
{ value: 'delete', name: 'Delete' },
|
||||
],
|
||||
displayName: 'Operation',
|
||||
default: 'create',
|
||||
},
|
||||
{
|
||||
name: 'createField',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Create Field',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateField',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Update Field',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deleteField',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Delete Field',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
// Should have 3 separate conditionals (one for each operation)
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(allOf?.length).toBe(3);
|
||||
|
||||
// Verify each conditional is correct
|
||||
const createCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'create',
|
||||
) as IDependency;
|
||||
expect(createCondition?.then?.allOf[0].required).toContain('createField');
|
||||
|
||||
const updateCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'update',
|
||||
) as IDependency;
|
||||
expect(updateCondition?.then?.allOf[0].required).toContain('updateField');
|
||||
|
||||
const deleteCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'delete',
|
||||
) as IDependency;
|
||||
expect(deleteCondition?.then?.allOf[0].required).toContain('deleteField');
|
||||
});
|
||||
|
||||
it('should add "false" displayOptions.show dependant value as allof condition', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{ name: 'field1', type: 'string', required: true, displayName: 'Field 1', default: '' },
|
||||
{
|
||||
name: 'field2',
|
||||
type: 'options',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'opt1', name: 'opt1' },
|
||||
{ value: 'opt2', name: 'opt2' },
|
||||
],
|
||||
displayName: 'Field 2',
|
||||
default: 'opt1',
|
||||
},
|
||||
{
|
||||
name: 'field3',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Field 3',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field2: [false], // boolean false as dependant value
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
// Cast properties as IDataObject
|
||||
const props = schema.properties as IDataObject;
|
||||
|
||||
expect(props).toBeDefined();
|
||||
expect(props.field1).toEqual({ type: 'string' });
|
||||
expect(props.field2).toEqual({
|
||||
type: 'string',
|
||||
enum: ['opt1', 'opt2'],
|
||||
});
|
||||
expect(props.field3).toEqual({ type: 'string' });
|
||||
|
||||
// field1 and field2 required globally, field3 required conditionally
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['field1', 'field2']));
|
||||
expect(schema.required).not.toContain('field3');
|
||||
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(Array.isArray(allOf)).toBe(true);
|
||||
expect(allOf?.length).toBeGreaterThan(0);
|
||||
|
||||
const condition = allOf?.find((cond) => (cond as any).if?.properties?.field2) as IDependency;
|
||||
expect(condition).toBeDefined();
|
||||
expect((condition.if?.properties as any).field2).toEqual({
|
||||
enum: [false], // boolean false as dependant value
|
||||
});
|
||||
|
||||
// then block requires field3 when field2 === false
|
||||
expect(condition.then?.allOf.some((req: any) => req.required?.includes('field3'))).toBe(true);
|
||||
// no else block: hidden fields are optional, not forbidden
|
||||
expect((condition as any).else).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not forbid conditional fields belonging to inactive conditions', () => {
|
||||
// Mirrors the mongoDb scenario: two fields depend on the same options field
|
||||
// but with different values. A payload that includes both the active
|
||||
// and the inactive conditional field must validate.
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'configurationType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'connectionString', name: 'Connection String' },
|
||||
{ value: 'values', name: 'Values' },
|
||||
],
|
||||
displayName: 'Configuration Type',
|
||||
default: 'values',
|
||||
},
|
||||
{
|
||||
name: 'connectionString',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Connection String',
|
||||
default: '',
|
||||
displayOptions: { show: { configurationType: ['connectionString'] } },
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Host',
|
||||
default: '',
|
||||
displayOptions: { show: { configurationType: ['values'] } },
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
// No `not: { required }` anywhere in the generated schema
|
||||
expect(JSON.stringify(schema)).not.toContain('"not"');
|
||||
|
||||
// A payload using connectionString but also carrying the inactive `host` field
|
||||
// must be accepted.
|
||||
const connectionStringPayload = {
|
||||
configurationType: 'connectionString',
|
||||
connectionString: 'mongodb://localhost:27017/mydb',
|
||||
host: 'localhost',
|
||||
};
|
||||
expect(validate(connectionStringPayload, schema as unknown as Schema).valid).toBe(true);
|
||||
|
||||
// And the reverse direction.
|
||||
const valuesPayload = {
|
||||
configurationType: 'values',
|
||||
host: 'localhost',
|
||||
connectionString: 'mongodb://localhost:27017/mydb',
|
||||
};
|
||||
expect(validate(valuesPayload, schema as unknown as Schema).valid).toBe(true);
|
||||
|
||||
// Required fields are still enforced via the `then` block.
|
||||
const missingRequired = { configurationType: 'values' };
|
||||
expect(validate(missingRequired, schema as unknown as Schema).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCredential', () => {
|
||||
let credentialsRepository: CredentialsRepository;
|
||||
let ownerUser: User;
|
||||
let memberUser: User;
|
||||
|
||||
const credentialsService = new CredentialsService(
|
||||
mock(), // credentialsRepository
|
||||
mock(),
|
||||
mock(), // sharedCredentialsRepository
|
||||
mock(), // ownershipService
|
||||
mock(), // logger
|
||||
mock(), // errorReporter
|
||||
mock(), // credentialsTester
|
||||
mock(), // externalHooks
|
||||
mock(), // credentialTypes
|
||||
mock(), // projectRepository
|
||||
mock(), // projectService
|
||||
mock(), // roleService
|
||||
mock(), // userRepository
|
||||
mock(), // credentialsFinderService
|
||||
mock(), // credentialsHelper
|
||||
mock(), // externalSecretsConfig
|
||||
mock(), // externalSecretsProviderAccessCheckService
|
||||
mock(), // connectionStatusProxy
|
||||
mock(), // instanceCredentialAssignmentRepository
|
||||
mock(), // instanceCredentialUseRegistry
|
||||
mock(), // dbLockService
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
credentialsRepository = mock<CredentialsRepository>();
|
||||
|
||||
vi.spyOn(credentialsService, 'decrypt');
|
||||
|
||||
vi.spyOn(Container, 'get').mockImplementation((serviceClass) => {
|
||||
if (serviceClass === CredentialsService) {
|
||||
return credentialsService;
|
||||
}
|
||||
if (serviceClass === CredentialsRepository) {
|
||||
return credentialsRepository;
|
||||
}
|
||||
if (serviceClass === Cipher) {
|
||||
return cipher;
|
||||
}
|
||||
if (serviceClass === SecretsProviderAccessCheckService) {
|
||||
return mockSecretsProviderAccessCheckService;
|
||||
}
|
||||
if (serviceClass === ExternalSecretsConfig) {
|
||||
return mockExternalSecretsConfig;
|
||||
}
|
||||
return mock();
|
||||
});
|
||||
|
||||
//vi.clearAllMocks();
|
||||
|
||||
ownerUser = { id: 'user-with-permission', role: GLOBAL_OWNER_ROLE } as User;
|
||||
memberUser = { id: 'user-without-permission', role: GLOBAL_MEMBER_ROLE } as User;
|
||||
});
|
||||
|
||||
describe('external secrets', () => {
|
||||
const owningProjectData: Partial<Project> = {
|
||||
id: 'nUnAvqXSO4nw522z',
|
||||
name: 'Test Project',
|
||||
type: 'team',
|
||||
icon: { type: 'icon', value: 'layers' },
|
||||
};
|
||||
const owningProject = {
|
||||
role: 'credential:owner',
|
||||
project: owningProjectData as Project,
|
||||
} as SharedCredentials;
|
||||
it('should throw error when user without permission tries to add external secret expression', async () => {
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: 'cred-id',
|
||||
name: 'Test Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
shared: [owningProject],
|
||||
});
|
||||
|
||||
credentialsRepository.findOne = vi.fn().mockResolvedValue(existingCredential);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
// mock credential that doesn't have secret expression yet
|
||||
vi.mocked(credentialsService.decrypt).mockResolvedValue({ apiKey: 'regular-secret' });
|
||||
|
||||
await expect(
|
||||
updateCredential(existingCredential, memberUser, {
|
||||
data: { apiKey: '{{ $secrets.vault.myKey }}' },
|
||||
}),
|
||||
).rejects.toThrow('Lacking permissions to reference external secrets in credentials');
|
||||
});
|
||||
|
||||
it('should throw error when user without permission tries to modify existing external secret expression', async () => {
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: 'cred-id',
|
||||
name: 'Test Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
shared: [owningProject],
|
||||
});
|
||||
|
||||
credentialsRepository.findOne = vi.fn().mockResolvedValue(existingCredential);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Mock credential that already has secret expression
|
||||
vi.mocked(credentialsService.decrypt).mockResolvedValue({
|
||||
apiKey: '{{ $secrets.vault.oldKey }}',
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateCredential(existingCredential, memberUser, {
|
||||
data: { apiKey: '{{ $secrets.vault.newKey }}' },
|
||||
}),
|
||||
).rejects.toThrow('Lacking permissions to reference external secrets in credentials');
|
||||
});
|
||||
|
||||
it('should throw error when external secret store referenced in expression is not shared with current project', async () => {
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: 'UdGtZBYb2TLDgSHy',
|
||||
name: 'Test Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
shared: [owningProject],
|
||||
});
|
||||
const secretProviderKey = 'vault';
|
||||
const secretExpression = `={{ $secrets.${secretProviderKey}.myKey }}`;
|
||||
|
||||
credentialsRepository.findOne = vi.fn().mockResolvedValue(existingCredential);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(credentialsService.decrypt).mockResolvedValue({
|
||||
apiKey: 'currentPlainTextValue',
|
||||
});
|
||||
vi.mocked(
|
||||
mockSecretsProviderAccessCheckService.isProviderAvailableInProject,
|
||||
).mockResolvedValue(false);
|
||||
mockExternalSecretsConfig.externalSecretsForProjects = true;
|
||||
|
||||
await expect(
|
||||
updateCredential(existingCredential, ownerUser, {
|
||||
data: { apiKey: secretExpression },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'The secret provider "vault" used in "apiKey" does not exist in this project',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow updates when no external secret expression is being changed', async () => {
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: 'cred-id',
|
||||
name: 'Test Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
shared: [owningProject],
|
||||
});
|
||||
|
||||
credentialsRepository.findOne = vi.fn().mockResolvedValue(existingCredential);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Mock credential that has existing secret expression
|
||||
vi.mocked(credentialsService.decrypt).mockResolvedValue({
|
||||
apiKey: '{{ $secrets.vault.myKey }}',
|
||||
});
|
||||
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await updateCredential(existingCredential, memberUser, {
|
||||
name: 'Updated Name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow updates of non-secret data without specific external-secret permission', async () => {
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: 'cred-id',
|
||||
name: 'Test Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
shared: [owningProject],
|
||||
});
|
||||
|
||||
credentialsRepository.findOne = vi.fn().mockResolvedValue(existingCredential);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Mock credential not using any external secret expressions
|
||||
vi.mocked(credentialsService.decrypt).mockResolvedValue({ apiKey: 'regular-key' });
|
||||
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await updateCredential(existingCredential, memberUser, {
|
||||
data: { apiKey: 'another-regular-key' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow user with permission to add external secret expression', async () => {
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: 'cred-id',
|
||||
name: 'Test Credential',
|
||||
type: 'testApi',
|
||||
isManaged: false,
|
||||
shared: [owningProject],
|
||||
});
|
||||
credentialsRepository.findOne = vi.fn().mockResolvedValue(existingCredential);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(credentialsService.decrypt).mockResolvedValue({ apiKey: 'regular-key' });
|
||||
vi.mocked(
|
||||
mockSecretsProviderAccessCheckService.isProviderAvailableInProject,
|
||||
).mockResolvedValue(true);
|
||||
credentialsRepository.update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await updateCredential(existingCredential, ownerUser, {
|
||||
data: { apiKey: '{{ $secrets.vault.myKey }}' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
import type { CredentialsEntity } from '@n8n/db';
|
||||
import { validate, type Schema } from 'jsonschema';
|
||||
import type { GenericValue, IDataObject, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import type { IDependency } from '@/public-api/types';
|
||||
|
||||
import { buildSharedForCredential, toJsonSchema } from '../credentials.utils';
|
||||
|
||||
describe('credentials.utils', () => {
|
||||
describe('buildSharedForCredential', () => {
|
||||
it('returns one shared entry when credential is shared with one project', () => {
|
||||
const createdAt = new Date('2024-01-01T00:00:00.000Z');
|
||||
const updatedAt = new Date('2024-01-02T00:00:00.000Z');
|
||||
const credential = {
|
||||
shared: [
|
||||
{
|
||||
role: 'credential:owner',
|
||||
createdAt,
|
||||
updatedAt,
|
||||
project: { id: 'proj-1', name: 'My Project' },
|
||||
},
|
||||
],
|
||||
} as unknown as CredentialsEntity;
|
||||
expect(buildSharedForCredential(credential)).toEqual([
|
||||
{
|
||||
id: 'proj-1',
|
||||
name: 'My Project',
|
||||
role: 'credential:owner',
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns multiple shared entries and skips shared entries without project', () => {
|
||||
const createdAt1 = new Date('2024-01-01T00:00:00.000Z');
|
||||
const updatedAt1 = new Date('2024-01-02T00:00:00.000Z');
|
||||
const createdAt2 = new Date('2024-02-01T00:00:00.000Z');
|
||||
const updatedAt2 = new Date('2024-02-02T00:00:00.000Z');
|
||||
const credential = {
|
||||
shared: [
|
||||
{
|
||||
role: 'credential:owner',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: updatedAt1,
|
||||
project: { id: 'proj-1', name: 'Project One' },
|
||||
},
|
||||
{ role: 'credential:user', createdAt: createdAt2, updatedAt: updatedAt2, project: null },
|
||||
{
|
||||
role: 'credential:user',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: updatedAt2,
|
||||
project: { id: 'proj-2', name: 'Project Two' },
|
||||
},
|
||||
],
|
||||
} as unknown as CredentialsEntity;
|
||||
expect(buildSharedForCredential(credential)).toEqual([
|
||||
{
|
||||
id: 'proj-1',
|
||||
name: 'Project One',
|
||||
role: 'credential:owner',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: updatedAt1,
|
||||
},
|
||||
{
|
||||
id: 'proj-2',
|
||||
name: 'Project Two',
|
||||
role: 'credential:user',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: updatedAt2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJsonSchema', () => {
|
||||
it('should create separate conditionals for different values of the same dependant field', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'keyType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'passphrase', name: 'Passphrase' },
|
||||
{ value: 'pemKey', name: 'PEM Key' },
|
||||
],
|
||||
displayName: 'Key Type',
|
||||
default: 'passphrase',
|
||||
},
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Secret',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
keyType: ['passphrase'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'privateKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Private Key',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
keyType: ['pemKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publicKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Public Key',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
keyType: ['pemKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
const props = schema.properties as IDataObject;
|
||||
expect(props).toBeDefined();
|
||||
expect(props.keyType).toEqual({
|
||||
type: 'string',
|
||||
enum: ['passphrase', 'pemKey'],
|
||||
});
|
||||
|
||||
expect(schema.required).not.toContain('secret');
|
||||
expect(schema.required).not.toContain('privateKey');
|
||||
expect(schema.required).not.toContain('publicKey');
|
||||
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(Array.isArray(allOf)).toBe(true);
|
||||
expect(allOf?.length).toBe(2);
|
||||
|
||||
const passphraseCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.keyType?.enum?.[0] === 'passphrase',
|
||||
) as IDependency;
|
||||
expect(passphraseCondition).toBeDefined();
|
||||
expect(passphraseCondition.then?.allOf).toHaveLength(1);
|
||||
expect(passphraseCondition.then?.allOf[0].required).toContain('secret');
|
||||
expect(passphraseCondition.then?.allOf[0].required).not.toContain('privateKey');
|
||||
expect(passphraseCondition.then?.allOf[0].required).not.toContain('publicKey');
|
||||
|
||||
const pemKeyCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.keyType?.enum?.[0] === 'pemKey',
|
||||
) as IDependency;
|
||||
expect(pemKeyCondition).toBeDefined();
|
||||
expect(pemKeyCondition.then?.allOf).toHaveLength(2);
|
||||
expect(
|
||||
pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('privateKey')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('publicKey')),
|
||||
).toBe(true);
|
||||
expect(pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('secret'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle properties with no displayOptions as globally required', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{ name: 'apiKey', type: 'string', required: true, displayName: 'API Key', default: '' },
|
||||
{ name: 'domain', type: 'string', required: true, displayName: 'Domain', default: '' },
|
||||
{
|
||||
name: 'optionalField',
|
||||
type: 'string',
|
||||
required: false,
|
||||
displayName: 'Optional',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['apiKey', 'domain']));
|
||||
expect(schema.required).not.toContain('optionalField');
|
||||
expect(schema.allOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mix of required and conditional properties', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{ name: 'apiKey', type: 'string', required: true, displayName: 'API Key', default: '' },
|
||||
{
|
||||
name: 'authType',
|
||||
type: 'options',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'basic', name: 'Basic' },
|
||||
{ value: 'oauth2', name: 'OAuth2' },
|
||||
],
|
||||
displayName: 'Auth Type',
|
||||
default: 'basic',
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Username',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authType: ['basic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Password',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authType: ['basic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'clientId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Client ID',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authType: ['oauth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['apiKey', 'authType']));
|
||||
expect(schema.required).not.toContain('username');
|
||||
expect(schema.required).not.toContain('password');
|
||||
expect(schema.required).not.toContain('clientId');
|
||||
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(allOf?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle properties with multiple options depending on same field', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'create', name: 'Create' },
|
||||
{ value: 'update', name: 'Update' },
|
||||
{ value: 'delete', name: 'Delete' },
|
||||
],
|
||||
displayName: 'Operation',
|
||||
default: 'create',
|
||||
},
|
||||
{
|
||||
name: 'createField',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Create Field',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateField',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Update Field',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deleteField',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Delete Field',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(allOf?.length).toBe(3);
|
||||
|
||||
const createCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'create',
|
||||
) as IDependency;
|
||||
expect(createCondition?.then?.allOf[0].required).toContain('createField');
|
||||
|
||||
const updateCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'update',
|
||||
) as IDependency;
|
||||
expect(updateCondition?.then?.allOf[0].required).toContain('updateField');
|
||||
|
||||
const deleteCondition = allOf?.find(
|
||||
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'delete',
|
||||
) as IDependency;
|
||||
expect(deleteCondition?.then?.allOf[0].required).toContain('deleteField');
|
||||
});
|
||||
|
||||
it('should add "false" displayOptions.show dependant value as allof condition', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{ name: 'field1', type: 'string', required: true, displayName: 'Field 1', default: '' },
|
||||
{
|
||||
name: 'field2',
|
||||
type: 'options',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'opt1', name: 'opt1' },
|
||||
{ value: 'opt2', name: 'opt2' },
|
||||
],
|
||||
displayName: 'Field 2',
|
||||
default: 'opt1',
|
||||
},
|
||||
{
|
||||
name: 'field3',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Field 3',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field2: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
const props = schema.properties as IDataObject;
|
||||
|
||||
expect(props).toBeDefined();
|
||||
expect(props.field1).toEqual({ type: 'string' });
|
||||
expect(props.field2).toEqual({
|
||||
type: 'string',
|
||||
enum: ['opt1', 'opt2'],
|
||||
});
|
||||
expect(props.field3).toEqual({ type: 'string' });
|
||||
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['field1', 'field2']));
|
||||
expect(schema.required).not.toContain('field3');
|
||||
|
||||
const allOf = schema.allOf as GenericValue[] | IDataObject[];
|
||||
expect(Array.isArray(allOf)).toBe(true);
|
||||
expect(allOf?.length).toBeGreaterThan(0);
|
||||
|
||||
const condition = allOf?.find((cond) => (cond as any).if?.properties?.field2) as IDependency;
|
||||
expect(condition).toBeDefined();
|
||||
expect((condition.if?.properties as any).field2).toEqual({
|
||||
enum: [false],
|
||||
});
|
||||
|
||||
expect(condition.then?.allOf.some((req: any) => req.required?.includes('field3'))).toBe(true);
|
||||
expect((condition as any).else).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not forbid conditional fields belonging to inactive conditions', () => {
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'configurationType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'connectionString', name: 'Connection String' },
|
||||
{ value: 'values', name: 'Values' },
|
||||
],
|
||||
displayName: 'Configuration Type',
|
||||
default: 'values',
|
||||
},
|
||||
{
|
||||
name: 'connectionString',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Connection String',
|
||||
default: '',
|
||||
displayOptions: { show: { configurationType: ['connectionString'] } },
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayName: 'Host',
|
||||
default: '',
|
||||
displayOptions: { show: { configurationType: ['values'] } },
|
||||
},
|
||||
];
|
||||
|
||||
const schema = toJsonSchema(properties);
|
||||
|
||||
expect(JSON.stringify(schema)).not.toContain('"not"');
|
||||
|
||||
const connectionStringPayload = {
|
||||
configurationType: 'connectionString',
|
||||
connectionString: 'mongodb://localhost:27017/mydb',
|
||||
host: 'localhost',
|
||||
};
|
||||
expect(validate(connectionStringPayload, schema as unknown as Schema).valid).toBe(true);
|
||||
|
||||
const valuesPayload = {
|
||||
configurationType: 'values',
|
||||
host: 'localhost',
|
||||
connectionString: 'mongodb://localhost:27017/mydb',
|
||||
};
|
||||
expect(validate(valuesPayload, schema as unknown as Schema).valid).toBe(true);
|
||||
|
||||
const missingRequired = { configurationType: 'values' };
|
||||
expect(validate(missingRequired, schema as unknown as Schema).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import type { CredentialsEntity } from '@n8n/db';
|
||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import type { CredentialsEntity, ICredentialsDb, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CredentialTypes } from '@/credential-types';
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
@@ -13,6 +14,7 @@ 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 { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
import { toPublicApiCredentialResponse } from './credentials.mapper';
|
||||
import {
|
||||
@@ -21,17 +23,7 @@ import {
|
||||
validCredentialTypeForUpdate,
|
||||
validCredentialsPropertiesForUpdate,
|
||||
} from './credentials.middleware';
|
||||
import {
|
||||
buildSharedForCredential,
|
||||
CredentialsIsNotUpdatableError,
|
||||
getCredential,
|
||||
getSharedCredentials,
|
||||
removeCredential,
|
||||
sanitizeCredentials,
|
||||
saveCredential,
|
||||
toJsonSchema,
|
||||
updateCredential,
|
||||
} from './credentials.service';
|
||||
import { buildSharedForCredential, sanitizeCredentials, toJsonSchema } from './credentials.utils';
|
||||
import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import {
|
||||
@@ -42,6 +34,77 @@ import {
|
||||
} from '../../shared/middlewares/global.middleware';
|
||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||
|
||||
async function buildUpdatePayload({
|
||||
credentialsService,
|
||||
user,
|
||||
existingCredential,
|
||||
body,
|
||||
clearOauthTokenData,
|
||||
}: {
|
||||
credentialsService: CredentialsService;
|
||||
user: User;
|
||||
existingCredential: CredentialsEntity;
|
||||
body: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
data?: ICredentialDataDecryptedObject;
|
||||
isGlobal?: boolean;
|
||||
isResolvable?: boolean;
|
||||
isPartialData?: boolean;
|
||||
};
|
||||
clearOauthTokenData: boolean;
|
||||
}): Promise<{
|
||||
updatePayload: ICredentialsDb;
|
||||
decryptedDataForDeps?: ICredentialDataDecryptedObject;
|
||||
}> {
|
||||
let updatePayload: ICredentialsDb;
|
||||
let decryptedDataForDeps: ICredentialDataDecryptedObject | undefined;
|
||||
|
||||
if (body.data) {
|
||||
const preparedCredentialData = await credentialsService.prepareUpdateData(
|
||||
user,
|
||||
{
|
||||
name: body.name ?? existingCredential.name,
|
||||
type: body.type ?? existingCredential.type,
|
||||
data: body.data,
|
||||
},
|
||||
existingCredential,
|
||||
{
|
||||
dataMerge: body.isPartialData ? 'partial' : 'replace',
|
||||
clearOauthTokenData,
|
||||
},
|
||||
);
|
||||
|
||||
decryptedDataForDeps = preparedCredentialData.data as unknown as ICredentialDataDecryptedObject;
|
||||
|
||||
updatePayload = await credentialsService.createEncryptedData({
|
||||
id: existingCredential.id,
|
||||
name: preparedCredentialData.name,
|
||||
type: preparedCredentialData.type,
|
||||
data: decryptedDataForDeps,
|
||||
});
|
||||
} else {
|
||||
updatePayload = {
|
||||
id: existingCredential.id,
|
||||
name: body.name ?? existingCredential.name,
|
||||
type: body.type ?? existingCredential.type,
|
||||
data: existingCredential.data,
|
||||
createdAt: existingCredential.createdAt,
|
||||
updatedAt: existingCredential.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (body.isGlobal !== undefined) {
|
||||
updatePayload.isGlobal = body.isGlobal;
|
||||
}
|
||||
if (body.isResolvable !== undefined) {
|
||||
updatePayload.isResolvable = body.isResolvable;
|
||||
}
|
||||
updatePayload.updatedAt = new Date();
|
||||
|
||||
return { updatePayload, decryptedDataForDeps };
|
||||
}
|
||||
|
||||
type CredentialsHandlers = {
|
||||
getCredentials: PublicAPIEndpoint<CredentialRequest.GetAll>;
|
||||
getCredential: PublicAPIEndpoint<CredentialRequest.Get>;
|
||||
@@ -61,15 +124,16 @@ const credentialsHandlers: CredentialsHandlers = {
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 250);
|
||||
|
||||
const repo = Container.get(CredentialsRepository);
|
||||
const [credentials, count] = await repo.findAndCount({
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: ['id', 'name', 'type', 'createdAt', 'updatedAt'],
|
||||
relations: ['shared', 'shared.project'],
|
||||
order: { createdAt: 'DESC' },
|
||||
where: { usageScope: 'project' },
|
||||
});
|
||||
const { credentials, count } = await Container.get(CredentialsService).getManyAndCount(
|
||||
req.user,
|
||||
{
|
||||
listQueryOptions: {
|
||||
take: limit,
|
||||
skip: offset,
|
||||
sortBy: 'createdAt:desc',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = credentials.map((credential: CredentialsEntity) => {
|
||||
const shared = buildSharedForCredential(credential);
|
||||
@@ -99,7 +163,9 @@ const credentialsHandlers: CredentialsHandlers = {
|
||||
async (req, res) => {
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
const credential = await getCredential(credentialId);
|
||||
const credential = await Container.get(CredentialsFinderService).findById(credentialId, {
|
||||
includeSharedProject: true,
|
||||
});
|
||||
if (!credential) {
|
||||
throw new NotFoundError('Credential not found');
|
||||
}
|
||||
@@ -132,8 +198,46 @@ const credentialsHandlers: CredentialsHandlers = {
|
||||
validCredentialsProperties,
|
||||
publicApiScope('credential:create'),
|
||||
async (req, res) => {
|
||||
const savedCredential = await saveCredential(req.body, req.user);
|
||||
return res.json(savedCredential);
|
||||
const credentialsService = Container.get(CredentialsService);
|
||||
const { scopes: _scopes, ...credential } = await credentialsService.createUnmanagedCredential(
|
||||
{
|
||||
type: req.body.type,
|
||||
name: req.body.name,
|
||||
data: req.body.data,
|
||||
projectId: req.body.projectId,
|
||||
isResolvable: req.body.isResolvable,
|
||||
usageScope: 'project',
|
||||
},
|
||||
req.user,
|
||||
);
|
||||
|
||||
const project = await credentialsService.findCredentialOwningProject(credential.id);
|
||||
|
||||
Container.get(EventService).emit('credentials-created', {
|
||||
user: req.user,
|
||||
credentialType: credential.type,
|
||||
credentialId: credential.id,
|
||||
publicApi: true,
|
||||
projectId: project?.id,
|
||||
projectType: project?.type,
|
||||
isDynamic: credential.isResolvable ?? false,
|
||||
jweEnabled: req.body.data.jweEnabled === true,
|
||||
});
|
||||
|
||||
return res.json(
|
||||
toPublicApiCredentialResponse({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
isManaged: credential.isManaged,
|
||||
isGlobal: credential.isGlobal,
|
||||
isResolvable: credential.isResolvable,
|
||||
resolvableAllowFallback: credential.resolvableAllowFallback,
|
||||
resolverId: credential.resolverId,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
}),
|
||||
);
|
||||
},
|
||||
],
|
||||
updateCredential: [
|
||||
@@ -143,12 +247,20 @@ const credentialsHandlers: CredentialsHandlers = {
|
||||
projectScope('credential:update', 'credential'),
|
||||
async (req, res) => {
|
||||
const { id: credentialId } = req.params;
|
||||
const credentialsService = Container.get(CredentialsService);
|
||||
|
||||
const existingCredential = await getCredential(credentialId);
|
||||
const existingCredential = await Container.get(CredentialsFinderService).findById(
|
||||
credentialId,
|
||||
{ includeSharedProject: true },
|
||||
);
|
||||
if (!existingCredential) {
|
||||
throw new NotFoundError('Credential not found');
|
||||
}
|
||||
|
||||
if (existingCredential.isManaged) {
|
||||
throw new BadRequestError('Managed credentials cannot be updated.');
|
||||
}
|
||||
|
||||
if (req.body.isGlobal !== undefined && req.body.isGlobal !== existingCredential.isGlobal) {
|
||||
if (!Container.get(LicenseState).isSharingLicensed()) {
|
||||
throw new ForbiddenError('You are not licensed for sharing credentials');
|
||||
@@ -180,17 +292,30 @@ const credentialsHandlers: CredentialsHandlers = {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedCredential = await updateCredential(existingCredential, req.user, req.body);
|
||||
const isChangingAuthType =
|
||||
req.body.type !== undefined && req.body.type !== existingCredential.type;
|
||||
const isTogglingToPrivate =
|
||||
Boolean(req.body.isResolvable) && !existingCredential.isResolvable;
|
||||
|
||||
return res.json(toPublicApiCredentialResponse(updatedCredential));
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialsIsNotUpdatableError) {
|
||||
throw new BadRequestError(error.message);
|
||||
}
|
||||
const { updatePayload, decryptedDataForDeps } = await buildUpdatePayload({
|
||||
credentialsService,
|
||||
user: req.user,
|
||||
existingCredential,
|
||||
body: req.body,
|
||||
clearOauthTokenData: isTogglingToPrivate || isChangingAuthType,
|
||||
});
|
||||
|
||||
throw error;
|
||||
const updatedCredential = await credentialsService.update(
|
||||
credentialId,
|
||||
updatePayload,
|
||||
decryptedDataForDeps,
|
||||
);
|
||||
|
||||
if (!updatedCredential) {
|
||||
throw new NotFoundError('Credential not found');
|
||||
}
|
||||
|
||||
return res.json(toPublicApiCredentialResponse(updatedCredential));
|
||||
},
|
||||
],
|
||||
transferCredential: [
|
||||
@@ -213,33 +338,18 @@ const credentialsHandlers: CredentialsHandlers = {
|
||||
projectScope('credential:delete', 'credential'),
|
||||
async (req, res) => {
|
||||
const { id: credentialId } = req.params;
|
||||
let credential: CredentialsEntity | undefined;
|
||||
|
||||
if (!hasGlobalScope(req.user, ['credential:read'])) {
|
||||
const shared = await getSharedCredentials(req.user.id, credentialId);
|
||||
|
||||
if (shared?.role === 'credential:owner') {
|
||||
credential = shared.credentials;
|
||||
}
|
||||
} else {
|
||||
credential = (await getCredential(credentialId)) ?? undefined;
|
||||
}
|
||||
const credential = await Container.get(CredentialsFinderService).findCredentialForUser(
|
||||
credentialId,
|
||||
req.user,
|
||||
['credential:delete'],
|
||||
);
|
||||
|
||||
if (!credential) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
if (credential.isResolvable) {
|
||||
const owningProject = await Container.get(
|
||||
SharedCredentialsRepository,
|
||||
).findCredentialOwningProject(credentialId);
|
||||
await Container.get(CredentialsService).ensureCanManageEndUserCredential(
|
||||
req.user,
|
||||
owningProject?.id,
|
||||
);
|
||||
}
|
||||
|
||||
await removeCredential(req.user, credential);
|
||||
await Container.get(CredentialsService).delete(req.user, credentialId);
|
||||
return res.json(sanitizeCredentials(credential));
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,9 +6,10 @@ import { validate } from 'jsonschema';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { CredentialTypes } from '@/credential-types';
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
|
||||
import { getCredential, toJsonSchema } from './credentials.service';
|
||||
import { toJsonSchema } from './credentials.utils';
|
||||
import type { CredentialRequest } from '../../../types';
|
||||
|
||||
/**
|
||||
@@ -109,7 +110,8 @@ export const validCredentialsPropertiesForUpdate = async (
|
||||
if (data !== undefined) {
|
||||
// Fetch existing credential to get type if not provided
|
||||
if (type === undefined) {
|
||||
const existingCredential = await getCredential(credentialId);
|
||||
const existingCredential =
|
||||
await Container.get(CredentialsFinderService).findById(credentialId);
|
||||
if (!existingCredential) {
|
||||
return res.status(404).json({ message: 'Credential not found' });
|
||||
}
|
||||
@@ -127,7 +129,7 @@ export const validCredentialsPropertiesForUpdate = async (
|
||||
|
||||
// If type is provided but data is not, check if type is changing
|
||||
if (type !== undefined && data === undefined) {
|
||||
const existingCredential = await getCredential(credentialId);
|
||||
const existingCredential = await Container.get(CredentialsFinderService).findById(credentialId);
|
||||
if (!existingCredential) {
|
||||
return res.status(404).json({ message: 'Credential not found' });
|
||||
}
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
import type { PublicApiCredentialResponse } from '@n8n/api-types';
|
||||
import type { User, ICredentialsDb, SharedCredentials } from '@n8n/db';
|
||||
import { CredentialsEntity, CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { Credentials } from 'n8n-core';
|
||||
import {
|
||||
BaseError,
|
||||
type DisplayCondition,
|
||||
type ICredentialDataDecryptedObject,
|
||||
type IDataObject,
|
||||
type INodeProperties,
|
||||
type INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import {
|
||||
validateAccessToReferencedSecretProviders,
|
||||
validateExternalSecretsPermissions,
|
||||
} from '@/credentials/validation';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
|
||||
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
|
||||
|
||||
import { toPublicApiCredentialResponse } from './credentials.mapper';
|
||||
import type { IDependency, IJsonSchema } from '../../../types';
|
||||
|
||||
export class CredentialsIsNotUpdatableError extends BaseError {}
|
||||
|
||||
function isNodePropertyOptions(options: unknown): options is INodePropertyOptions[] {
|
||||
return (
|
||||
Array.isArray(options) &&
|
||||
options.every(
|
||||
(item) => typeof item === 'object' && item !== null && 'value' in item && 'name' in item,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared entry for credential list: project id/name plus sharing role and timestamps.
|
||||
* Derived from credential.shared (SharedCredentials + Project), limited to these fields.
|
||||
*/
|
||||
export type CredentialListSharedItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the shared array for a credential list item from credential.shared.
|
||||
* Each entry has id, name from the project and role, createdAt, updatedAt from the shared relation.
|
||||
*/
|
||||
export function buildSharedForCredential(
|
||||
credential: CredentialsEntity,
|
||||
): CredentialListSharedItem[] {
|
||||
const shared = credential.shared;
|
||||
return shared
|
||||
.filter((sh) => typeof sh.project?.id === 'string')
|
||||
.map((sh) => ({
|
||||
id: sh.project.id,
|
||||
name: sh.project.name,
|
||||
role: sh.role,
|
||||
createdAt: sh.createdAt,
|
||||
updatedAt: sh.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCredential(credentialId: string): Promise<CredentialsEntity | null> {
|
||||
return await Container.get(CredentialsRepository).findOne({
|
||||
where: { id: credentialId, usageScope: 'project' },
|
||||
relations: ['shared', 'shared.project'],
|
||||
});
|
||||
}
|
||||
|
||||
function isProjectScopedExternalSecretsEnabled() {
|
||||
return Container.get(ExternalSecretsConfig).externalSecretsForProjects;
|
||||
}
|
||||
|
||||
export async function getSharedCredentials(
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
): Promise<SharedCredentials | null> {
|
||||
return await Container.get(SharedCredentialsRepository).findOne({
|
||||
where: {
|
||||
project: { projectRelations: { userId } },
|
||||
credentialsId: credentialId,
|
||||
},
|
||||
relations: ['credentials'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a credential via the internal CredentialsService, which handles project
|
||||
* resolution, validation, and encryption.
|
||||
*/
|
||||
export async function saveCredential(
|
||||
payload: {
|
||||
type: string;
|
||||
name: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
projectId?: string;
|
||||
isResolvable?: boolean;
|
||||
},
|
||||
user: User,
|
||||
): Promise<PublicApiCredentialResponse> {
|
||||
const { scopes: _scopes, ...credential } = await Container.get(
|
||||
CredentialsService,
|
||||
).createUnmanagedCredential(
|
||||
{
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
data: payload.data,
|
||||
projectId: payload.projectId,
|
||||
isResolvable: payload.isResolvable,
|
||||
usageScope: 'project',
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
const project = await Container.get(SharedCredentialsRepository).findCredentialOwningProject(
|
||||
credential.id,
|
||||
);
|
||||
|
||||
Container.get(EventService).emit('credentials-created', {
|
||||
user,
|
||||
credentialType: credential.type,
|
||||
credentialId: credential.id,
|
||||
publicApi: true,
|
||||
projectId: project?.id,
|
||||
projectType: project?.type,
|
||||
isDynamic: credential.isResolvable ?? false,
|
||||
jweEnabled: payload.data.jweEnabled === true,
|
||||
});
|
||||
|
||||
const credentialForApi = {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
isManaged: credential.isManaged,
|
||||
isGlobal: credential.isGlobal,
|
||||
isResolvable: credential.isResolvable,
|
||||
resolvableAllowFallback: credential.resolvableAllowFallback,
|
||||
resolverId: credential.resolverId,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
};
|
||||
|
||||
return toPublicApiCredentialResponse(credentialForApi);
|
||||
}
|
||||
|
||||
export async function updateCredential(
|
||||
existingCredential: ICredentialsDb,
|
||||
user: User,
|
||||
updateData: {
|
||||
type?: string;
|
||||
name?: string;
|
||||
data?: ICredentialDataDecryptedObject;
|
||||
isGlobal?: boolean;
|
||||
isResolvable?: boolean;
|
||||
isPartialData?: boolean;
|
||||
},
|
||||
): Promise<ICredentialsDb> {
|
||||
if (existingCredential.isManaged) {
|
||||
throw new CredentialsIsNotUpdatableError('Managed credentials cannot be updated.');
|
||||
}
|
||||
|
||||
const credentialId = existingCredential.id;
|
||||
|
||||
// Merge the update data with existing credential
|
||||
const credentialData: Partial<CredentialsEntity> = {};
|
||||
|
||||
if (updateData.name !== undefined) {
|
||||
credentialData.name = updateData.name;
|
||||
}
|
||||
|
||||
if (updateData.type !== undefined) {
|
||||
credentialData.type = updateData.type;
|
||||
}
|
||||
|
||||
// If data is provided, encrypt it
|
||||
if (updateData.data !== undefined) {
|
||||
const credentialsService = Container.get(CredentialsService);
|
||||
|
||||
// Decrypt existing data to access oauthTokenData
|
||||
const decryptedData = await credentialsService.decrypt(
|
||||
existingCredential as CredentialsEntity,
|
||||
true,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain -- credential will always have an owner
|
||||
const projectOwningCredential = existingCredential.shared?.find(
|
||||
(shared) => shared.role === 'credential:owner',
|
||||
)!;
|
||||
|
||||
await validateExternalSecretsPermissions({
|
||||
user,
|
||||
projectId: projectOwningCredential.project.id,
|
||||
dataToSave: updateData.data,
|
||||
decryptedExistingData: decryptedData,
|
||||
});
|
||||
|
||||
if (isProjectScopedExternalSecretsEnabled() && decryptedData) {
|
||||
await validateAccessToReferencedSecretProviders(
|
||||
projectOwningCredential.project.id,
|
||||
updateData.data,
|
||||
Container.get(SecretsProviderAccessCheckService),
|
||||
'update',
|
||||
);
|
||||
}
|
||||
|
||||
let dataToEncrypt: ICredentialDataDecryptedObject;
|
||||
|
||||
// If isPartialData is true, merge with existing decrypted data and unredact
|
||||
if (updateData.isPartialData === true) {
|
||||
// First merge existing decrypted data with new data
|
||||
// This ensures all existing fields are preserved unless explicitly overridden
|
||||
const mergedData = {
|
||||
...decryptedData,
|
||||
...updateData.data,
|
||||
};
|
||||
|
||||
// Then unredact any redacted values (e.g., replace "***" with original values)
|
||||
dataToEncrypt = credentialsService.unredact(mergedData, decryptedData);
|
||||
} else {
|
||||
// isPartialData is false or undefined (default): replace entire data object
|
||||
dataToEncrypt = updateData.data;
|
||||
}
|
||||
|
||||
const newCredential = new CredentialsEntity();
|
||||
Object.assign(newCredential, {
|
||||
id: credentialId,
|
||||
name: updateData.name ?? existingCredential.name,
|
||||
type: updateData.type ?? existingCredential.type,
|
||||
data: dataToEncrypt,
|
||||
});
|
||||
|
||||
const encryptedData = await encryptCredential(newCredential);
|
||||
Object.assign(credentialData, encryptedData);
|
||||
}
|
||||
|
||||
if (updateData.isResolvable !== undefined) {
|
||||
credentialData.isResolvable = updateData.isResolvable;
|
||||
}
|
||||
|
||||
if (updateData.isGlobal !== undefined) {
|
||||
credentialData.isGlobal = updateData.isGlobal;
|
||||
}
|
||||
|
||||
credentialData.updatedAt = new Date();
|
||||
|
||||
await Container.get(CredentialsRepository).update(credentialId, credentialData);
|
||||
|
||||
// credential exists since we just updated it
|
||||
return (await getCredential(credentialId))!;
|
||||
}
|
||||
|
||||
export async function removeCredential(
|
||||
user: User,
|
||||
credentials: CredentialsEntity,
|
||||
): Promise<ICredentialsDb> {
|
||||
await Container.get(ExternalHooks).run('credentials.delete', [credentials.id]);
|
||||
Container.get(EventService).emit('credentials-deleted', {
|
||||
user,
|
||||
credentialType: credentials.type,
|
||||
credentialId: credentials.id,
|
||||
});
|
||||
return await Container.get(CredentialsRepository).remove(credentials);
|
||||
}
|
||||
|
||||
export async function encryptCredential(credential: CredentialsEntity): Promise<ICredentialsDb> {
|
||||
// Encrypt the data
|
||||
const coreCredential = new Credentials({ id: null, name: credential.name }, credential.type);
|
||||
|
||||
// @ts-expect-error entity data typed as string
|
||||
await coreCredential.setData(credential.data);
|
||||
|
||||
return coreCredential.getDataToSave() as ICredentialsDb;
|
||||
}
|
||||
|
||||
export function sanitizeCredentials(credentials: CredentialsEntity): Partial<CredentialsEntity>;
|
||||
export function sanitizeCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
): Array<Partial<CredentialsEntity>>;
|
||||
|
||||
export function sanitizeCredentials(
|
||||
credentials: CredentialsEntity | CredentialsEntity[],
|
||||
): Partial<CredentialsEntity> | Array<Partial<CredentialsEntity>> {
|
||||
const argIsArray = Array.isArray(credentials);
|
||||
const credentialsList = argIsArray ? credentials : [credentials];
|
||||
|
||||
const sanitizedCredentials = credentialsList.map((credential) => {
|
||||
const { data, shared, ...rest } = credential;
|
||||
return rest;
|
||||
});
|
||||
|
||||
return argIsArray ? sanitizedCredentials : sanitizedCredentials[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* toJsonSchema
|
||||
* Take an array of credentials parameter and map it
|
||||
* to a JSON Schema (see https://json-schema.org/). With
|
||||
* the JSON Schema definition we can validate the credential's shape
|
||||
* @param properties - Credentials properties
|
||||
*/
|
||||
export function toJsonSchema(properties: INodeProperties[]): IDataObject {
|
||||
const jsonSchema: IJsonSchema = {
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {},
|
||||
allOf: [],
|
||||
required: [],
|
||||
};
|
||||
|
||||
const optionsValues: { [key: string]: string[] } = {};
|
||||
const resolveProperties: string[] = [];
|
||||
|
||||
// get all possible values of properties type "options"
|
||||
// so we can later resolve the displayOptions dependencies
|
||||
properties
|
||||
.filter((property) => property.type === 'options')
|
||||
.forEach((property) => {
|
||||
Object.assign(optionsValues, {
|
||||
[property.name]: isNodePropertyOptions(property.options)
|
||||
? property.options.map((option) => option.value)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
let requiredFields: string[] = [];
|
||||
|
||||
const propertyRequiredDependencies: { [key: string]: IDependency } = {};
|
||||
|
||||
// add all credential's properties to the properties
|
||||
// object in the JSON Schema definition. This allows us
|
||||
// to later validate that only this properties are set in
|
||||
// the credentials sent in the API call.
|
||||
// eslint-disable-next-line complexity
|
||||
properties.forEach((property) => {
|
||||
if (property.required) {
|
||||
requiredFields.push(property.name);
|
||||
}
|
||||
if (property.type === 'options') {
|
||||
// if the property is type options,
|
||||
// include all possible values in the enum property.
|
||||
Object.assign(jsonSchema.properties, {
|
||||
[property.name]: {
|
||||
type: 'string',
|
||||
enum: isNodePropertyOptions(property.options)
|
||||
? property.options.map((data) => data.value)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
Object.assign(jsonSchema.properties, {
|
||||
[property.name]: {
|
||||
type: property.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// if the credential property has a dependency
|
||||
// then add a JSON Schema condition that satisfy each property value
|
||||
// e.x: If A has value X then required B, else required C
|
||||
// see https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
|
||||
if (property.displayOptions?.show) {
|
||||
const dependantName = Object.keys(property.displayOptions?.show)[0] || '';
|
||||
const displayOptionsValues = property.displayOptions.show[dependantName];
|
||||
let dependantValue: DisplayCondition | string | number | boolean = '';
|
||||
|
||||
if (
|
||||
displayOptionsValues &&
|
||||
Array.isArray(displayOptionsValues) &&
|
||||
displayOptionsValues[0] !== undefined &&
|
||||
displayOptionsValues[0] !== null
|
||||
) {
|
||||
dependantValue = displayOptionsValues[0];
|
||||
}
|
||||
|
||||
// Create a unique key for each dependant name and value combination
|
||||
// so that if multiple properties depend on the same property but different values
|
||||
// they get their own if-then-else block
|
||||
const dependencyKey = `${dependantName}:${JSON.stringify(dependantValue)}`;
|
||||
|
||||
if (!resolveProperties.includes(dependencyKey)) {
|
||||
let conditionalValue;
|
||||
if (typeof dependantValue === 'object' && dependantValue._cnd) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const [key, targetValue] = Object.entries(dependantValue._cnd)[0];
|
||||
|
||||
if (key === 'eq') {
|
||||
conditionalValue = {
|
||||
const: [targetValue],
|
||||
};
|
||||
} else if (key === 'not') {
|
||||
conditionalValue = {
|
||||
not: {
|
||||
const: [targetValue],
|
||||
},
|
||||
};
|
||||
} else if (key === 'gt') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
exclusiveMinimum: [targetValue],
|
||||
};
|
||||
} else if (key === 'gte') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
minimum: [targetValue],
|
||||
};
|
||||
} else if (key === 'lt') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
exclusiveMaximum: [targetValue],
|
||||
};
|
||||
} else if (key === 'lte') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
maximum: [targetValue],
|
||||
};
|
||||
} else if (key === 'startsWith') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `^${targetValue}`,
|
||||
};
|
||||
} else if (key === 'endsWith') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `${targetValue}$`,
|
||||
};
|
||||
} else if (key === 'includes') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `${targetValue}`,
|
||||
};
|
||||
} else if (key === 'regex') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `${targetValue}`,
|
||||
};
|
||||
} else {
|
||||
conditionalValue = {
|
||||
enum: [dependantValue],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
conditionalValue = {
|
||||
enum: [dependantValue],
|
||||
};
|
||||
}
|
||||
propertyRequiredDependencies[dependencyKey] = {
|
||||
if: {
|
||||
properties: {
|
||||
[dependantName]: conditionalValue,
|
||||
},
|
||||
// Require the controlling field in the `if` so the condition only
|
||||
// matches when it is actually present and equal. Without this, an
|
||||
// absent controlling field makes `properties` vacuously true and the
|
||||
// `then` block would fire unexpectedly.
|
||||
required: [dependantName],
|
||||
},
|
||||
then: {
|
||||
allOf: [],
|
||||
},
|
||||
};
|
||||
resolveProperties.push(dependencyKey);
|
||||
}
|
||||
|
||||
// Only enforce a field as required when the credential actually marks it `required`.
|
||||
if (property.required) {
|
||||
propertyRequiredDependencies[dependencyKey].then?.allOf.push({
|
||||
required: [property.name],
|
||||
});
|
||||
}
|
||||
// Requiredness is now conditional, so drop it from the global required list.
|
||||
requiredFields = requiredFields.filter((field) => field !== property.name);
|
||||
}
|
||||
});
|
||||
Object.assign(jsonSchema, { required: requiredFields });
|
||||
|
||||
// Drop conditionals that ended up with no required fields, so credentials whose
|
||||
// conditional fields are all optional produce no `allOf` constraints.
|
||||
jsonSchema.allOf = Object.values(propertyRequiredDependencies).filter(
|
||||
(dependency) => (dependency.then?.allOf.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
if (!jsonSchema.allOf.length) {
|
||||
delete jsonSchema.allOf;
|
||||
}
|
||||
|
||||
return jsonSchema as unknown as IDataObject;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { CredentialsEntity } from '@n8n/db';
|
||||
import {
|
||||
type DisplayCondition,
|
||||
type IDataObject,
|
||||
type INodeProperties,
|
||||
type INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IDependency, IJsonSchema } from '../../../types';
|
||||
|
||||
function isNodePropertyOptions(options: unknown): options is INodePropertyOptions[] {
|
||||
return (
|
||||
Array.isArray(options) &&
|
||||
options.every(
|
||||
(item) => typeof item === 'object' && item !== null && 'value' in item && 'name' in item,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared entry for credential list: project id/name plus sharing role and timestamps.
|
||||
* Derived from credential.shared (SharedCredentials + Project), limited to these fields.
|
||||
*/
|
||||
export type CredentialListSharedItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the shared array for a credential list item from credential.shared.
|
||||
* Each entry has id, name from the project and role, createdAt, updatedAt from the shared relation.
|
||||
*/
|
||||
export function buildSharedForCredential(
|
||||
credential: CredentialsEntity,
|
||||
): CredentialListSharedItem[] {
|
||||
const shared = credential.shared;
|
||||
return shared
|
||||
.filter((sh) => typeof sh.project?.id === 'string')
|
||||
.map((sh) => ({
|
||||
id: sh.project.id,
|
||||
name: sh.project.name,
|
||||
role: sh.role,
|
||||
createdAt: sh.createdAt,
|
||||
updatedAt: sh.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function sanitizeCredentials(credential: CredentialsEntity): Partial<CredentialsEntity> {
|
||||
const { data, shared, ...rest } = credential;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* toJsonSchema
|
||||
* Take an array of credentials parameter and map it
|
||||
* to a JSON Schema (see https://json-schema.org/). With
|
||||
* the JSON Schema definition we can validate the credential's shape
|
||||
* @param properties - Credentials properties
|
||||
*/
|
||||
export function toJsonSchema(properties: INodeProperties[]): IDataObject {
|
||||
const jsonSchema: IJsonSchema = {
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {},
|
||||
allOf: [],
|
||||
required: [],
|
||||
};
|
||||
|
||||
const optionsValues: { [key: string]: string[] } = {};
|
||||
const resolveProperties: string[] = [];
|
||||
|
||||
// get all possible values of properties type "options"
|
||||
// so we can later resolve the displayOptions dependencies
|
||||
properties
|
||||
.filter((property) => property.type === 'options')
|
||||
.forEach((property) => {
|
||||
Object.assign(optionsValues, {
|
||||
[property.name]: isNodePropertyOptions(property.options)
|
||||
? property.options.map((option) => option.value)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
let requiredFields: string[] = [];
|
||||
|
||||
const propertyRequiredDependencies: { [key: string]: IDependency } = {};
|
||||
|
||||
// add all credential's properties to the properties
|
||||
// object in the JSON Schema definition. This allows us
|
||||
// to later validate that only this properties are set in
|
||||
// the credentials sent in the API call.
|
||||
// eslint-disable-next-line complexity
|
||||
properties.forEach((property) => {
|
||||
if (property.required) {
|
||||
requiredFields.push(property.name);
|
||||
}
|
||||
if (property.type === 'options') {
|
||||
// if the property is type options,
|
||||
// include all possible values in the enum property.
|
||||
Object.assign(jsonSchema.properties, {
|
||||
[property.name]: {
|
||||
type: 'string',
|
||||
enum: isNodePropertyOptions(property.options)
|
||||
? property.options.map((data) => data.value)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
Object.assign(jsonSchema.properties, {
|
||||
[property.name]: {
|
||||
type: property.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// if the credential property has a dependency
|
||||
// then add a JSON Schema condition that satisfy each property value
|
||||
// e.x: If A has value X then required B, else required C
|
||||
// see https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
|
||||
if (property.displayOptions?.show) {
|
||||
const dependantName = Object.keys(property.displayOptions?.show)[0] || '';
|
||||
const displayOptionsValues = property.displayOptions.show[dependantName];
|
||||
let dependantValue: DisplayCondition | string | number | boolean = '';
|
||||
|
||||
if (
|
||||
displayOptionsValues &&
|
||||
Array.isArray(displayOptionsValues) &&
|
||||
displayOptionsValues[0] !== undefined &&
|
||||
displayOptionsValues[0] !== null
|
||||
) {
|
||||
dependantValue = displayOptionsValues[0];
|
||||
}
|
||||
|
||||
// Create a unique key for each dependant name and value combination
|
||||
// so that if multiple properties depend on the same property but different values
|
||||
// they get their own if-then-else block
|
||||
const dependencyKey = `${dependantName}:${JSON.stringify(dependantValue)}`;
|
||||
|
||||
if (!resolveProperties.includes(dependencyKey)) {
|
||||
let conditionalValue;
|
||||
if (typeof dependantValue === 'object' && dependantValue._cnd) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const [key, targetValue] = Object.entries(dependantValue._cnd)[0];
|
||||
|
||||
if (key === 'eq') {
|
||||
conditionalValue = {
|
||||
const: [targetValue],
|
||||
};
|
||||
} else if (key === 'not') {
|
||||
conditionalValue = {
|
||||
not: {
|
||||
const: [targetValue],
|
||||
},
|
||||
};
|
||||
} else if (key === 'gt') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
exclusiveMinimum: [targetValue],
|
||||
};
|
||||
} else if (key === 'gte') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
minimum: [targetValue],
|
||||
};
|
||||
} else if (key === 'lt') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
exclusiveMaximum: [targetValue],
|
||||
};
|
||||
} else if (key === 'lte') {
|
||||
conditionalValue = {
|
||||
type: 'number',
|
||||
maximum: [targetValue],
|
||||
};
|
||||
} else if (key === 'startsWith') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `^${targetValue}`,
|
||||
};
|
||||
} else if (key === 'endsWith') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `${targetValue}$`,
|
||||
};
|
||||
} else if (key === 'includes') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `${targetValue}`,
|
||||
};
|
||||
} else if (key === 'regex') {
|
||||
conditionalValue = {
|
||||
type: 'string',
|
||||
pattern: `${targetValue}`,
|
||||
};
|
||||
} else {
|
||||
conditionalValue = {
|
||||
enum: [dependantValue],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
conditionalValue = {
|
||||
enum: [dependantValue],
|
||||
};
|
||||
}
|
||||
propertyRequiredDependencies[dependencyKey] = {
|
||||
if: {
|
||||
properties: {
|
||||
[dependantName]: conditionalValue,
|
||||
},
|
||||
// Require the controlling field in the `if` so the condition only
|
||||
// matches when it is actually present and equal. Without this, an
|
||||
// absent controlling field makes `properties` vacuously true and the
|
||||
// `then` block would fire unexpectedly.
|
||||
required: [dependantName],
|
||||
},
|
||||
then: {
|
||||
allOf: [],
|
||||
},
|
||||
};
|
||||
resolveProperties.push(dependencyKey);
|
||||
}
|
||||
|
||||
// Only enforce a field as required when the credential actually marks it `required`.
|
||||
if (property.required) {
|
||||
propertyRequiredDependencies[dependencyKey].then?.allOf.push({
|
||||
required: [property.name],
|
||||
});
|
||||
}
|
||||
// Requiredness is now conditional, so drop it from the global required list.
|
||||
requiredFields = requiredFields.filter((field) => field !== property.name);
|
||||
}
|
||||
});
|
||||
Object.assign(jsonSchema, { required: requiredFields });
|
||||
|
||||
// Drop conditionals that ended up with no required fields, so credentials whose
|
||||
// conditional fields are all optional produce no `allOf` constraints.
|
||||
jsonSchema.allOf = Object.values(propertyRequiredDependencies).filter(
|
||||
(dependency) => (dependency.then?.allOf.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
if (!jsonSchema.allOf.length) {
|
||||
delete jsonSchema.allOf;
|
||||
}
|
||||
|
||||
return jsonSchema as unknown as IDataObject;
|
||||
}
|
||||
@@ -61,22 +61,24 @@ describe('CredentialsFinderService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCredentialById', () => {
|
||||
describe('findById', () => {
|
||||
it('queries project credentials by default', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await credentialsFinderService.findCredentialById('credential-id');
|
||||
await credentialsFinderService.findById('credential-id');
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'credential-id', usageScope: 'project' },
|
||||
relations: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes instance credentials only when explicitly requested', async () => {
|
||||
it('includes instance credentials and shared project when requested', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await credentialsFinderService.findCredentialById('credential-id', {
|
||||
await credentialsFinderService.findById('credential-id', {
|
||||
includeInstanceCredentials: true,
|
||||
includeSharedProject: true,
|
||||
});
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
@@ -84,6 +86,7 @@ describe('CredentialsFinderService', () => {
|
||||
id: 'credential-id',
|
||||
usageScope: In(['project', 'instance']),
|
||||
},
|
||||
relations: { shared: { project: true } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import type { ListQuery } from '@n8n/db';
|
||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
@@ -546,18 +547,18 @@ describe('CredentialsRepository', () => {
|
||||
|
||||
// Both approaches need an explicit order: without one, Postgres is free to
|
||||
// return the rows in any order, and the two queries use different plans.
|
||||
const oldOptions = {
|
||||
const oldOptions: ListQuery.Options = {
|
||||
filter: { projectId: teamProject.id, name: 'Test' },
|
||||
take: 2,
|
||||
skip: 0,
|
||||
order: { id: 'ASC' as const },
|
||||
sortBy: 'id:asc',
|
||||
};
|
||||
|
||||
const newOptions = {
|
||||
const newOptions: ListQuery.Options = {
|
||||
filter: { name: 'Test' },
|
||||
take: 2,
|
||||
skip: 0,
|
||||
order: { id: 'ASC' as const },
|
||||
sortBy: 'id:asc',
|
||||
};
|
||||
|
||||
// ACT - Old Approach
|
||||
|
||||
Reference in New Issue
Block a user