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; 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> = {}) { 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(); }); });