mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(API): Add push endpoint to the public API for source control (#37873)
This commit is contained in:
parent
9418c23b81
commit
21755d2bf7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -733,6 +733,7 @@ export type RelayEventMap = {
|
|||
workflowsPushed: number;
|
||||
credsPushed: number;
|
||||
variablesPushed: number;
|
||||
publicApi: boolean;
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Pick<SourceControlledFile, 'id' | 'type'>>;
|
||||
};
|
||||
|
||||
@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<Pick<SourceControlledFile, 'id' | 'type'>>,
|
||||
origin: SourceControlActionOrigin,
|
||||
): Promise<SourceControlledFile[]> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export type SourceControlActionOrigin = 'ui' | 'publicApi';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<SourceControlPushResponsePublicDto> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@
|
|||
"GET /source-control/status": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /source-control/push": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /git-connections/{id}/push": {
|
||||
"status": "gap"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user