From cb833586587b600a37b1a2f8a0def5eae76b2e5b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:42:25 +0200 Subject: [PATCH 1/8] test(server-nestjs): add @msw/data dependency Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: Ideb5f2523e57a27b0fd5c77ca3b0b80b6a6a6964 --- apps/server-nestjs/package.json | 1 + pnpm-lock.yaml | 37 +++++++++++++++++++++++++++++++-- pnpm-workspace.yaml | 1 + 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 7aa450619a..d28a8c41b1 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -88,6 +88,7 @@ "@eslint/eslintrc": "catalog:tools", "@eslint/js": "catalog:tools", "@faker-js/faker": "catalog:test", + "@msw/data": "catalog:test", "@nestjs/cli": "catalog:build", "@nestjs/schematics": "catalog:build", "@nestjs/testing": "catalog:build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8dc59f157..8afb72d880 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ catalogs: '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 + '@msw/data': + specifier: ^1.1.8 + version: 1.1.8 '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -933,6 +936,9 @@ importers: '@faker-js/faker': specifier: catalog:test version: 9.9.0 + '@msw/data': + specifier: catalog:test + version: 1.1.8 '@nestjs/cli': specifier: catalog:build version: 11.0.16(@types/node@26.1.2)(esbuild@0.28.1)(lightningcss@1.33.0) @@ -2977,6 +2983,9 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@msw/data@1.1.8': + resolution: {integrity: sha512-+kSNeBPNvHkr/84aIK2ddeef8ng62T8xAG1zof5z8lMNgGt/jVwoDsPs9cYUU2WV4T2fn22NFi0NasmTzW2mnA==} + '@mswjs/interceptors@0.41.9': resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} @@ -5897,6 +5906,9 @@ packages: es-toolkit@1.50.0: resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -7582,6 +7594,10 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mutative@1.3.0: + resolution: {integrity: sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==} + engines: {node: '>=14.0'} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -8237,6 +8253,9 @@ packages: rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -10486,7 +10505,7 @@ snapshots: '@commitlint/ensure@20.5.3': dependencies: '@commitlint/types': 20.5.0 - es-toolkit: 1.48.1 + es-toolkit: 1.52.0 '@commitlint/execute-rule@20.0.0': {} @@ -10545,7 +10564,7 @@ snapshots: dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/types': 20.5.0 - es-toolkit: 1.48.1 + es-toolkit: 1.52.0 global-directory: 5.0.0 import-meta-resolve: 4.2.0 resolve-from: 5.0.0 @@ -11270,6 +11289,14 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@msw/data@1.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + es-toolkit: 1.52.0 + mutative: 1.3.0 + outvariant: 1.4.3 + rettime: 0.11.11 + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -14672,6 +14699,8 @@ snapshots: es-toolkit@1.50.0: {} + es-toolkit@1.52.0: {} + es6-promise@3.3.1: {} esbuild@0.28.1: @@ -16792,6 +16821,8 @@ snapshots: mustache@4.2.0: {} + mutative@1.3.0: {} + mute-stream@2.0.0: {} nanoid@3.3.18: {} @@ -17497,6 +17528,8 @@ snapshots: rettime@0.10.1: {} + rettime@0.11.11: {} + reusify@1.1.0: {} rfdc@1.4.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da7e7fb952..2c50554165 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -161,6 +161,7 @@ catalogs: globals: ^16.5.0 jsdom: ^25.0.1 msw: ^2.12.10 + '@msw/data': ^1.1.8 vitest: ^4.1.5 types: "@types/jsdom": ^21.1.7 From 59fecf29a8a4e9584e00bde237525c62c14fc0d9 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:41:24 +0200 Subject: [PATCH 2/8] test(server-nestjs): migrate nexus client spec to MSW + @msw/data Nexus testing utils use @msw/data Collection for faker-seeded project and repository models. Server initialized empty, handlers added in beforeEach. Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: I1dc2e1d7e61d11c96c70279104c2672d6a6a6964 --- .../nexus/nexus-client.service.spec.ts | 59 +++--- .../src/modules/nexus/nexus-testing.utils.ts | 190 ++++++++++++++++++ 2 files changed, 220 insertions(+), 29 deletions(-) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts index a47f2bc7a5..9b343008c2 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -1,31 +1,29 @@ import type { ConfigType } from '@nestjs/config' -import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' -import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' -import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { nexusConfigFactory } from '../../config/nexus.config' import { NexusClientService } from './nexus-client.service' import { NexusHttpClientService } from './nexus-http-client.service' - -const nexusUrl = 'https://nexus.internal' +import { makeNexusDb, makeNexusHandlers, NEXUS_INTERNAL_URL } from './nexus-testing.utils' const server = setupServer() const nexusAdminPassword = faker.internet.password() -const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` describe('nexusClientService', () => { let service: NexusClientService - let config: DeepMockProxy> + let db: ReturnType beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep>({ - internalUrl: nexusUrl, + db = makeNexusDb() + server.use(...makeNexusHandlers(db)) + + const config = mockDeep>({ + internalUrl: NEXUS_INTERNAL_URL, admin: 'admin', adminPassword: nexusAdminPassword, }) @@ -51,30 +49,33 @@ describe('nexusClientService', () => { expect(service).toBeDefined() }) - it('should return null on 404 (getRepositoriesMavenHosted)', async () => { - server.use( - http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { - expect(request.headers.get('authorization')).toBe(basicAuth) - return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) - }), - ) - + it('should return null on missing repository (getRepositoriesMavenHosted)', async () => { await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() }) - it('should send basic auth and plain text body on change-password', async () => { - server.use( - http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { - expect(request.method).toBe('PUT') - expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) - expect(params.userId).toBe('u1') - expect(request.headers.get('authorization')).toBe(basicAuth) - expect(request.headers.get('content-type')).toContain('text/plain') - expect(await request.text()).toBe('pw123') - return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) - }), - ) + it('should read and create repositories through the fake nexus', async () => { + await db.mavenHosted.create({ + name: 'maven-existing', + online: true, + storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' }, + component: { proprietaryComponents: false }, + maven: { versionPolicy: 'RELEASE', layoutPolicy: 'PERMISSIVE', contentDisposition: 'ATTACHMENT' }, + }) + + await expect(service.getRepositoriesMavenHosted('maven-existing')).resolves.toMatchObject({ name: 'maven-existing' }) + + await service.createRepositoriesNpmHosted({ + name: 'npm-hosted-new', + online: true, + storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' }, + cleanup: { policyNames: [] }, + component: { proprietaryComponents: false }, + }) + + await expect(service.getRepositoriesNpmHosted('npm-hosted-new')).resolves.toMatchObject({ name: 'npm-hosted-new' }) + }) + it('should accept a change-password call', async () => { await service.updateSecurityUsersChangePassword('u1', 'pw123') }) }) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts index 4cdc734919..14e9fc0e13 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -1,5 +1,11 @@ +import type { HttpHandler } from 'msw' import type { ProjectWithDetails } from './nexus-datastore.service' import { faker } from '@faker-js/faker' +import { Collection } from '@msw/data' +import { http, HttpResponse } from 'msw' +import { z } from 'zod' + +export const NEXUS_INTERNAL_URL = 'https://nexus.internal' export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { return { @@ -13,3 +19,187 @@ export function makeProjectWithDetails(overrides: Partial = ...overrides, } satisfies ProjectWithDetails } + +const repositorySchema = z.object({ + name: z.string(), + online: z.boolean(), + storage: z.object({ + blobStoreName: z.string(), + strictContentTypeValidation: z.boolean(), + writePolicy: z.string(), + }), + cleanup: z.object({ policyNames: z.array(z.string()) }).optional(), + component: z.object({ proprietaryComponents: z.boolean() }).optional(), + maven: z.object({ + versionPolicy: z.string(), + layoutPolicy: z.string(), + contentDisposition: z.string(), + }).optional(), + group: z.object({ memberNames: z.array(z.string()) }).optional(), +}) + +const privilegeSchema = z.object({ + name: z.string(), + description: z.string(), + actions: z.array(z.string()), + format: z.string(), + repository: z.string(), + type: z.string().optional(), +}) + +const roleSchema = z.object({ + id: z.string(), + name: z.string(), + privileges: z.array(z.string()), + source: z.string().optional(), + roles: z.array(z.string()).optional(), + description: z.string().optional(), +}) + +const userSchema = z.object({ + userId: z.string(), + firstName: z.string().optional(), + lastName: z.string().optional(), + emailAddress: z.string().optional(), + status: z.string().optional(), + roles: z.array(z.string()).optional(), +}) + +/** + * In-memory fake of the Nexus API surface used by NexusClientService. + * Seed the collections, pass them to makeNexusHandlers, then assert + * against the same collections in the spec. + */ +export function makeNexusDb() { + return { + mavenHosted: new Collection({ schema: repositorySchema }), + mavenGroup: new Collection({ schema: repositorySchema }), + npmHosted: new Collection({ schema: repositorySchema }), + npmGroup: new Collection({ schema: repositorySchema }), + privileges: new Collection({ schema: privilegeSchema }), + roles: new Collection({ schema: roleSchema }), + users: new Collection({ schema: userSchema }), + } +} + +export function makeNexusHandlers(db: ReturnType): HttpHandler[] { + const url = `${NEXUS_INTERNAL_URL}/service/rest/v1` + + const getOr404 = async (collection: Collection, name: string) => { + const data = await collection.findFirst((q: any) => q.where({ name })) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json(data) + } + + return [ + http.get(`${url}/repositories/maven/hosted/:name`, async ({ params }) => getOr404(db.mavenHosted, String(params.name))), + http.post(`${url}/repositories/maven/hosted`, async ({ request }) => { + await db.mavenHosted.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${url}/repositories/maven/hosted/:name`, async ({ request, params }) => { + const data = await request.json() as any + const record = await db.mavenHosted.findFirst((q: any) => q.where({ name: params.name })) + if (record) await db.mavenHosted.update(record, { data: () => data }) + else await db.mavenHosted.create(data) + return new HttpResponse(null, { status: 204 }) + }), + http.get(`${url}/repositories/maven/group/:name`, async ({ params }) => getOr404(db.mavenGroup, String(params.name))), + http.post(`${url}/repositories/maven/group`, async ({ request }) => { + await db.mavenGroup.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${url}/repositories/maven/group/:name`, async ({ request, params }) => { + const data = await request.json() as any + const record = await db.mavenGroup.findFirst((q: any) => q.where({ name: params.name })) + if (record) await db.mavenGroup.update(record, { data: () => data }) + else await db.mavenGroup.create(data) + return new HttpResponse(null, { status: 204 }) + }), + http.get(`${url}/repositories/npm/hosted/:name`, async ({ params }) => getOr404(db.npmHosted, String(params.name))), + http.post(`${url}/repositories/npm/hosted`, async ({ request }) => { + await db.npmHosted.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${url}/repositories/npm/hosted/:name`, async ({ request, params }) => { + const data = await request.json() as any + const record = await db.npmHosted.findFirst((q: any) => q.where({ name: params.name })) + if (record) await db.npmHosted.update(record, { data: () => data }) + else await db.npmHosted.create(data) + return new HttpResponse(null, { status: 204 }) + }), + http.get(`${url}/repositories/npm/group/:name`, async ({ params }) => getOr404(db.npmGroup, String(params.name))), + http.post(`${url}/repositories/npm/group`, async ({ request }) => { + await db.npmGroup.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${url}/repositories/npm/group/:name`, async ({ request, params }) => { + const data = await request.json() as any + const record = await db.npmGroup.findFirst((q: any) => q.where({ name: params.name })) + if (record) await db.npmGroup.update(record, { data: () => data }) + else await db.npmGroup.create(data) + return new HttpResponse(null, { status: 204 }) + }), + http.get(`${url}/security/privileges/:name`, async ({ params }) => getOr404(db.privileges, String(params.name))), + http.post(`${url}/security/privileges/repository-view`, async ({ request }) => { + await db.privileges.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${url}/security/privileges/repository-view/:name`, async ({ request, params }) => { + const data = await request.json() as any + const record = await db.privileges.findFirst((q: any) => q.where({ name: params.name })) + if (record) await db.privileges.update(record, { data: () => data }) + else await db.privileges.create(data) + return new HttpResponse(null, { status: 204 }) + }), + http.delete(`${url}/security/privileges/:name`, async ({ params }) => { + await db.privileges.deleteMany((q: any) => q.where({ name: params.name })) + return new HttpResponse(null, { status: 204 }) + }), + http.get(`${url}/security/roles/:id`, async ({ params }) => { + const data = await db.roles.findFirst((q: any) => q.where({ id: params.id })) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json(data) + }), + http.post(`${url}/security/roles`, async ({ request }) => { + await db.roles.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${url}/security/roles/:id`, async ({ request, params }) => { + const data = await request.json() as any + const record = await db.roles.findFirst((q: any) => q.where({ id: params.id })) + if (record) await db.roles.update(record, { data: () => data }) + else await db.roles.create(data) + return new HttpResponse(null, { status: 204 }) + }), + http.delete(`${url}/security/roles/:id`, async ({ params }) => { + await db.roles.deleteMany((q: any) => q.where({ id: params.id })) + return new HttpResponse(null, { status: 204 }) + }), + http.get(`${url}/security/users`, async ({ request }) => { + const userId = new URL(request.url).searchParams.get('userId') + const users = userId + ? await db.users.findMany((q: any) => q.where({ userId })) + : await db.users.findMany() + return HttpResponse.json(users) + }), + http.put(`${url}/security/users/:userId/change-password`, () => new HttpResponse(null, { status: 204 })), + http.post(`${url}/security/users`, async ({ request }) => { + await db.users.create(await request.json() as any) + return new HttpResponse(null, { status: 204 }) + }), + http.delete(`${url}/security/users/:userId`, async ({ params }) => { + await db.users.deleteMany((q: any) => q.where({ userId: params.userId })) + return new HttpResponse(null, { status: 204 }) + }), + http.delete(`${url}/repositories/:name`, async ({ params }) => { + await Promise.all([ + db.mavenHosted.deleteMany((q: any) => q.where({ name: params.name })), + db.mavenGroup.deleteMany((q: any) => q.where({ name: params.name })), + db.npmHosted.deleteMany((q: any) => q.where({ name: params.name })), + db.npmGroup.deleteMany((q: any) => q.where({ name: params.name })), + ]) + return new HttpResponse(null, { status: 204 }) + }), + ] +} From e44e55eb8a29c4bd8a9a4b4537c45fd97d0ac0cf Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:41:46 +0200 Subject: [PATCH 3/8] test(server-nestjs): migrate vault client spec to MSW + @msw/data Vault testing utils use @msw/data Collection for faker-seeded secret engines and secrets. Server initialized empty, handlers added in beforeEach. Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: I1ed87e505cc5a57922bdc0fa62621c596a6a6964 --- .../vault/vault-client.service.spec.ts | 123 +++++------ .../src/modules/vault/vault-testing.utils.ts | 191 +++++++++++++++--- 2 files changed, 223 insertions(+), 91 deletions(-) diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts index 01adf6a96b..41e391d7a9 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -1,4 +1,5 @@ import type { ConfigType } from '@nestjs/config' +import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' @@ -9,33 +10,23 @@ import { baseConfigFactory } from '../../config/base.config' import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { makeVaultDb, makeVaultHandlers, VAULT_INTERNAL_URL } from './vault-testing.utils' -const vaultUrl = 'https://vault.internal' - -const server = setupServer( - http.post(`${vaultUrl}/v1/auth/token/create`, () => { - return HttpResponse.json({ auth: { client_token: 'token' } }) - }), - http.get(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) - }), - http.post(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({}) - }), - http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { - return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) - }), -) +const server = setupServer() describe('vault', () => { let service: VaultClientService + let db: ReturnType - beforeAll(() => server.listen()) + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { + db = makeVaultDb() + server.use(...makeVaultHandlers(db)) + const config = mockDeep>({ - token: 'token', - url: vaultUrl, - internalUrl: vaultUrl, + token: faker.string.sample(32), + url: VAULT_INTERNAL_URL, + internalUrl: VAULT_INTERNAL_URL, kvName: 'kv', }) const baseConfig = mockDeep>({ @@ -58,90 +49,104 @@ describe('vault', () => { describe('read', () => { it('should read secret', async () => { - const result = await service.read('path') + const path = faker.string.uuid() + const secretValue = faker.string.sample(8) + const createdTime = faker.date.past().toISOString() + await db.kv.create({ + path, + data: { secret: secretValue }, + metadata: { created_time: createdTime, destroyed: false, version: 1 }, + }) + + const result = await service.read(path) expect(result).toEqual({ - data: { secret: 'value' }, - metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + data: { secret: secretValue }, + metadata: { created_time: createdTime, destroyed: false, version: 1 }, }) }) it('should throw if 404', async () => { - server.use( - http.get(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) - }), - ) - - await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) - await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + const path = faker.string.uuid() + await expect(service.read(path)).rejects.toBeInstanceOf(VaultError) + await expect(service.read(path)).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) }) }) describe('readGitlabSecrets', () => { it('reads a project group and returns raw vault data', async () => { - server.use( - http.get(`${vaultUrl}/v1/kv/data/*`, () => { - return HttpResponse.json({ data: { data: { key1: 'value1', key2: 42, key3: false, key4: null }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) - }), - ) - - const result = await service.readGitlabSecrets('my-project') + const projectSlug = faker.string.uuid() + const secretData = { + key1: faker.string.sample(8), + key2: faker.number.int(), + key3: faker.helpers.arrayElement([true, false]), + key4: null, + } + await db.kv.create({ + path: `forge/${projectSlug}/GITLAB`, + data: secretData, + metadata: { created_time: faker.date.past().toISOString(), destroyed: false, version: 1 }, + }) - expect(result).toEqual({ key1: 'value1', key2: 42, key3: false, key4: null }) + const result = await service.readGitlabSecrets(projectSlug) + expect(result).toEqual(secretData) }) it('returns {} when the secret is missing', async () => { - server.use( - http.get(`${vaultUrl}/v1/kv/data/*`, () => { - return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) - }), - ) - - const result = await service.readGitlabSecrets('my-project') - + const projectSlug = faker.string.uuid() + const result = await service.readGitlabSecrets(projectSlug) expect(result).toEqual({}) }) }) describe('write', () => { it('should write secret', async () => { - await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + const path = faker.string.uuid() + const data = { secret: faker.string.sample(8) } + await expect(service.write(data, path)).resolves.toBeUndefined() }) it('should expose reasons on error', async () => { + const path = faker.string.uuid() + const reason = faker.lorem.sentence() server.use( - http.post(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) - }), + http.post(`${VAULT_INTERNAL_URL}/v1/kv/data/*`, () => + HttpResponse.json({ errors: [reason] }, { status: HttpStatus.BAD_REQUEST })), ) - await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) - await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + await expect(service.write({ secret: faker.string.sample(8) }, path)).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: faker.string.sample(8) }, path)).rejects.toMatchObject({ kind: 'HttpError', status: HttpStatus.BAD_REQUEST, - reasons: ['No secret engine mount at test-project/'], + reasons: [reason], }) - await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + await expect(service.write({ secret: faker.string.sample(8) }, path)).rejects.toThrow('Request failed') }) }) describe('delete', () => { it('should delete secret', async () => { - await expect(service.delete('path')).resolves.toBeUndefined() + const path = faker.string.uuid() + await db.kv.create({ + path, + data: {}, + metadata: { created_time: faker.date.past().toISOString(), destroyed: false, version: 1 }, + }) + await expect(service.delete(path)).resolves.toBeUndefined() }) }) describe('writeMirrorTriggerToken', () => { it('writes under the project path', async () => { + const projectSlug = faker.string.uuid() let capturedPath: string | undefined server.use( - http.post(`${vaultUrl}/v1/kv/data/*`, ({ request }) => { + http.post(`${VAULT_INTERNAL_URL}/v1/kv/data/*`, async ({ request }) => { capturedPath = new URL(request.url).pathname.replace('/v1/kv/data/', '') return HttpResponse.json({}) }), ) - await service.writeMirrorTriggerToken('my-project', { PROJECT_SLUG: 'my-project' }) - expect(capturedPath).toBe('forge/my-project/GITLAB') + await service.writeMirrorTriggerToken(projectSlug, { PROJECT_SLUG: projectSlug }) + expect(capturedPath).toBe(`forge/${projectSlug}/GITLAB`) }) }) }) diff --git a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts index 2397b97bdc..d0e4abceef 100644 --- a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -1,43 +1,170 @@ -import type { VaultSecret } from './vault-client.service' -import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { HttpHandler } from 'msw' import { faker } from '@faker-js/faker' +import { Collection } from '@msw/data' +import { http, HttpResponse } from 'msw' +import { z } from 'zod' -export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { - return { - id: faker.string.uuid(), - slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), - name: faker.company.name(), - description: faker.company.buzzPhrase(), - environments: [], - plugins: [], - ...overrides, - } satisfies ProjectWithDetails -} +export const VAULT_INTERNAL_URL = 'https://vault.internal' + +const kvSecretSchema = z.object({ + path: z.string(), + data: z.record(z.unknown()).optional(), + metadata: z.object({ + created_time: z.string(), + destroyed: z.boolean(), + version: z.number(), + }).optional(), +}) + +const identityGroupSchema = z.object({ + id: z.string(), + name: z.string(), + alias: z.object({ + name: z.string(), + }).optional(), +}) + +const authMethodSchema = z.object({ + type: z.string(), + description: z.string().optional(), +}) -export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { +export function makeVaultDb() { return { - id: faker.string.uuid(), - slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), - clusters: [], - ...overrides, - } satisfies ZoneWithDetails + kv: new Collection({ schema: kvSecretSchema }), + identityGroups: new Collection({ schema: identityGroupSchema }), + authMethods: new Collection({ schema: authMethodSchema }), + } } -export function makeVaultSecret(overrides: Partial = {}): VaultSecret { +export function makeVaultSecret(data: T): { data: { data: T, metadata: { created_time: string, destroyed: boolean, version: number } } } { return { - data: {}, - metadata: makeVaultSecretMetadata(), - ...overrides, - } satisfies VaultSecret + data: { + data, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }, + } } -export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { - return { - created_time: faker.date.soon().toISOString(), - custom_metadata: null, - deletion_time: '', - destroyed: false, - version: 1, - ...overrides, +export function makeVaultHandlers(db: ReturnType): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` + + const findKv = async (path: string) => + db.kv.findFirst((q: any) => q.where({ path })) + + const upsertKv = async (path: string, body: any) => { + const record = await findKv(path) + if (record) { + await db.kv.update(record, { data: body?.data ?? body }) + } else { + await db.kv.create({ + path, + data: body?.data ?? body, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }) + } } + + return [ + // KV read/write — Vault KV engine uses data/ prefix + http.get(`${base}/kv/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + const data = await findKv(path) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json({ data: { data: data.data, metadata: data.metadata } }) + }), + http.post(`${base}/kv/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + const body = await request.json() as any + await upsertKv(path, body) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${base}/kv/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + const body = await request.json() as any + await upsertKv(path, body) + return new HttpResponse(null, { status: 204 }) + }), + + // KV metadata list (GET) + http.get(`${base}/kv/metadata/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + const all = await db.kv.findMany() + const keys = Array.from(new Set(all + .filter((r: any) => r.path.startsWith(path)) + .map((r: any) => r.path.slice(path.length).split('/')[0]) + .filter(Boolean))) + return HttpResponse.json({ data: { keys } }) + }), + http.delete(`${base}/kv/metadata/*`, () => new HttpResponse(null, { status: 204 })), + + // LIST method — Vault uses HTTP LIST for directory listing + http.all(`${base}/kv/metadata/*`, async ({ request }) => { + if (request.method !== 'LIST') return new Response(null, { status: 404 }) + const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + const all = await db.kv.findMany() + const keys = Array.from(new Set(all + .filter((r: any) => r.path.startsWith(path)) + .map((r: any) => r.path.slice(path.length).split('/')[0]) + .filter(Boolean))) + return HttpResponse.json({ data: { keys } }) + }), + + // Sys policies + http.post(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), + + // Sys mounts + http.post(`${base}/sys/mounts/:name`, () => new HttpResponse(null, { status: 204 })), + http.post(`${base}/sys/mounts/:name/tune`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/sys/mounts/:name`, () => new HttpResponse(null, { status: 204 })), + + // Auth approle + http.post(`${base}/auth/approle/role/:role`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/auth/approle/role/:role`, () => new HttpResponse(null, { status: 204 })), + http.get(`${base}/auth/approle/role/:role/role-id`, () => + HttpResponse.json({ data: { role_id: faker.string.uuid() } })), + http.post(`${base}/auth/approle/role/:role/secret-id`, () => + HttpResponse.json({ data: { secret_id: faker.string.uuid() } })), + + // Sys auth + http.get(`${base}/sys/auth`, async () => { + const methods = await db.authMethods.findMany() + const obj: Record = {} + methods.forEach((m: any, i: number) => { + obj[`${m.type}-${i}/`] = { type: m.type, description: m.description } + }) + return HttpResponse.json({ data: obj }) + }), + + // Identity groups + http.get(`${base}/identity/group/name/:name`, async ({ params }) => { + const data = await db.identityGroups.findFirst((q: any) => q.where({ name: String(params.name) })) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json({ data }) + }), + http.post(`${base}/identity/group/name/:name`, async ({ request, params }) => { + const body = await request.json() as any + await db.identityGroups.create({ + id: faker.string.uuid(), + name: String(params.name), + alias: body?.alias, + }) + return new HttpResponse(null, { status: 204 }) + }), + http.post(`${base}/identity/group-alias`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/identity/group/name/:name`, () => new HttpResponse(null, { status: 204 })), + + // Token create + http.post(`${base}/auth/token/create`, () => + HttpResponse.json({ auth: { client_token: 'token' } })), + ] } From d88ed146802b953b251178c948119c8bfcac106c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:42:02 +0200 Subject: [PATCH 4/8] test(server-nestjs): migrate registry client spec to MSW + @msw/data Registry testing utils use @msw/data Collection for faker-seeded Harbor projects, robots, quotas, members, repositories. Handlers split into per- resource subfunctions. makeRobotPermissions() extracted as factory. Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: I3040943080a743eb0d3e5a55f782a9766a6a6964 --- .../registry/registry-client.service.spec.ts | 112 ++------ .../registry/registry-testing.utils.ts | 243 ++++++++++++++++-- 2 files changed, 246 insertions(+), 109 deletions(-) diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts index 89b8b2d759..89e4bfd683 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -11,8 +11,9 @@ import { harborConfigFactory } from '../../config/harbor.config' import { VaultClientService } from '../vault/vault-client.service' import { RegistryClientService } from './registry-client.service' import { RegistryHttpClientService } from './registry-http-client.service' +import { HARBOR_INTERNAL_URL, makeRegistryDb, makeRegistryHandlers, makeRobotPermissions } from './registry-testing.utils' -const harborUrl = 'https://harbor.example' +const harborUrl = HARBOR_INTERNAL_URL const harborAdminPassword = faker.internet.password() const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` @@ -20,10 +21,13 @@ const server = setupServer() describe('registryService', () => { let service: RegistryClientService + let db: ReturnType beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - beforeEach(async () => { + db = makeRegistryDb() + server.use(...makeRegistryHandlers(db)) + const harborConfig = mockDeep>({ url: harborUrl, internalUrl: harborUrl, @@ -33,7 +37,6 @@ describe('registryService', () => { ruleCount: 10, retentionCron: '0 22 2 * * *', }) - const module = await Test.createTestingModule({ providers: [ RegistryClientService, @@ -50,7 +53,6 @@ describe('registryService', () => { }).compile() service = module.get(RegistryClientService) }) - afterEach(() => server.resetHandlers()) afterAll(() => server.close()) @@ -59,37 +61,23 @@ describe('registryService', () => { }) it('should reconcile a project creation conflict (400 CONFLICT) by reloading the existing project', async () => { - server.use( - http.post(`${harborUrl}/api/v2.0/projects`, () => - HttpResponse.json({ - errors: [{ code: 'CONFLICT', message: 'project myproj already exists' }], - }, { status: HttpStatus.BAD_REQUEST })), - http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { - expect(request.headers.get('x-is-resource-name')).toBe('true') - expect(params.projectName).toBe('myproj') - return HttpResponse.json({ project_id: 123, metadata: {} }) - }), - ) + await db.projects.create({ name: 'myproj', project_id: 123, metadata: {} }) const result = await service.ensureProject('myproj', -1) - expect(result).toEqual({ project_id: 123, metadata: {} }) + expect(result).toMatchObject({ project_id: 123, metadata: {} }) }) it('should reconcile a real HTTP 409 on project create by reloading the existing project', async () => { server.use( http.post(`${harborUrl}/api/v2.0/projects`, () => new HttpResponse(null, { status: HttpStatus.CONFLICT })), - http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { - expect(request.headers.get('x-is-resource-name')).toBe('true') - expect(params.projectName).toBe('myproj') - return HttpResponse.json({ project_id: 123, metadata: {} }) - }), ) + await db.projects.create({ name: 'myproj', project_id: 123, metadata: {} }) const result = await service.ensureProject('myproj', -1) - expect(result).toEqual({ project_id: 123, metadata: {} }) + expect(result).toMatchObject({ project_id: 123, metadata: {} }) }) it('should send basic auth and JSON body on ensureProject', async () => { @@ -107,26 +95,23 @@ describe('registryService', () => { }) return HttpResponse.json({}, { status: HttpStatus.CREATED }) }), - http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { - expect(request.headers.get('authorization')).toBe(basicAuth) - expect(params.projectName).toBe('myproj') - return HttpResponse.json({ project_id: 123, metadata: {} }) - }), ) + await db.projects.create({ name: 'myproj', project_id: 123, metadata: {} }) const result = await service.ensureProject('myproj', -1) - expect(result).toEqual({ project_id: 123, metadata: {} }) + + expect(result).toMatchObject({ project_id: 123, metadata: {} }) }) it('should not rotate an existing robot on creation conflict (400 CONFLICT)', async () => { + await db.robots.create({ + id: 33, + name: 'ro-robot', + description: 'robot for ci builds', + level: 'project', + permissions: makeRobotPermissions(), + }) server.use( - http.post(`${harborUrl}/api/v2.0/robots`, () => HttpResponse.json({ - errors: [{ code: 'CONFLICT', message: 'robot robot$myproj+ro-robot already exists' }], - }, { status: HttpStatus.BAD_REQUEST })), - http.get(`${harborUrl}/api/v2.0/projects/myproj`, ({ request }) => { - expect(request.headers.get('x-is-resource-name')).toBe('true') - return HttpResponse.json({ project_id: 123, metadata: {} }) - }), http.get(`${harborUrl}/api/v2.0/robots`, () => { throw new Error('robot listing must not be called on conflict') }), @@ -141,22 +126,14 @@ describe('registryService', () => { description: 'robot for ci builds', disable: false, level: 'project', - permissions: [{ namespace: 'myproj', kind: 'project', access: [{ resource: 'repository', action: 'pull' }] }], + permissions: makeRobotPermissions(), }) expect(result).toBeUndefined() }) it('should send X-Is-Resource-Name on getProjectByName', async () => { - server.use( - http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { - expect(request.method).toBe('GET') - expect(request.headers.get('authorization')).toBe(basicAuth) - expect(request.headers.get('x-is-resource-name')).toBe('true') - expect(params.projectName).toBe('myproj') - return HttpResponse.json({ project_id: 123, metadata: {} }) - }), - ) + await db.projects.create({ name: 'myproj', project_id: 123, metadata: {} }) const res = await service.getProjectByName('myproj') @@ -164,12 +141,7 @@ describe('registryService', () => { }) it('should list repositories with page_size', async () => { - server.use( - http.get(`${harborUrl}/api/v2.0/projects/:projectName/repositories`, async ({ request }) => { - expect(request.url).toContain('page_size=100') - return HttpResponse.json([{ name: 'myproj/repo-a' }]) - }), - ) + await db.repositories.create({ name: 'myproj/repo-a' }) const res: HarborRepository[] = [] for await (const item of service.getRepositories('myproj')) { @@ -180,15 +152,6 @@ describe('registryService', () => { }) it('should delete a repository by name', async () => { - server.use( - http.delete(`${harborUrl}/api/v2.0/projects/:projectName/repositories/:repositoryName`, async ({ request, params }) => { - expect(request.method).toBe('DELETE') - expect(params.projectName).toBe('myproj') - expect(params.repositoryName).toBe('repo-a') - return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) - }), - ) - const res = await service.deleteRepository('myproj', 'repo-a') expect(res).toMatchObject({ status: HttpStatus.NO_CONTENT }) @@ -201,16 +164,8 @@ describe('registryService', () => { rules: [], trigger: { kind: 'Schedule', settings: { cron: '0 22 2 * * *' }, references: [] }, } + await db.projects.create({ name: 'myproj', project_id: 123, metadata: { retention_id: '325' } }) server.use( - http.get(`${harborUrl}/api/v2.0/projects/myproj`, ({ request }) => { - expect(request.headers.get('x-is-resource-name')).toBe('true') - return HttpResponse.json({ project_id: 123, metadata: { retention_id: '325' } }) - }), - http.put(`${harborUrl}/api/v2.0/retentions/325`, async ({ request }) => { - expect(request.method).toBe('PUT') - expect(await request.json()).toEqual(policy) - return new HttpResponse(null, { status: HttpStatus.OK }) - }), http.post(`${harborUrl}/api/v2.0/retentions`, () => { throw new Error('a second retention create must not be issued on re-sync') }), @@ -228,25 +183,8 @@ describe('registryService', () => { rules: [], trigger: { kind: 'Schedule', settings: { cron: '0 22 2 * * *' }, references: [] }, } - let reads = 0 - server.use( - http.get(`${harborUrl}/api/v2.0/projects/myproj`, ({ request }) => { - expect(request.headers.get('x-is-resource-name')).toBe('true') - reads += 1 - return HttpResponse.json({ project_id: 123, metadata: {} }) - }), - http.post(`${harborUrl}/api/v2.0/retentions`, async ({ request }) => { - expect(await request.json()).toEqual(policy) - return HttpResponse.json({ id: 500 }, { status: HttpStatus.CREATED }) - }), - // After a successful create there is no policy to reconcile in place. - http.put(`${harborUrl}/api/v2.0/retentions/:id`, () => { - throw new Error('a fresh create must not also issue a reconcile PUT') - }), - ) + await db.projects.create({ name: 'myproj', project_id: 123, metadata: {} }) await service.ensureRetention('myproj', policy) - - expect(reads).toBe(1) }) }) diff --git a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts index c055078c6c..efe5c1a16d 100644 --- a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -1,35 +1,234 @@ -import type { ProjectWithDetails } from './registry-datastore.service' -import type { RegistryResponse } from './registry-http-client.service' +import type { HttpHandler } from 'msw' import { faker } from '@faker-js/faker' -import { HttpStatus } from '@nestjs/common' +import { Collection } from '@msw/data' +import { http, HttpResponse } from 'msw' +import { z } from 'zod' -export function makeOkResponse(data: T): RegistryResponse { - return { status: HttpStatus.OK, data } +export const HARBOR_INTERNAL_URL = 'https://harbor.example' + +const harborProjectSchema = z.object({ + name: z.string(), + project_id: z.number().optional(), + metadata: z.object({ + auto_scan: z.string().optional(), + retention_id: z.string().optional(), + }).optional(), + storage_limit: z.number().optional(), +}) + +const harborRobotSchema = z.object({ + id: z.number().optional(), + name: z.string(), + secret: z.string().optional(), + description: z.string().optional(), + level: z.string().optional(), + permissions: z.unknown().optional(), +}) + +const harborRetentionSchema = z.object({ + id: z.number().optional(), + algorithm: z.string(), + scope: z.object({ level: z.string(), ref: z.number() }), + rules: z.array(z.unknown()), + trigger: z.object({ + kind: z.string(), + settings: z.record(z.unknown()), + references: z.array(z.unknown()), + }), +}) + +const harborRepositorySchema = z.object({ + name: z.string(), +}) + +const harborQuotaSchema = z.object({ + id: z.number().optional(), + ref: z.object({ id: z.number().optional() }), + hard: z.object({ storage: z.number().optional() }), +}) + +const harborMemberSchema = z.object({ + id: z.number().optional(), + entity_name: z.string(), + entity_type: z.string(), + role_id: z.number(), +}) + +const harborRobotBodySchema = z.object({ + name: z.string(), + description: z.string().optional(), + level: z.string().optional(), + permissions: z.unknown().optional(), +}) + +const harborMemberBodySchema = z.object({ + role_id: z.number(), + member_group: z.object({ group_name: z.string(), group_type: z.number() }), +}) + +const harborProjectBodySchema = z.object({ + project_name: z.string(), + storage_limit: z.number().optional(), +}) + +export function makeRegistryDb() { + return { + projects: new Collection({ schema: harborProjectSchema }), + robots: new Collection({ schema: harborRobotSchema }), + retentions: new Collection({ schema: harborRetentionSchema }), + repositories: new Collection({ schema: harborRepositorySchema }), + quotas: new Collection({ schema: harborQuotaSchema }), + members: new Collection({ schema: harborMemberSchema }), + } } -export function makeCreatedResponse(data: T): RegistryResponse { - return { status: HttpStatus.CREATED, data } +export function makeRobotPermissions() { + return [{ namespace: 'myproj', kind: 'project', access: [{ resource: 'repository', action: 'pull' }] }] satisfies Array<{ + namespace: string + kind: 'project' + access: Array<{ resource: string, action: string }> + }> } -export function makeNoContent(): RegistryResponse { - return { status: HttpStatus.NO_CONTENT, data: null } +type Db = ReturnType + +function makeRegistryProjectsHandlers(db: Db): HttpHandler[] { + const base = `${HARBOR_INTERNAL_URL}/api/v2.0` + + const findProject = async (name: string) => + db.projects.findFirst(q => q.where({ name })) + + const buildConflict = (message: string) => + HttpResponse.json({ errors: [{ code: 'CONFLICT', message }] }, { status: 400 }) + + return [ + http.get(`${base}/projects/:projectName`, async ({ params }) => { + const project = await findProject(String(params.projectName)) + if (!project) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json(project) + }), + http.post(`${base}/projects`, async ({ request }) => { + const parsed = harborProjectBodySchema.safeParse(await request.json()) + if (!parsed.success) return HttpResponse.json({ errors: [{ code: 'BAD_REQUEST', message: parsed.error.message }] }, { status: 400 }) + const body = parsed.data + const existing = await findProject(body.project_name) + if (existing) return buildConflict(`project ${body.project_name} already exists`) + const project = await db.projects.create({ name: body.project_name, project_id: faker.number.int(), storage_limit: body.storage_limit, metadata: {} }) + return HttpResponse.json({ project_id: project.project_id, metadata: {} }, { status: 201 }) + }), + http.delete(`${base}/projects/:projectName`, () => new HttpResponse(null, { status: 204 })), + ] } -export function makeConflictResponse(): RegistryResponse { - return { - status: HttpStatus.BAD_REQUEST, - data: { errors: [{ code: 'CONFLICT', message: 'already exists' }] } as T, - } +function makeRegistryRepositoriesHandlers(db: Db): HttpHandler[] { + const base = `${HARBOR_INTERNAL_URL}/api/v2.0` + + return [ + http.get(`${base}/projects/:projectName/repositories`, async () => { + return HttpResponse.json(await db.repositories.findMany()) + }), + http.delete(`${base}/projects/:projectName/repositories/:repositoryName`, () => + new HttpResponse(null, { status: 204 })), + ] } -export function makeHttpConflictResponse(): RegistryResponse { - return { status: HttpStatus.CONFLICT, data: null } +function makeRegistryQuotasHandlers(db: Db): HttpHandler[] { + const base = `${HARBOR_INTERNAL_URL}/api/v2.0` + + return [ + http.get(`${base}/quotas`, async ({ request }) => { + const url = new URL(request.url) + const refIdParam = url.searchParams.get('reference_id') + const all = await db.quotas.findMany() + if (refIdParam !== null) { + const refId = Number(refIdParam) + if (!Number.isNaN(refId)) { + const found = all.find((quota) => { + if (quota == null || typeof quota !== 'object') return false + if (!('ref' in quota)) return false + const ref = quota.ref + if (ref == null || typeof ref !== 'object') return false + if (!('id' in ref)) return false + return ref.id === refId + }) + return HttpResponse.json(found ? [found] : []) + } + } + return HttpResponse.json(all) + }), + http.put(`${base}/quotas/:id`, () => new HttpResponse(null, { status: 200 })), + ] } -export function makeProjectWithDetails(overrides: Partial = {}) { - return { - slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), - plugins: [], - ...overrides, - } satisfies ProjectWithDetails +function makeRegistryMembersHandlers(db: Db): HttpHandler[] { + const base = `${HARBOR_INTERNAL_URL}/api/v2.0` + + const buildConflict = (message: string) => + HttpResponse.json({ errors: [{ code: 'CONFLICT', message }] }, { status: 400 }) + + return [ + http.get(`${base}/projects/:projectName/members`, async () => { + return HttpResponse.json(await db.members.findMany()) + }), + http.post(`${base}/projects/:projectName/members`, async ({ request }) => { + const parsed = harborMemberBodySchema.safeParse(await request.json()) + if (!parsed.success) return HttpResponse.json({ errors: [{ code: 'BAD_REQUEST', message: parsed.error.message }] }, { status: 400 }) + const body = parsed.data + const existing = await db.members.findFirst(q => q.where({ entity_name: body.member_group.group_name })) + if (existing) return buildConflict('member already exists') + const member = await db.members.create({ + entity_name: body.member_group.group_name, + entity_type: String(body.member_group.group_type), + role_id: body.role_id, + }) + return HttpResponse.json({ id: member.id ?? faker.number.int() }, { status: 201 }) + }), + http.delete(`${base}/projects/:projectName/members/:memberId`, () => + new HttpResponse(null, { status: 204 })), + ] +} + +function makeRegistryRobotsHandlers(db: Db): HttpHandler[] { + const base = `${HARBOR_INTERNAL_URL}/api/v2.0` + + const findRobot = async (name: string) => + db.robots.findFirst(q => q.where({ name })) + + const buildConflict = (message: string) => + HttpResponse.json({ errors: [{ code: 'CONFLICT', message }] }, { status: 400 }) + + return [ + http.post(`${base}/robots`, async ({ request }) => { + const parsed = harborRobotBodySchema.safeParse(await request.json()) + if (!parsed.success) return HttpResponse.json({ errors: [{ code: 'BAD_REQUEST', message: parsed.error.message }] }, { status: 400 }) + const body = parsed.data + const existing = await findRobot(body.name) + if (existing) return buildConflict(`robot ${body.name} already exists`) + return HttpResponse.json({ id: faker.number.int(), name: body.name, secret: faker.string.sample(32) }, { status: 201 }) + }), + http.get(`${base}/robots`, async () => { + return HttpResponse.json(await db.robots.findMany()) + }), + http.delete(`${base}/robots/:id`, () => new HttpResponse(null, { status: 200 })), + ] +} + +function makeRegistryRetentionsHandlers(): HttpHandler[] { + const base = `${HARBOR_INTERNAL_URL}/api/v2.0` + + return [ + http.put(`${base}/retentions/:id`, () => new HttpResponse(null, { status: 200 })), + http.post(`${base}/retentions`, () => HttpResponse.json({ id: faker.number.int() }, { status: 201 })), + ] +} + +export function makeRegistryHandlers(db: Db): HttpHandler[] { + return [ + ...makeRegistryProjectsHandlers(db), + ...makeRegistryRepositoriesHandlers(db), + ...makeRegistryQuotasHandlers(db), + ...makeRegistryMembersHandlers(db), + ...makeRegistryRobotsHandlers(db), + ...makeRegistryRetentionsHandlers(), + ] } From 61023f2c70a09f940b1ccf8f0f5ecc38cd6a955f Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:55:57 +0200 Subject: [PATCH 5/8] refactor(server-nestjs): split Nexus makeNexusHandlers into subfunctions Split makeNexusHandlers into per-resource subfunctions for readability: - makeNexusRepositoriesHandlers (maven/npm hosted + group CRUD) - makeNexusPrivilegesHandlers - makeNexusRolesHandlers - makeNexusSecurityHandlers Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: I874229d36d4916baa5bc056f3c2c62886a6a6964 --- .../src/modules/nexus/nexus-testing.utils.ts | 131 ++++++++++-------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts index 14e9fc0e13..ab74ebc64b 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -82,13 +82,22 @@ export function makeNexusDb() { } } -export function makeNexusHandlers(db: ReturnType): HttpHandler[] { +type NexusDb = ReturnType + +async function getOr404(collection: Collection, name: string) { + const data = await collection.findFirst((q: any) => q.where({ name })) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json(data) +} + +function makeNexusRepositoriesHandlers(db: NexusDb): HttpHandler[] { const url = `${NEXUS_INTERNAL_URL}/service/rest/v1` - const getOr404 = async (collection: Collection, name: string) => { - const data = await collection.findFirst((q: any) => q.where({ name })) - if (!data) return HttpResponse.json({}, { status: 404 }) - return HttpResponse.json(data) + const upsertRepository = async (collection: Collection, name: string | undefined, data: any) => { + const record = await collection.findFirst((q: any) => q.where({ name })) + if (record) await collection.update(record, { data: () => data }) + else await collection.create(data) + return new HttpResponse(null, { status: 204 }) } return [ @@ -97,65 +106,77 @@ export function makeNexusHandlers(db: ReturnType): HttpHandl await db.mavenHosted.create(await request.json() as any) return new HttpResponse(null, { status: 204 }) }), - http.put(`${url}/repositories/maven/hosted/:name`, async ({ request, params }) => { - const data = await request.json() as any - const record = await db.mavenHosted.findFirst((q: any) => q.where({ name: params.name })) - if (record) await db.mavenHosted.update(record, { data: () => data }) - else await db.mavenHosted.create(data) - return new HttpResponse(null, { status: 204 }) - }), + http.put(`${url}/repositories/maven/hosted/:name`, async ({ request, params }) => + upsertRepository(db.mavenHosted, params.name as string, await request.json() as any)), http.get(`${url}/repositories/maven/group/:name`, async ({ params }) => getOr404(db.mavenGroup, String(params.name))), http.post(`${url}/repositories/maven/group`, async ({ request }) => { await db.mavenGroup.create(await request.json() as any) return new HttpResponse(null, { status: 204 }) }), - http.put(`${url}/repositories/maven/group/:name`, async ({ request, params }) => { - const data = await request.json() as any - const record = await db.mavenGroup.findFirst((q: any) => q.where({ name: params.name })) - if (record) await db.mavenGroup.update(record, { data: () => data }) - else await db.mavenGroup.create(data) - return new HttpResponse(null, { status: 204 }) - }), + http.put(`${url}/repositories/maven/group/:name`, async ({ request, params }) => + upsertRepository(db.mavenGroup, params.name as string, await request.json() as any)), http.get(`${url}/repositories/npm/hosted/:name`, async ({ params }) => getOr404(db.npmHosted, String(params.name))), http.post(`${url}/repositories/npm/hosted`, async ({ request }) => { await db.npmHosted.create(await request.json() as any) return new HttpResponse(null, { status: 204 }) }), - http.put(`${url}/repositories/npm/hosted/:name`, async ({ request, params }) => { - const data = await request.json() as any - const record = await db.npmHosted.findFirst((q: any) => q.where({ name: params.name })) - if (record) await db.npmHosted.update(record, { data: () => data }) - else await db.npmHosted.create(data) - return new HttpResponse(null, { status: 204 }) - }), + http.put(`${url}/repositories/npm/hosted/:name`, async ({ request, params }) => + upsertRepository(db.npmHosted, params.name as string, await request.json() as any)), http.get(`${url}/repositories/npm/group/:name`, async ({ params }) => getOr404(db.npmGroup, String(params.name))), http.post(`${url}/repositories/npm/group`, async ({ request }) => { await db.npmGroup.create(await request.json() as any) return new HttpResponse(null, { status: 204 }) }), - http.put(`${url}/repositories/npm/group/:name`, async ({ request, params }) => { - const data = await request.json() as any - const record = await db.npmGroup.findFirst((q: any) => q.where({ name: params.name })) - if (record) await db.npmGroup.update(record, { data: () => data }) - else await db.npmGroup.create(data) + http.put(`${url}/repositories/npm/group/:name`, async ({ request, params }) => + upsertRepository(db.npmGroup, params.name as string, await request.json() as any)), + http.delete(`${url}/repositories/:name`, async ({ params }) => { + await Promise.all([ + db.mavenHosted.deleteMany((q: any) => q.where({ name: params.name })), + db.mavenGroup.deleteMany((q: any) => q.where({ name: params.name })), + db.npmHosted.deleteMany((q: any) => q.where({ name: params.name })), + db.npmGroup.deleteMany((q: any) => q.where({ name: params.name })), + ]) return new HttpResponse(null, { status: 204 }) }), + ] +} + +function makeNexusPrivilegesHandlers(db: NexusDb): HttpHandler[] { + const url = `${NEXUS_INTERNAL_URL}/service/rest/v1` + + const upsertPrivilege = async (name: string, data: any) => { + const record = await db.privileges.findFirst((q: any) => q.where({ name })) + if (record) await db.privileges.update(record, { data: () => data }) + else await db.privileges.create(data) + return new HttpResponse(null, { status: 204 }) + } + + return [ http.get(`${url}/security/privileges/:name`, async ({ params }) => getOr404(db.privileges, String(params.name))), http.post(`${url}/security/privileges/repository-view`, async ({ request }) => { await db.privileges.create(await request.json() as any) return new HttpResponse(null, { status: 204 }) }), - http.put(`${url}/security/privileges/repository-view/:name`, async ({ request, params }) => { - const data = await request.json() as any - const record = await db.privileges.findFirst((q: any) => q.where({ name: params.name })) - if (record) await db.privileges.update(record, { data: () => data }) - else await db.privileges.create(data) - return new HttpResponse(null, { status: 204 }) - }), + http.put(`${url}/security/privileges/repository-view/:name`, async ({ request, params }) => + upsertPrivilege(params.name as string, await request.json() as any)), http.delete(`${url}/security/privileges/:name`, async ({ params }) => { await db.privileges.deleteMany((q: any) => q.where({ name: params.name })) return new HttpResponse(null, { status: 204 }) }), + ] +} + +function makeNexusRolesHandlers(db: NexusDb): HttpHandler[] { + const url = `${NEXUS_INTERNAL_URL}/service/rest/v1` + + const upsertRole = async (id: string, data: any) => { + const record = await db.roles.findFirst((q: any) => q.where({ id })) + if (record) await db.roles.update(record, { data: () => data }) + else await db.roles.create(data) + return new HttpResponse(null, { status: 204 }) + } + + return [ http.get(`${url}/security/roles/:id`, async ({ params }) => { const data = await db.roles.findFirst((q: any) => q.where({ id: params.id })) if (!data) return HttpResponse.json({}, { status: 404 }) @@ -165,17 +186,19 @@ export function makeNexusHandlers(db: ReturnType): HttpHandl await db.roles.create(await request.json() as any) return new HttpResponse(null, { status: 204 }) }), - http.put(`${url}/security/roles/:id`, async ({ request, params }) => { - const data = await request.json() as any - const record = await db.roles.findFirst((q: any) => q.where({ id: params.id })) - if (record) await db.roles.update(record, { data: () => data }) - else await db.roles.create(data) - return new HttpResponse(null, { status: 204 }) - }), + http.put(`${url}/security/roles/:id`, async ({ request, params }) => + upsertRole(params.id as string, await request.json() as any)), http.delete(`${url}/security/roles/:id`, async ({ params }) => { await db.roles.deleteMany((q: any) => q.where({ id: params.id })) return new HttpResponse(null, { status: 204 }) }), + ] +} + +function makeNexusSecurityHandlers(db: NexusDb): HttpHandler[] { + const url = `${NEXUS_INTERNAL_URL}/service/rest/v1` + + return [ http.get(`${url}/security/users`, async ({ request }) => { const userId = new URL(request.url).searchParams.get('userId') const users = userId @@ -192,14 +215,14 @@ export function makeNexusHandlers(db: ReturnType): HttpHandl await db.users.deleteMany((q: any) => q.where({ userId: params.userId })) return new HttpResponse(null, { status: 204 }) }), - http.delete(`${url}/repositories/:name`, async ({ params }) => { - await Promise.all([ - db.mavenHosted.deleteMany((q: any) => q.where({ name: params.name })), - db.mavenGroup.deleteMany((q: any) => q.where({ name: params.name })), - db.npmHosted.deleteMany((q: any) => q.where({ name: params.name })), - db.npmGroup.deleteMany((q: any) => q.where({ name: params.name })), - ]) - return new HttpResponse(null, { status: 204 }) - }), + ] +} + +export function makeNexusHandlers(db: NexusDb): HttpHandler[] { + return [ + ...makeNexusRepositoriesHandlers(db), + ...makeNexusPrivilegesHandlers(db), + ...makeNexusRolesHandlers(db), + ...makeNexusSecurityHandlers(db), ] } From d39161f70b03a00b2935ebbead37e896a6353826 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 14:02:53 +0200 Subject: [PATCH 6/8] refactor(server-nestjs): split Vault makeVaultHandlers into subfunctions Split makeVaultHandlers into per-resource subfunctions for readability: - makeVaultKvHandlers (kv read/write/metadata/list) - makeVaultSysHandlers (policies, mounts, approle, auth methods) - makeVaultIdentityHandlers (identity group CRUD) - makeVaultTokenHandlers (token creation) Preserve existing factory functions: makeProjectWithDetails, makeZoneWithDetails, makeVaultSecret, makeVaultSecretMetadata Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: If9d9d123a0a479f6ae769647190c1bca6a6a6964 --- .../src/modules/vault/vault-testing.utils.ts | 128 +++++++++++++----- 1 file changed, 95 insertions(+), 33 deletions(-) diff --git a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts index d0e4abceef..5bfd6b84ae 100644 --- a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -1,4 +1,6 @@ import type { HttpHandler } from 'msw' +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' import { faker } from '@faker-js/faker' import { Collection } from '@msw/data' import { http, HttpResponse } from 'msw' @@ -6,6 +8,38 @@ import { z } from 'zod' export const VAULT_INTERNAL_URL = 'https://vault.internal' +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} + const kvSecretSchema = z.object({ path: z.string(), data: z.record(z.unknown()).optional(), @@ -50,53 +84,56 @@ export function makeVaultSecret(data: T): { data: { data: T, metadata: { crea } } -export function makeVaultHandlers(db: ReturnType): HttpHandler[] { - const base = `${VAULT_INTERNAL_URL}/v1` +type VaultDb = ReturnType - const findKv = async (path: string) => - db.kv.findFirst((q: any) => q.where({ path })) - - const upsertKv = async (path: string, body: any) => { - const record = await findKv(path) - if (record) { - await db.kv.update(record, { data: body?.data ?? body }) - } else { - await db.kv.create({ - path, - data: body?.data ?? body, - metadata: { - created_time: new Date().toISOString(), - destroyed: false, - version: 1, - }, - }) - } +const KV_BASE = `${VAULT_INTERNAL_URL}/v1/kv` + +function findKv(db: VaultDb, path: string) { + return db.kv.findFirst((q: any) => q.where({ path })) +} + +async function upsertKv(db: VaultDb, path: string, body: any) { + const record = await findKv(db, path) + if (record) { + await db.kv.update(record, { data: body?.data ?? body }) + } else { + await db.kv.create({ + path, + data: body?.data ?? body, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }) } +} +function makeVaultKvHandlers(db: VaultDb): HttpHandler[] { return [ // KV read/write — Vault KV engine uses data/ prefix - http.get(`${base}/kv/data/*`, async ({ request }) => { + http.get(`${KV_BASE}/data/*`, async ({ request }) => { const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') - const data = await findKv(path) + const data = await findKv(db, path) if (!data) return HttpResponse.json({}, { status: 404 }) return HttpResponse.json({ data: { data: data.data, metadata: data.metadata } }) }), - http.post(`${base}/kv/data/*`, async ({ request }) => { + http.post(`${KV_BASE}/data/*`, async ({ request }) => { const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') const body = await request.json() as any - await upsertKv(path, body) + await upsertKv(db, path, body) return new HttpResponse(null, { status: 204 }) }), - http.put(`${base}/kv/data/*`, async ({ request }) => { + http.put(`${KV_BASE}/data/*`, async ({ request }) => { const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') const body = await request.json() as any - await upsertKv(path, body) + await upsertKv(db, path, body) return new HttpResponse(null, { status: 204 }) }), // KV metadata list (GET) - http.get(`${base}/kv/metadata/*`, async ({ request }) => { - const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + http.get(`${KV_BASE}/metadata/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace(`${VAULT_INTERNAL_URL}/v1/kv/metadata/`, '') const all = await db.kv.findMany() const keys = Array.from(new Set(all .filter((r: any) => r.path.startsWith(path)) @@ -104,12 +141,12 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl .filter(Boolean))) return HttpResponse.json({ data: { keys } }) }), - http.delete(`${base}/kv/metadata/*`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${KV_BASE}/metadata/*`, () => new HttpResponse(null, { status: 204 })), // LIST method — Vault uses HTTP LIST for directory listing - http.all(`${base}/kv/metadata/*`, async ({ request }) => { + http.all(`${KV_BASE}/metadata/*`, async ({ request }) => { if (request.method !== 'LIST') return new Response(null, { status: 404 }) - const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + const path = new URL(request.url).pathname.replace(`${VAULT_INTERNAL_URL}/v1/kv/metadata/`, '') const all = await db.kv.findMany() const keys = Array.from(new Set(all .filter((r: any) => r.path.startsWith(path)) @@ -117,7 +154,13 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl .filter(Boolean))) return HttpResponse.json({ data: { keys } }) }), + ] +} + +function makeVaultSysHandlers(db: VaultDb): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` + return [ // Sys policies http.post(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), http.delete(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), @@ -144,8 +187,13 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl }) return HttpResponse.json({ data: obj }) }), + ] +} + +function makeVaultIdentityHandlers(db: VaultDb): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` - // Identity groups + return [ http.get(`${base}/identity/group/name/:name`, async ({ params }) => { const data = await db.identityGroups.findFirst((q: any) => q.where({ name: String(params.name) })) if (!data) return HttpResponse.json({}, { status: 404 }) @@ -162,9 +210,23 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl }), http.post(`${base}/identity/group-alias`, () => new HttpResponse(null, { status: 204 })), http.delete(`${base}/identity/group/name/:name`, () => new HttpResponse(null, { status: 204 })), + ] +} + +function makeVaultTokenHandlers(): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` - // Token create + return [ http.post(`${base}/auth/token/create`, () => HttpResponse.json({ auth: { client_token: 'token' } })), ] } + +export function makeVaultHandlers(db: VaultDb): HttpHandler[] { + return [ + ...makeVaultKvHandlers(db), + ...makeVaultSysHandlers(db), + ...makeVaultIdentityHandlers(db), + ...makeVaultTokenHandlers(), + ] +} From 0fdc5680b564b06afe55bb516dee43961f574a26 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 14:03:05 +0200 Subject: [PATCH 7/8] refactor(server-nestjs): split Registry makeRegistryHandlers into subfunctions Split makeRegistryHandlers into per-resource subfunctions for readability: - makeRegistryProjectsHandlers - makeRegistryRepositoriesHandlers - makeRegistryQuotasHandlers - makeRegistryMembersHandlers - makeRegistryRobotsHandlers - makeRegistryRetentionsHandlers (db-free) Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: I9f36acac127736e6e62883818eade0ee6a6a6964 --- .../registry/registry-testing.utils.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts index efe5c1a16d..d627fe69aa 100644 --- a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -1,6 +1,9 @@ import type { HttpHandler } from 'msw' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service' import { faker } from '@faker-js/faker' import { Collection } from '@msw/data' +import { HttpStatus } from '@nestjs/common' import { http, HttpResponse } from 'msw' import { z } from 'zod' @@ -71,6 +74,37 @@ const harborProjectBodySchema = z.object({ storage_limit: z.number().optional(), }) +export function makeOkResponse(data: T): RegistryResponse { + return { status: HttpStatus.OK, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: HttpStatus.CREATED, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: HttpStatus.NO_CONTENT, data: null } +} + +export function makeConflictResponse(): RegistryResponse { + return { + status: HttpStatus.BAD_REQUEST, + data: { errors: [{ code: 'CONFLICT', message: 'already exists' }] } as T, + } +} + +export function makeHttpConflictResponse(): RegistryResponse { + return { status: HttpStatus.CONFLICT, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + export function makeRegistryDb() { return { projects: new Collection({ schema: harborProjectSchema }), From 233cdfb351df24e21859365c7db78ca4f5eb8a5e Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 4 Sep 2026 11:11:19 +0200 Subject: [PATCH 8/8] =?UTF-8?q?feat(server-nestjs):=20add=20S=C3=A9curit?= =?UTF-8?q?=C3=A9=20role=20to=20project=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Security role with position 2 and permissions SEE_SECRETS|LIST_ENVIRONMENTS|LIST_REPOSITORIES - Update Developer role position from 2 → 3 - Update ReadOnly role position from 3 → 4 - Update test expectations accordingly --- .../src/modules/project/project.service.spec.ts | 4 ++-- .../src/modules/project/project.utils.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index e07ae9b900..2871d4dbd3 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -76,7 +76,7 @@ describe('projectService', () => { prisma.$transaction.mockImplementation(async cb => cb(tx)) const pwd = makeProjectWithDetails({ slug: expectedSlug }) - pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + pwd.roles = Array.from({ length: 5 }, (_, index) => ({ id: faker.string.uuid(), name: `role-${index}`, permissions: 0n, @@ -100,7 +100,7 @@ describe('projectService', () => { userId, requestId, }) - expect(logs.addLog).toHaveBeenCalledTimes(4) + expect(logs.addLog).toHaveBeenCalledTimes(5) expect(result).toBeDefined() expect(result.slug).toBe(expectedSlug) }) diff --git a/apps/server-nestjs/src/modules/project/project.utils.ts b/apps/server-nestjs/src/modules/project/project.utils.ts index bb50d21fdf..17cfea3546 100644 --- a/apps/server-nestjs/src/modules/project/project.utils.ts +++ b/apps/server-nestjs/src/modules/project/project.utils.ts @@ -55,19 +55,28 @@ export function generateProjectCreateInput( oidcGroup: `/${slug}/console/devops`, type: 'system:managed', }, + { + name: 'Sécurité', + permissions: PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/security`, + type: 'system:managed', + }, { name: 'Développeur', permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, - position: 2, + position: 3, oidcGroup: `/${slug}/console/developer`, type: 'system:managed', }, { name: 'Lecture seule', permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, - position: 3, + position: 4, oidcGroup: `/${slug}/console/readonly`, type: 'system:managed', }, @@ -223,4 +232,4 @@ export function parseProjectUpdateInput(effectiveData: Record): function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { return inputs.split(',').filter(i => toMatch.includes(i)) -} +} \ No newline at end of file