Files
2026-07-25 11:59:25 +02:00

472 lines
17 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_EVENTI_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_EVENTI_CLIENT_SECRET = 'test-secret';
process.env.KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS = 'test-service-client';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_eventi_test';
const eventoFindFirst = jest.fn();
const eventoFindMany = jest.fn();
const eventoFindUnique = jest.fn();
const eventoCreate = jest.fn();
const eventoUpdate = jest.fn();
const eventoDelete = jest.fn();
const risorsaCollegataCreate = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
evento: {
findFirst: (...args: unknown[]) => eventoFindFirst(...args),
findMany: (...args: unknown[]) => eventoFindMany(...args),
findUnique: (...args: unknown[]) => eventoFindUnique(...args),
create: (...args: unknown[]) => eventoCreate(...args),
update: (...args: unknown[]) => eventoUpdate(...args),
delete: (...args: unknown[]) => eventoDelete(...args),
},
risorsaCollegata: {
create: (...args: unknown[]) => risorsaCollegataCreate(...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 tokenFor(orgId: string, branche: string[], roles: string[] = []): string {
return signToken({
sub: 'user-1',
realm_access: { roles },
organization: { gruppo: { id: orgId, roles: [] } },
groups: branche.map((b) => `/${b}`),
});
}
// Token client-credentials (client di servizio): niente organization/groups, solo
// "azp" (client_id del chiamante), come emesso da Keycloak per un service account.
function serviceTokenFor(azp: string): string {
return signToken({ sub: `service-account-${azp}`, azp });
}
function evento(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'ev-1',
orgId: 'org-a',
brancaId: 'Lupetti',
parentId: null,
titolo: 'Uscita',
descrizione: null,
dataInizio: new Date('2026-08-01'),
dataFine: new Date('2026-08-10'),
tipo: 'campo',
location: null,
creatoDa: 'user-1',
creatoIl: new Date('2026-01-01'),
children: [] as unknown[],
risorseCollegate: [] as unknown[],
...overrides,
};
}
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 /eventi — profondità massima (max 2 livelli)', () => {
test('rifiuta la creazione di un nipote (3° livello) con 400', async () => {
// Il "parent" indicato in request ha a sua volta un parentId: è già un
// figlio di 2° livello, quindi crearvi sotto un evento sarebbe un 3° livello.
eventoFindFirst.mockResolvedValueOnce(evento({ id: 'ev-figlio', parentId: 'ev-nonno' }));
const response = await request(app)
.post('/eventi')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
.send({
titolo: 'Nipote',
dataInizio: '2026-08-02',
dataFine: '2026-08-03',
tipo: 'uscita',
parentId: 'ev-figlio',
});
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/profondità massima superata/);
expect(eventoCreate).not.toHaveBeenCalled();
});
});
describe('POST /eventi — contenimento date nel parent', () => {
test('rifiuta un figlio con date fuori dall\'intervallo del parent', async () => {
eventoFindFirst.mockResolvedValueOnce(
evento({ id: 'ev-parent', parentId: null, dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-10') }),
);
const response = await request(app)
.post('/eventi')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
.send({
titolo: 'Figlio fuori data',
dataInizio: '2026-07-30',
dataFine: '2026-08-05',
tipo: 'uscita',
parentId: 'ev-parent',
});
expect(response.status).toBe(400);
expect(eventoCreate).not.toHaveBeenCalled();
});
test('accetta un figlio con date contenute nell\'intervallo del parent', async () => {
const parent = evento({ id: 'ev-parent', parentId: null, dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-10') });
eventoFindFirst.mockResolvedValueOnce(parent);
eventoCreate.mockResolvedValueOnce(
evento({ id: 'ev-figlio', parentId: 'ev-parent', dataInizio: new Date('2026-08-02'), dataFine: new Date('2026-08-03') }),
);
const response = await request(app)
.post('/eventi')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
.send({
titolo: 'Figlio ok',
dataInizio: '2026-08-02',
dataFine: '2026-08-03',
tipo: 'uscita',
parentId: 'ev-parent',
});
expect(response.status).toBe(201);
expect(eventoCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ orgId: 'org-a', brancaId: 'Lupetti', parentId: 'ev-parent' }),
}),
);
});
});
describe('PUT /eventi/:id — isolamento branca', () => {
test("un capo-unità di un'altra branca non può modificare l'evento (403)", async () => {
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Esploratori' }));
const response = await request(app)
.put('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'], ['capo-unita'])}`)
.send({ titolo: 'Nuovo titolo' });
expect(response.status).toBe(403);
expect(eventoUpdate).not.toHaveBeenCalled();
});
test('capo-gruppo può modificare un evento di qualunque branca della propria org', async () => {
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Esploratori' }));
eventoUpdate.mockResolvedValueOnce(evento({ brancaId: 'Esploratori', titolo: 'Nuovo titolo' }));
const response = await request(app)
.put('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', [], ['capo-gruppo'])}`)
.send({ titolo: 'Nuovo titolo' });
expect(response.status).toBe(200);
expect(eventoUpdate).toHaveBeenCalled();
});
test('un membro della stessa branca può modificare l\'evento', async () => {
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Lupetti' }));
eventoUpdate.mockResolvedValueOnce(evento({ brancaId: 'Lupetti', titolo: 'Nuovo titolo' }));
const response = await request(app)
.put('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'], ['capo-unita'])}`)
.send({ titolo: 'Nuovo titolo' });
expect(response.status).toBe(200);
expect(eventoUpdate).toHaveBeenCalled();
});
});
describe('PUT /eventi/:id — contenimento date su modifica', () => {
test('rifiuta la modifica delle date del parent se non contengono più un figlio esistente', async () => {
const figlio = evento({ id: 'ev-figlio', parentId: 'ev-1', dataInizio: new Date('2026-08-05'), dataFine: new Date('2026-08-06') });
eventoFindFirst.mockResolvedValueOnce(evento({ id: 'ev-1', children: [figlio] }));
const response = await request(app)
.put('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
.send({ dataInizio: '2026-08-01', dataFine: '2026-08-05' });
expect(response.status).toBe(400);
expect(eventoUpdate).not.toHaveBeenCalled();
});
test('rifiuta la modifica delle date di un figlio se escono dall\'intervallo del parent', async () => {
const figlio = evento({ id: 'ev-figlio', parentId: 'ev-parent', dataInizio: new Date('2026-08-02'), dataFine: new Date('2026-08-03') });
eventoFindFirst
.mockResolvedValueOnce(figlio) // fetch dell'evento da modificare
.mockResolvedValueOnce(evento({ id: 'ev-parent', dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-10') })); // fetch del parent
const response = await request(app)
.put('/eventi/ev-figlio')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
.send({ dataFine: '2026-08-15' });
expect(response.status).toBe(400);
expect(eventoUpdate).not.toHaveBeenCalled();
});
});
describe('DELETE /eventi/:id', () => {
test('elimina un evento a cui l\'utente ha accesso', async () => {
eventoFindFirst.mockResolvedValueOnce(evento());
eventoDelete.mockResolvedValueOnce(evento());
const response = await request(app)
.delete('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(204);
expect(eventoDelete).toHaveBeenCalledWith({ where: { id: 'ev-1' } });
});
test("rifiuta l'eliminazione se l'utente non ha accesso alla branca (403)", async () => {
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Esploratori' }));
const response = await request(app)
.delete('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'], ['capo-unita'])}`);
expect(response.status).toBe(403);
expect(eventoDelete).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).delete('/eventi/ev-1');
expect(response.status).toBe(401);
expect(eventoDelete).not.toHaveBeenCalled();
});
});
describe('GET /eventi/:id', () => {
test('restituisce il dettaglio completo con figli e risorse collegate', async () => {
const figlio = evento({ id: 'ev-figlio', parentId: 'ev-1' });
const risorsa = {
id: 'r-1',
eventoId: 'ev-1',
tipoRisorsa: 'attivita',
risorsaId: 'att-1',
servizioOrigine: 'scouthub-attivita-be',
metadata: null,
creatoIl: new Date('2026-01-02'),
};
eventoFindFirst.mockResolvedValueOnce(evento({ children: [figlio], risorseCollegate: [risorsa] }));
const response = await request(app)
.get('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(200);
expect(response.body.figli).toHaveLength(1);
expect(response.body.figli[0].id).toBe('ev-figlio');
expect(response.body.risorseCollegate).toHaveLength(1);
expect(response.body.risorseCollegate[0]).toMatchObject({ id: 'r-1', tipoRisorsa: 'attivita' });
});
test("un'altra org non può leggere l'evento (404)", async () => {
eventoFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.get('/eventi/ev-1')
.set('Authorization', `Bearer ${tokenFor('org-b', ['Lupetti'])}`);
expect(response.status).toBe(404);
});
});
describe('GET /eventi?vista=anno', () => {
test('aggrega per giorno solo gli eventi radice, senza descrizione/risorse', async () => {
eventoFindMany.mockResolvedValueOnce([
evento({ id: 'ev-1', titolo: 'Campo estivo', dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-02') }),
]);
const response = await request(app)
.get('/eventi?vista=anno&anno=2026')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(200);
expect(eventoFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ orgId: 'org-a', parentId: null }) }),
);
expect(response.body).toEqual([
{ data: '2026-08-01', conteggio: 1, eventi: [{ titolo: 'Campo estivo', tipo: 'campo', brancaId: 'Lupetti' }] },
{ data: '2026-08-02', conteggio: 1, eventi: [{ titolo: 'Campo estivo', tipo: 'campo', brancaId: 'Lupetti' }] },
]);
});
test("risponde 400 se manca l'anno", async () => {
const response = await request(app)
.get('/eventi?vista=anno')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(400);
expect(eventoFindMany).not.toHaveBeenCalled();
});
});
describe('GET /eventi?vista=mese — intersezione date', () => {
test('un evento che inizia il mese prima e finisce dentro il mese richiesto compare nel risultato', async () => {
// Evento a cavallo tra agosto e settembre: dataInizio nel mese precedente,
// dataFine dentro il mese richiesto (settembre 2026).
eventoFindMany.mockResolvedValueOnce([
evento({
id: 'ev-a-cavallo',
titolo: 'Campo a cavallo',
dataInizio: new Date('2026-08-28'),
dataFine: new Date('2026-09-03'),
}),
]);
const response = await request(app)
.get('/eventi?vista=mese&mese=2026-09')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(200);
expect(eventoFindMany).toHaveBeenCalledWith({
where: {
orgId: 'org-a',
dataInizio: { lte: new Date(Date.UTC(2026, 8, 30, 23, 59, 59, 999)) },
dataFine: { gte: new Date(Date.UTC(2026, 8, 1)) },
},
orderBy: { dataInizio: 'asc' },
});
expect(response.body).toHaveLength(1);
expect(response.body[0].id).toBe('ev-a-cavallo');
});
test('applica il filtro brancaId quando presente', async () => {
eventoFindMany.mockResolvedValueOnce([]);
await request(app)
.get('/eventi?vista=mese&mese=2026-09&brancaId=Esploratori')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(eventoFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ brancaId: 'Esploratori' }) }),
);
});
test("risponde 400 se il formato di 'mese' non è YYYY-MM", async () => {
const response = await request(app)
.get('/eventi?vista=mese&mese=settembre-2026')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(400);
expect(eventoFindMany).not.toHaveBeenCalled();
});
test("risponde 400 se 'vista' non è valorizzato con anno o mese", async () => {
const response = await request(app)
.get('/eventi?vista=settimana')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
expect(response.status).toBe(400);
expect(eventoFindMany).not.toHaveBeenCalled();
});
});
describe('POST /eventi/:id/risorse — machine-to-machine', () => {
test('crea la risorsa collegata con credenziali di servizio autorizzate', async () => {
eventoFindUnique.mockResolvedValueOnce(evento());
risorsaCollegataCreate.mockResolvedValueOnce({
id: 'r-1',
eventoId: 'ev-1',
tipoRisorsa: 'attivita',
risorsaId: 'att-1',
servizioOrigine: 'scouthub-attivita-be',
metadata: null,
creatoIl: new Date('2026-01-01'),
});
const response = await request(app)
.post('/eventi/ev-1/risorse')
.set('Authorization', `Bearer ${serviceTokenFor('test-service-client')}`)
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
expect(response.status).toBe(201);
expect(risorsaCollegataCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
eventoId: 'ev-1',
tipoRisorsa: 'attivita',
risorsaId: 'att-1',
servizioOrigine: 'scouthub-attivita-be',
}),
});
});
test("risponde 404 se l'evento non esiste", async () => {
eventoFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.post('/eventi/ev-inesistente/risorse')
.set('Authorization', `Bearer ${serviceTokenFor('test-service-client')}`)
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
expect(response.status).toBe(404);
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
});
test('risponde 401 senza alcun token', async () => {
const response = await request(app)
.post('/eventi/ev-1/risorse')
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
expect(response.status).toBe(401);
expect(eventoFindUnique).not.toHaveBeenCalled();
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
});
test("risponde 403 se il client (azp) non è nella whitelist KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS", async () => {
const response = await request(app)
.post('/eventi/ev-1/risorse')
.set('Authorization', `Bearer ${serviceTokenFor('client-non-autorizzato')}`)
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
expect(response.status).toBe(403);
expect(eventoFindUnique).not.toHaveBeenCalled();
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
});
test('un token utente valido (senza azp di un client di servizio) viene comunque respinto', async () => {
const response = await request(app)
.post('/eventi/ev-1/risorse')
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
expect(response.status).toBe(403);
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
});
});