271 lines
9.5 KiB
TypeScript
271 lines
9.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 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<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, 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: [] } });
|
|
}
|
|
|
|
function adminCentraleToken(): string {
|
|
return signToken({
|
|
sub: 'user-admin',
|
|
email: 'admin@example.com',
|
|
realm_access: { roles: ['admin'] },
|
|
});
|
|
}
|
|
|
|
const ORA = Date.now();
|
|
function invitoFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
|
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();
|
|
});
|
|
|
|
test("admin può creare un invito anche in un'organizzazione che non è la propria", 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-altrui/inviti')
|
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
|
.send({ email: 'nuovo@example.com', ruolo: 'Capi' });
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(invitoCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ orgId: 'org-altrui' }),
|
|
}),
|
|
);
|
|
|
|
consoleLogSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|