From 21755d2bf7bbdeca9f967db73e5a40d6e9e0f080 Mon Sep 17 00:00:00 2001 From: Sam Wooler Date: Mon, 7 Sep 2026 15:32:31 +0000 Subject: [PATCH] feat(API): Add push endpoint to the public API for source control (#37873) --- packages/@n8n/api-types/src/dto/index.ts | 3 + ...-control-push-conflict-error-public.dto.ts | 9 + .../source-control-push-request-public.dto.ts | 15 + ...source-control-push-response-public.dto.ts | 8 + .../permissions/src/__tests__/types.test.ts | 2 + packages/@n8n/permissions/src/constants.ee.ts | 2 +- .../src/public-api-permissions.ee.ts | 1 + .../__tests__/http-error-serializers.test.ts | 42 ++ .../cli/src/errors/http-error-serializers.ts | 5 + .../__tests__/telemetry-event-relay.test.ts | 24 ++ .../cli/src/events/maps/relay.event-map.ts | 1 + .../events/relays/telemetry.event-relay.ts | 2 + .../__tests__/source-control.service.test.ts | 45 +-- .../source-control.service.ee.ts | 35 +- .../types/source-control-action-origin.ts | 1 + .../types/source-control-get-status.ts | 3 +- .../source-control.public.controller.ts | 62 ++- .../paths/pushSourceControl.generated.yml | 372 ++++++++++++++++++ .../v1/openapi.decorator-routes.generated.yml | 3 + .../public-api/source-control.test.ts | 250 ++++++++++++ .../settings/apiKeys/apiKeys.utils.test.ts | 1 + .../nodes/N8n/n8n-api-coverage.json | 3 + 22 files changed, 830 insertions(+), 59 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/source-control/source-control-push-conflict-error-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/source-control/source-control-push-request-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/source-control/source-control-push-response-public.dto.ts create mode 100644 packages/cli/src/modules/source-control.ee/types/source-control-action-origin.ts create mode 100644 packages/cli/src/public-api/v1/handlers/source-control/spec/paths/pushSourceControl.generated.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 4b8bc6384fd..4f7eb83bf69 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -100,6 +100,9 @@ export { PushWorkFolderRequestDto } from './source-control/push-work-folder-requ export { type GitCommitInfo } from './source-control/push-work-folder-response.dto'; export { SourceControlStatusQueryPublicDto } from './source-control/source-control-status-query-public.dto'; export { SourceControlStatusPublicDto } from './source-control/source-control-status-public.dto'; +export { SourceControlPushRequestPublicDto } from './source-control/source-control-push-request-public.dto'; +export { SourceControlPushResponsePublicDto } from './source-control/source-control-push-response-public.dto'; +export { SourceControlPushConflictErrorPublicDto } from './source-control/source-control-push-conflict-error-public.dto'; export { CloneGitConnectionDto, CreateGitConnectionDto, diff --git a/packages/@n8n/api-types/src/dto/source-control/source-control-push-conflict-error-public.dto.ts b/packages/@n8n/api-types/src/dto/source-control/source-control-push-conflict-error-public.dto.ts new file mode 100644 index 00000000000..06f0f875ebc --- /dev/null +++ b/packages/@n8n/api-types/src/dto/source-control/source-control-push-conflict-error-public.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema'; +import { Z } from '../../zod-class'; + +export class SourceControlPushConflictErrorPublicDto extends Z.class({ + message: z.string(), + conflicts: z.array(SourceControlledFileSchema), +}) {} diff --git a/packages/@n8n/api-types/src/dto/source-control/source-control-push-request-public.dto.ts b/packages/@n8n/api-types/src/dto/source-control/source-control-push-request-public.dto.ts new file mode 100644 index 00000000000..4aa3b22ed13 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/source-control/source-control-push-request-public.dto.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema'; +import { Z } from '../../zod-class'; + +const PushFileSelectorSchema = z.object({ + id: z.string().min(1), + type: SourceControlledFileSchema.shape.type, +}); + +export class SourceControlPushRequestPublicDto extends Z.class({ + commitMessage: z.string().trim().min(1).max(1000), + fileNames: z.array(PushFileSelectorSchema).min(1), + force: z.boolean().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/source-control/source-control-push-response-public.dto.ts b/packages/@n8n/api-types/src/dto/source-control/source-control-push-response-public.dto.ts new file mode 100644 index 00000000000..a6c8105e31e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/source-control/source-control-push-response-public.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema'; +import { Z } from '../../zod-class'; + +export class SourceControlPushResponsePublicDto extends Z.class({ + data: z.array(SourceControlledFileSchema), +}) {} diff --git a/packages/@n8n/permissions/src/__tests__/types.test.ts b/packages/@n8n/permissions/src/__tests__/types.test.ts index 9706705e805..e703ce018c6 100644 --- a/packages/@n8n/permissions/src/__tests__/types.test.ts +++ b/packages/@n8n/permissions/src/__tests__/types.test.ts @@ -22,6 +22,7 @@ describe('ApiKeyScope', () => { 'project:update', 'securityAudit:generate', 'sourceControl:pull', + 'sourceControl:push', 'sourceControl:read', 'tag:create', 'tag:delete', @@ -82,6 +83,7 @@ describe('Scope', () => { 'project:update', 'securityAudit:generate', 'sourceControl:pull', + 'sourceControl:push', 'sourceControl:read', 'tag:create', 'tag:delete', diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index df6714b315e..ad0e8918fdd 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -108,7 +108,7 @@ export const API_KEY_RESOURCES = { testRun: ['read', 'list', 'create', 'cancel'] as const, credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const, eventBusDestination: ['test', 'create', 'read', 'update', 'delete', 'list'] as const, - sourceControl: ['pull', 'read'] as const, + sourceControl: ['pull', 'push', 'read'] as const, gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'push', 'manageProjects', 'pull'] as const, workflowTags: ['update', 'list'] as const, executionTags: ['update', 'list'] as const, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index d6f1f55fafd..3db671a4c5b 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -13,6 +13,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'user:changeRole', 'user:delete', 'sourceControl:pull', + 'sourceControl:push', 'sourceControl:read', 'gitConnection:create', 'gitConnection:read', diff --git a/packages/cli/src/errors/__tests__/http-error-serializers.test.ts b/packages/cli/src/errors/__tests__/http-error-serializers.test.ts index cbaaea927bc..d925d5f9646 100644 --- a/packages/cli/src/errors/__tests__/http-error-serializers.test.ts +++ b/packages/cli/src/errors/__tests__/http-error-serializers.test.ts @@ -1,3 +1,4 @@ +import type { SourceControlledFile } from '@n8n/api-types'; import { UnexpectedError, UserError } from 'n8n-workflow'; import { classifyHttpError } from '@/errors/http-error-classifier'; @@ -5,6 +6,7 @@ import { serializeInternalRestError, serializePublicApiError, } from '@/errors/http-error-serializers'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error'; @@ -148,6 +150,46 @@ describe('http-error-serializers', () => { }); }); + it('both serializers expose source control push conflicts on a 409', () => { + const conflicts: SourceControlledFile[] = [ + { + file: 'workflows/wf-1.json', + id: 'wf-1', + name: 'My workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: true, + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ]; + const descriptor = classifyHttpError( + new ConflictError( + 'Push blocked by conflicting files. Pass `force: true` to push anyway.', + undefined, + { + conflicts, + }, + ), + ); + + expect(serializePublicApiError(descriptor)).toEqual({ + status: 409, + body: { + message: expect.stringContaining('conflicting files'), + conflicts, + }, + }); + expect(serializeInternalRestError(descriptor)).toEqual({ + status: 409, + body: { + code: 409, + message: expect.stringContaining('conflicting files'), + meta: { conflicts }, + }, + }); + }); + it('both serializers map UserError to 400', () => { const descriptor = classifyHttpError(new UserError('bad input')); expect(serializePublicApiError(descriptor)).toEqual({ diff --git a/packages/cli/src/errors/http-error-serializers.ts b/packages/cli/src/errors/http-error-serializers.ts index 977067de06c..f8dcad37674 100644 --- a/packages/cli/src/errors/http-error-serializers.ts +++ b/packages/cli/src/errors/http-error-serializers.ts @@ -31,6 +31,11 @@ export function serializePublicApiError(descriptor: HttpErrorDescriptor): { if (descriptor.meta?.violations !== undefined) { body.violations = descriptor.meta.violations; } + // Same reasoning for a blocked push: the caller needs to see every conflicting + // file to decide whether to force the push, not just the message. + if (descriptor.meta?.conflicts !== undefined) { + body.conflicts = descriptor.meta.conflicts; + } const workflowPublishBlockedDetails = { reason: descriptor.meta?.reason, workflowReviewRequestId: descriptor.meta?.workflowReviewRequestId, diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index eb5daadac0f..cc01fe93d0a 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -481,6 +481,7 @@ describe('TelemetryEventRelay', () => { workflowsPushed: 8, credsPushed: 5, variablesPushed: 3, + publicApi: false, }; eventService.emit('source-control-user-finished-push-ui', event); @@ -491,6 +492,29 @@ describe('TelemetryEventRelay', () => { workflows_pushed: 8, creds_pushed: 5, variables_pushed: 3, + public_api: false, + }); + }); + + it('should track on `source-control-user-finished-push-ui` event with publicApi: true', () => { + const event: RelayEventMap['source-control-user-finished-push-ui'] = { + userId: 'userId', + workflowsEligible: 10, + workflowsPushed: 8, + credsPushed: 5, + variablesPushed: 3, + publicApi: true, + }; + + eventService.emit('source-control-user-finished-push-ui', event); + + expect(telemetry.track).toHaveBeenCalledWith('User finished push via UI', { + user_id: 'userId', + workflows_eligible: 10, + workflows_pushed: 8, + creds_pushed: 5, + variables_pushed: 3, + public_api: true, }); }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 7c8ec117b49..a27013937ce 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -733,6 +733,7 @@ export type RelayEventMap = { workflowsPushed: number; credsPushed: number; variablesPushed: number; + publicApi: boolean; }; // #endregion diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 8762e53fa99..e185d886d76 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -392,6 +392,7 @@ export class TelemetryEventRelay extends EventRelay { workflowsPushed, credsPushed, variablesPushed, + publicApi, }: RelayEventMap['source-control-user-finished-push-ui']) { this.telemetry.track('User finished push via UI', { user_id: userId, @@ -399,6 +400,7 @@ export class TelemetryEventRelay extends EventRelay { workflows_pushed: workflowsPushed, creds_pushed: credsPushed, variables_pushed: variablesPushed, + public_api: publicApi, }); } diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts index 8df9e8aab9a..475fff2e7f5 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts @@ -340,12 +340,6 @@ describe('SourceControlService', () => { { id: 'authorized-wf', type: 'workflow', - status: 'deleted', - file: 'workflows/other-project-wf.json', - name: 'anything', - location: 'local', - conflict: false, - updatedAt: now, }, ], commitMessage: 'chore: tidy up', @@ -382,18 +376,7 @@ describe('SourceControlService', () => { // ACT & ASSERT await expect( sourceControlService.pushWorkfolder(user, { - fileNames: [ - { - id: 'out-of-scope-wf', - type: 'workflow', - status: 'deleted', - file: 'workflows/out-of-scope-wf.json', - name: 'out-of-scope', - location: 'local', - conflict: false, - updatedAt: new Date().toISOString(), - }, - ], + fileNames: [{ id: 'out-of-scope-wf', type: 'workflow' }], }), ).rejects.toThrow('You are not allowed to push these changes'); @@ -424,18 +407,7 @@ describe('SourceControlService', () => { // ACT & ASSERT await expect( sourceControlService.pushWorkfolder(user, { - fileNames: [ - { - id: 'authorized-wf', - type: 'workflow', - status: 'modified', - file: 'workflows/authorized-wf.json', - name: 'authorized', - location: 'local', - conflict: false, - updatedAt: new Date().toISOString(), - }, - ], + fileNames: [{ id: 'authorized-wf', type: 'workflow' }], }), ).rejects.toThrow('File path /outside-git/authorized-wf.json is invalid'); @@ -476,18 +448,7 @@ describe('SourceControlService', () => { // ACT const result = await sourceControlService.pushWorkfolder(user, { - fileNames: [ - { - id: 'wf-1', - type: 'workflow', - status: 'modified', - file: 'workflows/wf-1.json', - name: 'Workflow 1', - location: 'local', - conflict: false, - updatedAt: now, - }, - ], + fileNames: [{ id: 'wf-1', type: 'workflow' }], }); // ASSERT diff --git a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts index 206d9fe958b..5ccedbf954d 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts @@ -1,8 +1,4 @@ -import type { - PullWorkFolderRequestDto, - PushWorkFolderRequestDto, - SourceControlledFile, -} from '@n8n/api-types'; +import type { PullWorkFolderRequestDto, SourceControlledFile } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { type User } from '@n8n/db'; import { OnPubSubEvent } from '@n8n/decorators'; @@ -45,9 +41,16 @@ import { import { SourceControlScopedService } from './source-control-scoped.service'; import { SourceControlStatusService } from './source-control-status.service.ee'; import type { ImportResult } from './types/import-result'; +import type { SourceControlActionOrigin } from './types/source-control-action-origin'; import type { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; +export type PushWorkfolderOptions = { + commitMessage?: string; + force?: boolean; + fileNames: Array>; +}; + @Service() export class SourceControlService { /** Path to SSH private key in filesystem. */ @@ -307,14 +310,15 @@ export class SourceControlService { async pushWorkfolder( user: User, - options: PushWorkFolderRequestDto, + options: PushWorkfolderOptions, + origin: SourceControlActionOrigin = 'ui', ): Promise<{ statusCode: number; pushResult: PushResult | undefined; statusResult: SourceControlledFile[]; }> { return await this.workfolderMutex( - async () => await this.pushWorkfolderWithoutLock(user, options), + async () => await this.pushWorkfolderWithoutLock(user, options, origin), ); } @@ -327,12 +331,14 @@ export class SourceControlService { */ private async resolveAuthorizedFilesToPush( user: User, - requestedFiles: SourceControlledFile[], + requestedFiles: Array>, + origin: SourceControlActionOrigin, ): Promise { const allowedResources = await this.sourceControlStatusService.getStatus(user, { direction: 'push', verbose: false, preferLocalVersion: true, + origin, }); // No explicit selection: push everything the user is allowed to. @@ -370,7 +376,8 @@ export class SourceControlService { private async pushWorkfolderWithoutLock( user: User, - options: PushWorkFolderRequestDto, + options: PushWorkfolderOptions, + origin: SourceControlActionOrigin = 'ui', ): Promise<{ statusCode: number; pushResult: PushResult | undefined; @@ -384,7 +391,7 @@ export class SourceControlService { const context = await this.sourceControlContextFactory.createContext(user); - const filesToPush = await this.resolveAuthorizedFilesToPush(user, options.fileNames); + const filesToPush = await this.resolveAuthorizedFilesToPush(user, options.fileNames, origin); let statusResult: SourceControlledFile[] = filesToPush; @@ -510,10 +517,10 @@ export class SourceControlService { } // #region Tracking Information - this.eventService.emit( - 'source-control-user-finished-push-ui', - getTrackingInformationFromPostPushResult(user.id, statusResult), - ); + this.eventService.emit('source-control-user-finished-push-ui', { + ...getTrackingInformationFromPostPushResult(user.id, statusResult), + publicApi: origin === 'publicApi', + }); // #endregion return { diff --git a/packages/cli/src/modules/source-control.ee/types/source-control-action-origin.ts b/packages/cli/src/modules/source-control.ee/types/source-control-action-origin.ts new file mode 100644 index 00000000000..22f850d56d3 --- /dev/null +++ b/packages/cli/src/modules/source-control.ee/types/source-control-action-origin.ts @@ -0,0 +1 @@ +export type SourceControlActionOrigin = 'ui' | 'publicApi'; diff --git a/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts b/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts index 7d20ffe934a..be0eaaafe65 100644 --- a/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts +++ b/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts @@ -7,6 +7,7 @@ import type { ExportableFolder } from './exportable-folders'; import type { ExportableProjectWithFileName } from './exportable-project'; import type { ExportableTagEntity, ExportableWorkflowTagMapping } from './exportable-tags'; import type { ExportableVariable } from './exportable-variable'; +import type { SourceControlActionOrigin } from './source-control-action-origin'; import type { SourceControlWorkflowVersionId } from './source-control-workflow-version-id'; export interface SourceControlGetStatusVerboseResult { @@ -60,7 +61,7 @@ export class SourceControlGetStatus { @IsOptional() verbose: boolean; - origin?: 'ui' | 'publicApi'; + origin?: SourceControlActionOrigin; constructor(values: { direction: 'push' | 'pull'; diff --git a/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts b/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts index f88f39a9d67..2df774a6d38 100644 --- a/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts @@ -1,20 +1,30 @@ -import { SourceControlStatusPublicDto, SourceControlStatusQueryPublicDto } from '@n8n/api-types'; +import { + SourceControlPushConflictErrorPublicDto, + SourceControlPushRequestPublicDto, + SourceControlPushResponsePublicDto, + SourceControlStatusPublicDto, + SourceControlStatusQueryPublicDto, +} from '@n8n/api-types'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { AuthenticatedRequest } from '@n8n/db'; import { ApiDescription, + ApiErrorResponse, ApiKeyScope, ApiResponse, ApiSummary, ApiTags, + Body, Get, Licensed, + Post, PublicApiController, Query, } from '@n8n/decorators'; import type { Response } from 'express'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service'; import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee'; @@ -61,4 +71,54 @@ export class SourceControlPublicController { return { data: files }; } + + @Post('/push') + @Licensed(LICENSE_FEATURES.SOURCE_CONTROL) + @ApiKeyScope('sourceControl:push') + @ApiSummary('Push local source control changes') + @ApiDescription( + 'Commits and pushes the selected files to the connected Git repository. Each entry in ' + + '`fileNames` is resolved against a fresh preview of the pending changes.', + ) + @ApiTags(tags) + @ApiResponse(200, SourceControlPushResponsePublicDto) + @ApiErrorResponse(400) + @ApiErrorResponse(403) + @ApiErrorResponse(409, { + dto: SourceControlPushConflictErrorPublicDto, + description: + 'The push includes files with unresolved conflicts. Retry with `force: true` to push anyway.', + }) + async pushSourceControl( + req: AuthenticatedRequest, + _res: Response, + @Body body: SourceControlPushRequestPublicDto, + ): Promise { + await this.sourceControlScopedService.ensureIsAllowedToPush(req); + + if (!this.sourceControlPreferencesService.isSourceControlConnected()) { + throw new BadRequestError('Source Control is not connected to a repository'); + } + + const result = await this.sourceControlService.pushWorkfolder( + req.user, + { + commitMessage: body.commitMessage, + fileNames: body.fileNames, + force: body.force, + }, + 'publicApi', + ); + + if (result.statusCode === 409) { + const conflicts = result.statusResult.filter((file) => file.conflict); + throw new ConflictError( + 'Push blocked by conflicting files. Pass `force: true` to push anyway.', + undefined, + { conflicts }, + ); + } + + return { data: result.statusResult }; + } } diff --git a/packages/cli/src/public-api/v1/handlers/source-control/spec/paths/pushSourceControl.generated.yml b/packages/cli/src/public-api/v1/handlers/source-control/spec/paths/pushSourceControl.generated.yml new file mode 100644 index 00000000000..1761093aaa8 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/source-control/spec/paths/pushSourceControl.generated.yml @@ -0,0 +1,372 @@ +operationId: pushSourceControl +tags: + - SourceControl +summary: Push local source control changes +description: Commits and pushes the selected files to the connected Git repository. Each entry in `fileNames` is resolved against a fresh preview of the pending changes. +x-required-scope: sourceControl:push +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + commitMessage: + type: string + minLength: 1 + maxLength: 1000 + fileNames: + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + type: + type: string + enum: + - credential + - workflow + - tags + - variables + - file + - folders + - project + - datatable + required: + - id + - type + minItems: 1 + force: + type: boolean + required: + - commitMessage + - fileNames +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + file: + type: string + id: + type: string + name: + type: string + type: + type: string + enum: + - credential + - workflow + - tags + - variables + - file + - folders + - project + - datatable + status: + type: string + enum: + - new + - modified + - deleted + - created + - renamed + - conflicted + - ignored + - staged + - unknown + location: + type: string + enum: + - local + - remote + conflict: + type: boolean + updatedAt: + type: string + pushed: + type: boolean + isLocalPublished: + type: boolean + isRemoteArchived: + type: boolean + parentFolderId: + type: string + nullable: true + folderPath: + type: array + items: + type: string + remoteFolderPath: + type: array + items: + type: string + owner: + type: object + properties: + type: + type: string + enum: + - personal + - team + projectId: + type: string + projectName: + type: string + required: + - type + - projectId + - projectName + publishingError: + type: string + publishingErrorDetails: + type: object + properties: + reason: + type: string + enum: + - review_pending + - changes_requested + - insufficient_api_key_scope + - insufficient_permissions + workflowReviewRequestId: + type: string + minLength: 1 + versionId: + type: string + minLength: 1 + required: + - reason + additionalProperties: false + contentImportPolicy: + type: object + properties: + violations: + type: array + items: + type: object + properties: + kind: + type: string + checkId: + type: string + message: + type: string + subject: + type: string + subjectType: + type: string + scope: + type: string + matchedRuleId: + type: string + required: + - kind + - checkId + - message + checkErrors: + type: array + items: + type: object + properties: + checkId: + type: string + correlationId: + type: string + required: + - checkId + - correlationId + required: + - violations + - checkErrors + required: + - file + - id + - name + - type + - status + - location + - conflict + - updatedAt + required: + - data + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '409': + description: 'The push includes files with unresolved conflicts. Retry with `force: true` to push anyway.' + content: + application/json: + schema: + type: object + properties: + message: + type: string + conflicts: + type: array + items: + type: object + properties: + file: + type: string + id: + type: string + name: + type: string + type: + type: string + enum: + - credential + - workflow + - tags + - variables + - file + - folders + - project + - datatable + status: + type: string + enum: + - new + - modified + - deleted + - created + - renamed + - conflicted + - ignored + - staged + - unknown + location: + type: string + enum: + - local + - remote + conflict: + type: boolean + updatedAt: + type: string + pushed: + type: boolean + isLocalPublished: + type: boolean + isRemoteArchived: + type: boolean + parentFolderId: + type: string + nullable: true + folderPath: + type: array + items: + type: string + remoteFolderPath: + type: array + items: + type: string + owner: + type: object + properties: + type: + type: string + enum: + - personal + - team + projectId: + type: string + projectName: + type: string + required: + - type + - projectId + - projectName + publishingError: + type: string + publishingErrorDetails: + type: object + properties: + reason: + type: string + enum: + - review_pending + - changes_requested + - insufficient_api_key_scope + - insufficient_permissions + workflowReviewRequestId: + type: string + minLength: 1 + versionId: + type: string + minLength: 1 + required: + - reason + additionalProperties: false + contentImportPolicy: + type: object + properties: + violations: + type: array + items: + type: object + properties: + kind: + type: string + checkId: + type: string + message: + type: string + subject: + type: string + subjectType: + type: string + scope: + type: string + matchedRuleId: + type: string + required: + - kind + - checkId + - message + checkErrors: + type: array + items: + type: object + properties: + checkId: + type: string + correlationId: + type: string + required: + - checkId + - correlationId + required: + - violations + - checkErrors + required: + - file + - id + - name + - type + - status + - location + - conflict + - updatedAt + required: + - message + - conflicts + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml 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 5002d516f4b..a77d72127f9 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 @@ -91,6 +91,9 @@ paths: /source-control/status: get: $ref: ./handlers/source-control/spec/paths/getSourceControlStatus.generated.yml + /source-control/push: + post: + $ref: ./handlers/source-control/spec/paths/pushSourceControl.generated.yml /tags: get: $ref: ./handlers/tags/spec/paths/getTags.generated.yml diff --git a/packages/cli/test/integration/public-api/source-control.test.ts b/packages/cli/test/integration/public-api/source-control.test.ts index 65d472b30d2..856e11943c7 100644 --- a/packages/cli/test/integration/public-api/source-control.test.ts +++ b/packages/cli/test/integration/public-api/source-control.test.ts @@ -3,6 +3,8 @@ import { mockInstance } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee'; @@ -336,4 +338,252 @@ describe('Source Control (Public API)', () => { ); }); }); + + describe('POST /source-control/push', () => { + const pushUrl = '/source-control/push'; + const validBody = { + commitMessage: 'chore: sync', + fileNames: [{ id: 'wf-1', type: 'workflow' }], + }; + + it('should return 401 when API key is missing', async () => { + const response = await testServer.publicApiAgentWithoutApiKey().post(pushUrl).send(validBody); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ message: 'Unauthorized' }); + }); + + it('should return 401 when API key is invalid', async () => { + const response = await testServer + .publicApiAgentWithApiKey('not-a-real-api-key') + .post(pushUrl) + .send(validBody); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 403 when API key lacks sourceControl:push scope', async () => { + testServer.license.enable('feat:sourceControl'); + const member = await createMemberWithApiKey({ scopes: ['tag:list'] }); + + const response = await testServer.publicApiAgentFor(member).post(pushUrl).send(validBody); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'Forbidden' }); + }); + + it('should return 403 when API key has sourceControl:read but not sourceControl:push', async () => { + testServer.license.enable('feat:sourceControl'); + const member = await createMemberWithApiKey({ scopes: ['sourceControl:read'] }); + + const response = await testServer.publicApiAgentFor(member).post(pushUrl).send(validBody); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'Forbidden' }); + }); + + it('should return 403 when Source Control is not licensed', async () => { + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toContain('feat:sourceControl'); + }); + + it('should return 403 for a member with a push-scoped key but no authorized project', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + const member = await createMemberWithApiKey({ scopes: ['sourceControl:push'] }); + + const response = await testServer.publicApiAgentFor(member).post(pushUrl).send(validBody); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'You are not allowed to push changes' }); + }); + + it('should authorize before revealing whether a repository is connected', async () => { + testServer.license.enable('feat:sourceControl'); + // Source control deliberately left disconnected. + const member = await createMemberWithApiKey({ scopes: ['sourceControl:push'] }); + + const response = await testServer.publicApiAgentFor(member).post(pushUrl).send(validBody); + + // 403 from RBAC, not the 400 that would disclose the connection state. + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'You are not allowed to push changes' }); + }); + + it('should return 400 when licensed but Source Control is not connected', async () => { + testServer.license.enable('feat:sourceControl'); + + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: 'Source Control is not connected to a repository', + }); + }); + + it('should return 400 when fileNames is empty', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const response = await testServer + .publicApiAgentFor(owner) + .post(pushUrl) + .send({ commitMessage: 'chore: sync', fileNames: [] }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: 'request/body/fileNames Array must contain at least 1 element(s)', + }); + }); + + it('should return 400 when fileNames is missing', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const response = await testServer + .publicApiAgentFor(owner) + .post(pushUrl) + .send({ commitMessage: 'chore: sync' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "request/body must have required property 'fileNames'", + }); + }); + + it('should return 400 when commitMessage is missing', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const response = await testServer + .publicApiAgentFor(owner) + .post(pushUrl) + .send({ fileNames: [{ id: 'wf-1', type: 'workflow' }] }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "request/body must have required property 'commitMessage'", + }); + }); + + it('should return 200 with the push envelope and pass only (id, type) selectors through', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const statusResult = [sourceControlledFileFixture('wf-1', { pushed: true })]; + const pushSpy = vi + .spyOn(Container.get(SourceControlService), 'pushWorkfolder') + .mockResolvedValue({ statusCode: 200, pushResult: undefined, statusResult }); + + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ data: statusResult }); + expect(pushSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: owner.id }), + { + commitMessage: 'chore: sync', + fileNames: [{ id: 'wf-1', type: 'workflow' }], + force: undefined, + }, + 'publicApi', + ); + }); + + it('should return 409 with the conflicting files when unforced conflicts exist', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const statusResult = [sourceControlledFileFixture('wf-1', { conflict: true })]; + vi.spyOn(Container.get(SourceControlService), 'pushWorkfolder').mockResolvedValue({ + statusCode: 409, + pushResult: undefined, + statusResult, + }); + + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + message: expect.stringContaining('conflicting files'), + conflicts: statusResult, + }); + }); + + it('should only list the conflicting files, not the whole selection, on 409', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const conflictingFile = sourceControlledFileFixture('wf-1', { conflict: true }); + const cleanFile = sourceControlledFileFixture('wf-2', { conflict: false }); + vi.spyOn(Container.get(SourceControlService), 'pushWorkfolder').mockResolvedValue({ + statusCode: 409, + pushResult: undefined, + statusResult: [conflictingFile, cleanFile], + }); + + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + message: expect.stringContaining('conflicting files'), + conflicts: [conflictingFile], + }); + }); + + it('should return 200 when force is true even with conflicts', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + const statusResult = [sourceControlledFileFixture('wf-1', { pushed: true })]; + const pushSpy = vi + .spyOn(Container.get(SourceControlService), 'pushWorkfolder') + .mockResolvedValue({ statusCode: 200, pushResult: undefined, statusResult }); + + const response = await testServer + .publicApiAgentFor(owner) + .post(pushUrl) + .send({ ...validBody, force: true }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ data: statusResult }); + expect(pushSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: owner.id }), + expect.objectContaining({ force: true }), + 'publicApi', + ); + }); + + it('should return 400 when pushing onto a read-only branch', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + vi.spyOn(Container.get(SourceControlService), 'pushWorkfolder').mockRejectedValue( + new BadRequestError('Cannot push onto read-only branch.'), + ); + + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ message: 'Cannot push onto read-only branch.' }); + }); + + it('should return 403 when pushing an unauthorized file', async () => { + testServer.license.enable('feat:sourceControl'); + mockConnected(); + + vi.spyOn(Container.get(SourceControlService), 'pushWorkfolder').mockRejectedValue( + new ForbiddenError('You are not allowed to push these changes'), + ); + + const response = await testServer.publicApiAgentFor(owner).post(pushUrl).send(validBody); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'You are not allowed to push these changes' }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts index 71b895d0b87..e36677ca3fa 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts @@ -33,6 +33,7 @@ describe('classifyScope', () => { ['execution:retry', 'write'], ['securityAudit:generate', 'write'], ['sourceControl:pull', 'write'], + ['sourceControl:push', 'write'], ['sourceControl:read', 'read'], ['communityPackage:install', 'write'], ['dataTableRow:upsert', 'write'], diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 05c67eb4b1b..2c7583b0d08 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -247,6 +247,9 @@ "GET /source-control/status": { "status": "gap" }, + "POST /source-control/push": { + "status": "gap" + }, "POST /git-connections/{id}/push": { "status": "gap" },