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(), + ] }