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 listOrganizationMembers = jest.fn(); const removeMemberFromOrganization = jest.fn(); const assignUserToGroup = jest.fn(); const removeUserFromGroup = jest.fn(); const getUserGroupsInOrganization = jest.fn(); const assignRealmRoleToUser = jest.fn(); const removeRealmRoleFromUser = jest.fn(); const getUserRealmRoles = jest.fn(); const addMemberToOrganization = jest.fn(); const findUserByEmail = jest.fn(); jest.mock('../../src/keycloak-admin', () => ({ listOrganizationMembers: (...args: unknown[]) => listOrganizationMembers(...args), removeMemberFromOrganization: (...args: unknown[]) => removeMemberFromOrganization(...args), assignUserToGroup: (...args: unknown[]) => assignUserToGroup(...args), removeUserFromGroup: (...args: unknown[]) => removeUserFromGroup(...args), getUserGroupsInOrganization: (...args: unknown[]) => getUserGroupsInOrganization(...args), assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args), removeRealmRoleFromUser: (...args: unknown[]) => removeRealmRoleFromUser(...args), getUserRealmRoles: (...args: unknown[]) => getUserRealmRoles(...args), addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args), findUserByEmail: (...args: unknown[]) => findUserByEmail(...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(payload: object): string { return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }); } function capoGruppoToken(orgId: string): string { return signToken({ sub: 'user-capo', email: 'capo@example.com', realm_access: { roles: ['capo-gruppo'] }, organization: { alfa: { id: orgId, roles: [] } }, }); } function nonCapoToken(orgId: string): string { return signToken({ sub: 'user-censito', email: 'censito@example.com', realm_access: { roles: ['censito'] }, organization: { alfa: { id: orgId, roles: [] } }, }); } function adminCentraleToken(): string { return signToken({ sub: 'user-admin', email: 'admin@example.com', realm_access: { roles: ['admin'] }, }); } 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/:orgId/membri', () => { test('aggiunge un utente già registrato con successo e risponde 201', async () => { findUserByEmail.mockResolvedValueOnce({ id: 'user-1' }); addMemberToOrganization.mockResolvedValueOnce(undefined); assignUserToGroup.mockResolvedValueOnce(undefined); assignRealmRoleToUser.mockResolvedValueOnce(undefined); const response = await request(app) .post('/gruppi/org-1/membri') .set('Authorization', `Bearer ${adminCentraleToken()}`) .send({ email: 'gia-registrato@example.com', ruolo: 'Capi' }); expect(response.status).toBe(201); expect(response.body).toEqual({ userId: 'user-1', email: 'gia-registrato@example.com', ruolo: 'Capi' }); expect(findUserByEmail).toHaveBeenCalledWith('gia-registrato@example.com'); expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-1'); expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Capi'); expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-1', 'Capi'); }); test("risponde 404 se non esiste un utente registrato con quell'email", async () => { findUserByEmail.mockResolvedValueOnce(null); const response = await request(app) .post('/gruppi/org-1/membri') .set('Authorization', `Bearer ${adminCentraleToken()}`) .send({ email: 'sconosciuto@example.com', ruolo: 'Capi' }); expect(response.status).toBe(404); expect(addMemberToOrganization).not.toHaveBeenCalled(); }); test('risponde 502 se una chiamata Keycloak fallisce a metà sequenza', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); findUserByEmail.mockResolvedValueOnce({ id: 'user-1' }); addMemberToOrganization.mockResolvedValueOnce(undefined); assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile')); const response = await request(app) .post('/gruppi/org-1/membri') .set('Authorization', `Bearer ${adminCentraleToken()}`) .send({ email: 'gia-registrato@example.com', ruolo: 'Capi' }); expect(response.status).toBe(502); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 2'), expect.anything()); expect(assignRealmRoleToUser).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); test('risponde 403 se chiamato da capo-gruppo, anche sulla propria organizzazione', async () => { const response = await request(app) .post('/gruppi/org-1/membri') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`) .send({ email: 'gia-registrato@example.com', ruolo: 'Capi' }); expect(response.status).toBe(403); expect(findUserByEmail).not.toHaveBeenCalled(); }); }); describe('GET /gruppi/:orgId/membri', () => { test('restituisce la lista membri con ruolo e gruppo interno', async () => { listOrganizationMembers.mockResolvedValueOnce([ { userId: 'user-1', email: 'uno@example.com' }, { userId: 'user-2', email: 'due@example.com' }, ]); getUserGroupsInOrganization.mockImplementation(async (_orgId: string, userId: string) => userId === 'user-1' ? [{ groupId: 'g-capi', nome: 'Capi' }] : [], ); getUserRealmRoles.mockImplementation(async (userId: string) => (userId === 'user-1' ? ['Capi'] : [])); const response = await request(app) .get('/gruppi/org-1/membri') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`); expect(response.status).toBe(200); expect(response.body).toEqual([ { userId: 'user-1', email: 'uno@example.com', ruolo: 'Capi', gruppoInterno: 'Capi' }, { userId: 'user-2', email: 'due@example.com', ruolo: null, gruppoInterno: null }, ]); expect(listOrganizationMembers).toHaveBeenCalledWith('org-1'); }); test("risponde 403 se l'organization non è la propria", async () => { const response = await request(app) .get('/gruppi/org-1/membri') .set('Authorization', `Bearer ${capoGruppoToken('org-2')}`); expect(response.status).toBe(403); expect(listOrganizationMembers).not.toHaveBeenCalled(); }); test('risponde 403 se manca il ruolo capo-gruppo', async () => { const response = await request(app) .get('/gruppi/org-1/membri') .set('Authorization', `Bearer ${nonCapoToken('org-1')}`); expect(response.status).toBe(403); expect(listOrganizationMembers).not.toHaveBeenCalled(); }); test("admin accede con successo anche a un'organizzazione che non è la propria", async () => { listOrganizationMembers.mockResolvedValueOnce([{ userId: 'user-1', email: 'uno@example.com' }]); getUserGroupsInOrganization.mockResolvedValueOnce([]); getUserRealmRoles.mockResolvedValueOnce([]); const response = await request(app) .get('/gruppi/org-altrui/membri') .set('Authorization', `Bearer ${adminCentraleToken()}`); expect(response.status).toBe(200); expect(listOrganizationMembers).toHaveBeenCalledWith('org-altrui'); }); }); describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => { test('rimuove gruppo e ruolo correnti e assegna quelli nuovi', async () => { getUserGroupsInOrganization.mockResolvedValueOnce([{ groupId: 'g-capi', nome: 'Capi' }]); getUserRealmRoles.mockResolvedValueOnce(['Capi']); const response = await request(app) .put('/gruppi/org-1/membri/user-1/ruolo') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`) .send({ ruolo: 'Aiuto capi' }); expect(response.status).toBe(200); expect(response.body).toEqual({ userId: 'user-1', ruolo: 'Aiuto capi' }); expect(removeUserFromGroup).toHaveBeenCalledWith('user-1', 'g-capi'); expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Aiuto capi'); expect(removeRealmRoleFromUser).toHaveBeenCalledWith('user-1', 'Capi'); expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-1', 'Aiuto capi'); }); test("risponde 403 se l'organization non è la propria", async () => { const response = await request(app) .put('/gruppi/org-1/membri/user-1/ruolo') .set('Authorization', `Bearer ${capoGruppoToken('org-2')}`) .send({ ruolo: 'Aiuto capi' }); expect(response.status).toBe(403); expect(assignUserToGroup).not.toHaveBeenCalled(); }); test("risponde 400 se manca il campo 'ruolo'", async () => { const response = await request(app) .put('/gruppi/org-1/membri/user-1/ruolo') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`) .send({}); expect(response.status).toBe(400); expect(assignUserToGroup).not.toHaveBeenCalled(); }); test('risponde 502 se una chiamata Keycloak fallisce', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); getUserGroupsInOrganization.mockResolvedValueOnce([]); getUserRealmRoles.mockResolvedValueOnce([]); assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile')); const response = await request(app) .put('/gruppi/org-1/membri/user-1/ruolo') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`) .send({ ruolo: 'Aiuto capi' }); expect(response.status).toBe(502); expect(consoleErrorSpy).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); test("admin può cambiare ruolo anche in un'organizzazione che non è la propria", async () => { getUserGroupsInOrganization.mockResolvedValueOnce([]); getUserRealmRoles.mockResolvedValueOnce([]); const response = await request(app) .put('/gruppi/org-altrui/membri/user-1/ruolo') .set('Authorization', `Bearer ${adminCentraleToken()}`) .send({ ruolo: 'Aiuto capi' }); expect(response.status).toBe(200); expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Aiuto capi'); }); }); describe('DELETE /gruppi/:orgId/membri/:userId', () => { test("rimuove il membro dall'organization e risponde 204", async () => { removeMemberFromOrganization.mockResolvedValueOnce(undefined); const response = await request(app) .delete('/gruppi/org-1/membri/user-1') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`); expect(response.status).toBe(204); expect(removeMemberFromOrganization).toHaveBeenCalledWith('org-1', 'user-1'); }); test("risponde 403 se l'organization non è la propria", async () => { const response = await request(app) .delete('/gruppi/org-1/membri/user-1') .set('Authorization', `Bearer ${capoGruppoToken('org-2')}`); expect(response.status).toBe(403); expect(removeMemberFromOrganization).not.toHaveBeenCalled(); }); test('risponde 502 se Keycloak fallisce nel rimuovere il membro', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); removeMemberFromOrganization.mockRejectedValueOnce(new Error('Keycloak non raggiungibile')); const response = await request(app) .delete('/gruppi/org-1/membri/user-1') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`); expect(response.status).toBe(502); expect(consoleErrorSpy).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); test("admin può rimuovere un membro anche in un'organizzazione che non è la propria", async () => { removeMemberFromOrganization.mockResolvedValueOnce(undefined); const response = await request(app) .delete('/gruppi/org-altrui/membri/user-1') .set('Authorization', `Bearer ${adminCentraleToken()}`); expect(response.status).toBe(204); expect(removeMemberFromOrganization).toHaveBeenCalledWith('org-altrui', 'user-1'); }); });