Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 64 additions & 59 deletions apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<typeof makeVaultDb>

beforeAll(() => server.listen())
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
beforeEach(async () => {
db = makeVaultDb()
server.use(...makeVaultHandlers(db))

const config = mockDeep<ConfigType<typeof vaultConfigFactory>>({
token: 'token',
url: vaultUrl,
internalUrl: vaultUrl,
token: faker.string.sample(32),
url: VAULT_INTERNAL_URL,
internalUrl: VAULT_INTERNAL_URL,
kvName: 'kv',
})
const baseConfig = mockDeep<ConfigType<typeof baseConfigFactory>>({
Expand All @@ -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`)
})
})
})
191 changes: 159 additions & 32 deletions apps/server-nestjs/src/modules/vault/vault-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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> = {}): VaultSecret {
export function makeVaultSecret<T>(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']> = {}): VaultSecret['metadata'] {
return {
created_time: faker.date.soon().toISOString(),
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
...overrides,
export function makeVaultHandlers(db: ReturnType<typeof makeVaultDb>): 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<string, { type: string, description?: string }> = {}
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' } })),
]
}
Loading