feat(API): Add source control status endpoint to the public API (#37001)

This commit is contained in:
Sam Wooler 2026-09-04 12:15:26 +00:00 committed by GitHub
parent 52499b929e
commit daab1959ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 717 additions and 123 deletions

View File

@ -98,6 +98,8 @@ export {
} from './source-control/pull-work-folder-request.dto';
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
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 {
CloneGitConnectionDto,
CreateGitConnectionDto,

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 SourceControlStatusPublicDto extends Z.class({
data: z.array(SourceControlledFileSchema),
}) {}

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class SourceControlStatusQueryPublicDto extends Z.class({
direction: z.enum(['push', 'pull']),
}) {}

View File

@ -71,6 +71,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
"sourceControl:pull",
"sourceControl:push",
"sourceControl:manage",
"sourceControl:read",
"gitConnection:create",
"gitConnection:read",
"gitConnection:update",

View File

@ -22,6 +22,7 @@ describe('ApiKeyScope', () => {
'project:update',
'securityAudit:generate',
'sourceControl:pull',
'sourceControl:read',
'tag:create',
'tag:delete',
'tag:list',
@ -81,6 +82,7 @@ describe('Scope', () => {
'project:update',
'securityAudit:generate',
'sourceControl:pull',
'sourceControl:read',
'tag:create',
'tag:delete',
'tag:list',

View File

@ -34,7 +34,7 @@ export const RESOURCES = {
saml: ['manage'] as const,
securityAudit: ['generate'] as const,
securitySettings: ['manage'] as const,
sourceControl: ['pull', 'push', 'manage'] as const,
sourceControl: ['pull', 'push', 'manage', 'read'] as const,
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'push', 'manageProjects', 'pull'] as const,
tag: [...DEFAULT_OPERATIONS] as const,
user: [
@ -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'] as const,
sourceControl: ['pull', '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:read',
'gitConnection:create',
'gitConnection:read',
'gitConnection:update',

View File

@ -61,6 +61,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'sourceControl:pull',
'sourceControl:push',
'sourceControl:manage',
'sourceControl:read',
'gitConnection:create',
'gitConnection:read',
'gitConnection:update',

View File

@ -367,6 +367,7 @@ describe('TelemetryEventRelay', () => {
workflowUpdates: 5,
workflowConflicts: 2,
credConflicts: 1,
publicApi: false,
};
eventService.emit('source-control-user-started-pull-ui', event);
@ -375,6 +376,25 @@ describe('TelemetryEventRelay', () => {
workflow_updates: 5,
workflow_conflicts: 2,
cred_conflicts: 1,
public_api: false,
});
});
it('should track on `source-control-user-started-pull-ui` event with publicApi: true', () => {
const event: RelayEventMap['source-control-user-started-pull-ui'] = {
workflowUpdates: 5,
workflowConflicts: 2,
credConflicts: 1,
publicApi: true,
};
eventService.emit('source-control-user-started-pull-ui', event);
expect(telemetry.track).toHaveBeenCalledWith('User started pull via UI', {
workflow_updates: 5,
workflow_conflicts: 2,
cred_conflicts: 1,
public_api: true,
});
});
@ -414,6 +434,7 @@ describe('TelemetryEventRelay', () => {
credsEligible: 5,
credsEligibleWithConflicts: 1,
variablesEligible: 3,
publicApi: false,
};
eventService.emit('source-control-user-started-push-ui', event);
@ -425,6 +446,31 @@ describe('TelemetryEventRelay', () => {
creds_eligible: 5,
creds_eligible_with_conflicts: 1,
variables_eligible: 3,
public_api: false,
});
});
it('should track on `source-control-user-started-push-ui` event with publicApi: true', () => {
const event: RelayEventMap['source-control-user-started-push-ui'] = {
userId: 'userId',
workflowsEligible: 10,
workflowsEligibleWithConflicts: 2,
credsEligible: 5,
credsEligibleWithConflicts: 1,
variablesEligible: 3,
publicApi: true,
};
eventService.emit('source-control-user-started-push-ui', event);
expect(telemetry.track).toHaveBeenCalledWith('User started push via UI', {
user_id: 'userId',
workflows_eligible: 10,
workflows_eligible_with_conflicts: 2,
creds_eligible: 5,
creds_eligible_with_conflicts: 1,
variables_eligible: 3,
public_api: true,
});
});

View File

@ -685,6 +685,7 @@ export type RelayEventMap = {
workflowUpdates: number;
workflowConflicts: number;
credConflicts: number;
publicApi: boolean;
};
'source-control-user-finished-pull-ui': {
@ -704,6 +705,7 @@ export type RelayEventMap = {
credsEligible: number;
credsEligibleWithConflicts: number;
variablesEligible: number;
publicApi: boolean;
};
'source-control-user-finished-push-ui': {

View File

@ -335,12 +335,14 @@ export class TelemetryEventRelay extends EventRelay {
workflowUpdates,
workflowConflicts,
credConflicts,
publicApi,
}: RelayEventMap['source-control-user-started-pull-ui']) {
this.telemetry.track('User started pull via UI', {
user_id: userId,
workflow_updates: workflowUpdates,
workflow_conflicts: workflowConflicts,
cred_conflicts: credConflicts,
public_api: publicApi,
});
}
@ -371,6 +373,7 @@ export class TelemetryEventRelay extends EventRelay {
credsEligible,
credsEligibleWithConflicts,
variablesEligible,
publicApi,
}: RelayEventMap['source-control-user-started-push-ui']) {
this.telemetry.track('User started push via UI', {
user_id: userId,
@ -379,6 +382,7 @@ export class TelemetryEventRelay extends EventRelay {
creds_eligible: credsEligible,
creds_eligible_with_conflicts: credsEligibleWithConflicts,
variables_eligible: variablesEligible,
public_api: publicApi,
});
}

View File

@ -51,6 +51,7 @@ describe('getStatus', () => {
mock(),
);
const sourceControlContextFactory = mock<SourceControlContextFactory>();
const eventService = mock<EventService>();
const sourceControlStatusService = new SourceControlStatusService(
mockLogger(),
gitService,
@ -60,7 +61,7 @@ describe('getStatus', () => {
tagRepository,
folderRepository,
workflowRepository,
mock<EventService>(),
eventService,
);
beforeEach(() => {
@ -888,6 +889,78 @@ describe('getStatus', () => {
});
});
describe('telemetry', () => {
const user = globalAdminUserWithId;
it('emits `source-control-user-started-push-ui` with publicApi: false when origin is not set', async () => {
await sourceControlStatusService.getStatus(user, {
direction: 'push',
verbose: false,
preferLocalVersion: true,
});
expect(eventService.emit).toHaveBeenCalledWith(
'source-control-user-started-push-ui',
expect.objectContaining({ userId: user.id, publicApi: false }),
);
});
it('emits `source-control-user-started-push-ui` with publicApi: false when origin is `ui`', async () => {
await sourceControlStatusService.getStatus(user, {
direction: 'push',
verbose: false,
preferLocalVersion: true,
origin: 'ui',
});
expect(eventService.emit).toHaveBeenCalledWith(
'source-control-user-started-push-ui',
expect.objectContaining({ userId: user.id, publicApi: false }),
);
});
it('emits `source-control-user-started-push-ui` with publicApi: true when origin is `publicApi`', async () => {
await sourceControlStatusService.getStatus(user, {
direction: 'push',
verbose: false,
preferLocalVersion: true,
origin: 'publicApi',
});
expect(eventService.emit).toHaveBeenCalledWith(
'source-control-user-started-push-ui',
expect.objectContaining({ userId: user.id, publicApi: true }),
);
});
it('emits `source-control-user-started-pull-ui` with publicApi: false when origin is not set', async () => {
await sourceControlStatusService.getStatus(user, {
direction: 'pull',
verbose: false,
preferLocalVersion: false,
});
expect(eventService.emit).toHaveBeenCalledWith(
'source-control-user-started-pull-ui',
expect.objectContaining({ userId: user.id, publicApi: false }),
);
});
it('emits `source-control-user-started-pull-ui` with publicApi: true when origin is `publicApi`', async () => {
await sourceControlStatusService.getStatus(user, {
direction: 'pull',
verbose: false,
preferLocalVersion: false,
origin: 'publicApi',
});
expect(eventService.emit).toHaveBeenCalledWith(
'source-control-user-started-pull-ui',
expect.objectContaining({ userId: user.id, publicApi: true }),
);
});
});
describe('workflows', () => {
const user = globalAdminUser;

View File

@ -197,16 +197,26 @@ export class SourceControlStatusService {
...projectsResult.files,
];
const publicApi = options.origin === 'publicApi';
if (options.direction === 'push') {
this.eventService.emit(
'source-control-user-started-push-ui',
getTrackingInformationFromPrePushResult(user.id, sourceControlledFiles),
const trackingInformation = getTrackingInformationFromPrePushResult(
user.id,
sourceControlledFiles,
);
this.eventService.emit('source-control-user-started-push-ui', {
...trackingInformation,
publicApi,
});
} else if (options.direction === 'pull') {
this.eventService.emit(
'source-control-user-started-pull-ui',
getTrackingInformationFromPullResult(user.id, sourceControlledFiles),
const trackingInformation = getTrackingInformationFromPullResult(
user.id,
sourceControlledFiles,
);
this.eventService.emit('source-control-user-started-pull-ui', {
...trackingInformation,
publicApi,
});
}
if (collectVerbose) {

View File

@ -60,6 +60,8 @@ export class SourceControlGetStatus {
@IsOptional()
verbose: boolean;
origin?: 'ui' | 'publicApi';
constructor(values: {
direction: 'push' | 'pull';
preferLocalVersion: string | boolean;
@ -68,5 +70,7 @@ export class SourceControlGetStatus {
this.direction = values.direction || 'push';
this.preferLocalVersion = booleanFromString(values.preferLocalVersion) || true;
this.verbose = booleanFromString(values.verbose) || false;
// Never read from `values`, an untrusted `origin` query parameter must not be propagated through.
this.origin = 'ui';
}
}

View File

@ -7,5 +7,6 @@ import './executions.public.controller';
import './git-connections.public.controller';
import './role-mapping-rules.public.controller';
import './roles.public.controller';
import './source-control.public.controller';
import './tags.public.controller';
import './workflows.public.controller';

View File

@ -0,0 +1,64 @@
import { SourceControlStatusPublicDto, SourceControlStatusQueryPublicDto } from '@n8n/api-types';
import { LICENSE_FEATURES } from '@n8n/constants';
import type { AuthenticatedRequest } from '@n8n/db';
import {
ApiDescription,
ApiKeyScope,
ApiResponse,
ApiSummary,
ApiTags,
Get,
Licensed,
PublicApiController,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.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';
const tags = ['SourceControl'];
@PublicApiController('/source-control')
export class SourceControlPublicController {
constructor(
private readonly sourceControlService: SourceControlService,
private readonly sourceControlPreferencesService: SourceControlPreferencesService,
private readonly sourceControlScopedService: SourceControlScopedService,
) {}
@Get('/status')
@Licensed(LICENSE_FEATURES.SOURCE_CONTROL)
@ApiKeyScope('sourceControl:read')
@ApiSummary('Preview pending source control changes')
@ApiDescription(
'Previews the pending changes between the instance and the connected Git repository in either the `push` or `pull` direction.',
)
@ApiTags(tags)
@ApiResponse(200, SourceControlStatusPublicDto)
async getSourceControlStatus(
req: AuthenticatedRequest,
_res: Response,
@Query query: SourceControlStatusQueryPublicDto,
): Promise<SourceControlStatusPublicDto> {
await this.sourceControlScopedService.ensureIsAllowedToGetStatus(req);
if (!this.sourceControlPreferencesService.isSourceControlConnected()) {
throw new BadRequestError('Source Control is not connected to a repository');
}
const result = await this.sourceControlService.getStatus(req.user, {
direction: query.direction,
preferLocalVersion: query.direction === 'push',
verbose: false,
origin: 'publicApi',
});
// `verbose: false` above guarantees a flat file list at runtime, narrow explicitly rather than casting.
const files = Array.isArray(result) ? result : result.sourceControlledFiles;
return { data: files };
}
}

View File

@ -0,0 +1,181 @@
operationId: getSourceControlStatus
tags:
- SourceControl
summary: Preview pending source control changes
description: Previews the pending changes between the instance and the connected Git repository in either the `push` or `pull` direction.
x-required-scope: sourceControl:read
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
enum:
- push
- pull
required: true
name: direction
in: query
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

View File

@ -79,6 +79,9 @@ paths:
$ref: ./handlers/roles/spec/paths/updateRole.generated.yml
delete:
$ref: ./handlers/roles/spec/paths/deleteRole.generated.yml
/source-control/status:
get:
$ref: ./handlers/source-control/spec/paths/getSourceControlStatus.generated.yml
/tags:
get:
$ref: ./handlers/tags/spec/paths/getTags.generated.yml

View File

@ -17,17 +17,21 @@ export const decodeCursor = (cursor: string): PaginationOffsetDecoded | Paginati
* Resolves the offset and limit to query with for a list endpoint either from defaults
* or from a provided cursor.
*/
export function resolveOffsetPagination(query: {
export function resolveOffsetPagination({
cursor,
limit: queryLimit,
offset: queryOffset,
}: {
cursor?: string;
limit: number;
offset?: number;
}): { offset: number; limit: number } {
let { limit } = query;
let offset = query.offset ?? 0;
let limit = queryLimit;
let offset = queryOffset ?? 0;
if (query.cursor) {
if (cursor) {
try {
const decoded = decodeCursor(query.cursor);
const decoded = decodeCursor(cursor);
if (!('offset' in decoded)) {
throw new BadRequestError('An invalid cursor was provided');
}

View File

@ -10,7 +10,22 @@ import { Telemetry } from '@/telemetry';
import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
describe('POST /source-control/pull (Public API)', () => {
const sourceControlledFileFixture = (
id: string,
overrides: Partial<SourceControlledFile> = {},
): SourceControlledFile => ({
file: `workflows/${id}.json`,
id,
name: `Workflow ${id}`,
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
updatedAt: '2024-01-01T00:00:00.000Z',
...overrides,
});
describe('Source Control (Public API)', () => {
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
mockInstance(Telemetry);
@ -25,139 +40,300 @@ describe('POST /source-control/pull (Public API)', () => {
vi.restoreAllMocks();
});
const pullUrl = '/source-control/pull';
const validBody = { autoPublish: 'none' as const };
const mockConnected = () => {
const preferences = Container.get(SourceControlPreferencesService);
vi.spyOn(preferences, 'isSourceControlConnected').mockReturnValue(true);
};
const sourceControlledFileFixture = (id: string): SourceControlledFile => ({
file: `workflows/${id}.json`,
id,
name: `Workflow ${id}`,
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
updatedAt: '2024-01-01T00:00:00.000Z',
});
describe('POST /source-control/pull', () => {
const pullUrl = '/source-control/pull';
const validBody = { autoPublish: 'none' as const };
it('should return 401 when API key is missing', async () => {
const response = await testServer.publicApiAgentWithoutApiKey().post(pullUrl).send(validBody);
it('should return 401 when API key is missing', async () => {
const response = await testServer.publicApiAgentWithoutApiKey().post(pullUrl).send(validBody);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('should return 401 when API key is invalid', async () => {
const response = await testServer
.publicApiAgentWithApiKey('not-a-real-api-key')
.post(pullUrl)
.send(validBody);
it('should return 401 when API key is invalid', async () => {
const response = await testServer
.publicApiAgentWithApiKey('not-a-real-api-key')
.post(pullUrl)
.send(validBody);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message');
});
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message');
});
it('should return 403 when API key lacks sourceControl:pull scope', async () => {
testServer.license.enable('feat:sourceControl');
const member = await createMemberWithApiKey({ scopes: ['tag:list'] });
it('should return 403 when API key lacks sourceControl:pull scope', async () => {
testServer.license.enable('feat:sourceControl');
const member = await createMemberWithApiKey({ scopes: ['tag:list'] });
const response = await testServer.publicApiAgentFor(member).post(pullUrl).send(validBody);
const response = await testServer.publicApiAgentFor(member).post(pullUrl).send(validBody);
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
it('should return 401 when Source Control is not licensed', async () => {
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
it('should return 401 when Source Control is not licensed', async () => {
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
expect(response.status).toBe(401);
expect(response.body).toEqual({
status: 'Error',
message: 'Source Control feature is not licensed',
expect(response.status).toBe(401);
expect(response.body).toEqual({
status: 'Error',
message: 'Source Control feature is not licensed',
});
});
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(pullUrl).send(validBody);
expect(response.status).toBe(400);
expect(response.body).toEqual({
status: 'Error',
message: 'Source Control is not connected to a repository',
});
});
it('should return 200 and import result when pull succeeds', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const statusResult: SourceControlledFile[] = [
sourceControlledFileFixture('wf-1'),
sourceControlledFileFixture('wf-2'),
sourceControlledFileFixture('wf-3'),
];
const pullSpy = vi
.spyOn(Container.get(SourceControlService), 'pullWorkfolder')
.mockResolvedValue({ statusCode: 200, statusResult });
const emitSpy = vi.spyOn(Container.get(EventService), 'emit').mockImplementation(() => true);
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(statusResult);
expect(pullSpy).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
'source-control-user-pulled-api',
expect.objectContaining({ forced: false }),
);
});
it('should return 409 when pull reports conflicts', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const statusResult: SourceControlledFile[] = [];
vi.spyOn(Container.get(SourceControlService), 'pullWorkfolder').mockResolvedValue({
statusCode: 409,
statusResult,
});
const response = await testServer
.publicApiAgentFor(owner)
.post(pullUrl)
.send({ force: false });
expect(response.status).toBe(409);
expect(response.body).toEqual(statusResult);
});
it('should return 400 as plain text when pullWorkfolder throws', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
vi.spyOn(Container.get(SourceControlService), 'pullWorkfolder').mockRejectedValue(
new Error('Git operation failed'),
);
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
expect(response.status).toBe(400);
expect(response.text).toBe('Git operation failed');
});
it('should return 400 as plain text when body fails PullWorkFolderRequestDto validation', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const response = await testServer
.publicApiAgentFor(owner)
.post(pullUrl)
.send({ autoPublish: 'not-a-valid-mode' });
expect(response.status).toBe(400);
expect(response.text.length).toBeGreaterThan(0);
});
});
it('should return 400 when licensed but Source Control is not connected', async () => {
testServer.license.enable('feat:sourceControl');
describe('GET /source-control/status', () => {
const statusUrl = '/source-control/status';
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
it('should return 403 when API key lacks sourceControl:read scope', async () => {
testServer.license.enable('feat:sourceControl');
const member = await createMemberWithApiKey({ scopes: ['tag:list'] });
expect(response.status).toBe(400);
expect(response.body).toEqual({
status: 'Error',
message: 'Source Control is not connected to a repository',
});
});
const response = await testServer
.publicApiAgentFor(member)
.get(statusUrl)
.query({ direction: 'push' });
it('should return 200 and import result when pull succeeds', async () => {
testServer.license.enable('feat:sourceControl');
const preferences = Container.get(SourceControlPreferencesService);
vi.spyOn(preferences, 'isSourceControlConnected').mockReturnValue(true);
const statusResult: SourceControlledFile[] = [
sourceControlledFileFixture('wf-1'),
sourceControlledFileFixture('wf-2'),
sourceControlledFileFixture('wf-3'),
];
const pullSpy = vi
.spyOn(Container.get(SourceControlService), 'pullWorkfolder')
.mockResolvedValue({ statusCode: 200, statusResult });
const emitSpy = vi.spyOn(Container.get(EventService), 'emit').mockImplementation(() => true);
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(statusResult);
expect(pullSpy).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
'source-control-user-pulled-api',
expect.objectContaining({ forced: false }),
);
});
it('should return 409 when pull reports conflicts', async () => {
testServer.license.enable('feat:sourceControl');
const preferences = Container.get(SourceControlPreferencesService);
vi.spyOn(preferences, 'isSourceControlConnected').mockReturnValue(true);
const statusResult: SourceControlledFile[] = [];
vi.spyOn(Container.get(SourceControlService), 'pullWorkfolder').mockResolvedValue({
statusCode: 409,
statusResult,
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send({ force: false });
it('should return 403 when API key has sourceControl:pull but not sourceControl:read', async () => {
testServer.license.enable('feat:sourceControl');
const member = await createMemberWithApiKey({ scopes: ['sourceControl:pull'] });
expect(response.status).toBe(409);
expect(response.body).toEqual(statusResult);
});
const response = await testServer
.publicApiAgentFor(member)
.get(statusUrl)
.query({ direction: 'push' });
it('should return 400 as plain text when pullWorkfolder throws', async () => {
testServer.license.enable('feat:sourceControl');
const preferences = Container.get(SourceControlPreferencesService);
vi.spyOn(preferences, 'isSourceControlConnected').mockReturnValue(true);
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
vi.spyOn(Container.get(SourceControlService), 'pullWorkfolder').mockRejectedValue(
new Error('Git operation failed'),
);
it('should return 403 when Source Control is not licensed', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.get(statusUrl)
.query({ direction: 'push' });
const response = await testServer.publicApiAgentFor(owner).post(pullUrl).send(validBody);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message');
expect(response.body.message).toContain('feat:sourceControl');
});
expect(response.status).toBe(400);
expect(response.text).toBe('Git operation failed');
});
it('should return 400 when licensed but Source Control is not connected', async () => {
testServer.license.enable('feat:sourceControl');
it('should return 400 as plain text when body fails PullWorkFolderRequestDto validation', async () => {
testServer.license.enable('feat:sourceControl');
const preferences = Container.get(SourceControlPreferencesService);
vi.spyOn(preferences, 'isSourceControlConnected').mockReturnValue(true);
const response = await testServer
.publicApiAgentFor(owner)
.get(statusUrl)
.query({ direction: 'push' });
const response = await testServer
.publicApiAgentFor(owner)
.post(pullUrl)
.send({ autoPublish: 'not-a-valid-mode' });
expect(response.status).toBe(400);
expect(response.body).toEqual({
message: 'Source Control is not connected to a repository',
});
});
expect(response.status).toBe(400);
expect(response.text.length).toBeGreaterThan(0);
it('should return 400 when direction is missing', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const response = await testServer.publicApiAgentFor(owner).get(statusUrl);
expect(response.status).toBe(400);
});
it('should return 400 when direction is invalid', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const response = await testServer
.publicApiAgentFor(owner)
.get(statusUrl)
.query({ direction: 'sideways' });
expect(response.status).toBe(400);
});
it('should return 403 for a member with a read-only key but no authorized project', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const member = await createMemberWithApiKey({ scopes: ['sourceControl:read'] });
const response = await testServer
.publicApiAgentFor(member)
.get(statusUrl)
.query({ direction: 'push' });
expect(response.status).toBe(403);
});
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:read'] });
const response = await testServer
.publicApiAgentFor(member)
.get(statusUrl)
.query({ direction: 'push' });
// 403 from RBAC, not the 400 that would disclose the connection state.
expect(response.status).toBe(403);
});
it('should return 200 with the status envelope for direction=push', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const files = [
sourceControlledFileFixture('wf-1'),
sourceControlledFileFixture('wf-2'),
sourceControlledFileFixture('wf-3'),
];
const getStatusSpy = vi
.spyOn(Container.get(SourceControlService), 'getStatus')
.mockResolvedValue(files);
const response = await testServer
.publicApiAgentFor(owner)
.get(statusUrl)
.query({ direction: 'push' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ data: files });
expect(getStatusSpy).toHaveBeenCalledWith(
expect.objectContaining({ id: owner.id }),
expect.objectContaining({
direction: 'push',
preferLocalVersion: true,
verbose: false,
origin: 'publicApi',
}),
);
});
it('should return 200 with the status envelope for direction=pull', async () => {
testServer.license.enable('feat:sourceControl');
mockConnected();
const files = [
sourceControlledFileFixture('wf-1'),
sourceControlledFileFixture('wf-2'),
sourceControlledFileFixture('wf-3'),
];
const getStatusSpy = vi
.spyOn(Container.get(SourceControlService), 'getStatus')
.mockResolvedValue(files);
const response = await testServer
.publicApiAgentFor(owner)
.get(statusUrl)
.query({ direction: 'pull' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ data: files });
expect(getStatusSpy).toHaveBeenCalledWith(
expect.objectContaining({ id: owner.id }),
expect.objectContaining({
direction: 'pull',
// Pull previews the incoming side, matching what `pullWorkfolder` applies.
preferLocalVersion: false,
verbose: false,
origin: 'publicApi',
}),
);
});
});
});

View File

@ -33,6 +33,7 @@ describe('classifyScope', () => {
['execution:retry', 'write'],
['securityAudit:generate', 'write'],
['sourceControl:pull', 'write'],
['sourceControl:read', 'read'],
['communityPackage:install', 'write'],
['dataTableRow:upsert', 'write'],
] as Array<[ApiKeyScope, string]>)('classifies %s as %s', (scope, expected) => {

View File

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