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..e6ed1c0d88 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 @@ -75,6 +75,31 @@ describe('vault', () => { await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) }) + + it('should throw InvalidResponse when the data wrapper is missing', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + ) + + await expect(service.read('path')).rejects.toMatchObject({ + kind: 'InvalidResponse', + message: 'Missing "data" field', + method: 'GET', + path: 'kv/data/path', + }) + }) + + it('should throw InvalidResponse when the data field is null', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: null }) + }), + ) + + await expect(service.read('path')).rejects.toMatchObject({ kind: 'InvalidResponse' }) + }) }) describe('readGitlabSecrets', () => { @@ -144,4 +169,367 @@ describe('vault', () => { expect(capturedPath).toBe('forge/my-project/GITLAB') }) }) + + describe('http error mapping', () => { + it.each([ + HttpStatus.FORBIDDEN, + HttpStatus.CONFLICT, + HttpStatus.INTERNAL_SERVER_ERROR, + HttpStatus.SERVICE_UNAVAILABLE, + ])('maps status %i to HttpError with the status preserved', async (status) => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: [`permission denied (${status})`] }, { status }) + }), + ) + + await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status, + method: 'POST', + reasons: [`permission denied (${status})`], + }) + }) + + it('preserves reasons on a 404 NotFound', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: ['no handler for route'] }, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.write({}, 'path')).rejects.toMatchObject({ + kind: 'NotFound', + status: HttpStatus.NOT_FOUND, + reasons: ['no handler for route'], + }) + }) + + it('leaves reasons undefined when the error body has no errors array', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ unexpected: 'shape' }, { status: HttpStatus.BAD_GATEWAY }) + }), + ) + + await expect(service.write({}, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.BAD_GATEWAY, + reasons: undefined, + }) + }) + + it('wraps a non-JSON error body (proxy HTML page) in HttpError', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return new HttpResponse('gateway error', { status: HttpStatus.SERVICE_UNAVAILABLE, headers: { 'content-type': 'text/html' } }) + }), + ) + + await expect(service.write({}, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.SERVICE_UNAVAILABLE, + method: 'POST', + }) + }) + + it('swallows a non-JSON 404 via the NotFound guard', async () => { + server.use( + http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse('not json', { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.delete('path')).resolves.toBeUndefined() + }) + + it('maps network failure to Unexpected', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.error() + }), + ) + + await expect(service.write({}, 'path')).rejects.toMatchObject({ + kind: 'Unexpected', + method: 'POST', + path: 'kv/data/path', + }) + }) + + it('returns null on 204 No Content', async () => { + server.use( + http.get(`${vaultUrl}/v1/sys/auth`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await expect(service.getSysAuth()).resolves.toEqual({}) + }) + + it('coalesces a missing data wrapper to {} on getSysAuth', async () => { + server.use( + http.get(`${vaultUrl}/v1/sys/auth`, () => { + return HttpResponse.json({}) + }), + ) + + await expect(service.getSysAuth()).resolves.toEqual({}) + }) + }) + + describe('listKvMetadata', () => { + it('returns keys on success', async () => { + let capturedMethod: string | undefined + server.use( + http.all(`${vaultUrl}/v1/kv/metadata/*`, async ({ request }) => { + capturedMethod = request.method + return HttpResponse.json({ data: { keys: ['SONAR', 'tech/'] } }) + }), + ) + + await expect(service.listKvMetadata('kv', 'forge/my-project')).resolves.toEqual(['SONAR', 'tech/']) + expect(capturedMethod).toBe('LIST') + }) + + it('throws InvalidResponse when data.keys is missing', async () => { + server.use( + http.all(`${vaultUrl}/v1/kv/metadata/*`, () => { + return HttpResponse.json({ data: {} }) + }), + ) + + await expect(service.listKvMetadata('kv', 'forge/my-project')).rejects.toMatchObject({ + kind: 'InvalidResponse', + message: 'Missing "data.keys" field', + method: 'LIST', + }) + }) + + it('returns [] on 404 instead of throwing', async () => { + server.use( + http.all(`${vaultUrl}/v1/kv/metadata/*`, () => { + return HttpResponse.json({ errors: [] }, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.listKvMetadata('kv', 'forge/my-project')).resolves.toEqual([]) + }) + + it('rethrows non-404 HttpErrors', async () => { + server.use( + http.all(`${vaultUrl}/v1/kv/metadata/*`, () => { + return HttpResponse.json({ errors: ['sealed'] }, { status: HttpStatus.SERVICE_UNAVAILABLE }) + }), + ) + + await expect(service.listKvMetadata('kv', 'forge/my-project')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.SERVICE_UNAVAILABLE, + }) + }) + }) + + describe('approle edges', () => { + let capturedBody: any + + beforeEach(() => { + capturedBody = undefined + server.use( + http.post(`${vaultUrl}/v1/auth/approle/role/:role`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({}) + }), + ) + }) + + it('upserts with empty token_policies sent verbatim', async () => { + await service.upsertAuthApproleRole('zone-prod', { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: [], + }) + + expect(capturedBody.token_policies).toEqual([]) + expect(capturedBody.token_type).toBe('batch') + }) + + it('reads role-id', async () => { + server.use( + http.get(`${vaultUrl}/v1/auth/approle/role/:role/role-id`, () => { + return HttpResponse.json({ data: { role_id: 'role-id-123' } }) + }), + ) + + await expect(service.getAuthApproleRoleRoleId('zone-prod')).resolves.toBe('role-id-123') + }) + + it('throws InvalidResponse when role-id data wrapper is missing', async () => { + server.use( + http.get(`${vaultUrl}/v1/auth/approle/role/:role/role-id`, () => { + return HttpResponse.json({}) + }), + ) + + await expect(service.getAuthApproleRoleRoleId('zone-prod')).rejects.toMatchObject({ + kind: 'InvalidResponse', + method: 'GET', + path: 'auth/approle/role/zone-prod/role-id', + }) + }) + + it('creates a secret-id', async () => { + server.use( + http.post(`${vaultUrl}/v1/auth/approle/role/:role/secret-id`, () => { + return HttpResponse.json({ data: { secret_id: 'secret-id-123' } }) + }), + ) + + await expect(service.createAuthApproleRoleSecretId('zone-prod')).resolves.toBe('secret-id-123') + }) + + it('throws InvalidResponse when secret_id is missing from the response', async () => { + server.use( + http.post(`${vaultUrl}/v1/auth/approle/role/:role/secret-id`, () => { + return HttpResponse.json({}) + }), + ) + + await expect(service.createAuthApproleRoleSecretId('zone-prod')).rejects.toMatchObject({ + kind: 'InvalidResponse', + method: 'POST', + path: 'auth/approle/role/zone-prod/secret-id', + }) + }) + + it('propagates 403 as HttpError on secret-id creation', async () => { + server.use( + http.post(`${vaultUrl}/v1/auth/approle/role/:role/secret-id`, () => { + return HttpResponse.json({ errors: ['permission denied'] }, { status: HttpStatus.FORBIDDEN }) + }), + ) + + await expect(service.createAuthApproleRoleSecretId('zone-prod')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.FORBIDDEN, + }) + }) + }) + + describe('identity group edges', () => { + it('passes empty policies through verbatim on upsert', async () => { + let capturedBody: any + server.use( + http.post(`${vaultUrl}/v1/identity/group/name/:name`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({}) + }), + ) + + await service.upsertIdentityGroupName('project-x-admin', { name: 'project-x-admin', type: 'external', policies: [] }) + + expect(capturedBody.policies).toEqual([]) + expect(capturedBody.type).toBe('external') + }) + + // Locks current leniency: a missing data wrapper passes straight through; only the + // ensureIdentityGroup caller (vault.service.ts) validates data.id afterwards. + it('returns a data-less response unchanged from getIdentityGroupName', async () => { + server.use( + http.get(`${vaultUrl}/v1/identity/group/name/:name`, () => { + return HttpResponse.json({}) + }), + ) + + await expect(service.getIdentityGroupName('project-x-admin')).resolves.toEqual({}) + }) + + it('throws InvalidResponse on an empty (204) getIdentityGroupName response', async () => { + server.use( + http.get(`${vaultUrl}/v1/identity/group/name/:name`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await expect(service.getIdentityGroupName('project-x-admin')).rejects.toMatchObject({ + kind: 'InvalidResponse', + message: 'Empty response', + }) + }) + + it('deletes identity groups', async () => { + let method: string | undefined + server.use( + http.delete(`${vaultUrl}/v1/identity/group/name/:name`, ({ request }) => { + method = request.method + return HttpResponse.json({}) + }), + ) + + await expect(service.deleteIdentityGroupName('project-x-admin')).resolves.toBeUndefined() + expect(method).toBe('DELETE') + }) + }) + + describe('credential helpers', () => { + it('readGitlabMirrorCreds returns null on NotFound', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/*`, () => { + return HttpResponse.json({ errors: [] }, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.readGitlabMirrorCreds('my-project', 'repo')).resolves.toBeNull() + }) + + it('readGitlabMirrorCreds rethrows non-NotFound errors', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/*`, () => { + return HttpResponse.json({ errors: ['boom'] }, { status: HttpStatus.INTERNAL_SERVER_ERROR }) + }), + ) + + await expect(service.readGitlabMirrorCreds('my-project', 'repo')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.INTERNAL_SERVER_ERROR, + }) + }) + + it('readTechnReadOnlyCreds rethrows non-NotFound errors', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/*`, () => { + return HttpResponse.json({ errors: ['boom'] }, { status: HttpStatus.CONFLICT }) + }), + ) + + await expect(service.readTechnReadOnlyCreds('my-project')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.CONFLICT, + }) + }) + + it('deleteGitlabMirrorCreds tolerates NotFound', async () => { + server.use( + http.delete(`${vaultUrl}/v1/kv/metadata/*`, () => { + return HttpResponse.json({ errors: [] }, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.deleteGitlabMirrorCreds('my-project', 'repo')).resolves.toBeUndefined() + }) + + it('deleteSonarqubeUser tolerates NotFound', async () => { + server.use( + http.delete(`${vaultUrl}/v1/kv/metadata/*`, () => { + return HttpResponse.json({ errors: [] }, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.deleteSonarqubeUser('my-project')).resolves.toBeUndefined() + }) + }) }) diff --git a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts index 25d4d43af5..e611b4356c 100644 --- a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -108,7 +108,13 @@ export class VaultHttpClientService { } private async throwForStatus(response: Response, method: string, path: string): Promise { - const responseBody = await response.json() + let responseBody: unknown + try { + responseBody = await response.json() + } catch { + // A non-JSON error body (proxy HTML page) must not escape the VaultError contract. + responseBody = undefined + } const vaultErrorBody = z.object({ errors: z.array(z.string()) }).safeParse(responseBody) const reasons = vaultErrorBody.success ? vaultErrorBody.data.errors : undefined const reasonsPart = reasons?.length ? ` reasons=${reasons.join('; ')}` : '' diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index 268f60efa3..df5ae42161 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -8,11 +8,16 @@ import { baseConfigFactory } from '../../config/base.config' import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' +import { VaultError } from './vault-http-client.service' import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' import { VaultService } from './vault.service' const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ +function makeVaultError(status: number): VaultError { + return new VaultError('HttpError', 'Request failed', { status }) +} + describe('vaultService', () => { let service: VaultService let datastore: DeepMockProxy @@ -156,4 +161,125 @@ describe('vaultService', () => { expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) }) + + describe('zone lifecycle', () => { + it('upserts mount, tech policy and approle for a zone', async () => { + await service.upsertZone('prod') + + expect(client.createSysMount).toHaveBeenCalledWith('zone-prod', expect.objectContaining({ type: 'kv', options: { version: 2 } })) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('tech--zone-prod--ro', { + policy: 'path "zone-prod/*" { capabilities = ["read"] }', + }) + expect(client.upsertAuthApproleRole).toHaveBeenCalledWith('zone-prod', expect.objectContaining({ + token_type: 'batch', + token_policies: ['tech--zone-prod--ro'], + })) + }) + + it('falls back to tuning the mount when creation reports 400', async () => { + client.createSysMount.mockRejectedValue(makeVaultError(400)) + + await service.upsertZone('prod') + + expect(client.tuneSysMount).toHaveBeenCalledWith('zone-prod', { options: { version: 2 } }) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('tech--zone-prod--ro', expect.any(Object)) + expect(client.upsertAuthApproleRole).toHaveBeenCalled() + }) + + it('does not tune and rethrows when creation fails with another status', async () => { + client.createSysMount.mockRejectedValue(makeVaultError(403)) + + await expect(service.upsertZone('prod')).rejects.toSatisfy((error: unknown) => + error instanceof VaultError && error.kind === 'HttpError' && error.status === 403) + expect(client.tuneSysMount).not.toHaveBeenCalled() + expect(client.upsertSysPoliciesAcl).not.toHaveBeenCalled() + }) + + it('deletes mount, policy and approle for a zone', async () => { + await service.deleteZone('prod') + + expect(client.deleteSysMounts).toHaveBeenCalledWith('zone-prod') + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('tech--zone-prod--ro') + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith('zone-prod') + }) + + it('tolerates NotFound on every zone teardown call', async () => { + client.deleteSysMounts.mockRejectedValue(new VaultError('NotFound', 'Not Found')) + client.deleteSysPoliciesAcl.mockRejectedValue(new VaultError('NotFound', 'Not Found')) + client.deleteAuthApproleRole.mockRejectedValue(new VaultError('NotFound', 'Not Found')) + + await expect(service.deleteZone('prod')).resolves.toBeUndefined() + }) + + it('surfaces a partial failure when a teardown call fails with HttpError', async () => { + client.deleteAuthApproleRole.mockRejectedValue(makeVaultError(500)) + + await expect(service.deleteZone('prod')).rejects.toSatisfy((error: unknown) => + error instanceof VaultError && error.kind === 'HttpError' && error.status === 500) + // mount deletion still happened before the failure surfaced + expect(client.deleteSysMounts).toHaveBeenCalledWith('zone-prod') + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('tech--zone-prod--ro') + }) + + it('reports OK plugin results on the zone events', async () => { + const zone = makeZoneWithDetails({ slug: 'prod' }) + + const upsertResult = await service.handleUpsertZone(zone) + const deleteResult = await service.handleDeleteZone(zone) + + expect(upsertResult.vault.status).toBe('OK') + expect(deleteResult.vault.status).toBe('OK') + }) + }) + + describe('project secrets cleanup', () => { + it('lists project secrets recursively across nested folders', async () => { + client.listKvMetadata.mockImplementation(async (_kvName: string, path: string) => { + if (path === 'forge/my-project') return ['SONAR', 'envs/'] + if (path === 'forge/my-project/envs') return ['dev/', 'prod/GITLAB'] + if (path === 'forge/my-project/envs/dev') return ['TOKEN'] + return [] + }) + + await expect(service.listProjectSecrets('my-project')).resolves.toEqual([ + 'SONAR', + 'envs/dev/TOKEN', + 'envs/prod/GITLAB', + ]) + }) + + it('returns [] when the project has no secrets', async () => { + client.listKvMetadata.mockResolvedValue([]) + + await expect(service.listProjectSecrets('my-project')).resolves.toEqual([]) + }) + + it('deletes each secret under the full project path', async () => { + client.listKvMetadata.mockResolvedValue(['SONAR', 'GITLAB']) + + await service.deleteProjectSecrets('my-project') + + expect(client.delete).toHaveBeenCalledWith('forge/my-project/SONAR') + expect(client.delete).toHaveBeenCalledWith('forge/my-project/GITLAB') + }) + + it('tolerates NotFound on individual secret deletes', async () => { + client.listKvMetadata.mockResolvedValue(['SONAR']) + client.delete.mockRejectedValue(new VaultError('NotFound', 'Not Found')) + + await expect(service.deleteProjectSecrets('my-project')).resolves.toBeUndefined() + }) + + it('propagates a partial batch delete failure', async () => { + client.listKvMetadata.mockResolvedValue(['SONAR', 'GITLAB']) + client.delete.mockImplementation(async (path: string) => { + if (path === 'forge/my-project/GITLAB') throw makeVaultError(500) + }) + + await expect(service.deleteProjectSecrets('my-project')).rejects.toMatchObject({ + kind: 'HttpError', + status: 500, + }) + }) + }) }) diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 5169984e42..5709dc3c6e 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -515,7 +515,7 @@ export class VaultService { span?.setAttribute('vault.secrets.count', secrets.length) const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) - await Promise.allSettled(secrets.map(async (relativePath) => { + const results = await Promise.allSettled(secrets.map(async (relativePath) => { const fullPath = `${projectPath}/${relativePath}` try { await this.client.delete(fullPath) @@ -524,6 +524,8 @@ export class VaultService { throw error } })) + const rejected = results.find(result => result.status === 'rejected') + if (rejected) throw rejected.reason } private async listRecursive(