From 5e1c7600eda26b59c24a2d38e2521012d26d5c34 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 7 Sep 2026 17:21:29 +0200 Subject: [PATCH] fix(observability): restore legacy dual-bucket Grafana RBAC sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getListPerms filled only one stage bucket pair (prod if any prod env exists, else hors-prod), where the legacy plugin filled both. Since reconcileGroupMembership removes members absent from the desired list, the first project.upsert under server-nestjs wiped every membership of the opposite pair — and Grafana maps org roles from exactly those Keycloak groups, so users deterministically lost access. Restore per-stage bucketing: a user lands in prod when a prod env exists AND in hors-prod when a real (named) non-prod stage exists (RBAC doc §3: 'Les deux peuvent coexister'). Fix the spec assertions that codified the single-bucket contract and add a reconcile regression guard. Extends the observability service spec with an E2E-gated suite that drives the production event chain (event bus -> AppEventsService -> ObservabilityService -> getListPerms -> real Keycloak from the dev stack) and asserts final group membership against real Postgres + Keycloak, regression-locking the dual-bucket fill end to end. Refs #2686 Signed-off-by: William Phetsinorath Change-Id: Ic8c8e2f352d164737f7a8ea3635e3d1b6a6a6964 --- .../observability.service.spec.ts | 45 +- .../observability/observability.utils.spec.ts | 40 +- .../observability/observability.utils.ts | 24 +- .../test/observability.e2e-spec.ts | 464 ++++++++++++++++++ 4 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 apps/server-nestjs/test/observability.e2e-spec.ts diff --git a/apps/server-nestjs/src/modules/observability/observability.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability.service.spec.ts index f5de0e54fb..ae08b730bf 100644 --- a/apps/server-nestjs/src/modules/observability/observability.service.spec.ts +++ b/apps/server-nestjs/src/modules/observability/observability.service.spec.ts @@ -1,6 +1,6 @@ import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' -import { DISABLED } from '@cpn-console/shared' +import { DISABLED, PROJECT_PERMS } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' @@ -13,6 +13,9 @@ import { makeProject } from './observability-testing.utils' import { ENABLED_PLUGIN_KEY } from './observability.constants' import { ObservabilityService } from './observability.service' +const PROD_STAGE = { name: 'prod' } as const +const HPROD_STAGE = { name: 'hprod' } as const + describe('observabilityService', () => { let service: ObservabilityService let datastore: DeepMockProxy @@ -106,4 +109,44 @@ describe('observabilityService', () => { expect(client.deleteProjectConfig).not.toHaveBeenCalled() }) }) + + describe('syncKeycloakGroups', () => { + it('does not remove existing members from either pair when both stages exist (regression: grafana access loss)', async () => { + const project = makeProject({ + ownerId: 'owner-1', + members: [{ + roleIds: ['role-ro'], + user: { id: 'user-ro', email: 'ro@test.com' }, + }], + roles: [{ id: 'role-ro', permissions: PROJECT_PERMS.LIST_ENVIRONMENTS, oidcGroup: '', type: 'managed' }], + environments: [ + { id: 'env-prod', name: 'prod', stage: PROD_STAGE }, + { id: 'env-hprod', name: 'dev', stage: HPROD_STAGE }, + ], + }) + + const pairs: Record = { + 'hprod-RW': { id: 'g-hprod-rw', name: 'hprod-RW', path: '/test-project/grafana/hprod-RW', members: [{ id: 'owner-1' }] }, + 'hprod-RO': { id: 'g-hprod-ro', name: 'hprod-RO', path: '/test-project/grafana/hprod-RO', members: [{ id: 'owner-1' }, { id: 'user-ro' }] }, + 'prod-RW': { id: 'g-prod-rw', name: 'prod-RW', path: '/test-project/grafana/prod-RW', members: [{ id: 'owner-1' }] }, + 'prod-RO': { id: 'g-prod-ro', name: 'prod-RO', path: '/test-project/grafana/prod-RO', members: [{ id: 'owner-1' }, { id: 'user-ro' }] }, + } + keycloak.getSubGroups.mockImplementation((parentId: string) => + (async function* () { + if (parentId === 'group-1') yield { id: 'g-grafana', name: 'grafana' } + })(), + ) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId: string, name: string) => + Promise.resolve(pairs[name] ?? { id: `sub-${name}`, name, path: `/test-project/grafana/${name}` }), + ) + keycloak.getGroupMembers.mockImplementation((groupId: string) => + Promise.resolve(Object.values(pairs).find(g => g.id === groupId)?.members ?? []), + ) + + await service.handleUpsert(project) + + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + expect(keycloak.addUserToGroup).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts b/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts index bf9f38e7eb..68ebb49169 100644 --- a/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts +++ b/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts @@ -26,7 +26,7 @@ describe('getListPerms', () => { expect(perms.prod.view).toContain('owner-1') }) - it('assigns to prod bucket when a prod environment exists', () => { + it('fills the prod bucket when only a prod environment exists', () => { const project = makeProject({ ownerId: 'owner-1', environments: [{ id: 'env-1', name: 'prod-env', stage: PROD_STAGE }], @@ -36,7 +36,7 @@ describe('getListPerms', () => { expect(perms['hors-prod'].edit).not.toContain('owner-1') }) - it('assigns to hors-prod bucket when no prod environment', () => { + it('fills the hors-prod bucket when only non-prod environments exist', () => { const project = makeProject({ ownerId: 'owner-1', environments: [{ id: 'env-1', name: 'dev-env', stage: HPROD_STAGE }], @@ -46,6 +46,42 @@ describe('getListPerms', () => { expect(perms.prod.edit).not.toContain('owner-1') }) + it('assigns to BOTH buckets when both stages exist (legacy parity)', () => { + const project = makeProject({ + ownerId: 'owner-1', + environments: [ + { id: 'env-prod', name: 'prod', stage: PROD_STAGE }, + { id: 'env-hprod', name: 'hprod', stage: HPROD_STAGE }, + ], + }) + const perms = getListPerms(project) + expect(perms.prod.edit).toContain('owner-1') + expect(perms['hors-prod'].edit).toContain('owner-1') + expect(perms.prod.view).toContain('owner-1') + expect(perms['hors-prod'].view).toContain('owner-1') + }) + + it('assigns a RO member to BOTH buckets when both stages exist (legacy parity)', () => { + const roRoleId = 'role-ro' + const project = makeProject({ + ownerId: 'owner-1', + members: [{ + roleIds: [roRoleId], + user: { id: 'user-ro', email: 'ro@test.com' }, + }], + roles: [{ id: roRoleId, permissions: PROJECT_PERMS.LIST_ENVIRONMENTS, oidcGroup: '', type: 'managed' }], + environments: [ + { id: 'env-prod', name: 'prod', stage: PROD_STAGE }, + { id: 'env-hprod', name: 'hprod', stage: HPROD_STAGE }, + ], + }) + const perms = getListPerms(project) + expect(perms.prod.view).toContain('user-ro') + expect(perms['hors-prod'].view).toContain('user-ro') + expect(perms.prod.edit).not.toContain('user-ro') + expect(perms['hors-prod'].edit).not.toContain('user-ro') + }) + it('grants ro but not rw to a member with only LIST_ENVIRONMENTS', () => { const roRoleId = 'role-ro' const project = makeProject({ diff --git a/apps/server-nestjs/src/modules/observability/observability.utils.ts b/apps/server-nestjs/src/modules/observability/observability.utils.ts index 14f62df357..bc2bd2fa31 100644 --- a/apps/server-nestjs/src/modules/observability/observability.utils.ts +++ b/apps/server-nestjs/src/modules/observability/observability.utils.ts @@ -73,17 +73,31 @@ export function getListPerms(project: ProjectWithDetails): ListPerms { prod: { edit: [], view: [] }, } + const hasProd = project.environments.some(e => isProdStage(e.stage)) + const hasHprod = project.environments.some(e => e.stage?.name != null && !isProdStage(e.stage)) for (const userId of projectUserIds) { - const { ro, rw } = resolveUserPerms(project, rolesById, userId) - const hasProd = project.environments.some(e => isProdStage(e.stage)) - const bucket = hasProd ? perms.prod : perms['hors-prod'] - if (rw && !bucket.edit.includes(userId)) bucket.edit.push(userId) - if (ro && !bucket.view.includes(userId)) bucket.view.push(userId) + const userPerms = resolveUserPerms(project, rolesById, userId) + // Legacy parity (console-plugin-observability): the permission roster goes + // to EVERY stage bucket the project exposes — prod if a prod environment + // exists AND hors-prod if a non-prod one does. Reconcile removes anyone + // absent from the desired list, so a single-bucket fill would wipe the + // other pair's Keycloak memberships on the first sync. + if (hasProd) addUserToBucket(perms.prod, userId, userPerms) + if (hasHprod) addUserToBucket(perms['hors-prod'], userId, userPerms) } return perms } +function addUserToBucket( + bucket: ListPerms['prod'], + userId: string, + { ro, rw }: { ro: boolean, rw: boolean }, +): void { + if (rw && !bucket.edit.includes(userId)) bucket.edit.push(userId) + if (ro && !bucket.view.includes(userId)) bucket.view.push(userId) +} + function resolveUserPerms( project: ProjectWithDetails, rolesById: Record, diff --git a/apps/server-nestjs/test/observability.e2e-spec.ts b/apps/server-nestjs/test/observability.e2e-spec.ts new file mode 100644 index 0000000000..4d79f6ee85 --- /dev/null +++ b/apps/server-nestjs/test/observability.e2e-spec.ts @@ -0,0 +1,464 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { DISABLED, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ObservabilityClientService } from '../src/modules/observability/observability-client.service' +import { projectSelect } from '../src/modules/observability/observability-datastore.service' +import { + GRAFANA_GROUP_NAME, + GRAFANA_SUBGROUP_HPROD_RO, + GRAFANA_SUBGROUP_HPROD_RW, + GRAFANA_SUBGROUP_PROD_RO, + GRAFANA_SUBGROUP_PROD_RW, +} from '../src/modules/observability/observability.constants' +import { ObservabilityModule } from '../src/modules/observability/observability.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { KEYCLOAK_GROUP_SYNC_TIMEOUT } from './constants' + +const canRunObservabilityE2E + = Boolean(process.env.E2E) + +const describeWithObservability = describe.runIf(canRunObservabilityE2E) + +const ALL_GRAFANA_SUBGROUPS = [ + GRAFANA_SUBGROUP_PROD_RW, + GRAFANA_SUBGROUP_PROD_RO, + GRAFANA_SUBGROUP_HPROD_RW, + GRAFANA_SUBGROUP_HPROD_RO, +] as const + +describeWithObservability('ObservabilityService (e2e)', () => { + let moduleRef: TestingModule + let eventEmitter: EventEmitter2 + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + let prisma: PrismaService + + let ownerId: string + let memberRoId: string + let memberRwId: string + let roleRoId: string + let roleRwId: string + let testProjectId: string + let testProjectSlug: string + let envProdId: string + let zoneId: string + let kubeconfigId: string + let clusterId: string + + function grafanaSubgroupPath(subgroupName: string): string { + return `/${testProjectSlug}/${GRAFANA_GROUP_NAME}/${subgroupName}` + } + + async function subgroupMembers(subgroupName: string): Promise { + const subgroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(grafanaSubgroupPath(subgroupName))) + const members = await keycloak.getGroupMembers(subgroup.id) + return members.map(member => member.id).filter((id): id is string => Boolean(id)).sort((a, b) => a.localeCompare(b)) + } + + beforeAll(async () => { + // GitLab (chart/values plumbing) is overridden: the dev compose ships no GitLab + // service, and the dual-bucket regression under test lives entirely in the + // Keycloak group sync. Keycloak and Postgres stay real. + const gitlabStub = { + upsertProjectGroupSystemRepo: vi.fn(async (slug: string) => ({ id: 42, path: slug })), + generateCreateOrUpdateAction: vi.fn(async () => null), + maybeCreateCommit: vi.fn(async () => undefined), + getOrCreateProjectGroupPublicUrl: vi.fn(async () => 'https://gitlab.example.com'), + getOrCreateProjectSubGroup: vi.fn(async (path: string) => ({ id: 43, path })), + getGroupByPath: vi.fn(async (path: string) => ({ id: 44, path })), + getOrCreateInfraGroupRepo: vi.fn(async (slug: string) => ({ id: 45, path: slug })), + } + + moduleRef = await Test.createTestingModule({ + imports: [ObservabilityModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }) + .overrideProvider(GitlabClientService) + .useValue(gitlabStub) + .overrideProvider(ObservabilityClientService) + .useValue({ + getOrCreateValuesRepo: vi.fn(async () => ({ id: 1 })), + updateProjectConfig: vi.fn(async () => 'updated'), + deleteProjectConfig: vi.fn(async () => undefined), + }) + .compile() + + await moduleRef.init() + + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + prisma = moduleRef.get(PrismaService) + eventEmitter = moduleRef.get(EventEmitter2) + + memberRoId = faker.string.uuid() + memberRwId = faker.string.uuid() + roleRoId = faker.string.uuid() + roleRwId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + envProdId = faker.string.uuid() + + // The Keycloak plugin (real, via KeycloakModule) creates the project root + // group on the first upsert; seed it here so the sync under test never + // races its creation order. + await keycloakAdminClient.groups.create({ name: testProjectSlug }) + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create users in Keycloak (they must exist in the realm to receive grafana/* memberships) + const owner = await keycloakAdminClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + ownerId = owner.id ?? ownerId + + const roUser = await keycloakAdminClient.users.create({ + username: `test-user-ro-${memberRoId}`, + email: faker.internet.email({ firstName: 'test-ro', provider: 'example.com' }), + enabled: true, + firstName: 'Test', + lastName: 'ReadOnly', + }) + memberRoId = roUser.id + + const rwUser = await keycloakAdminClient.users.create({ + username: `test-user-rw-${memberRwId}`, + email: faker.internet.email({ firstName: 'test-rw', provider: 'example.com' }), + enabled: true, + firstName: 'Test', + lastName: 'ReadWrite', + }) + memberRwId = rwUser.id + + // Create users in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + await prisma.user.create({ + data: { + id: memberRoId, + email: faker.internet.email({ firstName: 'test-ro', provider: 'example.com' }), + firstName: 'Test', + lastName: 'ReadOnly', + type: 'human', + }, + }) + await prisma.user.create({ + data: { + id: memberRwId, + email: faker.internet.email({ firstName: 'test-rw', provider: 'example.com' }), + firstName: 'Test', + lastName: 'ReadWrite', + type: 'human', + }, + }) + + // Shared stages are seeded by migrations (dev, staging, integration, prod); + // hors-prod is any stage not named prod. + const prodStage = await prisma.stage.findUniqueOrThrow({ where: { name: 'prod' } }) + const devStage = await prisma.stage.findUniqueOrThrow({ where: { name: 'dev' } }) + + zoneId = faker.string.uuid() + kubeconfigId = faker.string.uuid() + clusterId = faker.string.uuid() + const zoneSlug = faker.string.alphanumeric({ length: 10 }).toLowerCase() + + await prisma.zone.create({ + data: { + id: zoneId, + slug: zoneSlug, + label: `Zone ${zoneSlug}`, + argocdUrl: 'https://example.com', + }, + }) + + await prisma.kubeconfig.create({ + data: { + id: kubeconfigId, + user: { token: faker.string.alphanumeric({ length: 16 }) }, + cluster: { server: 'https://example.com' }, + }, + }) + + const cluster = await prisma.cluster.create({ + data: { + id: clusterId, + label: faker.helpers.slugify(`cluster-${faker.string.uuid()}`.slice(0, 40)), + secretName: faker.string.uuid(), + kubeConfigId: kubeconfigId, + infos: null, + memory: 100, + cpu: 100, + gpu: 0, + zoneId, + }, + }) + + envProdId = faker.string.uuid() + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: [ + { + id: roleRoId, + name: faker.helpers.slugify(`test-role-ro-${faker.string.uuid()}`), + oidcGroup: `/${testProjectSlug}/role-ro`, + permissions: BigInt(PROJECT_PERMS.LIST_ENVIRONMENTS), + position: 0, + }, + { + id: roleRwId, + name: faker.helpers.slugify(`test-role-rw-${faker.string.uuid()}`), + oidcGroup: `/${testProjectSlug}/role-rw`, + permissions: BigInt(PROJECT_PERMS.MANAGE_ENVIRONMENTS), + position: 1, + }, + ], + }, + members: { + create: [ + { userId: memberRoId, roleIds: [roleRoId] }, + { userId: memberRwId, roleIds: [roleRwId] }, + ], + }, + environments: { + create: [ + { + id: envProdId, + name: 'prod', + stageId: prodStage.id, + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + }, + { + name: 'dev', + stageId: devStage.id, + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + }, + ], + }, + plugins: { + create: { + pluginName: 'observability', + key: 'enabled', + value: 'true', + }, + }, + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const projectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + if (projectGroup?.id) { + await keycloak.deleteGroup(projectGroup.id) + } + + for (const userId of [ownerId, memberRoId, memberRwId]) { + await keycloakAdminClient.users.del({ id: userId }).catch(() => {}) + } + + // Clean DB. Every delete is guarded by its concrete id: an unassigned + // id here means beforeAll failed mid-seed, and Prisma treats + // `undefined` in a unique filter as "match all rows". + if (prisma) { + if (testProjectId) { + // ProjectRole and Repository rows carry non-cascading FKs to Project: + // delete them explicitly or the project delete fails silently. + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.projectPlugin.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.projectRole.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + if (clusterId) { + await prisma.cluster.deleteMany({ where: { id: clusterId } }).catch(() => {}) + } + if (kubeconfigId) { + await prisma.kubeconfig.deleteMany({ where: { id: kubeconfigId } }).catch(() => {}) + } + if (zoneId) { + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + } + const userIds = [ownerId, memberRoId, memberRwId].filter((id): id is string => Boolean(id)) + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }).catch(() => {}) + } + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create grafana groups and populate both stage buckets on project.upsert', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await eventEmitter.emitAsync('project.upsert', project) + + const expectedEdit = [ownerId, memberRwId].sort((a, b) => a.localeCompare(b)) + const expectedView = [ownerId, memberRoId, memberRwId].sort((a, b) => a.localeCompare(b)) + + for (const subgroupName of ALL_GRAFANA_SUBGROUPS) { + const subgroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(grafanaSubgroupPath(subgroupName))) + expect(subgroup.name).toBe(subgroupName) + } + + expect(await subgroupMembers(GRAFANA_SUBGROUP_PROD_RW)).toEqual(expectedEdit) + expect(await subgroupMembers(GRAFANA_SUBGROUP_PROD_RO)).toEqual(expectedView) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RW)).toEqual(expectedEdit) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RO)).toEqual(expectedView) + }, KEYCLOAK_GROUP_SYNC_TIMEOUT) + + it('should keep hors-prod memberships when the prod environment is removed', async () => { + // Regression: the first server-nestjs sync wiped the opposite stage pair + await prisma.environment.deleteMany({ where: { id: envProdId } }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await eventEmitter.emitAsync('project.upsert', project) + + const expectedEdit = [ownerId, memberRwId].sort((a, b) => a.localeCompare(b)) + const expectedView = [ownerId, memberRoId, memberRwId].sort((a, b) => a.localeCompare(b)) + + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RW)).toEqual(expectedEdit) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RO)).toEqual(expectedView) + + // prod buckets converge to empty: no prod environment remains + expect(await subgroupMembers(GRAFANA_SUBGROUP_PROD_RW)).toEqual([]) + expect(await subgroupMembers(GRAFANA_SUBGROUP_PROD_RO)).toEqual([]) + }, KEYCLOAK_GROUP_SYNC_TIMEOUT) + + it('should remove member from all grafana groups when removed from the project', async () => { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId, userId: memberRwId } }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await eventEmitter.emitAsync('project.upsert', project) + + // Post-removal edit roster is the owner alone; view keeps the RO member + const expectedEdit = [ownerId].sort((a, b) => a.localeCompare(b)) + const expectedView = [ownerId, memberRoId].sort((a, b) => a.localeCompare(b)) + + expect(await subgroupMembers(GRAFANA_SUBGROUP_PROD_RW)).toEqual([]) + expect(await subgroupMembers(GRAFANA_SUBGROUP_PROD_RO)).toEqual([]) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RW)).toEqual(expectedEdit) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RO)).toEqual(expectedView) + }, KEYCLOAK_GROUP_SYNC_TIMEOUT) + + it('should not touch Keycloak groups when the plugin is disabled', async () => { + await prisma.projectPlugin.updateMany({ + where: { projectId: testProjectId, pluginName: 'observability', key: 'enabled' }, + data: { value: DISABLED }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await eventEmitter.emitAsync('project.upsert', project) + + // Membership state from the previous sync must be untouched + const expectedEdit = [ownerId].sort((a, b) => a.localeCompare(b)) + const expectedView = [ownerId, memberRoId].sort((a, b) => a.localeCompare(b)) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RW)).toEqual(expectedEdit) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RO)).toEqual(expectedView) + }, KEYCLOAK_GROUP_SYNC_TIMEOUT) + + it('should recreate grafana groups if deleted in Keycloak', async () => { + const grafanaGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/${GRAFANA_GROUP_NAME}`)) + await keycloak.deleteGroup(grafanaGroup.id) + + await prisma.projectPlugin.updateMany({ + where: { projectId: testProjectId, pluginName: 'observability', key: 'enabled' }, + data: { value: 'true' }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await eventEmitter.emitAsync('project.upsert', project) + + const expectedEdit = [ownerId].sort((a, b) => a.localeCompare(b)) + const expectedView = [ownerId, memberRoId].sort((a, b) => a.localeCompare(b)) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RW)).toEqual(expectedEdit) + expect(await subgroupMembers(GRAFANA_SUBGROUP_HPROD_RO)).toEqual(expectedView) + }, KEYCLOAK_GROUP_SYNC_TIMEOUT) + + it('should delete grafana groups on project.delete', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await eventEmitter.emitAsync('project.delete', project) + + for (const subgroupName of ALL_GRAFANA_SUBGROUPS) { + expect(await keycloak.getGroupByPath(grafanaSubgroupPath(subgroupName))).toBeUndefined() + } + + // The Keycloak plugin removes the project root group on project.delete + expect(await keycloak.getGroupByPath(`/${testProjectSlug}`)).toBeUndefined() + }, KEYCLOAK_GROUP_SYNC_TIMEOUT) +})