Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -41,6 +42,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
ProjectRolesModule,
ProjectSecretsModule,
ProjectServicesModule,
RepositoryModule,
ScheduleModule.forRoot(),
SystemSettingsModule,
VersionModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RepositoryService>

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<RepositoryService>()

module = await Test.createTestingModule({
controllers: [RepositoryController],
providers: [
{ provide: RepositoryService, useValue: service },
],
})
.overrideGuard(ProjectGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<RepositoryController>(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()
})
})
})
90 changes: 90 additions & 0 deletions apps/server-nestjs/src/modules/repository/repository.controller.ts
Original file line number Diff line number Diff line change
@@ -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<Repository[]> {
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<Repository> {
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<Repository> {
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<void> {
return this.repositoryService.deleteRepository(project.id, repositoryId, user.userId, request.id)
}
}
26 changes: 26 additions & 0 deletions apps/server-nestjs/src/modules/repository/repository.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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>(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' }))
})
})
})
23 changes: 17 additions & 6 deletions apps/server-nestjs/src/modules/repository/repository.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<Repository[]> {
Expand All @@ -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)
Expand Down Expand Up @@ -71,6 +75,13 @@ export class RepositoryService {
repository: Repository,
credentialUpdate: RepositoryMirrorCredentialUpdate,
): Promise<void> {
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
Expand Down