From 6307a5aaf100c8fedc6471f25be3171b9870ce77 Mon Sep 17 00:00:00 2001 From: Sam Wooler Date: Mon, 14 Sep 2026 14:28:25 +0000 Subject: [PATCH] refactor(API): Migrate DELETE /credentials/{credentialId} and PUT /credentials/{credentialId}/transfer to the decorator pattern (#38454) Co-authored-by: Claude Sonnet 5 --- .../transfer-credential-public.dto.test.ts | 17 +++++ .../dto/credentials/credential-public.dto.ts | 13 ++++ packages/@n8n/api-types/src/dto/index.ts | 2 + packages/cli/src/public-api/types.ts | 4 - .../credentials.public.controller.ts | 65 ++++++++++++++++ .../credentials/credentials.handler.ts | 43 +---------- .../spec/paths/credentials.id.transfer.yml | 32 -------- .../credentials/spec/paths/credentials.id.yml | 27 ------- .../spec/paths/deleteCredential.generated.yml | 70 +++++++++++++++++ .../paths/transferCredential.generated.yml | 42 +++++++++++ .../credentials/spec/schemas/credential.yml | 34 --------- .../spec/schemas/parameters/credentialId.yml | 6 -- .../v1/openapi.decorator-routes.generated.yml | 5 ++ packages/cli/src/public-api/v1/openapi.yml | 4 - .../v1/shared/spec/schemas/_index.yml | 2 - .../public-api/credentials.test.ts | 75 ++++++++++++++++++- .../nodes/N8n/n8n-api-coverage.json | 4 +- 17 files changed, 288 insertions(+), 157 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/credentials/__tests__/transfer-credential-public.dto.test.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.yml create mode 100644 packages/cli/src/public-api/v1/handlers/credentials/spec/paths/deleteCredential.generated.yml create mode 100644 packages/cli/src/public-api/v1/handlers/credentials/spec/paths/transferCredential.generated.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/transfer-credential-public.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/transfer-credential-public.dto.test.ts new file mode 100644 index 00000000000..1a1e30d72b4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/transfer-credential-public.dto.test.ts @@ -0,0 +1,17 @@ +import { TransferCredentialPublicDto } from '../credential-public.dto'; + +describe('TransferCredentialPublicDto', () => { + test('accepts destinationProjectId and strips unknown keys', () => { + const result = TransferCredentialPublicDto.safeParse({ + destinationProjectId: '1234', + somethingElse: true, + }); + + expect(result).toMatchObject({ success: true, data: { destinationProjectId: '1234' } }); + expect(result.data).not.toHaveProperty('somethingElse'); + }); + + test('rejects a missing destinationProjectId', () => { + expect(TransferCredentialPublicDto.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts index d20b5cade0c..e16096946a1 100644 --- a/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts @@ -22,6 +22,12 @@ export const credentialPublicSchema = publicApiCredentialResponseSchema.extend({ export class CredentialPublicDto extends Z.class(credentialPublicSchema.shape) {} +export const credentialDeletedPublicSchema = credentialPublicSchema.extend({ + usageScope: z.enum(['project', 'instance']), +}); + +export class DeleteCredentialPublicDto extends Z.class(credentialDeletedPublicSchema.shape) {} + export const credentialListItemPublicSchema = credentialPublicSchema .pick({ id: true, name: true, type: true, createdAt: true, updatedAt: true }) .extend({ shared: z.array(credentialSharedPublicSchema) }); @@ -97,3 +103,10 @@ export class UpdateCredentialPublicDto extends Z.class({ 'If false, replaces the entire data object.', }), }) {} + +export class TransferCredentialPublicDto extends Z.class({ + destinationProjectId: z.string().openapi({ + description: 'The ID of the project to transfer the credential to.', + example: 'VmwOO9HeTEj20kxM', + }), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 612673e4b37..9835d052c99 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -133,6 +133,8 @@ export { CredentialListPublicDto, CreateCredentialPublicDto, UpdateCredentialPublicDto, + DeleteCredentialPublicDto, + TransferCredentialPublicDto, credentialPublicSchema, } from './credentials/credential-public.dto'; export { ListCredentialsQueryDto } from './credentials/list-credentials-query.dto'; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index b96ec271038..42b2f6a0fd8 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -136,10 +136,6 @@ export declare namespace UserRequest { export declare namespace CredentialRequest { type Test = AuthenticatedRequest<{ id: string }, {}, {}, {}>; - - type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; - - type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>; } export declare namespace InsightsRequest { diff --git a/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts b/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts index 9f4b88b6834..d4a9667ce7f 100644 --- a/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts @@ -3,8 +3,10 @@ import { CreateCredentialPublicDto, CredentialListPublicDto, CredentialPublicDto, + DeleteCredentialPublicDto, ListCredentialsQueryDto, UpdateCredentialPublicDto, + TransferCredentialPublicDto, } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb, User } from '@n8n/db'; @@ -16,12 +18,14 @@ import { ApiSummary, ApiTags, Body, + Delete, Get, Param, Patch, Post, ProjectScope, PublicApiController, + Put, Query, } from '@n8n/decorators'; import { hasGlobalScope } from '@n8n/permissions'; @@ -31,6 +35,7 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; 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'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; @@ -78,6 +83,14 @@ function toCredentialPublicDto(credential: CredentialPublicDtoSource): Credentia }; } +/** The delete response adds `usageScope` to the standard credential fields. Carry over from legacy EOV handler. */ +function toDeleteCredentialPublicDto(credential: CredentialsEntity): DeleteCredentialPublicDto { + return { + ...toCredentialPublicDto(credential), + usageScope: credential.usageScope, + }; +} + function toCredentialListItem(credential: CredentialsEntity) { return { id: credential.id, @@ -102,6 +115,7 @@ export class CredentialsPublicController { private readonly credentialsHelper: CredentialsHelper, private readonly licenseState: LicenseState, private readonly eventService: EventService, + private readonly enterpriseCredentialsService: EnterpriseCredentialsService, ) {} @Get('/') @@ -363,4 +377,55 @@ export class CredentialsPublicController { return { updatePayload, decryptedDataForDeps }; } + + @Delete('/:credentialId') + @ApiKeyScope('credential:delete') + @ProjectScope('credential:delete') + @ApiSummary('Delete credential by ID') + @ApiDescription( + 'Deletes a credential from your instance. You must be the owner of the credential.', + ) + @ApiTags(['Credential']) + @ApiResponse(200, DeleteCredentialPublicDto) + @ApiErrorResponse(404) + async deleteCredential( + req: AuthenticatedRequest, + _res: Response, + @Param('credentialId', credentialIdParamSchema) credentialId: string, + ): Promise { + const credential = await this.credentialsFinderService.findCredentialForUser( + credentialId, + req.user, + ['credential:delete'], + ); + + if (!credential) { + throw new NotFoundError('Not Found'); + } + + await this.credentialsService.delete(req.user, credentialId); + + return toDeleteCredentialPublicDto(credential); + } + + @Put('/:credentialId/transfer') + @ApiKeyScope('credential:move') + @ProjectScope('credential:move') + @ApiSummary('Transfer a credential to another project.') + @ApiDescription('Transfer a credential to another project.') + @ApiTags(['Credential']) + @ApiResponse(204) + @ApiErrorResponse(404) + async transferCredential( + req: AuthenticatedRequest, + _res: Response, + @Param('credentialId', credentialIdParamSchema) credentialId: string, + @Body body: TransferCredentialPublicDto, + ): Promise { + await this.enterpriseCredentialsService.transferOne( + req.user, + credentialId, + body.destinationProjectId, + ); + } } diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts index 14caa63bbb5..5d4bfe3e585 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts @@ -1,23 +1,18 @@ import { Container } from '@n8n/di'; -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'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { sanitizeCredentials, toJsonSchema } from './credentials.utils'; +import { toJsonSchema } from './credentials.utils'; import type { CredentialTypeRequest, CredentialRequest } from '../../../types'; import type { PublicAPIEndpoint } from '../../shared/handler.types'; import { publicApiScope, projectScope } from '../../shared/middlewares/global.middleware'; type CredentialsHandlers = { testCredential: PublicAPIEndpoint; - transferCredential: PublicAPIEndpoint; - deleteCredential: PublicAPIEndpoint; getCredentialType: PublicAPIEndpoint; }; @@ -42,42 +37,6 @@ const credentialsHandlers: CredentialsHandlers = { } }, ], - transferCredential: [ - publicApiScope('credential:move'), - projectScope('credential:move', 'credential'), - async (req, res) => { - const body = z.object({ destinationProjectId: z.string() }).parse(req.body); - - await Container.get(EnterpriseCredentialsService).transferOne( - req.user, - req.params.id, - body.destinationProjectId, - ); - - return res.status(204).send(); - }, - ], - deleteCredential: [ - publicApiScope('credential:delete'), - projectScope('credential:delete', 'credential'), - async (req, res) => { - const { id: credentialId } = req.params; - - const credential = await Container.get(CredentialsFinderService).findCredentialForUser( - credentialId, - req.user, - ['credential:delete'], - ); - - if (!credential) { - throw new NotFoundError('Not Found'); - } - - await Container.get(CredentialsService).delete(req.user, credentialId); - return res.json(sanitizeCredentials(credential)); - }, - ], - getCredentialType: [ async (req, res) => { const { credentialTypeName } = req.params; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml deleted file mode 100644 index b5fd3b1533b..00000000000 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml +++ /dev/null @@ -1,32 +0,0 @@ -put: - x-eov-operation-id: transferCredential - x-required-scope: credential:move - x-eov-operation-handler: v1/handlers/credentials/credentials.handler - tags: - - Credential - summary: Transfer a credential to another project. - description: Transfer a credential to another project. - parameters: - - $ref: '../schemas/parameters/credentialId.yml' - requestBody: - description: Destination project for the credential transfer. - content: - application/json: - schema: - type: object - properties: - destinationProjectId: - type: string - description: The ID of the project to transfer the credential to. - required: - - destinationProjectId - required: true - responses: - '200': - description: Operation successful. - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.yml deleted file mode 100644 index a54a7d5282d..00000000000 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/credentials.id.yml +++ /dev/null @@ -1,27 +0,0 @@ -delete: - x-eov-operation-id: deleteCredential - x-required-scope: credential:delete - x-eov-operation-handler: v1/handlers/credentials/credentials.handler - tags: - - Credential - summary: Delete credential by ID - description: Deletes a credential from your instance. You must be the owner of the credentials - operationId: deleteCredential - parameters: - - name: id - in: path - description: The credential ID that needs to be deleted - required: true - schema: - type: string - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/credential.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/deleteCredential.generated.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/deleteCredential.generated.yml new file mode 100644 index 00000000000..81b873edd7e --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/deleteCredential.generated.yml @@ -0,0 +1,70 @@ +operationId: deleteCredential +tags: + - Credential +summary: Delete credential by ID +description: Deletes a credential from your instance. You must be the owner of the credential. +x-required-scope: credential:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the credential. + name: credentialId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + type: + type: string + isManaged: + type: boolean + isGlobal: + type: boolean + isResolvable: + type: boolean + resolvableAllowFallback: + type: boolean + resolverId: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + usageScope: + type: string + enum: + - project + - instance + required: + - id + - name + - type + - isManaged + - isGlobal + - isResolvable + - resolvableAllowFallback + - resolverId + - createdAt + - updatedAt + - usageScope + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/transferCredential.generated.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/transferCredential.generated.yml new file mode 100644 index 00000000000..21e24e6ef2b --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/transferCredential.generated.yml @@ -0,0 +1,42 @@ +operationId: transferCredential +tags: + - Credential +summary: Transfer a credential to another project. +description: Transfer a credential to another project. +x-required-scope: credential:move +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the credential. + name: credentialId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + destinationProjectId: + type: string + description: The ID of the project to transfer the credential to. + example: VmwOO9HeTEj20kxM + required: + - destinationProjectId +responses: + '204': + description: Operation successful. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml deleted file mode 100644 index 485d01a99f7..00000000000 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml +++ /dev/null @@ -1,34 +0,0 @@ -required: - - name - - type - - data -type: object -properties: - id: - type: string - readOnly: true - example: R2DjclaysHbqn778 - name: - type: string - example: Joe's Github Credentials - type: - type: string - example: githubApi - data: - type: object - writeOnly: true - example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' } - isResolvable: - type: boolean - example: false - description: Whether this credential has resolvable fields - createdAt: - type: string - format: date-time - readOnly: true - example: '2022-04-29T11:02:29.842Z' - updatedAt: - type: string - format: date-time - readOnly: true - example: '2022-04-29T11:02:29.842Z' diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml deleted file mode 100644 index f16676ce0b9..00000000000 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: id -in: path -description: The ID of the credential. -required: true -schema: - type: string diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 4be61063e05..1d0238a746e 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -13,6 +13,11 @@ paths: $ref: ./handlers/credentials/spec/paths/getCredential.generated.yml patch: $ref: ./handlers/credentials/spec/paths/updateCredential.generated.yml + delete: + $ref: ./handlers/credentials/spec/paths/deleteCredential.generated.yml + /credentials/{credentialId}/transfer: + put: + $ref: ./handlers/credentials/spec/paths/transferCredential.generated.yml /executions: get: $ref: ./handlers/executions/spec/paths/getExecutions.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index f63f46e1add..844c2fdb934 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -93,14 +93,10 @@ paths: $ref: './handlers/sso-oidc/spec/paths/settings.sso.oidc.yml' /settings/sso/saml: $ref: './handlers/sso-saml/spec/paths/settings.sso.saml.yml' - /credentials/{id}: - $ref: './handlers/credentials/spec/paths/credentials.id.yml' /credentials/{id}/test: $ref: './handlers/credentials/spec/paths/credentials.id.test.yml' /credentials/schema/{credentialTypeName}: $ref: './handlers/credentials/spec/paths/credentials.schema.id.yml' - /credentials/{id}/transfer: - $ref: './handlers/credentials/spec/paths/credentials.id.transfer.yml' /tags/{id}: $ref: './handlers/tags/spec/paths/tags.id.yml' /workflows/{id}/{versionId}: diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml index 6709190fce9..cce55116c86 100644 --- a/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml @@ -14,8 +14,6 @@ WorkflowSettings: $ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml' WorkflowList: $ref: './../../../handlers/workflows/spec/schemas/workflowList.yml' -Credential: - $ref: './../../../handlers/credentials/spec/schemas/credential.yml' CredentialType: $ref: './../../../handlers/credentials/spec/schemas/credentialType.yml' Audit: diff --git a/packages/cli/test/integration/public-api/credentials.test.ts b/packages/cli/test/integration/public-api/credentials.test.ts index 34d2069774e..c5fe01f44ae 100644 --- a/packages/cli/test/integration/public-api/credentials.test.ts +++ b/packages/cli/test/integration/public-api/credentials.test.ts @@ -674,10 +674,20 @@ describe('DELETE /credentials/:id', () => { expect(response.statusCode).toBe(200); - const { name, type } = response.body; - - expect(name).toBe(savedCredential.name); - expect(type).toBe(savedCredential.type); + // Exact-shape check - proves the DTO's allowlisted fields + expect(response.body).toStrictEqual({ + id: savedCredential.id, + name: savedCredential.name, + type: savedCredential.type, + isManaged: savedCredential.isManaged, + isGlobal: savedCredential.isGlobal, + isResolvable: savedCredential.isResolvable, + resolvableAllowFallback: savedCredential.resolvableAllowFallback, + resolverId: savedCredential.resolverId, + createdAt: savedCredential.createdAt.toISOString(), + updatedAt: savedCredential.updatedAt.toISOString(), + usageScope: savedCredential.usageScope, + }); const deletedCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -805,6 +815,20 @@ describe('DELETE /credentials/:id', () => { expect(response.statusCode).toBe(404); }); + + test('should return 403 when the API key lacks credential:delete', async () => { + const savedCredential = await saveCredential(dbCredential(), { user: owner }); + const agent = await makeGlobalRoleUserAgent(['credential:read']); + + const response = await agent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(403); + + const stillThere = await Container.get(CredentialsRepository).findOneBy({ + id: savedCredential.id, + }); + expect(stillThere).not.toBeNull(); + }); }); describe('PATCH /credentials/:id', () => { @@ -1716,6 +1740,49 @@ describe('PUT /credentials/:id/transfer', () => { */ expect(response.statusCode).toBe(400); }); + + test('should return 403 when the API key lacks credential:move', async () => { + const [firstProject, secondProject] = await Promise.all([ + createTeamProject('first-project', owner), + createTeamProject('second-project', owner), + ]); + const credentials = await createCredentials( + { name: 'Test', type: 'test', data: '' }, + firstProject, + ); + const agent = await makeGlobalRoleUserAgent(['credential:read']); + + const response = await agent + .put(`/credentials/${credentials.id}/transfer`) + .send({ destinationProjectId: secondProject.id }); + + expect(response.statusCode).toBe(403); + + const sharings = await getCredentialSharings(credentials); + expect(sharings).toHaveLength(1); + expect(sharings[0]).toMatchObject({ projectId: firstProject.id }); + }); + + test('should not transfer a credential the user has no access to', async () => { + const [firstProject, secondProject] = await Promise.all([ + createTeamProject('first-project', owner), + createTeamProject('second-project', owner), + ]); + const credentials = await createCredentials( + { name: 'Test', type: 'test', data: '' }, + firstProject, + ); + + const response = await authMemberAgent + .put(`/credentials/${credentials.id}/transfer`) + .send({ destinationProjectId: secondProject.id }); + + expect(response.statusCode).toBe(403); + + const sharings = await getCredentialSharings(credentials); + expect(sharings).toHaveLength(1); + expect(sharings[0]).toMatchObject({ projectId: firstProject.id }); + }); }); const credentialPayload = (): CredentialPayload => ({ diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 80a88c054ae..6d540231ba7 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -54,7 +54,7 @@ "PATCH /credentials/{credentialId}": { "status": "gap" }, - "DELETE /credentials/{id}": { + "DELETE /credentials/{credentialId}": { "status": "covered", "nodeOperation": "credential:delete" }, @@ -62,7 +62,7 @@ "status": "covered", "nodeOperation": "credential:getSchema" }, - "PUT /credentials/{id}/transfer": { + "PUT /credentials/{credentialId}/transfer": { "status": "gap" }, "POST /credentials/{id}/test": {