246 lines
8.5 KiB
TypeScript
246 lines
8.5 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_ORG_SERVICE_CLIENT_ID = 'test-client';
|
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_SECRET = 'test-secret';
|
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_home_test';
|
|
process.env.FRONTEND_BASE_URL = 'http://localhost:4200';
|
|
|
|
const linkIngressoFindFirst = jest.fn();
|
|
const linkIngressoCreate = jest.fn();
|
|
const linkIngressoFindUnique = jest.fn();
|
|
const richiestaIngressoFindFirst = jest.fn();
|
|
const richiestaIngressoCreate = jest.fn();
|
|
|
|
jest.mock('../../src/db/prisma', () => ({
|
|
prisma: {
|
|
linkIngresso: {
|
|
findFirst: (...args: unknown[]) => linkIngressoFindFirst(...args),
|
|
create: (...args: unknown[]) => linkIngressoCreate(...args),
|
|
findUnique: (...args: unknown[]) => linkIngressoFindUnique(...args),
|
|
},
|
|
richiestaIngresso: {
|
|
findFirst: (...args: unknown[]) => richiestaIngressoFindFirst(...args),
|
|
create: (...args: unknown[]) => richiestaIngressoCreate(...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 capoGruppoToken(orgId: string): string {
|
|
return signToken({
|
|
sub: 'user-capo',
|
|
email: 'capo@example.com',
|
|
realm_access: { roles: ['capo-gruppo'] },
|
|
organization: { alfa: { id: orgId, roles: [] } },
|
|
});
|
|
}
|
|
|
|
function adminCentraleToken(): string {
|
|
return signToken({
|
|
sub: 'user-admin',
|
|
email: 'admin@example.com',
|
|
realm_access: { roles: ['admin'] },
|
|
});
|
|
}
|
|
|
|
function utenteToken(userId: string, email: string): string {
|
|
return signToken({ sub: userId, email, realm_access: { roles: [] } });
|
|
}
|
|
|
|
function linkIngressoFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
|
return {
|
|
id: 'link-1',
|
|
token: 'token-abc',
|
|
orgId: 'org-1',
|
|
attivo: true,
|
|
creatoDa: 'user-capo',
|
|
createdAt: new Date(),
|
|
gruppoScout: { nome: 'Gruppo Alfa' },
|
|
...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 /gruppi/:orgId/link-ingresso', () => {
|
|
test('crea un nuovo link attivo e risponde 201 se non ne esiste già uno', async () => {
|
|
linkIngressoFindFirst.mockResolvedValueOnce(null);
|
|
linkIngressoCreate.mockResolvedValueOnce({});
|
|
|
|
const response = await request(app)
|
|
.post('/gruppi/org-1/link-ingresso')
|
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.token).toEqual(expect.any(String));
|
|
expect(response.body.url).toBe(`http://localhost:4200/ingresso/${response.body.token}`);
|
|
|
|
expect(linkIngressoCreate).toHaveBeenCalledWith({
|
|
data: { token: response.body.token, orgId: 'org-1', attivo: true, creatoDa: 'user-capo' },
|
|
});
|
|
});
|
|
|
|
test('è idempotente: restituisce 200 con il link già attivo senza crearne uno nuovo', async () => {
|
|
linkIngressoFindFirst.mockResolvedValueOnce(linkIngressoFixture());
|
|
|
|
const response = await request(app)
|
|
.post('/gruppi/org-1/link-ingresso')
|
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ token: 'token-abc', url: 'http://localhost:4200/ingresso/token-abc' });
|
|
expect(linkIngressoCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('admin può generare il link anche per un altro gruppo', async () => {
|
|
linkIngressoFindFirst.mockResolvedValueOnce(null);
|
|
linkIngressoCreate.mockResolvedValueOnce({});
|
|
|
|
const response = await request(app)
|
|
.post('/gruppi/org-altrui/link-ingresso')
|
|
.set('Authorization', `Bearer ${adminCentraleToken()}`);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(linkIngressoFindFirst).toHaveBeenCalledWith({ where: { orgId: 'org-altrui', attivo: true } });
|
|
});
|
|
|
|
test("risponde 403 se il capo gruppo prova a generare il link per un'altra organizzazione", async () => {
|
|
const response = await request(app)
|
|
.post('/gruppi/org-1/link-ingresso')
|
|
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(linkIngressoFindFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('risponde 401 senza token', async () => {
|
|
const response = await request(app).post('/gruppi/org-1/link-ingresso');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(linkIngressoFindFirst).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('GET /link-ingresso/:token', () => {
|
|
test('è pubblico e restituisce nomeGruppo e gruppoId per un link attivo', async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture());
|
|
|
|
const response = await request(app).get('/link-ingresso/token-abc');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ nomeGruppo: 'Gruppo Alfa', gruppoId: 'org-1' });
|
|
});
|
|
|
|
test('risponde 404 se il token non esiste', async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(null);
|
|
|
|
const response = await request(app).get('/link-ingresso/token-inesistente');
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test('risponde 404 se il link non è più attivo', async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture({ attivo: false }));
|
|
|
|
const response = await request(app).get('/link-ingresso/token-abc');
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /link-ingresso/:token/richiedi', () => {
|
|
test('crea una richiesta PENDING e risponde 201', async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture());
|
|
richiestaIngressoFindFirst.mockResolvedValueOnce(null);
|
|
richiestaIngressoCreate.mockResolvedValueOnce({ id: 'richiesta-1' });
|
|
|
|
const response = await request(app)
|
|
.post('/link-ingresso/token-abc/richiedi')
|
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body).toEqual({ richiestaId: 'richiesta-1' });
|
|
|
|
expect(richiestaIngressoCreate).toHaveBeenCalledWith({
|
|
data: {
|
|
userId: 'user-nuovo',
|
|
email: 'nuovo@example.com',
|
|
orgId: 'org-1',
|
|
origine: 'LINK',
|
|
stato: 'PENDING',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('risponde 404 se il token non esiste', async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(null);
|
|
|
|
const response = await request(app)
|
|
.post('/link-ingresso/token-inesistente/richiedi')
|
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('risponde 404 se il link non è più attivo', async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture({ attivo: false }));
|
|
|
|
const response = await request(app)
|
|
.post('/link-ingresso/token-abc/richiedi')
|
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("risponde 409 se l'utente ha già una richiesta PENDING per lo stesso gruppo", async () => {
|
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture());
|
|
richiestaIngressoFindFirst.mockResolvedValueOnce({ id: 'richiesta-esistente' });
|
|
|
|
const response = await request(app)
|
|
.post('/link-ingresso/token-abc/richiedi')
|
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('risponde 401 senza token di autenticazione', async () => {
|
|
const response = await request(app).post('/link-ingresso/token-abc/richiedi');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
|
});
|
|
});
|