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:
Irénée
2026-08-20 07:28:36 +00:00
committed by GitHub
parent 48b245c0b4
commit ce4d58ea13
25 changed files with 792 additions and 4 deletions
@@ -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(),
}) {}
+1 -1
View File
@@ -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';
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"