From 3c6bdec15113388d11ff6eab62d71f72ce6cf8f4 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:41:24 +0200 Subject: [PATCH 1/4] 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 ed7f557eeea0344752eb30520cf9e0b05cc95904 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:41:46 +0200 Subject: [PATCH 2/4] 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 a8b2864ab7dfca9d8924fbb615c59a89323bb473 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:42:02 +0200 Subject: [PATCH 3/4] 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 df2798c84e069939369cd3bd0d12ec8397e5768e Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:55:57 +0200 Subject: [PATCH 4/4] 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), ] }