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