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 magazzinoVoceFindMany = jest.fn(); const magazzinoVoceFindFirst = jest.fn(); const magazzinoVoceCreate = jest.fn(); const magazzinoVoceUpdate = jest.fn(); const magazzinoVoceDelete = jest.fn(); const materialeFindUnique = jest.fn(); jest.mock('../../src/db/prisma', () => ({ prisma: { magazzinoVoce: { findMany: (...args: unknown[]) => magazzinoVoceFindMany(...args), findFirst: (...args: unknown[]) => magazzinoVoceFindFirst(...args), create: (...args: unknown[]) => magazzinoVoceCreate(...args), update: (...args: unknown[]) => magazzinoVoceUpdate(...args), delete: (...args: unknown[]) => magazzinoVoceDelete(...args), }, materiale: { findUnique: (...args: unknown[]) => materialeFindUnique(...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 tokenOrg(orgId: string): string { return signToken({ sub: 'user-1', realm_access: { roles: ['censito'] }, organization: { gruppo: { id: orgId, roles: [] } }, }); } function materialeApprovato(id = 'm-1') { return { id, nome: 'Tenda canadese', categoria: 'campeggio', unitaMisura: 'pz', stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date(), }; } function voceConMateriale(overrides: Partial> = {}) { return { id: 'mv-1', orgId: 'org-a', materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', posizione: 'scaffale A', note: null, materiale: materialeApprovato(), ...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('GET /magazzino', () => { test('restituisce l\'inventario dell\'org corrente, con nome/categoria del materiale', async () => { magazzinoVoceFindMany.mockResolvedValueOnce([voceConMateriale()]); const response = await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(200); expect(magazzinoVoceFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: { orgId: 'org-a' } }), ); expect(response.body).toEqual([ { id: 'mv-1', orgId: 'org-a', materialeId: 'm-1', materialeNome: 'Tenda canadese', materialeCategoria: 'campeggio', quantitaPosseduta: 3, stato: 'buono', posizione: 'scaffale A', note: null, }, ]); }); test('org diverse ottengono query filtrate su org_id diversi', async () => { magazzinoVoceFindMany.mockResolvedValue([]); await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-a')}`); await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-b')}`); expect(magazzinoVoceFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } })); expect(magazzinoVoceFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } })); }); test('risponde 401 senza token', async () => { const response = await request(app).get('/magazzino'); expect(response.status).toBe(401); expect(magazzinoVoceFindMany).not.toHaveBeenCalled(); }); }); describe('POST /magazzino', () => { test('aggiunge una voce per l\'org corrente se il materiale è approvato', async () => { materialeFindUnique.mockResolvedValueOnce(materialeApprovato()); magazzinoVoceCreate.mockResolvedValueOnce(voceConMateriale()); const response = await request(app) .post('/magazzino') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', posizione: 'scaffale A' }); expect(response.status).toBe(201); expect(magazzinoVoceCreate).toHaveBeenCalledWith({ data: { materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', posizione: 'scaffale A', note: undefined, orgId: 'org-a', }, include: { materiale: true }, }); }); test('ignora un eventuale org_id inviato dal client, usa sempre quello del token', async () => { materialeFindUnique.mockResolvedValueOnce(materialeApprovato()); magazzinoVoceCreate.mockResolvedValueOnce(voceConMateriale()); await request(app) .post('/magazzino') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', orgId: 'org-spoofed' }); expect(magazzinoVoceCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a' }) }), ); }); test('risponde 400 e invita a proporre il materiale se non esiste nel catalogo', async () => { materialeFindUnique.mockResolvedValueOnce(null); const response = await request(app) .post('/magazzino') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ materialeId: 'inesistente', quantitaPosseduta: 1, stato: 'buono' }); expect(response.status).toBe(400); expect(response.body.message).toMatch(/POST \/materiali\/proposte/); expect(magazzinoVoceCreate).not.toHaveBeenCalled(); }); test('risponde 400 se il materiale esiste ma non è ancora approvato', async () => { materialeFindUnique.mockResolvedValueOnce({ ...materialeApprovato(), stato: 'proposto' }); const response = await request(app) .post('/magazzino') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'buono' }); expect(response.status).toBe(400); expect(response.body.message).toMatch(/POST \/materiali\/proposte/); expect(magazzinoVoceCreate).not.toHaveBeenCalled(); }); test('risponde 400 se lo stato non è uno dei valori validi', async () => { const response = await request(app) .post('/magazzino') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'ottimo' }); expect(response.status).toBe(400); expect(materialeFindUnique).not.toHaveBeenCalled(); expect(magazzinoVoceCreate).not.toHaveBeenCalled(); }); test('risponde 401 senza token', async () => { const response = await request(app).post('/magazzino').send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'buono' }); expect(response.status).toBe(401); expect(magazzinoVoceCreate).not.toHaveBeenCalled(); }); }); describe('PUT /magazzino/:id — isolamento tra org', () => { test('un\'org non può modificare una voce di un\'altra org (404, non 403)', async () => { magazzinoVoceFindFirst.mockResolvedValueOnce(null); const response = await request(app) .put('/magazzino/mv-1') .set('Authorization', `Bearer ${tokenOrg('org-b')}`) .send({ quantitaPosseduta: 5 }); expect(response.status).toBe(404); expect(magazzinoVoceFindFirst).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'mv-1', orgId: 'org-b' } }), ); expect(magazzinoVoceUpdate).not.toHaveBeenCalled(); }); test('l\'org proprietaria può modificare la propria voce', async () => { magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale()); magazzinoVoceUpdate.mockResolvedValueOnce(voceConMateriale({ quantitaPosseduta: 5 })); const response = await request(app) .put('/magazzino/mv-1') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ quantitaPosseduta: 5 }); expect(response.status).toBe(200); expect(response.body.quantitaPosseduta).toBe(5); expect(magazzinoVoceUpdate).toHaveBeenCalledWith({ where: { id: 'mv-1' }, data: { quantitaPosseduta: 5 }, include: { materiale: true }, }); }); test('se si cambia materialeId, valida di nuovo che sia approvato', async () => { magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale()); materialeFindUnique.mockResolvedValueOnce({ ...materialeApprovato('m-2'), stato: 'proposto' }); const response = await request(app) .put('/magazzino/mv-1') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ materialeId: 'm-2' }); expect(response.status).toBe(400); expect(response.body.message).toMatch(/POST \/materiali\/proposte/); expect(magazzinoVoceUpdate).not.toHaveBeenCalled(); }); test('risponde 401 senza token', async () => { const response = await request(app).put('/magazzino/mv-1').send({ quantitaPosseduta: 1 }); expect(response.status).toBe(401); expect(magazzinoVoceFindFirst).not.toHaveBeenCalled(); }); }); describe('DELETE /magazzino/:id — isolamento tra org', () => { test('un\'org non può eliminare una voce di un\'altra org', async () => { magazzinoVoceFindFirst.mockResolvedValueOnce(null); const response = await request(app).delete('/magazzino/mv-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`); expect(response.status).toBe(404); expect(magazzinoVoceDelete).not.toHaveBeenCalled(); }); test('l\'org proprietaria può eliminare la propria voce', async () => { magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale()); magazzinoVoceDelete.mockResolvedValueOnce({ id: 'mv-1' }); const response = await request(app).delete('/magazzino/mv-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(204); expect(magazzinoVoceDelete).toHaveBeenCalledWith({ where: { id: 'mv-1' } }); }); test('risponde 401 senza token', async () => { const response = await request(app).delete('/magazzino/mv-1'); expect(response.status).toBe(401); expect(magazzinoVoceDelete).not.toHaveBeenCalled(); }); });