import { generateKeyPairSync } from 'crypto'; import request from 'supertest'; import nock from 'nock'; import jwt from 'jsonwebtoken'; process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test'; process.env.KEYCLOAK_REALM = 'scouthub'; process.env.KEYCLOAK_ORG_SERVICE_CLIENT_ID = 'test-client'; process.env.KEYCLOAK_ORG_SERVICE_CLIENT_SECRET = 'test-secret'; process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_home_test'; process.env.FRONTEND_BASE_URL = 'http://localhost:4200'; const createOrganization = jest.fn(); const createOrganizationGroup = jest.fn(); const addMemberToOrganization = jest.fn(); const gruppoScoutCreate = jest.fn(); jest.mock('../../src/keycloak-admin', () => ({ createOrganization: (...args: unknown[]) => createOrganization(...args), createOrganizationGroup: (...args: unknown[]) => createOrganizationGroup(...args), addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args), })); jest.mock('../../src/db/prisma', () => ({ prisma: { gruppoScout: { create: (...args: unknown[]) => gruppoScoutCreate(...args), }, }, })); import { app } from '../../src/app'; const KEYCLOAK_HOST = 'http://keycloak.test'; const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs'; const KID = 'test-kid'; const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const jwk = publicKey.export({ format: 'jwk' }) as Record; const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string; function signToken(roles: string[]): string { return jwt.sign( { sub: 'user-1', realm_access: { roles } }, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }, ); } const adminToken = () => signToken(['admin-centrale']); const nonAdminToken = () => signToken(['capo-gruppo']); function conflictError(): Error { return Object.assign(new Error('Conflict'), { isAxiosError: true, response: { status: 409 } }); } beforeAll(() => { nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, { keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }], }); }); afterAll(() => { nock.cleanAll(); }); beforeEach(() => { jest.clearAllMocks(); }); describe('POST /gruppi', () => { test('crea organizzazione, gruppi ruolo, membership e riga locale, rispondendo 201', async () => { createOrganization.mockResolvedValueOnce({ orgId: 'org-1' }); createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' }); addMemberToOrganization.mockResolvedValueOnce(undefined); gruppoScoutCreate.mockResolvedValueOnce({}); const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${adminToken()}`) .send({ nome: 'Gruppo Alfa', regione: 'Lombardia', ruoliDefault: ['Capi', 'Rover'] }); expect(response.status).toBe(201); expect(response.body).toEqual({ orgId: 'org-1', gruppiCreati: ['Capi', 'Rover'] }); expect(createOrganization).toHaveBeenCalledWith('Gruppo Alfa'); expect(createOrganizationGroup).toHaveBeenCalledTimes(2); expect(createOrganizationGroup).toHaveBeenNthCalledWith(1, 'org-1', 'Capi'); expect(createOrganizationGroup).toHaveBeenNthCalledWith(2, 'org-1', 'Rover'); expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-1'); expect(gruppoScoutCreate).toHaveBeenCalledWith({ data: { orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' }, }); }); test('usa i ruoli di default quando ruoliDefault non è specificato', async () => { createOrganization.mockResolvedValueOnce({ orgId: 'org-2' }); createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' }); addMemberToOrganization.mockResolvedValueOnce(undefined); gruppoScoutCreate.mockResolvedValueOnce({}); const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${adminToken()}`) .send({ nome: 'Gruppo Beta' }); expect(response.status).toBe(201); expect(response.body.gruppiCreati).toEqual(['Capi', 'Aiuto capi', 'Censiti']); expect(createOrganizationGroup).toHaveBeenCalledTimes(3); expect(addMemberToOrganization).toHaveBeenCalledWith('org-2', 'user-1'); }); test('risponde 409 se Keycloak segnala un nome duplicato, senza creare la riga locale', async () => { createOrganization.mockRejectedValueOnce(conflictError()); const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${adminToken()}`) .send({ nome: 'Gruppo Già Esistente' }); expect(response.status).toBe(409); expect(response.body.message).toMatch(/Gruppo Già Esistente/); expect(createOrganizationGroup).not.toHaveBeenCalled(); expect(gruppoScoutCreate).not.toHaveBeenCalled(); }); test('se la creazione di un gruppo ruolo fallisce a metà, non salva la riga locale e logga il punto di arresto', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); createOrganization.mockResolvedValueOnce({ orgId: 'org-3' }); createOrganizationGroup .mockResolvedValueOnce({ groupId: 'g-capi' }) .mockRejectedValueOnce(new Error('Keycloak non raggiungibile')); const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${adminToken()}`) .send({ nome: 'Gruppo Gamma', ruoliDefault: ['Capi', 'Aiuto capi'] }); expect(response.status).toBe(502); expect(gruppoScoutCreate).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 2'), expect.anything()); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('org-3'), expect.anything()); consoleErrorSpy.mockRestore(); }); test("se l'aggiunta del creatore all'organizzazione fallisce, non salva la riga locale e logga il punto di arresto", async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); createOrganization.mockResolvedValueOnce({ orgId: 'org-4' }); createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' }); addMemberToOrganization.mockRejectedValueOnce(new Error('Keycloak non raggiungibile')); const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${adminToken()}`) .send({ nome: 'Gruppo Delta due' }); expect(response.status).toBe(502); expect(gruppoScoutCreate).not.toHaveBeenCalled(); expect(addMemberToOrganization).toHaveBeenCalledWith('org-4', 'user-1'); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 3'), expect.anything()); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('org-4'), expect.anything()); consoleErrorSpy.mockRestore(); }); test('risponde 403 se il ruolo non è admin-centrale', async () => { const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${nonAdminToken()}`) .send({ nome: 'Gruppo Delta' }); expect(response.status).toBe(403); expect(createOrganization).not.toHaveBeenCalled(); }); test('risponde 401 senza token', async () => { const response = await request(app).post('/gruppi').send({ nome: 'Gruppo Epsilon' }); expect(response.status).toBe(401); expect(createOrganization).not.toHaveBeenCalled(); }); test("risponde 400 se manca il campo 'nome'", async () => { const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${adminToken()}`) .send({ regione: 'Piemonte' }); expect(response.status).toBe(400); expect(createOrganization).not.toHaveBeenCalled(); }); });