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_MAGAZZINO_CLIENT_ID = 'test-client'; process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret'; process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test'; const materialeFindMany = jest.fn(); const materialeFindUnique = jest.fn(); const materialeCreate = jest.fn(); const materialeUpdate = jest.fn(); jest.mock('../../src/db/prisma', () => ({ prisma: { materiale: { findMany: (...args: unknown[]) => materialeFindMany(...args), findUnique: (...args: unknown[]) => materialeFindUnique(...args), create: (...args: unknown[]) => materialeCreate(...args), update: (...args: unknown[]) => materialeUpdate(...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 utenteToken(orgId = 'org-1'): string { return signToken({ sub: 'user-1', realm_access: { roles: ['censito'] }, organization: { 'gruppo-alfa': { id: orgId, roles: [] } }, }); } function adminCatalogoToken(orgId = 'org-9'): string { return signToken({ sub: 'admin-1', realm_access: { roles: ['moderatore'] }, organization: { 'gruppo-omega': { id: orgId, roles: [] } }, }); } 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('GET /materiali', () => { test('non richiede autenticazione e restituisce solo i materiali approvati', async () => { materialeFindMany.mockResolvedValueOnce([ { id: 'm-1', nome: 'Tenda canadese', categoria: 'campeggio', unitaMisura: 'pz', stato: 'approvato', propostoDaOrgId: 'org-x', creatoIl: new Date('2026-01-01'), }, ]); const response = await request(app).get('/materiali'); expect(response.status).toBe(200); expect(response.body).toEqual([{ id: 'm-1', nome: 'Tenda canadese', categoria: 'campeggio', unitaMisura: 'pz' }]); expect(materialeFindMany).toHaveBeenCalledWith({ where: { stato: 'approvato' }, orderBy: { nome: 'asc' }, }); }); test('filtra per categoria quando richiesto', async () => { materialeFindMany.mockResolvedValueOnce([]); await request(app).get('/materiali').query({ categoria: 'cucina' }); expect(materialeFindMany).toHaveBeenCalledWith({ where: { stato: 'approvato', categoria: 'cucina' }, orderBy: { nome: 'asc' }, }); }); test('il catalogo pubblico esclude sempre le proposte non approvate, anche forzando uno stato via query string', async () => { materialeFindMany.mockResolvedValueOnce([]); await request(app).get('/materiali').query({ stato: 'proposto' }); // Il filtro "stato" non è un parametro accettato: la query verso il database // continua a chiedere solo lo stato "approvato". expect(materialeFindMany).toHaveBeenCalledWith({ where: { stato: 'approvato' }, orderBy: { nome: 'asc' }, }); }); }); describe('POST /materiali/proposte', () => { test('salva la proposta con stato "proposto" e proposto_da_org_id preso dal token', async () => { materialeCreate.mockResolvedValueOnce({ id: 'm-2', nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz', stato: 'proposto', propostoDaOrgId: 'org-1', creatoIl: new Date('2026-01-02'), }); const response = await request(app) .post('/materiali/proposte') .set('Authorization', `Bearer ${utenteToken('org-1')}`) .send({ nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz' }); expect(response.status).toBe(201); expect(response.body).toMatchObject({ propostoDaOrgId: 'org-1', stato: 'proposto' }); expect(materialeCreate).toHaveBeenCalledWith({ data: { nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz', propostoDaOrgId: 'org-1', stato: 'proposto' }, }); }); test('ignora un eventuale proposto_da_org_id inviato dal client, usa sempre quello del token', async () => { materialeCreate.mockResolvedValueOnce({ id: 'm-3', nome: 'Piccone', categoria: 'attrezzi', unitaMisura: 'pz', stato: 'proposto', propostoDaOrgId: 'org-1', creatoIl: new Date('2026-01-03'), }); await request(app) .post('/materiali/proposte') .set('Authorization', `Bearer ${utenteToken('org-1')}`) .send({ nome: 'Piccone', categoria: 'attrezzi', unitaMisura: 'pz', propostoDaOrgId: 'org-spoofed' }); expect(materialeCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ propostoDaOrgId: 'org-1' }), }); }); test('risponde 401 senza token', async () => { const response = await request(app) .post('/materiali/proposte') .send({ nome: 'Fornello', categoria: 'cucina', unitaMisura: 'pz' }); expect(response.status).toBe(401); expect(materialeCreate).not.toHaveBeenCalled(); }); test("risponde 400 se manca un campo obbligatorio", async () => { const response = await request(app) .post('/materiali/proposte') .set('Authorization', `Bearer ${utenteToken()}`) .send({ nome: 'Fornello' }); expect(response.status).toBe(400); expect(materialeCreate).not.toHaveBeenCalled(); }); }); describe('GET /materiali/proposte', () => { test('un utente normale non può accedere', async () => { const response = await request(app).get('/materiali/proposte').set('Authorization', `Bearer ${utenteToken()}`); expect(response.status).toBe(403); expect(materialeFindMany).not.toHaveBeenCalled(); }); test('un moderatore vede tutte le proposte pending', async () => { materialeFindMany.mockResolvedValueOnce([ { id: 'm-4', nome: 'Corda', categoria: 'attrezzi', unitaMisura: 'm', stato: 'proposto', propostoDaOrgId: 'org-7', creatoIl: new Date('2026-01-04'), }, ]); const response = await request(app).get('/materiali/proposte').set('Authorization', `Bearer ${adminCatalogoToken()}`); expect(response.status).toBe(200); expect(materialeFindMany).toHaveBeenCalledWith({ where: { stato: 'proposto' }, orderBy: { creatoIl: 'asc' }, }); expect(response.body).toEqual([ expect.objectContaining({ id: 'm-4', propostoDaOrgId: 'org-7', stato: 'proposto' }), ]); }); test('risponde 401 senza token', async () => { const response = await request(app).get('/materiali/proposte'); expect(response.status).toBe(401); expect(materialeFindMany).not.toHaveBeenCalled(); }); }); describe('PATCH /materiali/proposte/:id', () => { test('un utente normale non può decidere una proposta', async () => { const response = await request(app) .patch('/materiali/proposte/m-1') .set('Authorization', `Bearer ${utenteToken()}`) .send({ decisione: 'approvato' }); expect(response.status).toBe(403); expect(materialeUpdate).not.toHaveBeenCalled(); }); test('un moderatore può approvare una proposta pending', async () => { materialeFindUnique.mockResolvedValueOnce({ id: 'm-1', stato: 'proposto' }); materialeUpdate.mockResolvedValueOnce({ id: 'm-1', nome: 'Tenda', categoria: 'campeggio', unitaMisura: 'pz', stato: 'approvato', propostoDaOrgId: 'org-1', creatoIl: new Date('2026-01-01'), }); const response = await request(app) .patch('/materiali/proposte/m-1') .set('Authorization', `Bearer ${adminCatalogoToken()}`) .send({ decisione: 'approvato' }); expect(response.status).toBe(200); expect(response.body.stato).toBe('approvato'); expect(materialeUpdate).toHaveBeenCalledWith({ where: { id: 'm-1' }, data: { stato: 'approvato' } }); }); test('un moderatore può rifiutare una proposta pending', async () => { materialeFindUnique.mockResolvedValueOnce({ id: 'm-2', stato: 'proposto' }); materialeUpdate.mockResolvedValueOnce({ id: 'm-2', nome: 'Zaino', categoria: 'equipaggiamento', unitaMisura: 'pz', stato: 'rifiutato', propostoDaOrgId: 'org-2', creatoIl: new Date('2026-01-01'), }); const response = await request(app) .patch('/materiali/proposte/m-2') .set('Authorization', `Bearer ${adminCatalogoToken()}`) .send({ decisione: 'rifiutato' }); expect(response.status).toBe(200); expect(response.body.stato).toBe('rifiutato'); expect(materialeUpdate).toHaveBeenCalledWith({ where: { id: 'm-2' }, data: { stato: 'rifiutato' } }); }); test('risponde 404 se la proposta non esiste', async () => { materialeFindUnique.mockResolvedValueOnce(null); const response = await request(app) .patch('/materiali/proposte/inesistente') .set('Authorization', `Bearer ${adminCatalogoToken()}`) .send({ decisione: 'approvato' }); expect(response.status).toBe(404); expect(materialeUpdate).not.toHaveBeenCalled(); }); test('risponde 409 se la proposta è già stata decisa', async () => { materialeFindUnique.mockResolvedValueOnce({ id: 'm-1', stato: 'approvato' }); const response = await request(app) .patch('/materiali/proposte/m-1') .set('Authorization', `Bearer ${adminCatalogoToken()}`) .send({ decisione: 'rifiutato' }); expect(response.status).toBe(409); expect(materialeUpdate).not.toHaveBeenCalled(); }); test('risponde 401 senza token', async () => { const response = await request(app).patch('/materiali/proposte/m-1').send({ decisione: 'approvato' }); expect(response.status).toBe(401); expect(materialeUpdate).not.toHaveBeenCalled(); }); });