From c4ba31ef620ee1aa3ceffae5e1fa2c065fb0cddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Wed, 16 Jul 2025 14:38:13 +0200 Subject: [PATCH] feat: Add endpoint to retrieve single workflow from GH (#17220) Co-authored-by: Guillaume Jacquart --- .../source-control-git.service.test.ts | 37 ++++++++++ .../__tests__/source-control.service.test.ts | 69 ++++++++++++++++++- .../source-control-git.service.ee.ts | 16 +++++ .../source-control-scoped.service.ts | 12 +++- .../source-control.controller.ee.ts | 28 +++++++- .../source-control.service.ee.ts | 49 +++++++++++-- .../source-control.service.test.ts | 2 + 7 files changed, 201 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts index 473ee17796..822a462c86 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts @@ -68,4 +68,41 @@ describe('SourceControlGitService', () => { }); }); }); + + describe('getFileContent', () => { + it('should return file content at HEAD version', async () => { + // Arrange + const filePath = 'workflows/12345.json'; + const expectedContent = '{"id":"12345","name":"Test Workflow"}'; + const git = mock(); + const showSpy = jest.spyOn(git, 'show'); + showSpy.mockResolvedValue(expectedContent); + sourceControlGitService.git = git; + + // Act + const content = await sourceControlGitService.getFileContent(filePath); + + // Assert + expect(showSpy).toHaveBeenCalledWith([`HEAD:${filePath}`]); + expect(content).toBe(expectedContent); + }); + + it('should return file content at specific commit', async () => { + // Arrange + const filePath = 'workflows/12345.json'; + const commitHash = 'abc123'; + const expectedContent = '{"id":"12345","name":"Test Workflow"}'; + const git = mock(); + const showSpy = jest.spyOn(git, 'show'); + showSpy.mockResolvedValue(expectedContent); + sourceControlGitService.git = git; + + // Act + const content = await sourceControlGitService.getFileContent(filePath, commitHash); + + // Assert + expect(showSpy).toHaveBeenCalledWith([`${commitHash}:${filePath}`]); + expect(content).toBe(expectedContent); + }); + }); }); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 35b1f353a3..1f001f6c61 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -6,6 +6,7 @@ import type { User, FolderRepository, TagRepository, + WorkflowEntity, } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -15,7 +16,9 @@ import { SourceControlPreferencesService } from '@/environments.ee/source-contro import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import type { SourceControlGitService } from '../source-control-git.service.ee'; import type { SourceControlImportService } from '../source-control-import.service.ee'; +import type { SourceControlScopedService } from '../source-control-scoped.service'; import type { StatusExportableCredential } from '../types/exportable-credential'; import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id'; @@ -28,14 +31,17 @@ describe('SourceControlService', () => { mock(), ); const sourceControlImportService = mock(); + const sourceControlScopedService = mock(); const tagRepository = mock(); const folderRepository = mock(); + const gitService = mock(); const sourceControlService = new SourceControlService( mock(), - mock(), + gitService, preferencesService, mock(), sourceControlImportService, + sourceControlScopedService, tagRepository, folderRepository, mock(), @@ -375,4 +381,65 @@ describe('SourceControlService', () => { ).rejects.toThrowError(ForbiddenError); }); }); + + describe('getFileContent', () => { + it.each([{ type: 'workflow' as SourceControlledFile['type'], id: '1234', content: '{}' }])( + 'should return file content for $type', + async ({ type, id, content }) => { + jest.spyOn(gitService, 'getFileContent').mockResolvedValue(content); + const user = mock({ id: 'user-id', role: 'global:admin' }); + + const result = await sourceControlService.getRemoteFileEntity({ user, type, id }); + + expect(result).toEqual(JSON.parse(content)); + }, + ); + + it.each(['folders', 'credential', 'tags', 'variables'])( + 'should throw an error if the file type is not handled', + async (type) => { + const user = mock({ id: 'user-id', role: 'global:admin' }); + await expect( + sourceControlService.getRemoteFileEntity({ user, type, id: 'unknown' }), + ).rejects.toThrow(`Unsupported file type: ${type}`); + }, + ); + + it('should fail if the git service fails to get the file content', async () => { + jest.spyOn(gitService, 'getFileContent').mockRejectedValue(new Error('Git service error')); + const user = mock({ id: 'user-id', role: 'global:admin' }); + + await expect( + sourceControlService.getRemoteFileEntity({ user, type: 'workflow', id: '1234' }), + ).rejects.toThrow('Git service error'); + }); + + it('should throw an error if the user does not have access to the project', async () => { + const user = mock({ + id: 'user-id', + role: 'global:member', + }); + jest + .spyOn(sourceControlScopedService, 'getWorkflowsInAdminProjectsFromContext') + .mockResolvedValue([]); + + await expect( + sourceControlService.getRemoteFileEntity({ user, type: 'workflow', id: '1234' }), + ).rejects.toThrow('You are not allowed to access workflow with id 1234'); + }); + + it('should return content for an authorized workflow', async () => { + const user = mock({ id: 'user-id', role: 'global:member' }); + jest + .spyOn(sourceControlScopedService, 'getWorkflowsInAdminProjectsFromContext') + .mockResolvedValue([{ id: '1234' } as WorkflowEntity]); + jest.spyOn(gitService, 'getFileContent').mockResolvedValue('{}'); + const result = await sourceControlService.getRemoteFileEntity({ + user, + type: 'workflow', + id: '1234', + }); + expect(result).toEqual({}); + }); + }); }); diff --git a/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts index ff15d10225..9eb75f6a82 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts @@ -387,4 +387,20 @@ export class SourceControlGitService { const statusResult = await this.git.status(); return statusResult; } + + async getFileContent(filePath: string, commit: string = 'HEAD'): Promise { + if (!this.git) { + throw new UnexpectedError('Git is not initialized (getFileContent)'); + } + try { + const content = await this.git.show([`${commit}:${filePath}`]); + return content; + } catch (error) { + this.logger.error('Failed to get file content', { filePath, error }); + throw new UnexpectedError( + `Could not get content for file: ${filePath}: ${(error as Error)?.message}`, + { cause: error }, + ); + } + } } diff --git a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts index f006db076f..457ef9b9e6 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts @@ -12,10 +12,10 @@ import { hasGlobalScope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindOptionsWhere } from '@n8n/typeorm'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; - import { SourceControlContext } from './types/source-control-context'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; + @Service() export class SourceControlScopedService { constructor( @@ -65,17 +65,23 @@ export class SourceControlScopedService { async getWorkflowsInAdminProjectsFromContext( context: SourceControlContext, + id?: string, ): Promise { if (context.hasAccessToAllProjects()) { // In case the user is a global admin or owner, we don't need a filter return; } + const where = this.getWorkflowsInAdminProjectsFromContextFilter(context); + if (id) { + where.id = id; + } + return await this.workflowRepository.find({ select: { id: true, }, - where: this.getWorkflowsInAdminProjectsFromContextFilter(context), + where, }); } diff --git a/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts index 36fd7f0317..db64ba3a83 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts @@ -1,3 +1,4 @@ +import { IWorkflowToImport } from '@/interfaces'; import { PullWorkFolderRequestDto, PushWorkFolderRequestDto } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; @@ -5,9 +6,6 @@ import { Get, Post, Patch, RestController, GlobalScope, Body } from '@n8n/decora import express from 'express'; import type { PullResult } from 'simple-git'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; - import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants'; import { sourceControlLicensedMiddleware, @@ -22,6 +20,10 @@ import { SourceControlRequest } from './types/requests'; import { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { EventService } from '@/events/event.service'; + @RestController('/source-control') export class SourceControlController { constructor( @@ -252,4 +254,24 @@ export class SourceControlController { throw new BadRequestError((error as { message: string }).message); } } + + @Get('/remote-content/:type/:id', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) + async getFileContent( + req: AuthenticatedRequest & { params: { type: SourceControlledFile['type']; id: string } }, + ): Promise<{ content: IWorkflowToImport; type: SourceControlledFile['type'] }> { + try { + const { type, id } = req.params; + const content = await this.sourceControlService.getRemoteFileEntity({ + user: req.user, + type, + id, + }); + return { content, type }; + } catch (error) { + if (error instanceof ForbiddenError) { + throw error; + } + throw new BadRequestError((error as { message: string }).message); + } + } } diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 2626897c34..4275cd6dec 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -1,3 +1,4 @@ +import { IWorkflowToImport } from '@/interfaces'; import type { PullWorkFolderRequestDto, PushWorkFolderRequestDto, @@ -14,18 +15,15 @@ import { import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import { writeFileSync } from 'fs'; -import { UnexpectedError, UserError } from 'n8n-workflow'; +import { UnexpectedError, UserError, jsonParse } from 'n8n-workflow'; import path from 'path'; import type { PushResult } from 'simple-git'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; - import { SOURCE_CONTROL_DEFAULT_EMAIL, SOURCE_CONTROL_DEFAULT_NAME, SOURCE_CONTROL_README, + SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, } from './constants'; import { SourceControlExportService } from './source-control-export.service.ee'; import { SourceControlGitService } from './source-control-git.service.ee'; @@ -42,6 +40,7 @@ import { } from './source-control-helper.ee'; import { SourceControlImportService } from './source-control-import.service.ee'; import { SourceControlPreferencesService } from './source-control-preferences.service.ee'; +import { SourceControlScopedService } from './source-control-scoped.service'; import type { StatusExportableCredential } from './types/exportable-credential'; import type { ExportableFolder } from './types/exportable-folders'; import type { ImportResult } from './types/import-result'; @@ -50,6 +49,10 @@ import type { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { EventService } from '@/events/event.service'; + @Service() export class SourceControlService { /** Path to SSH private key in filesystem. */ @@ -65,6 +68,7 @@ export class SourceControlService { private sourceControlPreferencesService: SourceControlPreferencesService, private sourceControlExportService: SourceControlExportService, private sourceControlImportService: SourceControlImportService, + private sourceControlScopedService: SourceControlScopedService, private tagRepository: TagRepository, private folderRepository: FolderRepository, private readonly eventService: EventService, @@ -1048,4 +1052,39 @@ export class SourceControlService { await this.sanityCheck(); await this.gitService.setGitUserDetails(name, email); } + + async getRemoteFileEntity({ + user, + type, + id, + commit = 'HEAD', + }: { + user: User; + type: SourceControlledFile['type']; + id?: string; + commit?: string; + }): Promise { + await this.sanityCheck(); + const context = new SourceControlContext(user); + switch (type) { + case 'workflow': { + if (typeof id === 'undefined') { + throw new BadRequestError('Workflow ID is required to fetch workflow content'); + } + + const authorizedWorkflows = + await this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContext(context, id); + if (authorizedWorkflows && authorizedWorkflows.length === 0) { + throw new ForbiddenError(`You are not allowed to access workflow with id ${id}`); + } + const content = await this.gitService.getFileContent( + `${SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER}/${id}.json`, + commit, + ); + return jsonParse(content); + } + default: + throw new BadRequestError(`Unsupported file type: ${type}`); + } + } } diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts index c44297cb12..3491f57ea2 100644 --- a/packages/cli/test/integration/environments/source-control.service.test.ts +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -27,6 +27,7 @@ import { SourceControlExportService } from '@/environments.ee/source-control/sou import type { SourceControlGitService } from '@/environments.ee/source-control/source-control-git.service.ee'; import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee'; import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlScopedService } from '@/environments.ee/source-control/source-control-scoped.service'; import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; import type { ExportableFolder } from '@/environments.ee/source-control/types/exportable-folders'; @@ -428,6 +429,7 @@ describe('SourceControlService', () => { sourceControlPreferencesService, Container.get(SourceControlExportService), Container.get(SourceControlImportService), + Container.get(SourceControlScopedService), Container.get(TagRepository), Container.get(FolderRepository), Container.get(EventService),