Files
2026-07-25 12:09:12 +02:00

324 lines
13 KiB
TypeScript

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<string, unknown>;
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');
});
});