From ed7f557eeea0344752eb30520cf9e0b05cc95904 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 13:41:46 +0200 Subject: [PATCH] test(server-nestjs): migrate vault client spec to MSW + @msw/data Vault testing utils use @msw/data Collection for faker-seeded secret engines and secrets. Server initialized empty, handlers added in beforeEach. Refs #2655 Co-authored-by: Automata Signed-off-by: Shikanime Deva Signed-off-by: William Phetsinorath Change-Id: I1ed87e505cc5a57922bdc0fa62621c596a6a6964 --- .../vault/vault-client.service.spec.ts | 123 +++++------ .../src/modules/vault/vault-testing.utils.ts | 191 +++++++++++++++--- 2 files changed, 223 insertions(+), 91 deletions(-) diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts index 01adf6a96b..41e391d7a9 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -1,4 +1,5 @@ import type { ConfigType } from '@nestjs/config' +import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' @@ -9,33 +10,23 @@ import { baseConfigFactory } from '../../config/base.config' import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { makeVaultDb, makeVaultHandlers, VAULT_INTERNAL_URL } from './vault-testing.utils' -const vaultUrl = 'https://vault.internal' - -const server = setupServer( - http.post(`${vaultUrl}/v1/auth/token/create`, () => { - return HttpResponse.json({ auth: { client_token: 'token' } }) - }), - http.get(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) - }), - http.post(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({}) - }), - http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { - return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) - }), -) +const server = setupServer() describe('vault', () => { let service: VaultClientService + let db: ReturnType - beforeAll(() => server.listen()) + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { + db = makeVaultDb() + server.use(...makeVaultHandlers(db)) + const config = mockDeep>({ - token: 'token', - url: vaultUrl, - internalUrl: vaultUrl, + token: faker.string.sample(32), + url: VAULT_INTERNAL_URL, + internalUrl: VAULT_INTERNAL_URL, kvName: 'kv', }) const baseConfig = mockDeep>({ @@ -58,90 +49,104 @@ describe('vault', () => { describe('read', () => { it('should read secret', async () => { - const result = await service.read('path') + const path = faker.string.uuid() + const secretValue = faker.string.sample(8) + const createdTime = faker.date.past().toISOString() + await db.kv.create({ + path, + data: { secret: secretValue }, + metadata: { created_time: createdTime, destroyed: false, version: 1 }, + }) + + const result = await service.read(path) expect(result).toEqual({ - data: { secret: 'value' }, - metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + data: { secret: secretValue }, + metadata: { created_time: createdTime, destroyed: false, version: 1 }, }) }) it('should throw if 404', async () => { - server.use( - http.get(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) - }), - ) - - await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) - await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + const path = faker.string.uuid() + await expect(service.read(path)).rejects.toBeInstanceOf(VaultError) + await expect(service.read(path)).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) }) }) describe('readGitlabSecrets', () => { it('reads a project group and returns raw vault data', async () => { - server.use( - http.get(`${vaultUrl}/v1/kv/data/*`, () => { - return HttpResponse.json({ data: { data: { key1: 'value1', key2: 42, key3: false, key4: null }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) - }), - ) - - const result = await service.readGitlabSecrets('my-project') + const projectSlug = faker.string.uuid() + const secretData = { + key1: faker.string.sample(8), + key2: faker.number.int(), + key3: faker.helpers.arrayElement([true, false]), + key4: null, + } + await db.kv.create({ + path: `forge/${projectSlug}/GITLAB`, + data: secretData, + metadata: { created_time: faker.date.past().toISOString(), destroyed: false, version: 1 }, + }) - expect(result).toEqual({ key1: 'value1', key2: 42, key3: false, key4: null }) + const result = await service.readGitlabSecrets(projectSlug) + expect(result).toEqual(secretData) }) it('returns {} when the secret is missing', async () => { - server.use( - http.get(`${vaultUrl}/v1/kv/data/*`, () => { - return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) - }), - ) - - const result = await service.readGitlabSecrets('my-project') - + const projectSlug = faker.string.uuid() + const result = await service.readGitlabSecrets(projectSlug) expect(result).toEqual({}) }) }) describe('write', () => { it('should write secret', async () => { - await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + const path = faker.string.uuid() + const data = { secret: faker.string.sample(8) } + await expect(service.write(data, path)).resolves.toBeUndefined() }) it('should expose reasons on error', async () => { + const path = faker.string.uuid() + const reason = faker.lorem.sentence() server.use( - http.post(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) - }), + http.post(`${VAULT_INTERNAL_URL}/v1/kv/data/*`, () => + HttpResponse.json({ errors: [reason] }, { status: HttpStatus.BAD_REQUEST })), ) - await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) - await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + await expect(service.write({ secret: faker.string.sample(8) }, path)).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: faker.string.sample(8) }, path)).rejects.toMatchObject({ kind: 'HttpError', status: HttpStatus.BAD_REQUEST, - reasons: ['No secret engine mount at test-project/'], + reasons: [reason], }) - await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + await expect(service.write({ secret: faker.string.sample(8) }, path)).rejects.toThrow('Request failed') }) }) describe('delete', () => { it('should delete secret', async () => { - await expect(service.delete('path')).resolves.toBeUndefined() + const path = faker.string.uuid() + await db.kv.create({ + path, + data: {}, + metadata: { created_time: faker.date.past().toISOString(), destroyed: false, version: 1 }, + }) + await expect(service.delete(path)).resolves.toBeUndefined() }) }) describe('writeMirrorTriggerToken', () => { it('writes under the project path', async () => { + const projectSlug = faker.string.uuid() let capturedPath: string | undefined server.use( - http.post(`${vaultUrl}/v1/kv/data/*`, ({ request }) => { + http.post(`${VAULT_INTERNAL_URL}/v1/kv/data/*`, async ({ request }) => { capturedPath = new URL(request.url).pathname.replace('/v1/kv/data/', '') return HttpResponse.json({}) }), ) - await service.writeMirrorTriggerToken('my-project', { PROJECT_SLUG: 'my-project' }) - expect(capturedPath).toBe('forge/my-project/GITLAB') + await service.writeMirrorTriggerToken(projectSlug, { PROJECT_SLUG: projectSlug }) + expect(capturedPath).toBe(`forge/${projectSlug}/GITLAB`) }) }) }) diff --git a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts index 2397b97bdc..d0e4abceef 100644 --- a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -1,43 +1,170 @@ -import type { VaultSecret } from './vault-client.service' -import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { HttpHandler } from 'msw' import { faker } from '@faker-js/faker' +import { Collection } from '@msw/data' +import { http, HttpResponse } from 'msw' +import { z } from 'zod' -export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { - return { - id: faker.string.uuid(), - slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), - name: faker.company.name(), - description: faker.company.buzzPhrase(), - environments: [], - plugins: [], - ...overrides, - } satisfies ProjectWithDetails -} +export const VAULT_INTERNAL_URL = 'https://vault.internal' + +const kvSecretSchema = z.object({ + path: z.string(), + data: z.record(z.unknown()).optional(), + metadata: z.object({ + created_time: z.string(), + destroyed: z.boolean(), + version: z.number(), + }).optional(), +}) + +const identityGroupSchema = z.object({ + id: z.string(), + name: z.string(), + alias: z.object({ + name: z.string(), + }).optional(), +}) + +const authMethodSchema = z.object({ + type: z.string(), + description: z.string().optional(), +}) -export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { +export function makeVaultDb() { return { - id: faker.string.uuid(), - slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), - clusters: [], - ...overrides, - } satisfies ZoneWithDetails + kv: new Collection({ schema: kvSecretSchema }), + identityGroups: new Collection({ schema: identityGroupSchema }), + authMethods: new Collection({ schema: authMethodSchema }), + } } -export function makeVaultSecret(overrides: Partial = {}): VaultSecret { +export function makeVaultSecret(data: T): { data: { data: T, metadata: { created_time: string, destroyed: boolean, version: number } } } { return { - data: {}, - metadata: makeVaultSecretMetadata(), - ...overrides, - } satisfies VaultSecret + data: { + data, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }, + } } -export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { - return { - created_time: faker.date.soon().toISOString(), - custom_metadata: null, - deletion_time: '', - destroyed: false, - version: 1, - ...overrides, +export function makeVaultHandlers(db: ReturnType): HttpHandler[] { + const base = `${VAULT_INTERNAL_URL}/v1` + + const findKv = async (path: string) => + db.kv.findFirst((q: any) => q.where({ path })) + + const upsertKv = async (path: string, body: any) => { + const record = await findKv(path) + if (record) { + await db.kv.update(record, { data: body?.data ?? body }) + } else { + await db.kv.create({ + path, + data: body?.data ?? body, + metadata: { + created_time: new Date().toISOString(), + destroyed: false, + version: 1, + }, + }) + } } + + return [ + // KV read/write — Vault KV engine uses data/ prefix + http.get(`${base}/kv/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + const data = await findKv(path) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json({ data: { data: data.data, metadata: data.metadata } }) + }), + http.post(`${base}/kv/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + const body = await request.json() as any + await upsertKv(path, body) + return new HttpResponse(null, { status: 204 }) + }), + http.put(`${base}/kv/data/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace('/v1/kv/data/', '') + const body = await request.json() as any + await upsertKv(path, body) + return new HttpResponse(null, { status: 204 }) + }), + + // KV metadata list (GET) + http.get(`${base}/kv/metadata/*`, async ({ request }) => { + const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + const all = await db.kv.findMany() + const keys = Array.from(new Set(all + .filter((r: any) => r.path.startsWith(path)) + .map((r: any) => r.path.slice(path.length).split('/')[0]) + .filter(Boolean))) + return HttpResponse.json({ data: { keys } }) + }), + http.delete(`${base}/kv/metadata/*`, () => new HttpResponse(null, { status: 204 })), + + // LIST method — Vault uses HTTP LIST for directory listing + http.all(`${base}/kv/metadata/*`, async ({ request }) => { + if (request.method !== 'LIST') return new Response(null, { status: 404 }) + const path = new URL(request.url).pathname.replace(`${base}/kv/metadata/`, '') + const all = await db.kv.findMany() + const keys = Array.from(new Set(all + .filter((r: any) => r.path.startsWith(path)) + .map((r: any) => r.path.slice(path.length).split('/')[0]) + .filter(Boolean))) + return HttpResponse.json({ data: { keys } }) + }), + + // Sys policies + http.post(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/sys/policies/acl/:policy`, () => new HttpResponse(null, { status: 204 })), + + // Sys mounts + http.post(`${base}/sys/mounts/:name`, () => new HttpResponse(null, { status: 204 })), + http.post(`${base}/sys/mounts/:name/tune`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/sys/mounts/:name`, () => new HttpResponse(null, { status: 204 })), + + // Auth approle + http.post(`${base}/auth/approle/role/:role`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/auth/approle/role/:role`, () => new HttpResponse(null, { status: 204 })), + http.get(`${base}/auth/approle/role/:role/role-id`, () => + HttpResponse.json({ data: { role_id: faker.string.uuid() } })), + http.post(`${base}/auth/approle/role/:role/secret-id`, () => + HttpResponse.json({ data: { secret_id: faker.string.uuid() } })), + + // Sys auth + http.get(`${base}/sys/auth`, async () => { + const methods = await db.authMethods.findMany() + const obj: Record = {} + methods.forEach((m: any, i: number) => { + obj[`${m.type}-${i}/`] = { type: m.type, description: m.description } + }) + return HttpResponse.json({ data: obj }) + }), + + // Identity groups + http.get(`${base}/identity/group/name/:name`, async ({ params }) => { + const data = await db.identityGroups.findFirst((q: any) => q.where({ name: String(params.name) })) + if (!data) return HttpResponse.json({}, { status: 404 }) + return HttpResponse.json({ data }) + }), + http.post(`${base}/identity/group/name/:name`, async ({ request, params }) => { + const body = await request.json() as any + await db.identityGroups.create({ + id: faker.string.uuid(), + name: String(params.name), + alias: body?.alias, + }) + return new HttpResponse(null, { status: 204 }) + }), + http.post(`${base}/identity/group-alias`, () => new HttpResponse(null, { status: 204 })), + http.delete(`${base}/identity/group/name/:name`, () => new HttpResponse(null, { status: 204 })), + + // Token create + http.post(`${base}/auth/token/create`, () => + HttpResponse.json({ auth: { client_token: 'token' } })), + ] }