From 269ce01a817a80888897cd676b73e4ae78fdab3c Mon Sep 17 00:00:00 2001 From: shikanime Date: Wed, 26 Aug 2026 01:13:50 +0200 Subject: [PATCH 1/2] test(server-nestjs): adversarial coverage observability plugin/environment Co-authored-by: Automata --- .../observability-client.service.spec.ts | 158 ++++++++++++++++++ .../observability-plugin.service.spec.ts | 103 ++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts diff --git a/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts new file mode 100644 index 0000000000..fc5ce74327 --- /dev/null +++ b/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts @@ -0,0 +1,158 @@ +import type { ProjectSchema } from '@gitbeaker/core' +import type { CondensedProjectSchemaWith } from '../gitlab/gitlab-client.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { ObservabilityClientService } from './observability-client.service' +import { observabilityYamlInitData } from './observability.utils' + +describe('observabilityClientService', () => { + let service: ObservabilityClientService + let gitlab: { + getOrCreateGroupByPath: ReturnType + getGroupRepos: ReturnType + createGroupRepo: ReturnType + getFile: ReturnType + generateCreateOrUpdateAction: ReturnType + maybeCreateCommit: ReturnType + } + + const repo = { id: 1, name: 'values' } as unknown as CondensedProjectSchemaWith<'id'> + + beforeEach(async () => { + gitlab = { + getOrCreateGroupByPath: vi.fn(), + getGroupRepos: vi.fn(), + createGroupRepo: vi.fn(), + getFile: vi.fn(), + generateCreateOrUpdateAction: vi.fn(), + maybeCreateCommit: vi.fn(), + } + + const moduleRef = await Test.createTestingModule({ + providers: [ + ObservabilityClientService, + { provide: GitlabClientService, useValue: gitlab }, + ], + }).compile() + + service = moduleRef.get(ObservabilityClientService) + }) + + describe('getOrCreateValuesRepo', () => { + it('returns the existing repo when the group already contains it', async () => { + gitlab.getOrCreateGroupByPath.mockResolvedValue({ id: 7 }) + gitlab.getGroupRepos.mockImplementation(async function* () { + yield { id: 9, name: 'other' } + yield { id: 12, name: 'observability' } + }) + + await expect(service.getOrCreateValuesRepo()).resolves.toMatchObject({ id: 12 }) + expect(gitlab.createGroupRepo).not.toHaveBeenCalled() + }) + + it('creates the repo when absent from the group', async () => { + gitlab.getOrCreateGroupByPath.mockResolvedValue({ id: 7 }) + gitlab.getGroupRepos.mockImplementation(async function* () {}) + gitlab.createGroupRepo.mockResolvedValue({ id: 99, name: 'observability' } as ProjectSchema) + + await expect(service.getOrCreateValuesRepo()).resolves.toMatchObject({ id: 99 }) + expect(gitlab.createGroupRepo).toHaveBeenCalledWith(7, 'observability') + }) + }) + + describe('getValuesFile', () => { + it('falls back to a fresh init payload when the file is absent', async () => { + gitlab.getFile.mockResolvedValue(undefined) + + const data = await service.getValuesFile(repo) + expect(data).toEqual({ global: { tenants: {} } }) + // must be a clone: mutating the result must not corrupt the shared init constant + data.global!.tenants!['x'] = {} + expect(observabilityYamlInitData.global.tenants).toEqual({}) + }) + + it('parses and validates base64 yaml content', async () => { + const yaml = 'global:\n projects:\n pid-1:\n projectName: p\n projectRepository:\n url: https://r\n path: .\n envs: {}\n' + gitlab.getFile.mockResolvedValue({ + content: Buffer.from(yaml).toString('base64'), + }) + + const data = await service.getValuesFile(repo) + expect(data.global?.projects?.['pid-1']).toMatchObject({ projectName: 'p' }) + }) + + it('rejects schema-invalid yaml (zod guard)', async () => { + gitlab.getFile.mockResolvedValue({ + content: Buffer.from('global: 42\n').toString('base64'), + }) + + await expect(service.getValuesFile(repo)).rejects.toThrow() + }) + }) + + describe('updateProjectConfig', () => { + it('skips the commit when the stored value is equal to the desired one (idempotent re-run)', async () => { + gitlab.getFile.mockResolvedValue({ + content: Buffer.from( + `global:\n projects:\n pid:\n projectName: p\n projectRepository:\n url: https://r\n path: .\n envs: {}\n`, + ).toString('base64'), + }) + + await service.updateProjectConfig( + repo, + { id: 'pid', slug: 'p' }, + { projectName: 'p', projectRepository: { url: 'https://r', path: '.' }, envs: {} }, + ) + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('commits when the value differs and preserves sibling projects', async () => { + gitlab.getFile.mockResolvedValue({ + content: Buffer.from( + 'global:\n projects:\n other-id:\n projectName: other\n projectRepository:\n url: https://r2\n path: .\n envs: {}\n', + ).toString('base64'), + }) + gitlab.generateCreateOrUpdateAction.mockResolvedValue({ action: 'create', content: 'YAMLCONTENT' }) + + await service.updateProjectConfig( + repo, + { id: 'new-id', slug: 'new' }, + { projectName: 'new', projectRepository: { url: 'https://r3', path: '.' }, envs: {} }, + ) + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + // the service passes the merged yaml as the content arg to generateCreateOrUpdateAction + const contentArg = vi.mocked(gitlab.generateCreateOrUpdateAction).mock.calls[0][3] as string + expect(contentArg).toContain('other-id') + expect(contentArg).toContain('new-id') + }) + }) + + describe('deleteProjectConfig', () => { + it('is a no-op when the project is not in the values file', async () => { + gitlab.getFile.mockResolvedValue({ + content: Buffer.from('global:\n tenants: {}\n').toString('base64'), + }) + + await service.deleteProjectConfig(repo, { id: 'ghost', slug: 'g', name: 'g' }) + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('removes only the target project and commits', async () => { + gitlab.getFile.mockResolvedValue({ + content: Buffer.from( + 'global:\n projects:\n keep:\n projectName: k\n projectRepository:\n url: https://r\n path: .\n envs: {}\n drop:\n projectName: d\n projectRepository:\n url: https://r\n path: .\n envs: {}\n', + ).toString('base64'), + }) + gitlab.generateCreateOrUpdateAction.mockResolvedValue({ action: 'update', content: 'YAMLCONTENT' }) + + await service.deleteProjectConfig(repo, { id: 'drop', slug: 'd', name: 'd' }) + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + const contentArg = vi.mocked(gitlab.generateCreateOrUpdateAction).mock.calls[0][3] as string + expect(contentArg).toContain('keep') + expect(contentArg).not.toContain('drop') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts new file mode 100644 index 0000000000..2d68946706 --- /dev/null +++ b/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts @@ -0,0 +1,103 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { observabilityConfigFactory } from '../../config/observability.config' +import { ObservabilityDatastoreService } from './observability-datastore.service' +import { makeProject } from './observability-testing.utils' +import { ObservabilityPluginService } from './observability-plugin.service' + +describe('observabilityPluginService', () => { + let service: ObservabilityPluginService + let datastore: DeepMockProxy + let config: DeepMockProxy> + + beforeEach(async () => { + datastore = mockDeep() + config = mockDeep>({ + grafanaUrl: 'https://grafana.test', + chartVersion: '0.1.7', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + ObservabilityPluginService, + { provide: ObservabilityDatastoreService, useValue: datastore }, + { provide: observabilityConfigFactory.KEY, useValue: config }, + ], + }).compile() + + service = moduleRef.get(ObservabilityPluginService) + }) + + it('throws when the project does not exist', async () => { + datastore.getProjectForInfos.mockResolvedValue(null) + + await expect(service.infos('missing-id')).rejects.toThrow('Project not found') + }) + + it('advertises no dashboard urls when the project has no environments', async () => { + datastore.getProjectForInfos.mockResolvedValue(makeProject({ environments: [] })) + + const infos = await service.infos('project-id') + const urls = infos.to() + expect(urls).toEqual([]) + }) + + it('exposes an hprod url only for non-prod stages', async () => { + datastore.getProjectForInfos.mockResolvedValue(makeProject({ + slug: 'myproj', + // stage names other than PROD count as hprod + environments: [{ stage: { name: 'dev' } }] as never, + })) + + const infos = await service.infos('project-id') + expect(infos.to()).toHaveLength(1) + expect(infos.to()[0]).toMatchObject({ + to: 'https://grafana.test/hprod-myproj', + description: 'Hors production', + }) + }) + + it('exposes a prod url only when a prod-stage environment exists', async () => { + datastore.getProjectForInfos.mockResolvedValue(makeProject({ + slug: 'myproj', + environments: [{ stage: { name: 'prod' } }] as never, + })) + + const infos = await service.infos('project-id') + expect(infos.to()).toHaveLength(1) + expect(infos.to()[0]).toMatchObject({ to: 'https://grafana.test/prod-myproj' }) + }) + + it('exposes both urls when both environment kinds exist', async () => { + datastore.getProjectForInfos.mockResolvedValue(makeProject({ + slug: 'full', + environments: [{ stage: { name: 'prod' } }, { stage: { name: 'hprod' } }] as never, + })) + + const infos = await service.infos('project-id') + expect(infos.to().map(u => u.description)).toEqual(['Hors production', 'Production']) + }) + + it('keeps the static plugin descriptor contract (title, image, switch config)', async () => { + datastore.getProjectForInfos.mockResolvedValue(makeProject()) + + const infos = await service.infos('project-id') + expect(infos.title).toBe('Grafana') + expect(infos.imgSrc).toBe('/img/grafana.png') + expect(infos.name).toBe('observability') + // global switch defaults to enabled, admin-writable + expect(infos.config.global[0]).toMatchObject({ + key: 'enabled', + initialValue: 'enabled', + permissions: { admin: { read: true, write: true }, user: { read: true, write: false } }, + }) + // project instances text is read-only for everyone + expect(infos.config.project[0]?.permissions).toEqual({ + admin: { read: false, write: false }, + user: { read: false, write: false }, + }) + }) +}) From 33b51fa1c547f325ea063f51c3fb0a1fc7975cd0 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 28 Aug 2026 17:03:19 +0200 Subject: [PATCH 2/2] fix(server-nestjs): use gitlab factory and faker fixtures in observability specs Signed-off-by: William Phetsinorath Change-Id: I35c94382a862ea25935947720c4a05916a6a6964 --- .../observability-client.service.spec.ts | 5 +++-- .../observability-plugin.service.spec.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts index fc5ce74327..482215e516 100644 --- a/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts @@ -3,6 +3,7 @@ import type { CondensedProjectSchemaWith } from '../gitlab/gitlab-client.service import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { makeProjectSchema } from '../gitlab/gitlab-testing.utils' import { ObservabilityClientService } from './observability-client.service' import { observabilityYamlInitData } from './observability.utils' @@ -17,7 +18,7 @@ describe('observabilityClientService', () => { maybeCreateCommit: ReturnType } - const repo = { id: 1, name: 'values' } as unknown as CondensedProjectSchemaWith<'id'> + const repo = makeProjectSchema({ name: 'values' }) as unknown as CondensedProjectSchemaWith<'id'> beforeEach(async () => { gitlab = { @@ -68,7 +69,7 @@ describe('observabilityClientService', () => { const data = await service.getValuesFile(repo) expect(data).toEqual({ global: { tenants: {} } }) // must be a clone: mutating the result must not corrupt the shared init constant - data.global!.tenants!['x'] = {} + data.global!.tenants!.x = {} expect(observabilityYamlInitData.global.tenants).toEqual({}) }) diff --git a/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts index 2d68946706..4fb42bed3e 100644 --- a/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts @@ -1,12 +1,13 @@ import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { observabilityConfigFactory } from '../../config/observability.config' import { ObservabilityDatastoreService } from './observability-datastore.service' -import { makeProject } from './observability-testing.utils' import { ObservabilityPluginService } from './observability-plugin.service' +import { makeProject } from './observability-testing.utils' describe('observabilityPluginService', () => { let service: ObservabilityPluginService @@ -49,7 +50,7 @@ describe('observabilityPluginService', () => { datastore.getProjectForInfos.mockResolvedValue(makeProject({ slug: 'myproj', // stage names other than PROD count as hprod - environments: [{ stage: { name: 'dev' } }] as never, + environments: [{ id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'dev' } }], })) const infos = await service.infos('project-id') @@ -63,7 +64,7 @@ describe('observabilityPluginService', () => { it('exposes a prod url only when a prod-stage environment exists', async () => { datastore.getProjectForInfos.mockResolvedValue(makeProject({ slug: 'myproj', - environments: [{ stage: { name: 'prod' } }] as never, + environments: [{ id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'prod' } }], })) const infos = await service.infos('project-id') @@ -74,7 +75,10 @@ describe('observabilityPluginService', () => { it('exposes both urls when both environment kinds exist', async () => { datastore.getProjectForInfos.mockResolvedValue(makeProject({ slug: 'full', - environments: [{ stage: { name: 'prod' } }, { stage: { name: 'hprod' } }] as never, + environments: [ + { id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'prod' } }, + { id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'hprod' } }, + ], })) const infos = await service.infos('project-id')