305 lines
11 KiB
TypeScript
305 lines
11 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_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<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 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<Record<string, unknown>> = {}) {
|
|
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();
|
|
});
|
|
});
|