mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(API): Add Git connections public API (no-changelog) (#36272)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@ export class UpdateGitConnectionDto extends Z.class({
|
||||
password: z.string().min(1).optional(),
|
||||
}) {}
|
||||
|
||||
export class ConnectGitConnectionDto extends Z.class({
|
||||
export class CloneGitConnectionDto extends Z.class({
|
||||
branchName: branchNameSchema.optional(),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export {
|
||||
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
|
||||
export { type GitCommitInfo } from './source-control/push-work-folder-response.dto';
|
||||
export {
|
||||
ConnectGitConnectionDto,
|
||||
CloneGitConnectionDto,
|
||||
CreateGitConnectionDto,
|
||||
GitConnectionListPublicDto,
|
||||
GitConnectionPublicDto,
|
||||
|
||||
@@ -69,6 +69,12 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
|
||||
"sourceControl:pull",
|
||||
"sourceControl:push",
|
||||
"sourceControl:manage",
|
||||
"gitConnection:create",
|
||||
"gitConnection:read",
|
||||
"gitConnection:update",
|
||||
"gitConnection:delete",
|
||||
"gitConnection:list",
|
||||
"gitConnection:clone",
|
||||
"tag:create",
|
||||
"tag:read",
|
||||
"tag:update",
|
||||
|
||||
@@ -34,6 +34,7 @@ export const RESOURCES = {
|
||||
securityAudit: ['generate'] as const,
|
||||
securitySettings: ['manage'] as const,
|
||||
sourceControl: ['pull', 'push', 'manage'] as const,
|
||||
gitConnection: [...DEFAULT_OPERATIONS, 'clone'] as const,
|
||||
tag: [...DEFAULT_OPERATIONS] as const,
|
||||
user: [
|
||||
'resetPassword',
|
||||
@@ -107,6 +108,7 @@ export const API_KEY_RESOURCES = {
|
||||
credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const,
|
||||
eventBusDestination: ['test', 'create', 'read', 'update', 'delete', 'list'] as const,
|
||||
sourceControl: ['pull'] as const,
|
||||
gitConnection: [...DEFAULT_OPERATIONS, 'clone'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
executionTags: ['update', 'list'] as const,
|
||||
communityPackage: ['install', 'uninstall', 'update', 'list'] as const,
|
||||
|
||||
@@ -13,6 +13,12 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'user:changeRole',
|
||||
'user:delete',
|
||||
'sourceControl:pull',
|
||||
'gitConnection:create',
|
||||
'gitConnection:read',
|
||||
'gitConnection:update',
|
||||
'gitConnection:delete',
|
||||
'gitConnection:list',
|
||||
'gitConnection:clone',
|
||||
'securityAudit:generate',
|
||||
'securitySettings:manage',
|
||||
'saml:manage',
|
||||
|
||||
@@ -59,6 +59,12 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'sourceControl:pull',
|
||||
'sourceControl:push',
|
||||
'sourceControl:manage',
|
||||
'gitConnection:create',
|
||||
'gitConnection:read',
|
||||
'gitConnection:update',
|
||||
'gitConnection:delete',
|
||||
'gitConnection:list',
|
||||
'gitConnection:clone',
|
||||
'tag:create',
|
||||
'tag:read',
|
||||
'tag:update',
|
||||
|
||||
@@ -133,4 +133,28 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
|
||||
displayName: 'Manage project roles',
|
||||
description: 'Allows creating, editing, and deleting project role definitions.',
|
||||
},
|
||||
'gitConnection:create': {
|
||||
displayName: 'Create Git Connection',
|
||||
description: 'Allows creating Git connections and their authentication material.',
|
||||
},
|
||||
'gitConnection:read': {
|
||||
displayName: 'Read Git Connection',
|
||||
description: 'Allows reading Git connection configuration. Secrets are never returned.',
|
||||
},
|
||||
'gitConnection:update': {
|
||||
displayName: 'Update Git Connection',
|
||||
description: 'Allows updating Git connections, including their authentication material.',
|
||||
},
|
||||
'gitConnection:delete': {
|
||||
displayName: 'Delete Git Connection',
|
||||
description: 'Allows deleting Git connections and their local files.',
|
||||
},
|
||||
'gitConnection:list': {
|
||||
displayName: 'List Git Connections',
|
||||
description: 'Allows listing Git connections.',
|
||||
},
|
||||
'gitConnection:clone': {
|
||||
displayName: 'Clone Git Connection',
|
||||
description: 'Allows cloning and removing the local working copy of a Git connection.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('permissions', () => {
|
||||
securityAudit: {},
|
||||
securitySettings: {},
|
||||
sourceControl: {},
|
||||
gitConnection: {},
|
||||
tag: {},
|
||||
user: {},
|
||||
variable: {},
|
||||
@@ -129,6 +130,7 @@ describe('permissions', () => {
|
||||
securityAudit: {},
|
||||
securitySettings: {},
|
||||
sourceControl: {},
|
||||
gitConnection: {},
|
||||
tag: {
|
||||
create: true,
|
||||
list: true,
|
||||
|
||||
@@ -84,10 +84,10 @@ export class GitConnectionsService {
|
||||
return this.toPublic(saved);
|
||||
}
|
||||
|
||||
async connect(id: string, branchName?: string) {
|
||||
async clone(id: string, branchName?: string) {
|
||||
const connection = await this.getEntity(id);
|
||||
const effectiveBranch = branchName ?? connection.branchName;
|
||||
if (!effectiveBranch) throw new BadRequestError('A branch name is required to connect');
|
||||
if (!effectiveBranch) throw new BadRequestError('A branch name is required to clone');
|
||||
const credentials = await this.decryptCredentials(connection);
|
||||
await this.gitService.clone({
|
||||
connection,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
CloneGitConnectionDto,
|
||||
CreateGitConnectionDto,
|
||||
GitConnectionListPublicDto,
|
||||
GitConnectionPublicDto,
|
||||
ListGitConnectionsQueryDto,
|
||||
MAX_ITEMS_PER_PAGE,
|
||||
UpdateGitConnectionDto,
|
||||
} from '@n8n/api-types';
|
||||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import {
|
||||
ApiDescription,
|
||||
ApiErrorResponse,
|
||||
ApiKeyScope,
|
||||
ApiResponse,
|
||||
ApiSummary,
|
||||
ApiTags,
|
||||
Body,
|
||||
Delete,
|
||||
Get,
|
||||
GlobalScope,
|
||||
Licensed,
|
||||
Param,
|
||||
Post,
|
||||
PublicApiController,
|
||||
Put,
|
||||
Query,
|
||||
} from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error';
|
||||
import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service';
|
||||
|
||||
const tags = ['GitConnections'];
|
||||
|
||||
@PublicApiController('/git-connections')
|
||||
export class GitConnectionsPublicController {
|
||||
constructor(private readonly moduleRegistry: ModuleRegistry) {}
|
||||
|
||||
private async gitConnectionsService() {
|
||||
if (!this.moduleRegistry.isActive('git-connections')) {
|
||||
throw new ServiceUnavailableError('Git connections module is not enabled');
|
||||
}
|
||||
const { GitConnectionsService } = await import(
|
||||
'@/modules/git-connections.ee/git-connections.service.js'
|
||||
);
|
||||
return Container.get(GitConnectionsService);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:create')
|
||||
@GlobalScope('gitConnection:create')
|
||||
@ApiSummary('Create a Git connection')
|
||||
@ApiDescription('Creates a Git connection and its authentication material.')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(201, GitConnectionPublicDto)
|
||||
async createGitConnection(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body input: CreateGitConnectionDto,
|
||||
): Promise<GitConnectionPublicDto> {
|
||||
return await (await this.gitConnectionsService()).create(input);
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:list')
|
||||
@GlobalScope('gitConnection:list')
|
||||
@ApiSummary('List Git connections')
|
||||
@ApiDescription('Returns a cursor-paginated list of Git connections.')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, GitConnectionListPublicDto)
|
||||
async getGitConnections(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query query: ListGitConnectionsQueryDto,
|
||||
): Promise<GitConnectionListPublicDto> {
|
||||
let offset = 0;
|
||||
let { limit } = query;
|
||||
if (query.cursor) {
|
||||
try {
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
if (!('offset' in cursor)) throw new BadRequestError('An invalid cursor was provided');
|
||||
offset = cursor.offset;
|
||||
limit = cursor.limit;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) throw error;
|
||||
throw new BadRequestError('An invalid cursor was provided');
|
||||
}
|
||||
// A cursor is unsigned base64 the client can forge, so re-validate the
|
||||
// bounds already enforced on the raw query params before hitting the DB.
|
||||
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1) {
|
||||
throw new BadRequestError('An invalid cursor was provided');
|
||||
}
|
||||
limit = Math.min(limit, MAX_ITEMS_PER_PAGE);
|
||||
}
|
||||
const { data, count } = await (await this.gitConnectionsService()).list(offset, limit);
|
||||
return {
|
||||
data,
|
||||
nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:read')
|
||||
@GlobalScope('gitConnection:read')
|
||||
@ApiSummary('Retrieve a Git connection')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, GitConnectionPublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async getGitConnection(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
): Promise<GitConnectionPublicDto> {
|
||||
return await (await this.gitConnectionsService()).findOne(id);
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:update')
|
||||
@GlobalScope('gitConnection:update')
|
||||
@ApiSummary('Update a Git connection')
|
||||
@ApiDescription('Updates only the supplied fields. Secrets are never returned.')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, GitConnectionPublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async updateGitConnection(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
@Body input: UpdateGitConnectionDto,
|
||||
): Promise<GitConnectionPublicDto> {
|
||||
return await (await this.gitConnectionsService()).update(id, input);
|
||||
}
|
||||
|
||||
@Post('/:id/clone')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:clone')
|
||||
@GlobalScope('gitConnection:clone')
|
||||
@ApiSummary('Clone a Git connection')
|
||||
@ApiDescription('Clones the repository into local storage. Safe to call repeatedly.')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, GitConnectionPublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async cloneGitConnection(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
@Body input: CloneGitConnectionDto,
|
||||
): Promise<GitConnectionPublicDto> {
|
||||
return await (await this.gitConnectionsService()).clone(id, input.branchName);
|
||||
}
|
||||
|
||||
@Post('/:id/disconnect')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:clone')
|
||||
@GlobalScope('gitConnection:clone')
|
||||
@ApiSummary('Disconnect a Git connection')
|
||||
@ApiDescription(
|
||||
'Removes the local clone. The connection and its authentication material are retained.',
|
||||
)
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, GitConnectionPublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async disconnectGitConnection(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
): Promise<GitConnectionPublicDto> {
|
||||
return await (await this.gitConnectionsService()).disconnect(id);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:delete')
|
||||
@GlobalScope('gitConnection:delete')
|
||||
@ApiSummary('Delete a Git connection')
|
||||
@ApiDescription('Deletes a Git connection and its local files.')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(204)
|
||||
@ApiErrorResponse(404)
|
||||
async deleteGitConnection(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
): Promise<void> {
|
||||
await (await this.gitConnectionsService()).delete(id);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* decorator metadata is registered before PublicApiControllerRegistry /
|
||||
* scope-parity / discover run.
|
||||
*/
|
||||
import './git-connections.public.controller';
|
||||
import './role-mapping-rules.public.controller';
|
||||
import './roles.public.controller';
|
||||
import './tags.public.controller';
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
operationId: cloneGitConnection
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Clone a Git connection
|
||||
description: Clones the repository into local storage. Safe to call repeatedly.
|
||||
x-required-scope: gitConnection:clone
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
required: true
|
||||
name: id
|
||||
in: path
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
branchName:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
operationId: createGitConnection
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Create a Git connection
|
||||
description: Creates a Git connection and its authentication material.
|
||||
x-required-scope: gitConnection:create
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
repositoryUrl:
|
||||
type: string
|
||||
minLength: 1
|
||||
branchName:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
connectionType:
|
||||
type: string
|
||||
enum:
|
||||
- ssh
|
||||
- https
|
||||
keyGeneratorType:
|
||||
type: string
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- name
|
||||
- repositoryUrl
|
||||
- connectionType
|
||||
responses:
|
||||
'201':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
operationId: deleteGitConnection
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Delete a Git connection
|
||||
description: Deletes a Git connection and its local files.
|
||||
x-required-scope: gitConnection:delete
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
required: true
|
||||
name: id
|
||||
in: path
|
||||
responses:
|
||||
'204':
|
||||
description: Operation successful.
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
operationId: disconnectGitConnection
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Disconnect a Git connection
|
||||
description: Removes the local clone. The connection and its authentication material are retained.
|
||||
x-required-scope: gitConnection:clone
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
required: true
|
||||
name: id
|
||||
in: path
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
operationId: getGitConnection
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Retrieve a Git connection
|
||||
x-required-scope: gitConnection:read
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
required: true
|
||||
name: id
|
||||
in: path
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
operationId: getGitConnections
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: List Git connections
|
||||
description: Returns a cursor-paginated list of Git connections.
|
||||
x-required-scope: gitConnection:list
|
||||
parameters:
|
||||
- $ref: ../../../../shared/spec/parameters/limit.yml
|
||||
- $ref: ../../../../shared/spec/parameters/cursor.yml
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
repositoryUrl:
|
||||
type: string
|
||||
branchName:
|
||||
type: string
|
||||
nullable: true
|
||||
connectionType:
|
||||
type: string
|
||||
enum:
|
||||
- ssh
|
||||
- https
|
||||
keyGeneratorType:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
baseCommit:
|
||||
type: string
|
||||
nullable: true
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- repositoryUrl
|
||||
- branchName
|
||||
- connectionType
|
||||
- keyGeneratorType
|
||||
- baseCommit
|
||||
- createdAt
|
||||
- updatedAt
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- data
|
||||
- nextCursor
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
operationId: updateGitConnection
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Update a Git connection
|
||||
description: Updates only the supplied fields. Secrets are never returned.
|
||||
x-required-scope: gitConnection:update
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
required: true
|
||||
name: id
|
||||
in: path
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
repositoryUrl:
|
||||
type: string
|
||||
minLength: 1
|
||||
branchName:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
connectionType:
|
||||
type: string
|
||||
enum:
|
||||
- ssh
|
||||
- https
|
||||
keyGeneratorType:
|
||||
type: string
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
@@ -3,6 +3,24 @@ info:
|
||||
title: decorator-routes
|
||||
version: 0.0.0
|
||||
paths:
|
||||
/git-connections:
|
||||
post:
|
||||
$ref: ./handlers/git-connections/spec/paths/createGitConnection.generated.yml
|
||||
get:
|
||||
$ref: ./handlers/git-connections/spec/paths/getGitConnections.generated.yml
|
||||
/git-connections/{id}:
|
||||
get:
|
||||
$ref: ./handlers/git-connections/spec/paths/getGitConnection.generated.yml
|
||||
put:
|
||||
$ref: ./handlers/git-connections/spec/paths/updateGitConnection.generated.yml
|
||||
delete:
|
||||
$ref: ./handlers/git-connections/spec/paths/deleteGitConnection.generated.yml
|
||||
/git-connections/{id}/clone:
|
||||
post:
|
||||
$ref: ./handlers/git-connections/spec/paths/cloneGitConnection.generated.yml
|
||||
/git-connections/{id}/disconnect:
|
||||
post:
|
||||
$ref: ./handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml
|
||||
/role-mapping-rules:
|
||||
get:
|
||||
$ref: ./handlers/role-mapping-rules/spec/paths/getRoleMappingRules.generated.yml
|
||||
|
||||
@@ -39,6 +39,8 @@ tags:
|
||||
description: Operations about executions
|
||||
- name: Folders
|
||||
description: Operations about folders
|
||||
- name: GitConnections
|
||||
description: Operations about Git connections
|
||||
- name: Insights
|
||||
description: Operations about insights
|
||||
- name: LogStreaming
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
repositoryUrl:
|
||||
type: string
|
||||
branchName:
|
||||
type: string
|
||||
nullable: true
|
||||
connectionType:
|
||||
type: string
|
||||
enum:
|
||||
- ssh
|
||||
- https
|
||||
publicKey:
|
||||
type: string
|
||||
nullable: true
|
||||
keyGeneratorType:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
baseCommit:
|
||||
type: string
|
||||
nullable: true
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- repositoryUrl
|
||||
- branchName
|
||||
- connectionType
|
||||
- publicKey
|
||||
- keyGeneratorType
|
||||
- baseCommit
|
||||
- createdAt
|
||||
- updatedAt
|
||||
@@ -0,0 +1,140 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { GitConnectionRepository } from '@/modules/git-connections.ee/database/repositories/git-connection.repository';
|
||||
import { createOwnerWithApiKey } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
describe('Git connections in Public API', () => {
|
||||
const testServer = setupTestServer({
|
||||
endpointGroups: ['publicApi'],
|
||||
enabledFeatures: ['feat:gitConnections'],
|
||||
modules: ['git-connections'],
|
||||
});
|
||||
let owner: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
testServer.license.reset();
|
||||
await Container.get(GitConnectionRepository).delete({});
|
||||
owner = await createOwnerWithApiKey();
|
||||
});
|
||||
|
||||
it('creates, retrieves, lists, updates, disconnects, and deletes an HTTPS connection', async () => {
|
||||
const agent = testServer.publicApiAgentFor(owner);
|
||||
const createResponse = await agent.post('/git-connections').send({
|
||||
name: 'Deployments',
|
||||
repositoryUrl: 'https://example.com/org/repo.git',
|
||||
branchName: 'main',
|
||||
connectionType: 'https',
|
||||
username: 'git-user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(201);
|
||||
expect(createResponse.body).toMatchObject({
|
||||
name: 'Deployments',
|
||||
branchName: 'main',
|
||||
connectionType: 'https',
|
||||
publicKey: null,
|
||||
});
|
||||
expect(createResponse.body).not.toHaveProperty('username');
|
||||
expect(createResponse.body).not.toHaveProperty('password');
|
||||
expect(createResponse.body).not.toHaveProperty('connected');
|
||||
const id = createResponse.body.id as string;
|
||||
|
||||
const getResponse = await agent.get(`/git-connections/${id}`);
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.id).toBe(id);
|
||||
|
||||
const listResponse = await agent.get('/git-connections?limit=1');
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(listResponse.body.data).toHaveLength(1);
|
||||
expect(listResponse.body.data[0]).not.toHaveProperty('publicKey');
|
||||
|
||||
const updateResponse = await agent.put(`/git-connections/${id}`).send({ name: 'Renamed' });
|
||||
expect(updateResponse.status, JSON.stringify(updateResponse.body)).toBe(200);
|
||||
expect(updateResponse.body.name).toBe('Renamed');
|
||||
|
||||
const disconnectResponse = await agent.post(`/git-connections/${id}/disconnect`);
|
||||
expect(disconnectResponse.status).toBe(200);
|
||||
expect(disconnectResponse.body.id).toBe(id);
|
||||
expect(disconnectResponse.body).not.toHaveProperty('connected');
|
||||
|
||||
const deleteResponse = await agent.delete(`/git-connections/${id}`);
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
expect(await Container.get(GitConnectionRepository).findOneBy({ id })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a key without the source-control scope', async () => {
|
||||
const unscopedOwner = await createOwnerWithApiKey({ scopes: ['tag:list'] });
|
||||
const response = await testServer.publicApiAgentFor(unscopedOwner).get('/git-connections');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects requests when Git connections is not licensed', async () => {
|
||||
testServer.license.disable('feat:gitConnections');
|
||||
const response = await testServer.publicApiAgentFor(owner).get('/git-connections');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('generates an SSH key pair without exposing the private key', async () => {
|
||||
const response = await testServer.publicApiAgentFor(owner).post('/git-connections').send({
|
||||
name: 'SSH repository',
|
||||
repositoryUrl: 'git@example.com:org/repo.git',
|
||||
connectionType: 'ssh',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.publicKey).toMatch(/^ssh-ed25519 /);
|
||||
expect(response.body.keyGeneratorType).toBe('ed25519');
|
||||
expect(response.body).not.toHaveProperty('privateKey');
|
||||
const entity = await Container.get(GitConnectionRepository).findOneByOrFail({
|
||||
id: response.body.id,
|
||||
});
|
||||
expect(entity.encryptedPrivateKey).toBeTruthy();
|
||||
expect(entity.encryptedUsername).toBeNull();
|
||||
expect(entity.encryptedPassword).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects replacing only one HTTPS credential and leaves the entity unchanged', async () => {
|
||||
const agent = testServer.publicApiAgentFor(owner);
|
||||
const created = await agent.post('/git-connections').send({
|
||||
name: 'HTTPS repository',
|
||||
repositoryUrl: 'https://example.com/org/repo.git',
|
||||
connectionType: 'https',
|
||||
username: 'git-user',
|
||||
password: 'secret',
|
||||
});
|
||||
const before = await Container.get(GitConnectionRepository).findOneByOrFail({
|
||||
id: created.body.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.put(`/git-connections/${created.body.id}`)
|
||||
.send({ username: 'replacement' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const after = await Container.get(GitConnectionRepository).findOneByOrFail({
|
||||
id: created.body.id,
|
||||
});
|
||||
expect(after.encryptedUsername).toBe(before.encryptedUsername);
|
||||
expect(after.encryptedPassword).toBe(before.encryptedPassword);
|
||||
});
|
||||
|
||||
it('rejects mismatched URL and authentication types without persisting', async () => {
|
||||
const response = await testServer.publicApiAgentFor(owner).post('/git-connections').send({
|
||||
name: 'Invalid',
|
||||
repositoryUrl: 'git@example.com:org/repo.git',
|
||||
connectionType: 'https',
|
||||
username: 'git-user',
|
||||
password: 'secret',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await Container.get(GitConnectionRepository).count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ type ModuleName =
|
||||
| 'ldap'
|
||||
| 'redaction'
|
||||
| 'source-control'
|
||||
| 'git-connections'
|
||||
| 'token-exchange'
|
||||
| 'workflow-reviews';
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
||||
variable: {},
|
||||
projectVariable: {},
|
||||
sourceControl: {},
|
||||
gitConnection: {},
|
||||
externalSecretsProvider: {},
|
||||
externalSecret: {},
|
||||
project: {},
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
{
|
||||
"$comment": "Coverage manifest: n8n node vs n8n public API. When a new endpoint is added to the OpenAPI spec, add it here with status covered/gap/excluded. See test/N8n.api-coverage.test.ts.",
|
||||
"endpoints": {
|
||||
"GET /git-connections": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /git-connections": {
|
||||
"status": "gap"
|
||||
},
|
||||
"GET /git-connections/{id}": {
|
||||
"status": "gap"
|
||||
},
|
||||
"PUT /git-connections/{id}": {
|
||||
"status": "gap"
|
||||
},
|
||||
"DELETE /git-connections/{id}": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /git-connections/{id}/clone": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /git-connections/{id}/disconnect": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /audit": {
|
||||
"status": "covered",
|
||||
"nodeOperation": "audit:generate"
|
||||
|
||||
Reference in New Issue
Block a user