diff --git a/apps/server-nestjs/src/config/gitlab.config.spec.ts b/apps/server-nestjs/src/config/gitlab.config.spec.ts index 2aa91cf9d..d05542a03 100644 --- a/apps/server-nestjs/src/config/gitlab.config.spec.ts +++ b/apps/server-nestjs/src/config/gitlab.config.spec.ts @@ -17,8 +17,8 @@ describe('gitlabConfig', () => { url: 'https://gitlab.internal', internalUrl: 'https://gitlab.internal:8080', secretExposeInternalUrl: true, - mirrorTokenExpirationDays: 180, - mirrorTokenRotationThresholdDays: 90, + mirrorTokenExpirationDays: 365, + mirrorTokenRotationThresholdDays: 330, projectRootDir: 'forge-test/projects', }) }) diff --git a/apps/server-nestjs/src/config/gitlab.config.ts b/apps/server-nestjs/src/config/gitlab.config.ts index 31455a207..544195375 100644 --- a/apps/server-nestjs/src/config/gitlab.config.ts +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -6,8 +6,8 @@ const gitlabFeatureSchema = z.object({ GITLAB_TOKEN: z.string().min(1), GITLAB_URL: z.string().url(), GITLAB_INTERNAL_URL: z.string().url().optional(), - GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), - GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(365), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(330), GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), PROJECTS_ROOT_DIR: z.string().min(1), }).transform(raw => ({ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 51ea4fc3b..129ab437c 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -241,6 +241,7 @@ export class GitlabClientService { path: repoName, namespaceId: parentGroup.id, defaultBranch: 'main', + ciConfigPath: '.gitlab-ci-dso.yml', }) this.logger.log(`Created a GitLab project repository (path=${fullPath}, repoId=${created.id})`) return created @@ -375,6 +376,9 @@ export class GitlabClientService { this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) return await this.client.Users.create({ ...user, + canCreateGroup: false, + forceRandomPassword: true, + projectsLimit: 0, skipConfirmation: true, }) as UserSchema } @@ -465,8 +469,8 @@ export class GitlabClientService { const group = await this.getProjectGroup(projectSlug) if (!group) throw new Error('Unable to retrieve gitlab project group') return find( - this.offsetPaginate<{ name: string }>( - opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string }[], paginationInfo: OffsetPagination }>, + this.offsetPaginate<{ name: string, id: number }>( + opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string, id: number }[], paginationInfo: OffsetPagination }>, ), token => token.name === `${projectSlug}-bot`, ) @@ -487,6 +491,24 @@ export class GitlabClientService { return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) } + async revokeProjectToken(projectSlug: string, tokenId: number): Promise { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + this.logger.log(`Revoking a GitLab group access token (projectSlug=${projectSlug}, tokenId=${tokenId})`) + await this.client.GroupAccessTokens.revoke(group.id, tokenId) + } + + async validateProjectToken(group: CondensedGroupSchemaWith<'id'>, token: string): Promise { + try { + const res = await fetch(`${this.config.internalUrl ?? this.config.url}/api/v4/groups/${group.id}`, { + headers: { 'PRIVATE-TOKEN': token }, + }) + return res.ok + } catch { + return false + } + } + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) this.logger.verbose(`Resolving a GitLab pipeline trigger token (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 1eeecc671..412e801ac 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,7 +1,6 @@ import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' -import type { VaultSecret } from '../vault/vault-client.service' import type { ProjectWithDetails } from './gitlab-datastore.service' import { specificallyEnabled } from '@cpn-console/hooks' import { AccessLevel } from '@gitbeaker/core' @@ -32,7 +31,6 @@ import { } from './gitlab.constants' import { adminRoleFlag, - daysAgoFromNow, generateAccessLevelMapping, generateAdminRoleMapping, generateName, @@ -84,8 +82,12 @@ export class GitlabService { const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling a project delete event for ${project.slug}`) - await this.ensureProjectGroup(project) - this.logger.log(`GitLab sync completed for project ${project.slug}`) + const group = await this.gitlab.getGroupByPath(`${this.gitlabConfig.projectRootDir}/${project.slug}`) + if (group) { + await this.gitlab.deleteGroup(group) + this.logger.log(`Deleted GitLab project group (${project.slug})`) + } + this.logger.log(`GitLab cleanup completed for project ${project.slug}`) } // @Cron(CronExpression.EVERY_HOUR) @@ -484,14 +486,19 @@ export class GitlabService { private async getOrRotateMirrorCreds(projectSlug: string) { const span = trace.getActiveSpan() span?.setAttribute('project.slug', projectSlug) - const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) - if (!vaultSecret) return this.createMirrorAccessToken(projectSlug) - - const isExpiring = this.isMirrorCredsExpiring(vaultSecret) - span?.setAttribute('mirror.creds.expiring', isExpiring) - if (!isExpiring) { - span?.setAttribute('mirror.creds.rotated', false) - return vaultSecret.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + const currentToken = await this.gitlab.getProjectToken(projectSlug) + if (currentToken) { + const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) + if (vaultSecret?.data?.MIRROR_TOKEN) { + const group = await this.gitlab.getOrCreateProjectSubGroup(projectSlug) + const isValid = await this.gitlab.validateProjectToken(group, vaultSecret.data.MIRROR_TOKEN) + if (isValid) { + span?.setAttribute('mirror.creds.rotated', false) + return vaultSecret.data + } + this.logger.warn(`Mirror token invalid, revoking (projectSlug=${projectSlug}, tokenId=${currentToken.id})`) + await this.gitlab.revokeProjectToken(projectSlug, currentToken.id).catch(() => {}) + } } return this.createMirrorAccessToken(projectSlug) } @@ -511,12 +518,6 @@ export class GitlabService { return creds } - private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { - if (!vaultSecret?.metadata?.created_time) return false - const createdTime = new Date(vaultSecret.metadata.created_time) - return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays - } - private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { if (!externalRepoUrl) return undefined try { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index f737e77bc..3ad0b6a24 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -49,7 +49,11 @@ export class KeycloakService { const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling a project delete event for ${project.slug}`) - await this.purgeOrphanGroups([project]) + const projectGroup = await this.keycloak.getGroupByPath(`/${project.slug}`) + if (projectGroup?.id) { + await this.keycloak.deleteGroup(projectGroup.id) + this.logger.log(`Deleted Keycloak project group (${project.slug})`) + } this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts index bcb314397..26e824a58 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -18,6 +18,11 @@ export const projectSelect = { value: true, }, }, + owner: { + select: { + email: true, + }, + }, } satisfies Prisma.ProjectSelect export type ProjectWithDetails = Prisma.ProjectGetPayload<{ diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts index 0b7e8c0bb..b7906ab62 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -30,6 +30,7 @@ export function makeProjectWithDetails(overrides: Partial = slug: faker.internet.domainWord(), repositories: [], plugins: [], + owner: { email: faker.internet.email() }, ...overrides, } satisfies ProjectWithDetails } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index daab56119..2d0fbd36b 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -143,7 +143,7 @@ export class SonarqubeService implements OnModuleInit { span?.setAttribute('project.slug', project.slug) const rolePaths = await this.getProjectRoleGroupPaths(project) await Promise.all([ - this.ensureUser(project.slug, project.slug), + this.ensureUser(project), this.ensureProjectSonarGroups(rolePaths), this.ensureProjectRepositories(project, rolePaths), ]) @@ -197,28 +197,31 @@ export class SonarqubeService implements OnModuleInit { } @StartActiveSpan() - private async ensureUser(username: string, projectSlug: string): Promise { - const existingSecret = await this.vault.readSonarqubeUser(projectSlug) - const user = await this.findUser(username) + private async ensureUser(project: ProjectWithDetails): Promise { + const { slug, owner } = project + const existingSecret = await this.vault.readSonarqubeUser(slug) + const user = await this.findUser(slug) let newSecret: SonarqubeUserSecret | undefined + const userEmail = owner.email + if (!user) { - this.logger.log(`Creating SonarQube user (login=${username})`) + this.logger.log(`Creating SonarQube user (login=${slug}, email=${userEmail})`) const password = generateRandomPassword(30) - await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) - const token = await this.rotateToken(username) - newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + await this.client.createUser({ email: userEmail, local: 'true', login: slug, name: slug, password }) + const token = await this.rotateToken(slug) + newSecret = { SONAR_USERNAME: slug, SONAR_PASSWORD: password, SONAR_TOKEN: token } } else if (existingSecret) { - this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${slug})`) } else { - this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) - const token = await this.rotateToken(username) - newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${slug})`) + const token = await this.rotateToken(slug) + newSecret = { SONAR_USERNAME: slug, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } } if (newSecret) { - await this.vault.writeSonarqubeUser(projectSlug, newSecret) - this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + await this.vault.writeSonarqubeUser(slug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${slug})`) } } diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index 55a816bd0..c7d112bd7 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -76,6 +76,11 @@ export interface SonarqubeUserSecret { SONAR_TOKEN: string } +export interface MirrorUserSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + export interface VaultMetadata { created_time: string custom_metadata: Record | null @@ -204,7 +209,7 @@ export class VaultClientService { } @StartActiveSpan() - async readTechnReadOnlyCreds(projectSlug: string): Promise { + async readTechnReadOnlyCreds(projectSlug: string): Promise | null> { const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() span?.setAttribute('project.slug', projectSlug) diff --git a/apps/server-nestjs/test/argocd.e2e-spec.ts b/apps/server-nestjs/test/argocd.e2e-spec.ts index 67f42a78f..f3a98dac9 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -25,9 +25,9 @@ const canRunArgoCDE2E const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) -describeWithArgoCD('ArgoCDController (e2e)', {}, () => { +describeWithArgoCD('ArgoCDService (e2e)', () => { let moduleRef: TestingModule - let argocdController: ArgoCDService + let argocdService: ArgoCDService let gitlab: GitlabClientService let gitlabClient: Gitlab let vault: VaultClientService @@ -60,7 +60,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { await moduleRef.init() - argocdController = moduleRef.get(ArgoCDService) + argocdService = moduleRef.get(ArgoCDService) gitlab = moduleRef.get(GitlabClientService) gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vault = moduleRef.get(VaultClientService) @@ -212,7 +212,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` await vault.write({ e2e: true }, vaultProjectValuesPath) - }) + }, 144000) afterAll(async () => { if (vaultProjectValuesPath) { @@ -252,7 +252,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { const staleAction = await gitlab.generateCreateOrUpdateAction(infraProject, 'main', staleFilePath, 'stale: true\n') await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed stale values', staleAction ? [staleAction] : []) - await argocdController.handleUpsert(project) + await argocdService.handleUpsert(project) const expectedFilePath = `${project.name}/${clusterLabel}/${envDevName}/values.yaml` const file = await gitlabClient.RepositoryFiles.show(infraRepoId, expectedFilePath, 'main') @@ -294,7 +294,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { select: projectSelect, }) - await argocdController.handleUpsert(after) + await argocdService.handleUpsert(after) const updatedDev = await gitlabClient.RepositoryFiles.show(infraRepoId, devFilePath, 'main') const devRaw = Buffer.from(updatedDev.content, 'base64').toString('utf8') diff --git a/apps/server-nestjs/test/gitlab.e2e-spec.ts b/apps/server-nestjs/test/gitlab.e2e-spec.ts index 32f51abf3..01ca74d51 100644 --- a/apps/server-nestjs/test/gitlab.e2e-spec.ts +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -25,10 +25,10 @@ const canRunGitlabE2E const describeWithGitLab = describe.runIf(canRunGitlabE2E) -describeWithGitLab('GitlabController (e2e)', {}, () => { +describeWithGitLab('GitlabService (e2e)', () => { let moduleRef: TestingModule - let gitlabController: GitlabService - let gitlabService: GitlabClientService + let gitlabService: GitlabService + let gitlabClientService: GitlabClientService let gitlabClient: Gitlab let vaultService: VaultClientService let prisma: PrismaService @@ -46,8 +46,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await moduleRef.init() - gitlabController = moduleRef.get(GitlabService) - gitlabService = moduleRef.get(GitlabClientService) + gitlabService = moduleRef.get(GitlabService) + gitlabClientService = moduleRef.get(GitlabClientService) gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vaultService = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) @@ -109,9 +109,9 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { // Clean GitLab group if (testProjectSlug && config.projectsRootDir) { const fullPath = `${config.projectsRootDir}/${testProjectSlug}` - const group = await gitlabService.getGroupByPath(fullPath) + const group = await gitlabClientService.getGroupByPath(fullPath) if (group) { - await gitlabService.deleteGroup(group).catch(() => {}) + await gitlabClientService.deleteGroup(group).catch(() => {}) } } @@ -146,7 +146,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { }) // Act - await gitlabController.handleUpsert(project) + await gitlabService.handleUpsert(project) // Assert const groupPath = `${config.projectsRootDir}/${testProjectSlug}` @@ -155,11 +155,11 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { name: z.string(), full_path: z.string(), web_url: z.string(), - }).parse(await gitlabService.getGroupByPath(groupPath)) + }).parse(await gitlabClientService.getGroupByPath(groupPath)) expect(group.full_path).toBe(groupPath) // Check membership - const members = await gitlabService.getGroupMembers(group) + const members = await gitlabClientService.getGroupMembers(group) const isMember = members.some(m => m.id === ownerUser.id) expect(isMember).toBe(true) @@ -219,18 +219,31 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { select: projectSelect, }) - await gitlabController.handleUpsert(project) + await gitlabService.handleUpsert(project) const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), web_url: z.string(), - }).parse(await gitlabService.getGroupByPath(groupPath)) + }).parse(await gitlabClientService.getGroupByPath(groupPath)) - const members = await gitlabService.getGroupMembers(group) + const members = await gitlabClientService.getGroupMembers(group) const isNewMemberPresent = members.some(m => m.id === newUserGitlabId) expect(isNewMemberPresent).toBe(true) }, 72000) }) + + it('should remove project group from GitLab on delete', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await gitlabService.handleDelete(project) + + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = await gitlabClientService.getGroupByPath(groupPath) + expect(group).toBeUndefined() + }, 72000) }) diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts index b6efcd4e4..c45458fc1 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -24,9 +24,9 @@ const canRunKeycloakE2E const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) -describeWithKeycloak('KeycloakController (e2e)', () => { +describeWithKeycloak('KeycloakService (e2e)', () => { let moduleRef: TestingModule - let keycloakController: KeycloakService + let keycloakService: KeycloakService let keycloak: KeycloakClientService let keycloakAdminClient: KcAdminClient let prisma: PrismaService @@ -44,7 +44,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { await moduleRef.init() - keycloakController = moduleRef.get(KeycloakService) + keycloakService = moduleRef.get(KeycloakService) keycloak = moduleRef.get(KeycloakClientService) keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) prisma = moduleRef.get(PrismaService) @@ -147,7 +147,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Act - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Assert // Check main project group @@ -215,7 +215,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Act - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Assert const projectGroup = z.object({ @@ -278,7 +278,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Sync add - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Verify added const projectGroup = z.object({ @@ -303,7 +303,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Sync remove - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Verify removed members = await keycloak.getGroupMembers(projectGroup.id) @@ -343,7 +343,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Act - should not throw - await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + await expect(keycloakService.handleUpsert(project)).resolves.not.toThrow() // Cleanup await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) @@ -387,7 +387,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Sync to ensure they are added initially - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) const projectGroup = z.object({ id: z.string(), @@ -401,7 +401,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { expect(members.some(m => m.id === kcUser.id)).toBe(false) // Sync again - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Verify added back members = await keycloak.getGroupMembers(projectGroup.id) @@ -447,7 +447,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }) // Sync to create group - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Manually add user to Keycloak group const projectGroup = z.object({ @@ -460,7 +460,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { expect(members.some(m => m.id === kcUser.id)).toBe(true) // Sync again to remove user - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Verify removed members = await keycloak.getGroupMembers(projectGroup.id) @@ -478,7 +478,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { where: { id: testProjectId }, select: projectSelect, }) - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) const projectGroup = z.object({ id: z.string(), @@ -492,7 +492,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { expect(deletedProjectGroup).toBeUndefined() // Sync - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Verify recreated const recreatedProjectGroup = z.object({ @@ -507,7 +507,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { where: { id: testProjectId }, select: projectSelect, }) - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) const roleGroup = z.object({ id: z.string(), @@ -521,7 +521,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { expect(deletedRoleGroup).toBeUndefined() // Sync - await keycloakController.handleUpsert(project) + await keycloakService.handleUpsert(project) // Verify recreated const recreatedRoleGroup = z.object({ @@ -529,4 +529,19 @@ describeWithKeycloak('KeycloakController (e2e)', () => { }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) expect(recreatedRoleGroup?.name).toBe(testRoleName) }, 60000) + + it('should remove project groups from Keycloak on delete', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await keycloakService.handleDelete(project) + + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + const deletedConsoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console`) + expect(deletedConsoleGroup).toBeUndefined() + }, 60000) }) diff --git a/apps/server-nestjs/test/log.e2e-spec.ts b/apps/server-nestjs/test/log.e2e-spec.ts index 72214c56a..4d81a4e72 100644 --- a/apps/server-nestjs/test/log.e2e-spec.ts +++ b/apps/server-nestjs/test/log.e2e-spec.ts @@ -1,7 +1,9 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' @@ -10,6 +12,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { LogModule } from '../src/modules/log/log.module' import { LogService } from '../src/modules/log/log.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunLogE2E = Boolean(process.env.E2E) @@ -29,7 +32,7 @@ describeWithLog('LogService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -156,32 +159,35 @@ describeWithLog('LogService (e2e)', () => { const { total: allTotal, logs: allLogs } = await logService.getLogs({ offset: 0, - limit: 10, + limit: 50, projectId: undefined, clean: true, }) expect(allTotal).toBeGreaterThan(2) - expect(allLogs).toHaveLength(10) - expect(allLogs[0]).toMatchObject({ + + const globalEntry = allLogs.find(log => log.id === globalLog.id) + expect(globalEntry).toMatchObject({ id: globalLog.id, action: 'global-upsert', userId: null, }) - expect(allLogs[0].data).not.toHaveProperty('args') - expect(allLogs[0].data).not.toHaveProperty('results') - expect(allLogs[0].data).not.toHaveProperty('config') - expect(allLogs[1]).toMatchObject({ + expect(globalEntry?.data).not.toHaveProperty('args') + expect(globalEntry?.data).not.toHaveProperty('results') + expect(globalEntry?.data).not.toHaveProperty('config') + + const projectEntry = allLogs.find(log => log.id === projectLog.id) + expect(projectEntry).toMatchObject({ id: projectLog.id, action: 'project-upsert', userId: ownerId, }) - expect(allLogs[1].data).toMatchObject({ + expect(projectEntry?.data).toMatchObject({ warning: ['careful'], totalExecutionTime: 42, messageResume: 'done', }) - expect(allLogs[1].data).not.toHaveProperty('args') - expect(allLogs[1].data).not.toHaveProperty('results') - expect(allLogs[1].data).not.toHaveProperty('config') + expect(projectEntry?.data).not.toHaveProperty('args') + expect(projectEntry?.data).not.toHaveProperty('results') + expect(projectEntry?.data).not.toHaveProperty('config') }) }) diff --git a/apps/server-nestjs/test/nexus.e2e-spec.ts b/apps/server-nestjs/test/nexus.e2e-spec.ts index 806c34aa0..7eeee5087 100644 --- a/apps/server-nestjs/test/nexus.e2e-spec.ts +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -28,9 +28,9 @@ const canRunNexusE2E const describeWithNexus = describe.runIf(canRunNexusE2E) -describeWithNexus('NexusController (e2e)', () => { +describeWithNexus('NexusService (e2e)', () => { let moduleRef: TestingModule - let nexusController: NexusService + let nexusService: NexusService let nexusClient: NexusClientService let vaultService: VaultClientService let config: ConfigType @@ -47,7 +47,7 @@ describeWithNexus('NexusController (e2e)', () => { await moduleRef.init() - nexusController = moduleRef.get(NexusService) + nexusService = moduleRef.get(NexusService) nexusClient = moduleRef.get(NexusClientService) vaultService = moduleRef.get(VaultClientService) config = moduleRef.get(baseConfigFactory.KEY) @@ -70,7 +70,7 @@ describeWithNexus('NexusController (e2e)', () => { afterAll(async () => { if (testProjectSlug) { - await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + await nexusService.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) } if (prisma) { @@ -112,7 +112,7 @@ describeWithNexus('NexusController (e2e)', () => { select: projectSelect, }) - await nexusController.handleUpsert(project) + await nexusService.handleUpsert(project) const mavenReleaseRepo = `${testProjectSlug}-repository-release` const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` @@ -147,4 +147,24 @@ describeWithNexus('NexusController (e2e)', () => { expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() }) + + it('should remove project from Nexus on delete', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusService.handleDelete(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const repo = await nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo) + expect(repo).toBeNull() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeNull() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(false) + }) }) diff --git a/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/apps/server-nestjs/test/project-bulk.e2e-spec.ts index 3a1e80f39..8548971e5 100644 --- a/apps/server-nestjs/test/project-bulk.e2e-spec.ts +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -15,11 +15,11 @@ import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.modu import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) +const canRunProjectBulkE2E = Boolean(process.env.E2E) const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) -describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { +describeWithProjectBulk('ProjectBulkService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectBulkService diff --git a/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/apps/server-nestjs/test/project-hooks.e2e-spec.ts index ab6e75184..91d56e504 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -19,11 +19,11 @@ import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultService } from '../src/modules/vault/vault.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) +const canRunProjectHooksE2E = Boolean(process.env.E2E) const describeWithProjectHooks = describe.runIf(canRunProjectHooksE2E) -describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { +describeWithProjectHooks('ProjectHooksService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectHooksService diff --git a/apps/server-nestjs/test/project-members.e2e-spec.ts b/apps/server-nestjs/test/project-members.e2e-spec.ts index 10dec9786..315bdba2d 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -19,11 +19,11 @@ import { ProjectMembersModule } from '../src/modules/project-members/project-mem import { ProjectMembersService } from '../src/modules/project-members/project-members.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) +const canRunProjectMembersE2E = Boolean(process.env.E2E) const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) -describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { +describeWithProjectMembers('ProjectMembersService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectMembersService diff --git a/apps/server-nestjs/test/project-roles.e2e-spec.ts b/apps/server-nestjs/test/project-roles.e2e-spec.ts index 0f530ebf5..3f180330d 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -16,11 +16,11 @@ import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.m import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) +const canRunProjectRolesE2E = Boolean(process.env.E2E) const describeWithProjectRoles = describe.runIf(canRunProjectRolesE2E) -describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { +describeWithProjectRoles('ProjectRolesService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectRolesService diff --git a/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/apps/server-nestjs/test/project-secrets.e2e-spec.ts index 9eec3ec83..52414baf7 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -21,7 +21,7 @@ const canRunProjectSecretsE2E const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) -describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { +describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectSecretsService diff --git a/apps/server-nestjs/test/project-services.e2e-spec.ts b/apps/server-nestjs/test/project-services.e2e-spec.ts index 21a284ea9..4a8b41f22 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -11,18 +11,15 @@ import { PrismaService } from '../src/modules/infrastructure/database/prisma.ser import { EventsModule } from '../src/modules/infrastructure/events/events.module' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' import { ProjectServicesService } from '../src/modules/project-services/project-services.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunServicesE2E - = Boolean(process.env.E2E) - +const canRunServicesE2E = Boolean(process.env.E2E) const describeWithServices = describe.runIf(canRunServicesE2E) -const PLUGIN_NAME = 'gitlab' - -describeWithServices('ProjectServicesService (e2e)', {}, () => { +describeWithServices('ProjectServicesService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectServicesService @@ -103,8 +100,8 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { it('update stores project configuration', async () => { await service.update(projectId, { - gitlab: { - enabled: 'enabled', + nexus: { + activateNpmRepo: 'enabled', }, }, ['user']) @@ -113,7 +110,7 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { projectId_pluginName_key: { projectId, pluginName: PLUGIN_NAME, - key: 'user.enabled', + key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, }, }, select: { diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts index 0aa6b9011..0a670aa45 100644 --- a/apps/server-nestjs/test/project.e2e-spec.ts +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -13,16 +13,16 @@ import { PrismaService } from '../src/modules/infrastructure/database/prisma.ser import { EventsModule } from '../src/modules/infrastructure/events/events.module' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' -import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectModule } from '../src/modules/project/project.module' import { ProjectService } from '../src/modules/project/project.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) +const canRunProjectE2E = Boolean(process.env.E2E) const describeWithProject = describe.runIf(canRunProjectE2E) -describeWithProject('ProjectService (e2e)', {}, () => { +describeWithProject('ProjectService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectService @@ -32,7 +32,7 @@ describeWithProject('ProjectService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/registry.e2e-spec.ts b/apps/server-nestjs/test/registry.e2e-spec.ts index d86e89d6c..cf2d0e982 100644 --- a/apps/server-nestjs/test/registry.e2e-spec.ts +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -82,4 +82,13 @@ describeWithRegistry('RegistryService (e2e)', () => { expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) }) + + it('should remove project from Harbor on delete', async () => { + const result = await registry.handleDelete(makeProjectWithDetails({ slug: projectSlug })) + + expect(result.harbor?.status).toBe('OK') + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(404) + }) }) diff --git a/apps/server-nestjs/test/vault.e2e-spec.ts b/apps/server-nestjs/test/vault.e2e-spec.ts index 819c62dc9..b20cae90a 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -22,9 +22,9 @@ const canRunVaultE2E const describeWithVault = describe.runIf(canRunVaultE2E) -describeWithVault('VaultController (e2e)', () => { +describeWithVault('VaultService (e2e)', () => { let moduleRef: TestingModule - let vaultController: VaultService + let vaultService: VaultService let vaultClient: VaultClientService let prisma: PrismaService @@ -39,7 +39,7 @@ describeWithVault('VaultController (e2e)', () => { await moduleRef.init() - vaultController = moduleRef.get(VaultService) + vaultService = moduleRef.get(VaultService) vaultClient = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) @@ -60,7 +60,7 @@ describeWithVault('VaultController (e2e)', () => { afterAll(async () => { if (testProjectSlug) { - await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + await vaultService.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) } if (prisma) { @@ -95,11 +95,23 @@ describeWithVault('VaultController (e2e)', () => { select: projectSelect, }) - await vaultController.handleUpsert(project) + await vaultService.handleUpsert(project) - const group = await vaultClient.getIdentityGroupName(testProjectSlug) + const adminGroupName = `project-${testProjectSlug}-admin` + const group = await vaultClient.getIdentityGroupName(adminGroupName) expect(group.data?.id).toBeTruthy() - expect(group.data?.name).toBe(testProjectSlug) - expect(group.data?.alias?.name).toBe(`/${testProjectSlug}`) + expect(group.data?.name).toBe(adminGroupName) + }, 180000) + + it('should remove project from Vault on delete', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await vaultService.handleDelete(project) + + const adminGroupName = `project-${testProjectSlug}-admin` + await expect(vaultClient.getIdentityGroupName(adminGroupName)).rejects.toThrow('Not Found') }, 180000) }) diff --git a/apps/server-nestjs/test/zone.e2e-spec.ts b/apps/server-nestjs/test/zone.e2e-spec.ts new file mode 100644 index 000000000..98e29e493 --- /dev/null +++ b/apps/server-nestjs/test/zone.e2e-spec.ts @@ -0,0 +1,79 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { makeZoneWithDetails } from '../src/modules/vault/vault-testing.utils' +import { VaultModule } from '../src/modules/vault/vault.module' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunZoneE2E = Boolean(process.env.E2E) + +const describeWithZone = describe.runIf(canRunZoneE2E) + +describeWithZone('Zone lifecycle (e2e)', () => { + let moduleRef: TestingModule + let vaultService: VaultService + let vaultClient: VaultClientService + let prisma: PrismaService + + let zoneId: string + let zoneSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + vaultService = moduleRef.get(VaultService) + vaultClient = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + zoneId = faker.string.uuid() + zoneSlug = faker.helpers.slugify(`test-zone-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`) + }) + + afterAll(async () => { + if (zoneSlug) { + await vaultService.deleteZone(zoneSlug).catch(() => {}) + } + + if (prisma) { + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should provision zone secrets space in Vault (mount, policy, approle)', async () => { + const zone = makeZoneWithDetails({ id: zoneId, slug: zoneSlug }) + + await vaultService.handleUpsertZone(zone) + + const kvName = `zone-${zoneSlug}` + const roleId = await vaultClient.getAuthApproleRoleRoleId(kvName) + expect(roleId).toBeTruthy() + }, 180000) + + it('should remove zone from Vault on delete', async () => { + const zone = makeZoneWithDetails({ id: zoneId, slug: zoneSlug }) + + await vaultService.handleDeleteZone(zone) + + const kvName = `zone-${zoneSlug}` + await expect(vaultClient.getAuthApproleRoleRoleId(kvName)).rejects.toThrow() + }, 180000) +})