From 401269752641e7a20aa4f3ccac8092e146cdf61a Mon Sep 17 00:00:00 2001 From: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:24:35 +0200 Subject: [PATCH] feat(server-nestjs): expose repository API v2 routes --- apps/server-nestjs/src/main.module.ts | 2 + .../repository/repository.controller.spec.ts | 120 ++++++++++++++++++ .../repository/repository.controller.ts | 90 +++++++++++++ .../modules/repository/repository.module.ts | 26 ++++ .../repository/repository.service.spec.ts | 54 ++++++++ .../modules/repository/repository.service.ts | 23 +++- 6 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 apps/server-nestjs/src/modules/repository/repository.controller.spec.ts create mode 100644 apps/server-nestjs/src/modules/repository/repository.controller.ts create mode 100644 apps/server-nestjs/src/modules/repository/repository.module.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index c44f56b5d..03698156e 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -16,6 +16,7 @@ import { ProjectRolesModule } from './modules/project-roles/project-roles.module import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' import { ProjectServicesModule } from './modules/project-services/project-services.module' import { ProjectModule } from './modules/project/project.module' +import { RepositoryModule } from './modules/repository/repository.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' import { getDotenvPaths } from './utils/dotenv.utils' @@ -41,6 +42,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectRolesModule, ProjectSecretsModule, ProjectServicesModule, + RepositoryModule, ScheduleModule.forRoot(), SystemSettingsModule, VersionModule, diff --git a/apps/server-nestjs/src/modules/repository/repository.controller.spec.ts b/apps/server-nestjs/src/modules/repository/repository.controller.spec.ts new file mode 100644 index 000000000..02b43b158 --- /dev/null +++ b/apps/server-nestjs/src/modules/repository/repository.controller.spec.ts @@ -0,0 +1,120 @@ +import type { CreateRepository, UpdateRepository } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeRepository } from './repository-testing.utils' +import { RepositoryController } from './repository.controller' +import { RepositoryService } from './repository.service' + +describe('repositoryController', () => { + let module: TestingModule + let controller: RepositoryController + let service: DeepMockProxy + + let projectId: string + let repositoryId: string + let userId: string + let requestId: string + let projectSlug: string + let project: ProjectContext + let user: UserContext + let request: FastifyRequest + let validCreateRepository: CreateRepository + let validUpdateRepository: UpdateRepository + + beforeEach(async () => { + projectId = faker.string.uuid() + repositoryId = faker.string.uuid() + userId = faker.string.uuid() + requestId = faker.string.uuid() + projectSlug = faker.string.alphanumeric(8).toLowerCase() + project = { id: projectId, slug: projectSlug } + user = { userId } + request = { id: requestId } as FastifyRequest + validCreateRepository = { + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + isInfra: false, + isPrivate: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + } + validUpdateRepository = { + isPrivate: false, + deployRevision: faker.git.branch(), + } + + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [RepositoryController], + providers: [ + { provide: RepositoryService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(RepositoryController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('calls repositoryService.listByProjectId with the project id', async () => { + const expectedResult = [makeRepository({ projectId })] + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('calls repositoryService.createRepository with the body and request context', async () => { + const expectedResult = makeRepository({ projectId }) + service.createRepository.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateRepository, project, user, request) + + expect(service.createRepository).toHaveBeenCalledWith(projectId, projectSlug, validCreateRepository, userId, requestId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('calls repositoryService.updateRepository with the repository id, body and request context', async () => { + const expectedResult = makeRepository({ id: repositoryId, projectId }) + service.updateRepository.mockResolvedValue(expectedResult) + + const result = await controller.update(repositoryId, validUpdateRepository, project, user, request) + + expect(service.updateRepository).toHaveBeenCalledWith(projectId, projectSlug, repositoryId, validUpdateRepository, userId, requestId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('calls repositoryService.deleteRepository with the repository id and request context', async () => { + service.deleteRepository.mockResolvedValue(undefined) + + const result = await controller.delete(repositoryId, project, user, request) + + expect(service.deleteRepository).toHaveBeenCalledWith(projectId, repositoryId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/repository/repository.controller.ts b/apps/server-nestjs/src/modules/repository/repository.controller.ts new file mode 100644 index 000000000..b272801c1 --- /dev/null +++ b/apps/server-nestjs/src/modules/repository/repository.controller.ts @@ -0,0 +1,90 @@ +import type { CreateRepository, UpdateRepository } from '@cpn-console/shared' +import type { Repository } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { CreateRepositorySchema, UpdateRepositorySchema } from '@cpn-console/shared' +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + ParseUUIDPipe, + Post, + Put, + Req, + UseGuards, +} from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { RequireUserType } from '../infrastructure/permission/user/user-type.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { RepositoryService } from './repository.service' + +@Controller('api/v2/projects/:projectId/repositories') +@UseGuards(ProjectGuard) +export class RepositoryController { + constructor(@Inject(RepositoryService) private readonly repositoryService: RepositoryService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListRepositories') + list(@Project() project: ProjectContext): Promise { + return this.repositoryService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageRepositories') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireUserType('human') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateRepositorySchema)) data: CreateRepository, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.repositoryService.createRepository(project.id, project.slug, data, user.userId, request.id) + } + + @Put(':repositoryId') + @RequireProjectPermission('ManageRepositories') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireUserType('human') + @HttpCode(HttpStatus.OK) + update( + @Param('repositoryId', ParseUUIDPipe) repositoryId: string, + @Body(new ZodValidationPipe(UpdateRepositorySchema)) data: UpdateRepository, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.repositoryService.updateRepository(project.id, project.slug, repositoryId, data, user.userId, request.id) + } + + @Delete(':repositoryId') + @RequireProjectPermission('ManageRepositories') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireUserType('human') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('repositoryId', ParseUUIDPipe) repositoryId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.repositoryService.deleteRepository(project.id, repositoryId, user.userId, request.id) + } +} diff --git a/apps/server-nestjs/src/modules/repository/repository.module.ts b/apps/server-nestjs/src/modules/repository/repository.module.ts new file mode 100644 index 000000000..511e93c55 --- /dev/null +++ b/apps/server-nestjs/src/modules/repository/repository.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { VaultModule } from '../vault/vault.module' +import { RepositoryDatastoreService } from './repository-datastore.service' +import { RepositoryController } from './repository.controller' +import { RepositoryService } from './repository.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + ProjectPermissionModule, + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ], + controllers: [RepositoryController], + providers: [ + RepositoryDatastoreService, + RepositoryService, + ], +}) +export class RepositoryModule {} diff --git a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts index 48a2c5587..51b6971ae 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts @@ -236,4 +236,58 @@ describe('repositoryService', () => { expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() }) }) + + describe('without vault configured', () => { + let vaultlessService: RepositoryService + + beforeEach(async () => { + const vaultlessModule = await Test.createTestingModule({ + providers: [ + RepositoryService, + { provide: RepositoryDatastoreService, useValue: datastore }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + vaultlessService = vaultlessModule.get(RepositoryService) + }) + + it('creates a private repository without storing the mirror credentials', async () => { + const repository = makeRepository({ id: repositoryId, projectId, internalRepoName: validCreateRepository.internalRepoName, isPrivate: true }) + datastore.hasRepositoryWithName.mockResolvedValue(false) + datastore.createRepository.mockResolvedValue(repository) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await vaultlessService.createRepository(projectId, projectSlug, validCreateRepository, userId, requestId) + + expect(result).toEqual(repository) + expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' })) + }) + + it('updates a repository without applying the credential intent', async () => { + const updated = makeRepository({ id: repositoryId, projectId, isPrivate: true }) + datastore.getRepositoryById.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) + datastore.updateRepository.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await vaultlessService.updateRepository(projectId, projectSlug, repositoryId, { isPrivate: true, externalToken: faker.string.alphanumeric(16) }, userId, requestId) + + expect(result).toEqual(updated) + expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() + expect(vault.deleteGitlabMirrorCreds).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) + }) + + it('deletes a repository', async () => { + datastore.getRepositoryById.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) + datastore.deleteRepository.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await vaultlessService.deleteRepository(projectId, repositoryId, userId, requestId) + + expect(datastore.deleteRepository).toHaveBeenCalledWith(repositoryId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Delete Repository' })) + }) + }) }) diff --git a/apps/server-nestjs/src/modules/repository/repository.service.ts b/apps/server-nestjs/src/modules/repository/repository.service.ts index acf26b49e..16cd035fe 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.ts @@ -2,7 +2,7 @@ import type { CreateRepository, UpdateRepository } from '@cpn-console/shared' import type { Repository } from '@prisma/client' import type { EventLogAction } from '../events/app-events.service' import type { RepositoryMirrorCredentialUpdate } from './repository.utils' -import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' import { VaultClientService } from '../vault/vault-client.service' import { RepositoryDatastoreService } from './repository-datastore.service' @@ -15,7 +15,7 @@ export class RepositoryService { constructor( @Inject(RepositoryDatastoreService) private readonly repositoryDatastoreService: RepositoryDatastoreService, @Inject(AppEventsService) private readonly appEvents: AppEventsService, - @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(VaultClientService) @Optional() private readonly vault?: VaultClientService, ) {} listByProjectId(projectId: string): Promise { @@ -38,10 +38,14 @@ export class RepositoryService { // GIT_INPUT_PASSWORD from Vault (it never receives the token), and the reconcile // runs fire-and-forget. A token written after — or racing — the reconcile would // be lost, since it exists nowhere else once this request returns. - await this.vault.writeGitlabMirrorCreds(projectSlug, repository.internalRepoName, { - GIT_INPUT_USER: repositoryToCreate.externalUserName, - GIT_INPUT_PASSWORD: repositoryToCreate.externalToken, - }) + if (this.vault) { + await this.vault.writeGitlabMirrorCreds(projectSlug, repository.internalRepoName, { + GIT_INPUT_USER: repositoryToCreate.externalUserName, + GIT_INPUT_PASSWORD: repositoryToCreate.externalToken, + }) + } else { + this.logger.warn(`mirror credentials not stored (repositoryId=${repository.id}): vault not configured`) + } } this.reconcileProject(projectId, 'Create Repository', userId, requestId) @@ -71,6 +75,13 @@ export class RepositoryService { repository: Repository, credentialUpdate: RepositoryMirrorCredentialUpdate, ): Promise { + if (!this.vault) { + if (credentialUpdate.kind !== 'keep') { + this.logger.warn(`mirror credentials not ${credentialUpdate.kind === 'set' ? 'stored' : 'cleared'} (repositoryId=${repository.id}): vault not configured`) + } + return + } + switch (credentialUpdate.kind) { case 'set': // The mirror username comes from the just-updated row (legacy reads it from the