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 addMemberToOrganization = jest.fn(); const assignUserToGroup = jest.fn(); const assignRealmRoleToUser = jest.fn(); jest.mock('../../src/keycloak-admin', () => ({ addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args), assignUserToGroup: (...args: unknown[]) => assignUserToGroup(...args), assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args), })); const invitoCreate = jest.fn(); const invitoFindUnique = jest.fn(); const invitoUpdate = jest.fn(); jest.mock('../../src/db/prisma', () => ({ prisma: { invito: { create: (...args: unknown[]) => invitoCreate(...args), findUnique: (...args: unknown[]) => invitoFindUnique(...args), update: (...args: unknown[]) => invitoUpdate(...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, email = 'capo@example.com'): string { return signToken({ sub: 'user-capo', email, realm_access: { roles: ['capo-gruppo'] }, organization: { alfa: { id: orgId, roles: [] } }, }); } function utenteToken(email: string): string { return signToken({ sub: 'user-invitato', email, realm_access: { roles: [] } }); } const ORA = Date.now(); function invitoFixture(overrides: Partial> = {}) { return { id: 'invito-1', token: 'token-abc', email: 'invitato@example.com', orgId: 'org-1', ruolo: 'Capi', scadenza: new Date(ORA + 60_000), stato: 'pending', 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/inviti', () => { test('crea un invito pending e restituisce 201', async () => { invitoCreate.mockResolvedValueOnce({ id: 'invito-nuovo', scadenza: new Date(ORA + 7 * 24 * 60 * 60 * 1000) }); const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); const response = await request(app) .post('/gruppi/org-1/inviti') .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`) .send({ email: 'nuovo@example.com', ruolo: 'Capi' }); expect(response.status).toBe(201); expect(response.body.invitoId).toBe('invito-nuovo'); expect(invitoCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ email: 'nuovo@example.com', orgId: 'org-1', ruolo: 'Capi', stato: 'pending' }), }), ); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('http://localhost:4200/inviti/')); consoleLogSpy.mockRestore(); }); test("risponde 403 se il capo gruppo prova ad invitare in un'altra organization", async () => { const response = await request(app) .post('/gruppi/org-1/inviti') .set('Authorization', `Bearer ${capoGruppoToken('org-2')}`) .send({ email: 'nuovo@example.com', ruolo: 'Capi' }); expect(response.status).toBe(403); expect(invitoCreate).not.toHaveBeenCalled(); }); test('risponde 403 se manca il ruolo capo-gruppo', async () => { const token = signToken({ sub: 'user-x', email: 'x@example.com', realm_access: { roles: ['censito'] } }); const response = await request(app) .post('/gruppi/org-1/inviti') .set('Authorization', `Bearer ${token}`) .send({ email: 'nuovo@example.com', ruolo: 'Capi' }); expect(response.status).toBe(403); expect(invitoCreate).not.toHaveBeenCalled(); }); }); describe('GET /inviti/:token', () => { test('è pubblico e restituisce valido=true per un invito pending non scaduto', async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture()); const response = await request(app).get('/inviti/token-abc'); expect(response.status).toBe(200); expect(response.body).toEqual({ email: 'invitato@example.com', nomeGruppo: 'Gruppo Alfa', ruolo: 'Capi', valido: true, }); }); test('restituisce valido=false per un invito scaduto', async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture({ scadenza: new Date(ORA - 1_000) })); const response = await request(app).get('/inviti/token-abc'); expect(response.status).toBe(200); expect(response.body.valido).toBe(false); }); test('restituisce valido=false per un invito già accettato', async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture({ stato: 'accettato' })); const response = await request(app).get('/inviti/token-abc'); expect(response.status).toBe(200); expect(response.body.valido).toBe(false); }); test('risponde 404 se il token non esiste', async () => { invitoFindUnique.mockResolvedValueOnce(null); const response = await request(app).get('/inviti/token-inesistente'); expect(response.status).toBe(404); }); }); describe('POST /inviti/:token/accetta', () => { test('happy path: assegna su Keycloak, marca l\'invito come accettato e risponde 200', async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture()); addMemberToOrganization.mockResolvedValueOnce(undefined); assignUserToGroup.mockResolvedValueOnce(undefined); assignRealmRoleToUser.mockResolvedValueOnce(undefined); invitoUpdate.mockResolvedValueOnce({}); const response = await request(app) .post('/inviti/token-abc/accetta') .set('Authorization', `Bearer ${utenteToken('invitato@example.com')}`); expect(response.status).toBe(200); expect(response.body).toEqual({ organizationId: 'org-1', ruolo: 'Capi' }); expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-invitato'); expect(assignUserToGroup).toHaveBeenCalledWith('user-invitato', 'Capi'); expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-invitato', 'Capi'); expect(invitoUpdate).toHaveBeenCalledWith({ where: { token: 'token-abc' }, data: { stato: 'accettato' } }); }); test('risponde 410 se l\'invito è scaduto', async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture({ scadenza: new Date(ORA - 1_000) })); const response = await request(app) .post('/inviti/token-abc/accetta') .set('Authorization', `Bearer ${utenteToken('invitato@example.com')}`); expect(response.status).toBe(410); expect(addMemberToOrganization).not.toHaveBeenCalled(); expect(invitoUpdate).not.toHaveBeenCalled(); }); test('risponde 409 se l\'invito è già stato accettato', async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture({ stato: 'accettato' })); const response = await request(app) .post('/inviti/token-abc/accetta') .set('Authorization', `Bearer ${utenteToken('invitato@example.com')}`); expect(response.status).toBe(409); expect(addMemberToOrganization).not.toHaveBeenCalled(); expect(invitoUpdate).not.toHaveBeenCalled(); }); test("risponde 403 se l'email dell'utente autenticato non corrisponde a quella dell'invito", async () => { invitoFindUnique.mockResolvedValueOnce(invitoFixture()); const response = await request(app) .post('/inviti/token-abc/accetta') .set('Authorization', `Bearer ${utenteToken('qualcun-altro@example.com')}`); expect(response.status).toBe(403); expect(addMemberToOrganization).not.toHaveBeenCalled(); expect(invitoUpdate).not.toHaveBeenCalled(); }); test('risponde 401 senza autenticazione', async () => { const response = await request(app).post('/inviti/token-abc/accetta'); expect(response.status).toBe(401); expect(addMemberToOrganization).not.toHaveBeenCalled(); }); });