mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(API): Add source control status endpoint to the public API (#37001)
This commit is contained in:
parent
52499b929e
commit
daab1959ca
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class SourceControlStatusQueryPublicDto extends Z.class({
|
||||
direction: z.enum(['push', 'pull']),
|
||||
}) {}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'sourceControl:pull',
|
||||
'sourceControl:push',
|
||||
'sourceControl:manage',
|
||||
'sourceControl:read',
|
||||
'gitConnection:create',
|
||||
'gitConnection:read',
|
||||
'gitConnection:update',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -244,6 +244,9 @@
|
|||
"POST /source-control/pull": {
|
||||
"status": "gap"
|
||||
},
|
||||
"GET /source-control/status": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /git-connections/{id}/push": {
|
||||
"status": "gap"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user