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/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..ab74ebc64b 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,210 @@ 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 }), + } +} + +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 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 [ + 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 }) => + 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 }) => + 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 }) => + 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 }) => + 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 }) => + 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 }) + 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 }) => + 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 + ? 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 }) + }), + ] +} + +export function makeNexusHandlers(db: NexusDb): HttpHandler[] { + return [ + ...makeNexusRepositoriesHandlers(db), + ...makeNexusPrivilegesHandlers(db), + ...makeNexusRolesHandlers(db), + ...makeNexusSecurityHandlers(db), + ] +} 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 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..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,7 +1,78 @@ +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' + +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 makeOkResponse(data: T): RegistryResponse { return { status: HttpStatus.OK, data } @@ -26,10 +97,172 @@ export function makeHttpConflictResponse(): RegistryResponse { return { status: HttpStatus.CONFLICT, data: null } } -export function makeProjectWithDetails(overrides: Partial = {}) { +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 }), + 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 makeRobotPermissions() { + return [{ namespace: 'myproj', kind: 'project', access: [{ resource: 'repository', action: 'pull' }] }] satisfies Array<{ + namespace: string + kind: 'project' + access: Array<{ resource: string, action: string }> + }> +} + +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 })), + ] +} + +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 })), + ] +} + +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 })), + ] +} + +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(), + ] +} 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..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,6 +1,12 @@ +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' +import { z } from 'zod' + +export const VAULT_INTERNAL_URL = 'https://vault.internal' export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { return { @@ -23,14 +29,6 @@ export function makeZoneWithDetails(overrides: Partial = {}): Z } satisfies ZoneWithDetails } -export function makeVaultSecret(overrides: Partial = {}): VaultSecret { - return { - data: {}, - metadata: makeVaultSecretMetadata(), - ...overrides, - } satisfies VaultSecret -} - export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { return { created_time: faker.date.soon().toISOString(), @@ -41,3 +39,194 @@ export function makeVaultSecretMetadata(overrides: Partial(data: T): { data: { data: T, metadata: { created_time: string, destroyed: boolean, version: number } } } { + return { + data: { + data, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }, + } +} + +type VaultDb = ReturnType + +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(`${KV_BASE}/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + 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(`${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(db, path, body) + return new HttpResponse(null, { status: 204 }) + }), + 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(db, path, body) + return new HttpResponse(null, { status: 204 }) + }), + + // KV metadata list (GET) + 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)) + .map((r: any) => r.path.slice(path.length).split('/')[0]) + .filter(Boolean))) + return HttpResponse.json({ data: { keys } }) + }), + http.delete(`${KV_BASE}/metadata/*`, () => new HttpResponse(null, { status: 204 })), + + // LIST method — Vault uses HTTP LIST for directory listing + 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(`${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)) + .map((r: any) => r.path.slice(path.length).split('/')[0]) + .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 })), + + // 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 }) + }), + ] +} + +function makeVaultIdentityHandlers(db: VaultDb): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` + + 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 }) + 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 })), + ] +} + +function makeVaultTokenHandlers(): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` + + 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(), + ] +} 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