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), ] } 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-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts index d0e4abceef..5bfd6b84ae 100644 --- a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -1,4 +1,6 @@ import type { HttpHandler } from 'msw' +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' import { faker } from '@faker-js/faker' import { Collection } from '@msw/data' import { http, HttpResponse } from 'msw' @@ -6,6 +8,38 @@ import { z } from 'zod' export const VAULT_INTERNAL_URL = 'https://vault.internal' +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} + const kvSecretSchema = z.object({ path: z.string(), data: z.record(z.unknown()).optional(), @@ -50,53 +84,56 @@ export function makeVaultSecret(data: T): { data: { data: T, metadata: { crea } } -export function makeVaultHandlers(db: ReturnType): HttpHandler[] { - const base = `${VAULT_INTERNAL_URL}/v1` +type VaultDb = ReturnType - const findKv = async (path: string) => - db.kv.findFirst((q: any) => q.where({ path })) - - const upsertKv = async (path: string, body: any) => { - const record = await findKv(path) - if (record) { - await db.kv.update(record, { data: body?.data ?? body }) - } else { - await db.kv.create({ - path, - data: body?.data ?? body, - metadata: { - created_time: new Date().toISOString(), - destroyed: false, - version: 1, - }, - }) - } +const KV_BASE = `${VAULT_INTERNAL_URL}/v1/kv` + +function findKv(db: VaultDb, path: string) { + return db.kv.findFirst((q: any) => q.where({ path })) +} + +async function upsertKv(db: VaultDb, path: string, body: any) { + const record = await findKv(db, path) + if (record) { + await db.kv.update(record, { data: body?.data ?? body }) + } else { + await db.kv.create({ + path, + data: body?.data ?? body, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }) } +} +function makeVaultKvHandlers(db: VaultDb): HttpHandler[] { return [ // KV read/write — Vault KV engine uses data/ prefix - http.get(`${base}/kv/data/*`, async ({ request }) => { + http.get(`${KV_BASE}/data/*`, async ({ request }) => { const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') - const data = await findKv(path) + const data = await findKv(db, path) if (!data) return HttpResponse.json({}, { status: 404 }) return HttpResponse.json({ data: { data: data.data, metadata: data.metadata } }) }), - http.post(`${base}/kv/data/*`, async ({ request }) => { + http.post(`${KV_BASE}/data/*`, async ({ request }) => { const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') const body = await request.json() as any - await upsertKv(path, body) + await upsertKv(db, path, body) return new HttpResponse(null, { status: 204 }) }), - http.put(`${base}/kv/data/*`, async ({ request }) => { + http.put(`${KV_BASE}/data/*`, async ({ request }) => { const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') const body = await request.json() as any - await upsertKv(path, body) + await upsertKv(db, path, body) return new HttpResponse(null, { status: 204 }) }), // KV metadata list (GET) - http.get(`${base}/kv/metadata/*`, async ({ request }) => { - const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + http.get(`${KV_BASE}/metadata/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace(`${VAULT_INTERNAL_URL}/v1/kv/metadata/`, '') const all = await db.kv.findMany() const keys = Array.from(new Set(all .filter((r: any) => r.path.startsWith(path)) @@ -104,12 +141,12 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl .filter(Boolean))) return HttpResponse.json({ data: { keys } }) }), - http.delete(`${base}/kv/metadata/*`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${KV_BASE}/metadata/*`, () => new HttpResponse(null, { status: 204 })), // LIST method — Vault uses HTTP LIST for directory listing - http.all(`${base}/kv/metadata/*`, async ({ request }) => { + http.all(`${KV_BASE}/metadata/*`, async ({ request }) => { if (request.method !== 'LIST') return new Response(null, { status: 404 }) - const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + const path = new URL(request.url).pathname.replace(`${VAULT_INTERNAL_URL}/v1/kv/metadata/`, '') const all = await db.kv.findMany() const keys = Array.from(new Set(all .filter((r: any) => r.path.startsWith(path)) @@ -117,7 +154,13 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl .filter(Boolean))) return HttpResponse.json({ data: { keys } }) }), + ] +} + +function makeVaultSysHandlers(db: VaultDb): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` + return [ // Sys policies http.post(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), http.delete(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), @@ -144,8 +187,13 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl }) return HttpResponse.json({ data: obj }) }), + ] +} + +function makeVaultIdentityHandlers(db: VaultDb): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` - // Identity groups + return [ http.get(`${base}/identity/group/name/:name`, async ({ params }) => { const data = await db.identityGroups.findFirst((q: any) => q.where({ name: String(params.name) })) if (!data) return HttpResponse.json({}, { status: 404 }) @@ -162,9 +210,23 @@ export function makeVaultHandlers(db: ReturnType): HttpHandl }), http.post(`${base}/identity/group-alias`, () => new HttpResponse(null, { status: 204 })), http.delete(`${base}/identity/group/name/:name`, () => new HttpResponse(null, { status: 204 })), + ] +} + +function makeVaultTokenHandlers(): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` - // Token create + return [ http.post(`${base}/auth/token/create`, () => HttpResponse.json({ auth: { client_token: 'token' } })), ] } + +export function makeVaultHandlers(db: VaultDb): HttpHandler[] { + return [ + ...makeVaultKvHandlers(db), + ...makeVaultSysHandlers(db), + ...makeVaultIdentityHandlers(db), + ...makeVaultTokenHandlers(), + ] +}