From daab1959ca7cc6cf93b910a9f75bc76c2f71c33c Mon Sep 17 00:00:00 2001 From: Sam Wooler Date: Fri, 4 Sep 2026 12:15:26 +0000 Subject: [PATCH] feat(API): Add source control status endpoint to the public API (#37001) --- packages/@n8n/api-types/src/dto/index.ts | 2 + .../source-control-status-public.dto.ts | 8 + .../source-control-status-query-public.dto.ts | 7 + .../scope-information.test.ts.snap | 1 + .../permissions/src/__tests__/types.test.ts | 2 + packages/@n8n/permissions/src/constants.ee.ts | 4 +- .../src/public-api-permissions.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 1 + .../__tests__/telemetry-event-relay.test.ts | 46 ++ .../cli/src/events/maps/relay.event-map.ts | 2 + .../events/relays/telemetry.event-relay.ts | 4 + .../source-control-status.service.test.ts | 75 +++- .../source-control-status.service.ee.ts | 22 +- .../types/source-control-get-status.ts | 4 + .../src/public-api/v1/controllers/index.ts | 1 + .../source-control.public.controller.ts | 64 +++ .../getSourceControlStatus.generated.yml | 181 ++++++++ .../v1/openapi.decorator-routes.generated.yml | 3 + .../v1/shared/services/pagination.service.ts | 14 +- .../public-api/source-control.test.ts | 394 +++++++++++++----- .../settings/apiKeys/apiKeys.utils.test.ts | 1 + .../nodes/N8n/n8n-api-coverage.json | 3 + 22 files changed, 717 insertions(+), 123 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/source-control/source-control-status-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/source-control/source-control-status-query-public.dto.ts create mode 100644 packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts create mode 100644 packages/cli/src/public-api/v1/handlers/source-control/spec/paths/getSourceControlStatus.generated.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 8a91f7ce6e3..771a9e3fb8d 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/dto/source-control/source-control-status-public.dto.ts b/packages/@n8n/api-types/src/dto/source-control/source-control-status-public.dto.ts new file mode 100644 index 00000000000..61752aa8017 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/source-control/source-control-status-public.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema'; +import { Z } from '../../zod-class'; + +export class SourceControlStatusPublicDto extends Z.class({ + data: z.array(SourceControlledFileSchema), +}) {} diff --git a/packages/@n8n/api-types/src/dto/source-control/source-control-status-query-public.dto.ts b/packages/@n8n/api-types/src/dto/source-control/source-control-status-query-public.dto.ts new file mode 100644 index 00000000000..a08c2044631 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/source-control/source-control-status-query-public.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class SourceControlStatusQueryPublicDto extends Z.class({ + direction: z.enum(['push', 'pull']), +}) {} diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index a2fcf63a0ff..60bd53a40b2 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -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", diff --git a/packages/@n8n/permissions/src/__tests__/types.test.ts b/packages/@n8n/permissions/src/__tests__/types.test.ts index 22676ba7278..9706705e805 100644 --- a/packages/@n8n/permissions/src/__tests__/types.test.ts +++ b/packages/@n8n/permissions/src/__tests__/types.test.ts @@ -22,6 +22,7 @@ describe('ApiKeyScope', () => { 'project:update', 'securityAudit:generate', 'sourceControl:pull', + 'sourceControl: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', diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index d2050161de8..df6714b315e 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index acc1d5d3ae9..d6f1f55fafd 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -13,6 +13,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'user:changeRole', 'user:delete', 'sourceControl:pull', + 'sourceControl:read', 'gitConnection:create', 'gitConnection:read', 'gitConnection:update', diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index fc136bcdfcc..3abcfc91a04 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -61,6 +61,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'sourceControl:pull', 'sourceControl:push', 'sourceControl:manage', + 'sourceControl:read', 'gitConnection:create', 'gitConnection:read', 'gitConnection:update', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index bb29be9ba6f..7b73b85f1d6 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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, }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 9968bcbcc3f..f52604544b7 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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': { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 470a1e170fa..bf325670d11 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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, }); } diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts index dad281b6419..102cceb7409 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts @@ -51,6 +51,7 @@ describe('getStatus', () => { mock(), ); const sourceControlContextFactory = mock(); + const eventService = mock(); const sourceControlStatusService = new SourceControlStatusService( mockLogger(), gitService, @@ -60,7 +61,7 @@ describe('getStatus', () => { tagRepository, folderRepository, workflowRepository, - mock(), + 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; diff --git a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts index 8901c040cff..33730c09bb7 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts @@ -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) { diff --git a/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts b/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts index 39b8ebad943..7d20ffe934a 100644 --- a/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts +++ b/packages/cli/src/modules/source-control.ee/types/source-control-get-status.ts @@ -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'; } } diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index 59fbf36341d..03d8c9a3749 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -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'; diff --git a/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts b/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts new file mode 100644 index 00000000000..f88f39a9d67 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts @@ -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 { + 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 }; + } +} diff --git a/packages/cli/src/public-api/v1/handlers/source-control/spec/paths/getSourceControlStatus.generated.yml b/packages/cli/src/public-api/v1/handlers/source-control/spec/paths/getSourceControlStatus.generated.yml new file mode 100644 index 00000000000..0d34c776f34 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/source-control/spec/paths/getSourceControlStatus.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 6cf53b8bb3d..484fd3ff4c7 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/shared/services/pagination.service.ts b/packages/cli/src/public-api/v1/shared/services/pagination.service.ts index e4a9e39f7be..80aefb4ebdc 100644 --- a/packages/cli/src/public-api/v1/shared/services/pagination.service.ts +++ b/packages/cli/src/public-api/v1/shared/services/pagination.service.ts @@ -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'); } diff --git a/packages/cli/test/integration/public-api/source-control.test.ts b/packages/cli/test/integration/public-api/source-control.test.ts index 83916d188bc..65d472b30d2 100644 --- a/packages/cli/test/integration/public-api/source-control.test.ts +++ b/packages/cli/test/integration/public-api/source-control.test.ts @@ -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 => ({ + 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', + }), + ); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts index 3563d9b0c52..71b895d0b87 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.utils.test.ts @@ -33,6 +33,7 @@ describe('classifyScope', () => { ['execution:retry', 'write'], ['securityAudit:generate', 'write'], ['sourceControl:pull', 'write'], + ['sourceControl:read', 'read'], ['communityPackage:install', 'write'], ['dataTableRow:upsert', 'write'], ] as Array<[ApiKeyScope, string]>)('classifies %s as %s', (scope, expected) => { diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 2088ec58d09..aa6667bdc2e 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -244,6 +244,9 @@ "POST /source-control/pull": { "status": "gap" }, + "GET /source-control/status": { + "status": "gap" + }, "POST /git-connections/{id}/push": { "status": "gap" },