feat(API): Add missing credential endpoints (GET by ID and test) (#28519)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
Util: Sync API Docs / sync-public-api (push) Waiting to run

This commit is contained in:
Ali Elkhateeb 2026-04-20 22:56:51 +02:00 committed by GitHub
parent dd6c28c6d1
commit 9a65549575
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 600 additions and 78 deletions

View file

@ -250,9 +250,9 @@ export {
} from './schemas/community-package.schema'; } from './schemas/community-package.schema';
export { export {
publicApiCreatedCredentialSchema, publicApiCredentialResponseSchema,
type PublicApiCreatedCredential, type PublicApiCredentialResponse,
} from './schemas/credential-created.schema'; } from './schemas/credential-response.schema';
export { export {
instanceAiEventTypeSchema, instanceAiEventTypeSchema,

View file

@ -1,10 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
/** /**
* Plain credential row after creation * Plain credential row in public API responses.
* Used by the public API to validate results from `CredentialsService.createUnmanagedCredential`.
*/ */
export const publicApiCreatedCredentialSchema = z.object({ export const publicApiCredentialResponseSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
type: z.string(), type: z.string(),
@ -17,4 +16,4 @@ export const publicApiCreatedCredentialSchema = z.object({
updatedAt: z.coerce.date(), updatedAt: z.coerce.date(),
}); });
export type PublicApiCreatedCredential = z.infer<typeof publicApiCreatedCredentialSchema>; export type PublicApiCredentialResponse = z.infer<typeof publicApiCredentialResponseSchema>;

View file

@ -6,6 +6,7 @@ describe('ApiKeyScope', () => {
test('Valid scopes', () => { test('Valid scopes', () => {
const validScopes: ApiKeyScope[] = [ const validScopes: ApiKeyScope[] = [
'credential:create', 'credential:create',
'credential:read',
'credential:delete', 'credential:delete',
'credential:move', 'credential:move',
'execution:delete', 'execution:delete',

View file

@ -71,7 +71,7 @@ export const API_KEY_RESOURCES = {
project: ['create', 'update', 'delete', 'list'] as const, project: ['create', 'update', 'delete', 'list'] as const,
user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const, user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const,
execution: ['delete', 'read', 'retry', 'list', 'get', 'stop'] as const, execution: ['delete', 'read', 'retry', 'list', 'get', 'stop'] as const,
credential: ['create', 'update', 'move', 'delete', 'list'] as const, credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const,
sourceControl: ['pull'] as const, sourceControl: ['pull'] as const,
workflowTags: ['update', 'list'] as const, workflowTags: ['update', 'list'] as const,
executionTags: ['update', 'list'] as const, executionTags: ['update', 'list'] as const,

View file

@ -28,6 +28,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import * as validation from '@/credentials/validation'; import * as validation from '@/credentials/validation';
import type { CredentialsHelper } from '@/credentials-helper'; import type { CredentialsHelper } from '@/credentials-helper';
import type { ExternalHooks } from '@/external-hooks'; import type { ExternalHooks } from '@/external-hooks';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import type { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import type { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import * as checkAccess from '@/permissions.ee/check-access'; import * as checkAccess from '@/permissions.ee/check-access';
@ -793,6 +794,103 @@ describe('CredentialsService', () => {
}); });
}); });
describe('testById', () => {
it('throws CredentialNotFoundError when credential does not exist', async () => {
credentialsFinderService.findCredentialById.mockResolvedValue(null);
await expect(service.testById(ownerUser.id, 'missing-credential')).rejects.toThrow(
CredentialNotFoundError,
);
expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
});
it('decrypts stored credential and calls credentials tester', async () => {
const storedCredential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Test Credential',
type: 'githubApi',
});
const decryptedData = { accessToken: 'secret-token' } as ICredentialDataDecryptedObject;
const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;
credentialsFinderService.findCredentialById.mockResolvedValue(storedCredential);
credentialsTester.testCredentials.mockResolvedValue(testResult);
jest.spyOn(service, 'decrypt').mockReturnValue(decryptedData);
const result = await service.testById(ownerUser.id, storedCredential.id);
expect(credentialsFinderService.findCredentialById).toHaveBeenCalledWith(storedCredential.id);
expect(service.decrypt).toHaveBeenCalledWith(storedCredential, true);
expect(credentialsTester.testCredentials).toHaveBeenCalledWith(
ownerUser.id,
storedCredential.type,
{
id: storedCredential.id,
name: storedCredential.name,
type: storedCredential.type,
data: decryptedData,
},
);
expect(result).toEqual(testResult);
});
});
describe('testWithCredentials', () => {
it('throws CredentialNotFoundError when user cannot access credential', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
await expect(
service.testWithCredentials(ownerUser, {
id: 'missing-credential',
name: 'Missing Credential',
type: 'githubApi',
data: {},
}),
).rejects.toThrow(CredentialNotFoundError);
expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
});
it('prepares merged credentials and runs test', async () => {
const storedCredential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Stored Credential',
type: 'githubApi',
});
const decryptedData = { accessToken: 'stored-token' } as ICredentialDataDecryptedObject;
const unredactedData = { accessToken: 'live-token' } as ICredentialDataDecryptedObject;
const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
jest.spyOn(service, 'decrypt').mockReturnValue(decryptedData);
jest.spyOn(service, 'replaceCredentialContentsForSharee').mockResolvedValue(undefined);
jest.spyOn(service, 'getCredentialTypeProperties').mockReturnValue([]);
jest.spyOn(service, 'unredact').mockReturnValue(unredactedData);
credentialsTester.testCredentials.mockResolvedValue(testResult);
const payload = {
id: 'credential-id',
name: 'Stored Credential',
type: 'githubApi',
data: { accessToken: '***' },
};
const result = await service.testWithCredentials(ownerUser, payload);
expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
payload.id,
ownerUser,
['credential:read'],
);
expect(service.decrypt).toHaveBeenCalledWith(storedCredential, true);
expect(service.unredact).toHaveBeenCalledWith(payload.data, decryptedData, []);
expect(credentialsTester.testCredentials).toHaveBeenCalledWith(ownerUser.id, payload.type, {
...payload,
data: unredactedData,
});
expect(result).toEqual(testResult);
});
});
describe('getMany', () => { describe('getMany', () => {
const regularCredential = { const regularCredential = {
id: 'cred-1', id: 'cred-1',

View file

@ -28,7 +28,6 @@ import {
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { deepCopy } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { z } from 'zod'; import { z } from 'zod';
@ -37,6 +36,7 @@ import { CredentialsService } from './credentials.service';
import { EnterpriseCredentialsService } from './credentials.service.ee'; import { EnterpriseCredentialsService } from './credentials.service.ee';
import { getExternalSecretExpressionPaths } from './external-secrets.utils'; import { getExternalSecretExpressionPaths } from './external-secrets.utils';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -140,41 +140,15 @@ export class CredentialsController {
// TODO: Write at least test cases for the failure paths. // TODO: Write at least test cases for the failure paths.
@Post('/test') @Post('/test')
async testCredentials(req: CredentialRequest.Test) { async testCredentials(req: CredentialRequest.Test) {
const { credentials } = req.body; try {
return await this.credentialsService.testWithCredentials(req.user, req.body.credentials);
} catch (error) {
if (error instanceof CredentialNotFoundError) {
throw new ForbiddenError();
}
const storedCredential = await this.credentialsFinderService.findCredentialForUser( throw error;
credentials.id,
req.user,
['credential:read'],
);
if (!storedCredential) {
throw new ForbiddenError();
} }
const mergedCredentials = deepCopy(credentials);
const decryptedData = this.credentialsService.decrypt(storedCredential, true);
// When a sharee (or project viewer) opens a credential, the fields and the
// credential data are missing so the payload will be empty
// We need to replace the credential contents with the db version if that's the case
// So the credential can be tested properly
await this.credentialsService.replaceCredentialContentsForSharee(
req.user,
storedCredential,
decryptedData,
mergedCredentials,
);
if (mergedCredentials.data) {
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
this.credentialsService.getCredentialTypeProperties(storedCredential.type),
);
}
return await this.credentialsService.test(req.user.id, mergedCredentials);
} }
@Post('/') @Post('/')

View file

@ -43,6 +43,7 @@ import {
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper'; import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
@ -819,6 +820,37 @@ export class CredentialsService {
return await this.credentialsTester.testCredentials(userId, credentials.type, credentials); return await this.credentialsTester.testCredentials(userId, credentials.type, credentials);
} }
async testById(userId: User['id'], credentialId: string) {
const storedCredential = await this.credentialsFinderService.findCredentialById(credentialId);
if (!storedCredential) {
throw new CredentialNotFoundError(credentialId);
}
const credentials = await this.prepareCredentialsForTest({ storedCredential });
return await this.test(userId, credentials);
}
async testWithCredentials(user: User, credentials: ICredentialsDecrypted) {
const storedCredential = await this.credentialsFinderService.findCredentialForUser(
credentials.id,
user,
['credential:read'],
);
if (!storedCredential) {
throw new CredentialNotFoundError(credentials.id);
}
const mergedCredentials = await this.prepareCredentialsForTest({
storedCredential,
user,
credentialsToTest: credentials,
});
return await this.test(user.id, mergedCredentials);
}
// Take data and replace all sensitive values with a sentinel value. // Take data and replace all sensitive values with a sentinel value.
// This will replace password fields and oauth data. // This will replace password fields and oauth data.
redact(data: ICredentialDataDecryptedObject, credential: CredentialsEntity) { redact(data: ICredentialDataDecryptedObject, credential: CredentialsEntity) {
@ -1286,4 +1318,51 @@ export class CredentialsService {
return { ...credential, scopes }; return { ...credential, scopes };
} }
/**
* Build credentials payload ready to pass to credential testing.
*
* - If `credentialsToTest` is not provided, uses stored decrypted credential data.
* - If `credentialsToTest` is provided, normalizes it for testing:
* - fills payload data for sharees when needed
* - restores redacted values from stored decrypted data
*/
private async prepareCredentialsForTest({
storedCredential,
user,
credentialsToTest,
}: {
storedCredential: CredentialsEntity;
user?: User;
credentialsToTest?: ICredentialsDecrypted;
}): Promise<ICredentialsDecrypted> {
const decryptedData = this.decrypt(storedCredential, true);
const mergedCredentials: ICredentialsDecrypted = credentialsToTest
? deepCopy(credentialsToTest)
: {
id: storedCredential.id,
name: storedCredential.name,
type: storedCredential.type,
data: decryptedData,
};
if (user && credentialsToTest) {
await this.replaceCredentialContentsForSharee(
user,
storedCredential,
decryptedData,
mergedCredentials,
);
if (mergedCredentials.data) {
mergedCredentials.data = this.unredact(
mergedCredentials.data,
decryptedData,
this.getCredentialTypeProperties(storedCredential.type),
);
}
}
return mergedCredentials;
}
} }

View file

@ -1,7 +1,11 @@
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
export class CredentialNotFoundError extends UserError { export class CredentialNotFoundError extends UserError {
constructor(credentialId: string, credentialType: string) { constructor(credentialId: string, credentialType?: string) {
super(`Credential with ID "${credentialId}" does not exist for type "${credentialType}".`); super(
credentialType
? `Credential with ID "${credentialId}" does not exist for type "${credentialType}".`
: `Credential with ID "${credentialId}" was not found.`,
);
} }
} }

View file

@ -171,6 +171,8 @@ export declare namespace CredentialRequest {
{ limit?: number; cursor?: string; offset?: number } { limit?: number; cursor?: string; offset?: number }
>; >;
type Get = AuthenticatedRequest<{ id: string }>;
type Create = AuthenticatedRequest< type Create = AuthenticatedRequest<
{}, {},
{}, {},
@ -192,6 +194,8 @@ export declare namespace CredentialRequest {
{} {}
>; >;
type Test = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record<string, string>>; type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record<string, string>>;
type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>; type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>;

View file

@ -0,0 +1,66 @@
import { ZodError } from 'zod';
import { UnexpectedError } from 'n8n-workflow';
import { toPublicApiCredentialResponse } from '../credentials.mapper';
type MapperInput = Parameters<typeof toPublicApiCredentialResponse>[0];
const makeCredential = (overrides: Partial<MapperInput> = {}): MapperInput => ({
id: 'cred-1',
name: 'GitHub',
type: 'githubApi',
isManaged: false,
isGlobal: false,
isResolvable: false,
createdAt: new Date('2026-01-01T10:00:00.000Z'),
updatedAt: new Date('2026-01-02T10:00:00.000Z'),
...overrides,
});
describe('toPublicApiCredentialResponse', () => {
it('sets defaults for optional mapper fields', () => {
const response = toPublicApiCredentialResponse(makeCredential());
expect(response.resolvableAllowFallback).toBe(false);
expect(response.resolverId).toBeNull();
expect(response.id).toBe('cred-1');
expect(response.name).toBe('GitHub');
expect(response.type).toBe('githubApi');
expect(response.createdAt).toEqual(new Date('2026-01-01T10:00:00.000Z'));
expect(response.updatedAt).toEqual(new Date('2026-01-02T10:00:00.000Z'));
});
it('keeps provided optional mapper fields', () => {
const response = toPublicApiCredentialResponse(
makeCredential({
resolvableAllowFallback: true,
resolverId: 'resolver-1',
}),
);
expect(response.resolvableAllowFallback).toBe(true);
expect(response.resolverId).toBe('resolver-1');
});
it('throws UnexpectedError with parse cause for invalid payload', () => {
expect(() =>
toPublicApiCredentialResponse(
makeCredential({
createdAt: 'not-a-date' as unknown as Date,
}),
),
).toThrow(UnexpectedError);
try {
toPublicApiCredentialResponse(
makeCredential({
createdAt: 'not-a-date' as unknown as Date,
}),
);
} catch (error) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe('Failed to parse credential response');
expect(error.cause).toBeInstanceOf(ZodError);
}
});
});

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-argument */
import { LicenseState } from '@n8n/backend-common'; import { LicenseState } from '@n8n/backend-common';
import type { PublicApiCredentialResponse } from '@n8n/api-types';
import type { CredentialsEntity } from '@n8n/db'; import type { CredentialsEntity } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
@ -8,9 +9,11 @@ import type express from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
import { CredentialsService } from '@/credentials/credentials.service';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import { ResponseError } from '@/errors/response-errors/abstract/response.error'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { import {
validCredentialsProperties, validCredentialsProperties,
@ -29,6 +32,7 @@ import {
toJsonSchema, toJsonSchema,
updateCredential, updateCredential,
} from './credentials.service'; } from './credentials.service';
import { toPublicApiCredentialResponse } from './credentials.mapper';
import type { CredentialTypeRequest, CredentialRequest } from '../../../types'; import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
import { import {
publicApiScope, publicApiScope,
@ -37,6 +41,8 @@ import {
validCursor, validCursor,
} from '../../shared/middlewares/global.middleware'; } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service'; import { encodeNextCursor } from '../../shared/services/pagination.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
export = { export = {
getCredentials: [ getCredentials: [
@ -92,6 +98,48 @@ export = {
}); });
}, },
], ],
getCredential: [
publicApiScope('credential:read'),
projectScope('credential:read', 'credential'),
async (
req: CredentialRequest.Get,
res: express.Response,
): Promise<express.Response<PublicApiCredentialResponse>> => {
const { id: credentialId } = req.params;
const credential = await getCredential(credentialId);
if (!credential) {
throw new NotFoundError('Credential not found');
}
return res.json(toPublicApiCredentialResponse(credential));
},
],
testCredential: [
publicApiScope('credential:read'),
projectScope('credential:read', 'credential'),
async (
req: CredentialRequest.Test,
res: express.Response<{ status: 'OK' | 'Error'; message: string } | { message: string }>,
): Promise<
express.Response<{ status: 'OK' | 'Error'; message: string } | { message: string }>
> => {
const { id: credentialId } = req.params;
try {
const credentialTestResult = await Container.get(CredentialsService).testById(
req.user.id,
credentialId,
);
return res.json(credentialTestResult);
} catch (error) {
if (error instanceof CredentialNotFoundError) {
throw new NotFoundError(error.message);
}
throw error;
}
},
],
createCredential: [ createCredential: [
validCredentialType, validCredentialType,
validCredentialsProperties, validCredentialsProperties,
@ -99,10 +147,9 @@ export = {
async ( async (
req: CredentialRequest.Create, req: CredentialRequest.Create,
res: express.Response, res: express.Response,
): Promise<express.Response<Partial<CredentialsEntity>>> => { ): Promise<express.Response<PublicApiCredentialResponse>> => {
const savedCredential = await saveCredential(req.body, req.user); const savedCredential = await saveCredential(req.body, req.user);
return res.json(savedCredential);
return res.json(sanitizeCredentials(savedCredential));
}, },
], ],
updateCredential: [ updateCredential: [
@ -113,42 +160,37 @@ export = {
async ( async (
req: CredentialRequest.Update, req: CredentialRequest.Update,
res: express.Response, res: express.Response,
): Promise<express.Response<Partial<CredentialsEntity>>> => { ): Promise<express.Response<PublicApiCredentialResponse>> => {
const { id: credentialId } = req.params; const { id: credentialId } = req.params;
const existingCredential = await getCredential(credentialId); const existingCredential = await getCredential(credentialId);
if (!existingCredential) { if (!existingCredential) {
return res.status(404).json({ message: 'Credential not found' }); throw new NotFoundError('Credential not found');
} }
if (req.body.isGlobal !== undefined && req.body.isGlobal !== existingCredential.isGlobal) { if (req.body.isGlobal !== undefined && req.body.isGlobal !== existingCredential.isGlobal) {
if (!Container.get(LicenseState).isSharingLicensed()) { if (!Container.get(LicenseState).isSharingLicensed()) {
return res.status(403).json({ message: 'You are not licensed for sharing credentials' }); throw new ForbiddenError('You are not licensed for sharing credentials');
} }
const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally'); const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally');
if (!canShareGlobally) { if (!canShareGlobally) {
return res.status(403).json({ throw new ForbiddenError(
message: 'You do not have permission to change global sharing for credentials', 'You do not have permission to change global sharing for credentials',
}); );
} }
} }
try { try {
const updatedCredential = await updateCredential(existingCredential, req.user, req.body); const updatedCredential = await updateCredential(existingCredential, req.user, req.body);
return res.json(sanitizeCredentials(updatedCredential as CredentialsEntity)); return res.json(toPublicApiCredentialResponse(updatedCredential));
} catch (error) { } catch (error) {
if (error instanceof CredentialsIsNotUpdatableError) { if (error instanceof CredentialsIsNotUpdatableError) {
return res.status(400).json({ message: error.message }); throw new BadRequestError(error.message);
} }
if (error instanceof ResponseError) { throw error;
return res.status(error.httpStatusCode).json({ message: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
return res.status(500).json({ message });
} }
}, },
], ],
@ -184,11 +226,11 @@ export = {
credential = shared.credentials; credential = shared.credentials;
} }
} else { } else {
credential = (await getCredential(credentialId)) as CredentialsEntity; credential = (await getCredential(credentialId)) ?? undefined;
} }
if (!credential) { if (!credential) {
return res.status(404).json({ message: 'Not Found' }); throw new NotFoundError('Not Found');
} }
await removeCredential(req.user, credential); await removeCredential(req.user, credential);
@ -203,7 +245,7 @@ export = {
try { try {
Container.get(CredentialTypes).getByName(credentialTypeName); Container.get(CredentialTypes).getByName(credentialTypeName);
} catch (error) { } catch (error) {
return res.status(404).json({ message: 'Not Found' }); throw new NotFoundError('Not Found');
} }
const schema = Container.get(CredentialsHelper) const schema = Container.get(CredentialsHelper)

View file

@ -0,0 +1,30 @@
import {
publicApiCredentialResponseSchema,
type PublicApiCredentialResponse,
} from '@n8n/api-types';
import type { ICredentialsDb } from '@n8n/db';
import { UnexpectedError } from 'n8n-workflow';
export function toPublicApiCredentialResponse(
credential: Pick<
ICredentialsDb,
'id' | 'name' | 'type' | 'isManaged' | 'isGlobal' | 'isResolvable' | 'createdAt' | 'updatedAt'
> & {
resolvableAllowFallback?: boolean;
resolverId?: string | null;
},
): PublicApiCredentialResponse {
const parsed = publicApiCredentialResponseSchema.safeParse({
...credential,
resolvableAllowFallback: credential.resolvableAllowFallback ?? false,
resolverId: credential.resolverId ?? null,
});
if (!parsed.success) {
throw new UnexpectedError('Failed to parse credential response', {
cause: parsed.error,
});
}
return parsed.data;
}

View file

@ -1,4 +1,4 @@
import { publicApiCreatedCredentialSchema } from '@n8n/api-types'; import type { PublicApiCredentialResponse } from '@n8n/api-types';
import type { User, ICredentialsDb, SharedCredentials } from '@n8n/db'; import type { User, ICredentialsDb, SharedCredentials } from '@n8n/db';
import { CredentialsEntity, CredentialsRepository, SharedCredentialsRepository } from '@n8n/db'; import { CredentialsEntity, CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
@ -10,7 +10,6 @@ import {
type IDataObject, type IDataObject,
type INodeProperties, type INodeProperties,
type INodePropertyOptions, type INodePropertyOptions,
UnexpectedError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
@ -23,6 +22,7 @@ import { ExternalHooks } from '@/external-hooks';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import { toPublicApiCredentialResponse } from './credentials.mapper';
import type { IDependency, IJsonSchema } from '../../../types'; import type { IDependency, IJsonSchema } from '../../../types';
export class CredentialsIsNotUpdatableError extends BaseError {} export class CredentialsIsNotUpdatableError extends BaseError {}
@ -58,7 +58,7 @@ export function buildSharedForCredential(
})); }));
} }
export async function getCredential(credentialId: string): Promise<ICredentialsDb | null> { export async function getCredential(credentialId: string): Promise<CredentialsEntity | null> {
return await Container.get(CredentialsRepository).findOne({ return await Container.get(CredentialsRepository).findOne({
where: { id: credentialId }, where: { id: credentialId },
relations: ['shared', 'shared.project'], relations: ['shared', 'shared.project'],
@ -89,7 +89,7 @@ export async function getSharedCredentials(
export async function saveCredential( export async function saveCredential(
payload: { type: string; name: string; data: ICredentialDataDecryptedObject; projectId?: string }, payload: { type: string; name: string; data: ICredentialDataDecryptedObject; projectId?: string },
user: User, user: User,
): Promise<CredentialsEntity> { ): Promise<PublicApiCredentialResponse> {
const { scopes: _scopes, ...credential } = await Container.get( const { scopes: _scopes, ...credential } = await Container.get(
CredentialsService, CredentialsService,
).createUnmanagedCredential({ ...payload, projectId: payload.projectId ?? undefined }, user); ).createUnmanagedCredential({ ...payload, projectId: payload.projectId ?? undefined }, user);
@ -121,12 +121,7 @@ export async function saveCredential(
updatedAt: credential.updatedAt, updatedAt: credential.updatedAt,
}; };
const parsed = publicApiCreatedCredentialSchema.safeParse(credentialForApi); return toPublicApiCredentialResponse(credentialForApi);
if (!parsed.success) {
throw new UnexpectedError('Credential create response failed validation');
}
return Object.assign(new CredentialsEntity(), parsed.data, { shared: [] });
} }
export async function updateCredential( export async function updateCredential(

View file

@ -0,0 +1,26 @@
post:
x-eov-operation-id: testCredential
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Test credential by ID
description: Tests a credential by ID using the stored credential data.
operationId: testCredential
parameters:
- name: id
in: path
description: The credential ID
required: true
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/credentialTestResponse.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -1,3 +1,31 @@
get:
x-eov-operation-id: getCredential
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Get credential by ID
description: Retrieves a credential by ID. Credential data (secrets) is not included.
operationId: getCredential
parameters:
- name: id
in: path
description: The credential ID
required: true
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/create-credential-response.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
patch: patch:
x-eov-operation-id: updateCredential x-eov-operation-id: updateCredential
x-eov-operation-handler: v1/handlers/credentials/credentials.handler x-eov-operation-handler: v1/handlers/credentials/credentials.handler

View file

@ -0,0 +1,12 @@
type: object
required:
- status
- message
properties:
status:
type: string
enum:
- OK
- Error
message:
type: string

View file

@ -50,6 +50,8 @@ paths:
$ref: './handlers/credentials/spec/paths/credentials.yml' $ref: './handlers/credentials/spec/paths/credentials.yml'
/credentials/{id}: /credentials/{id}:
$ref: './handlers/credentials/spec/paths/credentials.id.yml' $ref: './handlers/credentials/spec/paths/credentials.id.yml'
/credentials/{id}/test:
$ref: './handlers/credentials/spec/paths/credentials.id.test.yml'
/credentials/schema/{credentialTypeName}: /credentials/schema/{credentialTypeName}:
$ref: './handlers/credentials/spec/paths/credentials.schema.id.yml' $ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
/credentials/{id}/transfer: /credentials/{id}/transfer:

View file

@ -4,6 +4,7 @@ import { createTeamProject, randomName, testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db'; import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { import {
CREDENTIAL_BLANKING_VALUE, CREDENTIAL_BLANKING_VALUE,
type ICredentialDataDecryptedObject, type ICredentialDataDecryptedObject,
@ -11,6 +12,7 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import { CredentialsTester } from '@/services/credentials-tester.service';
import { import {
affixRoleToSaveCredential, affixRoleToSaveCredential,
@ -365,6 +367,92 @@ describe('GET /credentials', () => {
}); });
}); });
describe('GET /credentials/:id', () => {
test('should return owned credential for owner without credential data', async () => {
const savedCredential = await saveCredential(dbCredential(), { user: owner });
const response = await authOwnerAgent.get(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({
id: savedCredential.id,
name: savedCredential.name,
type: savedCredential.type,
});
expect(response.body).not.toHaveProperty('data');
expect(response.body).not.toHaveProperty('shared');
});
test('should return owned credential for member', async () => {
const memberWithReadScope = await createMemberWithApiKey({ scopes: ['credential:read'] });
const authMemberWithReadScopeAgent = testServer.publicApiAgentFor(memberWithReadScope);
const savedCredential = await saveCredential(dbCredential(), { user: memberWithReadScope });
const response = await authMemberWithReadScopeAgent.get(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({
id: savedCredential.id,
name: savedCredential.name,
type: savedCredential.type,
});
expect(response.body).not.toHaveProperty('data');
expect(response.body).not.toHaveProperty('shared');
});
test('should not return non-owned credential for member', async () => {
const savedCredential = await saveCredential(dbCredential(), { user: owner });
const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(403);
});
test('should return 404 if credential does not exist', async () => {
const response = await authOwnerAgent.get('/credentials/123');
expect(response.statusCode).toBe(404);
});
});
describe('POST /credentials/:id/test', () => {
const mockCredentialsTester = mock<CredentialsTester>();
Container.set(CredentialsTester, mockCredentialsTester);
afterEach(() => {
mockCredentialsTester.testCredentials.mockClear();
});
test('should test credential with stored data when body is empty', async () => {
mockCredentialsTester.testCredentials.mockResolvedValue({
status: 'OK',
message: 'Credential tested successfully',
});
const credential = dbCredential();
const savedCredential = await saveCredential(credential, { user: owner });
const response = await authOwnerAgent.post(`/credentials/${savedCredential.id}/test`);
expect(response.statusCode).toBe(200);
expect(mockCredentialsTester.testCredentials).toHaveBeenCalledWith(
owner.id,
savedCredential.type,
expect.objectContaining({
id: savedCredential.id,
type: savedCredential.type,
data: credential.data,
}),
);
});
test('should return 404 if credential does not exist', async () => {
const response = await authOwnerAgent.post('/credentials/123/test');
expect(response.statusCode).toBe(404);
});
});
describe('DELETE /credentials/:id', () => { describe('DELETE /credentials/:id', () => {
test('should delete owned cred for owner', async () => { test('should delete owned cred for owner', async () => {
const savedCredential = await saveCredential(dbCredential(), { user: owner }); const savedCredential = await saveCredential(dbCredential(), { user: owner });

View file

@ -17,9 +17,12 @@ import {
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { getOwnerOnlyApiKeyScopes } from '@n8n/permissions'; import { getOwnerOnlyApiKeyScopes } from '@n8n/permissions';
import { mock } from 'jest-mock-extended';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import validator from 'validator'; import validator from 'validator';
import { CredentialsTester } from '@/services/credentials-tester.service';
import { affixRoleToSaveCredential, createCredentials } from '@test-integration/db/credentials'; import { affixRoleToSaveCredential, createCredentials } from '@test-integration/db/credentials';
import { createErrorExecution, createSuccessfulExecution } from '@test-integration/db/executions'; import { createErrorExecution, createSuccessfulExecution } from '@test-integration/db/executions';
import { createTag } from '@test-integration/db/tags'; import { createTag } from '@test-integration/db/tags';
@ -470,6 +473,74 @@ describe('Public API endpoints with API key scopes', () => {
expect(sharedCredential.credentials.name).toBe(payload.name); expect(sharedCredential.credentials.name).toBe(payload.name);
}); });
}); });
describe('GET /credentials/:id', () => {
test('should retrieve credential when API key has "credential:read" scope', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['credential:read'] });
const authOwnerAgent = testServer.publicApiAgentFor(owner);
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const response = await authOwnerAgent.get(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({
id: savedCredential.id,
name: savedCredential.name,
type: savedCredential.type,
});
expect(response.body).not.toHaveProperty('data');
expect(response.body).not.toHaveProperty('shared');
});
test('should fail to retrieve credential when API key doesn\'t have "credential:read" scope', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['tag:create'] });
const authOwnerAgent = testServer.publicApiAgentFor(owner);
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const response = await authOwnerAgent.get(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(403);
});
});
describe('POST /credentials/:id/test', () => {
const mockCredentialsTester = mock<CredentialsTester>();
Container.set(CredentialsTester, mockCredentialsTester);
beforeEach(() => {
mockCredentialsTester.testCredentials.mockResolvedValue({
status: 'OK',
message: 'Connection successful!',
});
});
afterEach(() => {
mockCredentialsTester.testCredentials.mockClear();
});
test('should test credential when API key has "credential:read" scope', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['credential:read'] });
const authOwnerAgent = testServer.publicApiAgentFor(owner);
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const response = await authOwnerAgent.post(`/credentials/${savedCredential.id}/test`);
expect(response.statusCode).toBe(200);
});
test('should fail to test credential when API key doesn\'t have "credential:read" scope', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['tag:create'] });
const authOwnerAgent = testServer.publicApiAgentFor(owner);
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const response = await authOwnerAgent.post(`/credentials/${savedCredential.id}/test`);
expect(response.statusCode).toBe(403);
});
});
describe('DELETE /credentials/:id', () => { describe('DELETE /credentials/:id', () => {
test('should delete credential when API key has "credential:delete" scope', async () => { test('should delete credential when API key has "credential:delete" scope', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['credential:delete'] }); const owner = await createOwnerWithApiKey({ scopes: ['credential:delete'] });

View file

@ -8,6 +8,9 @@
"GET /credentials": { "GET /credentials": {
"status": "gap" "status": "gap"
}, },
"GET /credentials/{id}": {
"status": "gap"
},
"POST /credentials": { "POST /credentials": {
"status": "covered", "status": "covered",
"nodeOperation": "credential:create" "nodeOperation": "credential:create"
@ -26,6 +29,9 @@
"PUT /credentials/{id}/transfer": { "PUT /credentials/{id}/transfer": {
"status": "gap" "status": "gap"
}, },
"POST /credentials/{id}/test": {
"status": "gap"
},
"GET /community-packages": { "GET /community-packages": {
"status": "gap" "status": "gap"
}, },
@ -193,9 +199,6 @@
"DELETE /data-tables/{dataTableId}/rows/delete": { "DELETE /data-tables/{dataTableId}/rows/delete": {
"status": "gap" "status": "gap"
}, },
"GET /insights/summary": {
"status": "gap"
},
"POST /projects": { "POST /projects": {
"status": "gap" "status": "gap"
}, },