feat(API): Add push endpoint to the public API for source control (#37873)

This commit is contained in:
Sam Wooler 2026-09-07 15:32:31 +00:00 committed by GitHub
parent 9418c23b81
commit 21755d2bf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 830 additions and 59 deletions

View File

@ -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,

View File

@ -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),
}) {}

View File

@ -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(),
}) {}

View File

@ -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),
}) {}

View File

@ -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',

View File

@ -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,

View File

@ -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',

View File

@ -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({

View File

@ -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,

View File

@ -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,
});
});
});

View File

@ -733,6 +733,7 @@ export type RelayEventMap = {
workflowsPushed: number;
credsPushed: number;
variablesPushed: number;
publicApi: boolean;
};
// #endregion

View File

@ -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,
});
}

View File

@ -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

View File

@ -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 {

View File

@ -0,0 +1 @@
export type SourceControlActionOrigin = 'ui' | 'publicApi';

View File

@ -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';

View File

@ -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 };
}
}

View File

@ -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

View File

@ -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

View File

@ -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' });
});
});
});

View File

@ -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'],

View File

@ -247,6 +247,9 @@
"GET /source-control/status": {
"status": "gap"
},
"POST /source-control/push": {
"status": "gap"
},
"POST /git-connections/{id}/push": {
"status": "gap"
},