Fix scouthub-home-be

This commit is contained in:
Lorenzo Sanesi
2026-07-25 12:09:12 +02:00
parent ac92ca43ce
commit 5b7c82d359
39 changed files with 2116 additions and 81 deletions
@@ -14,6 +14,7 @@ const createOrganization = jest.fn();
const createOrganizationGroup = jest.fn();
const addMemberToOrganization = jest.fn();
const gruppoScoutCreate = jest.fn();
const gruppoScoutFindMany = jest.fn();
jest.mock('../../src/keycloak-admin', () => ({
createOrganization: (...args: unknown[]) => createOrganization(...args),
@@ -25,6 +26,7 @@ jest.mock('../../src/db/prisma', () => ({
prisma: {
gruppoScout: {
create: (...args: unknown[]) => gruppoScoutCreate(...args),
findMany: (...args: unknown[]) => gruppoScoutFindMany(...args),
},
},
}));
@@ -47,7 +49,7 @@ function signToken(roles: string[]): string {
);
}
const adminToken = () => signToken(['admin-centrale']);
const adminToken = () => signToken(['admin']);
const nonAdminToken = () => signToken(['capo-gruppo']);
function conflictError(): Error {
@@ -166,7 +168,7 @@ describe('POST /gruppi', () => {
consoleErrorSpy.mockRestore();
});
test('risponde 403 se il ruolo non è admin-centrale', async () => {
test('risponde 403 se il ruolo non è admin', async () => {
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${nonAdminToken()}`)
@@ -193,3 +195,68 @@ describe('POST /gruppi', () => {
expect(createOrganization).not.toHaveBeenCalled();
});
});
describe('GET /gruppi', () => {
test('restituisce 200 con la lista dei gruppi scout per admin', async () => {
gruppoScoutFindMany.mockResolvedValueOnce([
{ orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' },
{ orgId: 'org-2', nome: 'Gruppo Beta', regione: null },
]);
const response = await request(app).get('/gruppi').set('Authorization', `Bearer ${adminToken()}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([
{ orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' },
{ orgId: 'org-2', nome: 'Gruppo Beta', regione: null },
]);
expect(gruppoScoutFindMany).toHaveBeenCalledWith({
select: { orgId: true, nome: true, regione: true },
orderBy: { nome: 'asc' },
});
});
test('risponde 403 se il ruolo non è admin', async () => {
const response = await request(app).get('/gruppi').set('Authorization', `Bearer ${nonAdminToken()}`);
expect(response.status).toBe(403);
expect(gruppoScoutFindMany).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/gruppi');
expect(response.status).toBe(401);
expect(gruppoScoutFindMany).not.toHaveBeenCalled();
});
});
describe('GET /gruppi/elenco-pubblico', () => {
test('restituisce 200 con solo orgId/nome, accessibile a un utente non admin', async () => {
gruppoScoutFindMany.mockResolvedValueOnce([
{ orgId: 'org-1', nome: 'Gruppo Alfa' },
{ orgId: 'org-2', nome: 'Gruppo Beta' },
]);
const response = await request(app)
.get('/gruppi/elenco-pubblico')
.set('Authorization', `Bearer ${nonAdminToken()}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([
{ orgId: 'org-1', nome: 'Gruppo Alfa' },
{ orgId: 'org-2', nome: 'Gruppo Beta' },
]);
expect(gruppoScoutFindMany).toHaveBeenCalledWith({
select: { orgId: true, nome: true },
orderBy: { nome: 'asc' },
});
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/gruppi/elenco-pubblico');
expect(response.status).toBe(401);
expect(gruppoScoutFindMany).not.toHaveBeenCalled();
});
});
@@ -61,6 +61,14 @@ 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 {
@@ -133,6 +141,25 @@ describe('POST /gruppi/:orgId/inviti', () => {
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', () => {
@@ -0,0 +1,245 @@
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();
});
});
@@ -18,6 +18,8 @@ const getUserGroupsInOrganization = jest.fn();
const assignRealmRoleToUser = jest.fn();
const removeRealmRoleFromUser = jest.fn();
const getUserRealmRoles = jest.fn();
const addMemberToOrganization = jest.fn();
const findUserByEmail = jest.fn();
jest.mock('../../src/keycloak-admin', () => ({
listOrganizationMembers: (...args: unknown[]) => listOrganizationMembers(...args),
@@ -28,6 +30,8 @@ jest.mock('../../src/keycloak-admin', () => ({
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
removeRealmRoleFromUser: (...args: unknown[]) => removeRealmRoleFromUser(...args),
getUserRealmRoles: (...args: unknown[]) => getUserRealmRoles(...args),
addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args),
findUserByEmail: (...args: unknown[]) => findUserByEmail(...args),
}));
import { app } from '../../src/app';
@@ -62,6 +66,14 @@ function nonCapoToken(orgId: string): string {
});
}
function adminCentraleToken(): string {
return signToken({
sub: 'user-admin',
email: 'admin@example.com',
realm_access: { roles: ['admin'] },
});
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
@@ -76,6 +88,68 @@ beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /gruppi/:orgId/membri', () => {
test('aggiunge un utente già registrato con successo e risponde 201', async () => {
findUserByEmail.mockResolvedValueOnce({ id: 'user-1' });
addMemberToOrganization.mockResolvedValueOnce(undefined);
assignUserToGroup.mockResolvedValueOnce(undefined);
assignRealmRoleToUser.mockResolvedValueOnce(undefined);
const response = await request(app)
.post('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ email: 'gia-registrato@example.com', ruolo: 'Capi' });
expect(response.status).toBe(201);
expect(response.body).toEqual({ userId: 'user-1', email: 'gia-registrato@example.com', ruolo: 'Capi' });
expect(findUserByEmail).toHaveBeenCalledWith('gia-registrato@example.com');
expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-1');
expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Capi');
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-1', 'Capi');
});
test("risponde 404 se non esiste un utente registrato con quell'email", async () => {
findUserByEmail.mockResolvedValueOnce(null);
const response = await request(app)
.post('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ email: 'sconosciuto@example.com', ruolo: 'Capi' });
expect(response.status).toBe(404);
expect(addMemberToOrganization).not.toHaveBeenCalled();
});
test('risponde 502 se una chiamata Keycloak fallisce a metà sequenza', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
findUserByEmail.mockResolvedValueOnce({ id: 'user-1' });
addMemberToOrganization.mockResolvedValueOnce(undefined);
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.post('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ email: 'gia-registrato@example.com', ruolo: 'Capi' });
expect(response.status).toBe(502);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 2'), expect.anything());
expect(assignRealmRoleToUser).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
test('risponde 403 se chiamato da capo-gruppo, anche sulla propria organizzazione', async () => {
const response = await request(app)
.post('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ email: 'gia-registrato@example.com', ruolo: 'Capi' });
expect(response.status).toBe(403);
expect(findUserByEmail).not.toHaveBeenCalled();
});
});
describe('GET /gruppi/:orgId/membri', () => {
test('restituisce la lista membri con ruolo e gruppo interno', async () => {
listOrganizationMembers.mockResolvedValueOnce([
@@ -116,6 +190,19 @@ describe('GET /gruppi/:orgId/membri', () => {
expect(response.status).toBe(403);
expect(listOrganizationMembers).not.toHaveBeenCalled();
});
test("admin accede con successo anche a un'organizzazione che non è la propria", async () => {
listOrganizationMembers.mockResolvedValueOnce([{ userId: 'user-1', email: 'uno@example.com' }]);
getUserGroupsInOrganization.mockResolvedValueOnce([]);
getUserRealmRoles.mockResolvedValueOnce([]);
const response = await request(app)
.get('/gruppi/org-altrui/membri')
.set('Authorization', `Bearer ${adminCentraleToken()}`);
expect(response.status).toBe(200);
expect(listOrganizationMembers).toHaveBeenCalledWith('org-altrui');
});
});
describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => {
@@ -173,6 +260,19 @@ describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => {
consoleErrorSpy.mockRestore();
});
test("admin può cambiare ruolo anche in un'organizzazione che non è la propria", async () => {
getUserGroupsInOrganization.mockResolvedValueOnce([]);
getUserRealmRoles.mockResolvedValueOnce([]);
const response = await request(app)
.put('/gruppi/org-altrui/membri/user-1/ruolo')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ ruolo: 'Aiuto capi' });
expect(response.status).toBe(200);
expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Aiuto capi');
});
});
describe('DELETE /gruppi/:orgId/membri/:userId', () => {
@@ -209,4 +309,15 @@ describe('DELETE /gruppi/:orgId/membri/:userId', () => {
consoleErrorSpy.mockRestore();
});
test("admin può rimuovere un membro anche in un'organizzazione che non è la propria", async () => {
removeMemberFromOrganization.mockResolvedValueOnce(undefined);
const response = await request(app)
.delete('/gruppi/org-altrui/membri/user-1')
.set('Authorization', `Bearer ${adminCentraleToken()}`);
expect(response.status).toBe(204);
expect(removeMemberFromOrganization).toHaveBeenCalledWith('org-altrui', 'user-1');
});
});
@@ -0,0 +1,330 @@
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 createOrganization = jest.fn();
const createOrganizationGroup = jest.fn();
const addMemberToOrganization = jest.fn();
const assignUserToGroup = jest.fn();
const assignRealmRoleToUser = jest.fn();
jest.mock('../../src/keycloak-admin', () => ({
createOrganization: (...args: unknown[]) => createOrganization(...args),
createOrganizationGroup: (...args: unknown[]) => createOrganizationGroup(...args),
addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args),
assignUserToGroup: (...args: unknown[]) => assignUserToGroup(...args),
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
}));
const richiestaCreazioneGruppoFindFirst = jest.fn();
const richiestaCreazioneGruppoCreate = jest.fn();
const richiestaCreazioneGruppoFindMany = jest.fn();
const richiestaCreazioneGruppoFindUnique = jest.fn();
const richiestaCreazioneGruppoUpdate = jest.fn();
const gruppoScoutCreate = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
richiestaCreazioneGruppo: {
findFirst: (...args: unknown[]) => richiestaCreazioneGruppoFindFirst(...args),
create: (...args: unknown[]) => richiestaCreazioneGruppoCreate(...args),
findMany: (...args: unknown[]) => richiestaCreazioneGruppoFindMany(...args),
findUnique: (...args: unknown[]) => richiestaCreazioneGruppoFindUnique(...args),
update: (...args: unknown[]) => richiestaCreazioneGruppoUpdate(...args),
},
gruppoScout: {
create: (...args: unknown[]) => gruppoScoutCreate(...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 utenteToken(userId: string, email: string): string {
return signToken({ sub: userId, email, realm_access: { roles: [] } });
}
function adminCentraleToken(): string {
return signToken({
sub: 'user-admin',
email: 'admin@example.com',
realm_access: { roles: ['admin'] },
});
}
function capoGruppoToken(orgId: string): string {
return signToken({
sub: 'user-capo',
email: 'capo@example.com',
realm_access: { roles: ['capo-gruppo'] },
organization: { alfa: { id: orgId, roles: [] } },
});
}
const ORA = new Date();
function richiestaFixture(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'richiesta-1',
userId: 'user-nuovo',
email: 'nuovo@example.com',
nomeProposto: 'Gruppo Alfa',
regione: 'Lombardia',
stato: 'PENDING',
createdAt: ORA,
updatedAt: ORA,
...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 /richieste-creazione-gruppo', () => {
test('crea una richiesta PENDING e risponde 201', async () => {
richiestaCreazioneGruppoFindFirst.mockResolvedValueOnce(null);
richiestaCreazioneGruppoCreate.mockResolvedValueOnce({ id: 'richiesta-1' });
const response = await request(app)
.post('/richieste-creazione-gruppo')
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
.send({ nomeProposto: 'Gruppo Alfa', regione: 'Lombardia' });
expect(response.status).toBe(201);
expect(response.body).toEqual({ id: 'richiesta-1' });
expect(richiestaCreazioneGruppoCreate).toHaveBeenCalledWith({
data: {
userId: 'user-nuovo',
email: 'nuovo@example.com',
nomeProposto: 'Gruppo Alfa',
regione: 'Lombardia',
stato: 'PENDING',
},
});
});
test("risponde 409 se l'utente ha già una richiesta PENDING", async () => {
richiestaCreazioneGruppoFindFirst.mockResolvedValueOnce(richiestaFixture());
const response = await request(app)
.post('/richieste-creazione-gruppo')
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
.send({ nomeProposto: 'Gruppo Beta' });
expect(response.status).toBe(409);
expect(richiestaCreazioneGruppoCreate).not.toHaveBeenCalled();
});
test("risponde 400 se manca il campo 'nomeProposto'", async () => {
const response = await request(app)
.post('/richieste-creazione-gruppo')
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
.send({ regione: 'Lombardia' });
expect(response.status).toBe(400);
expect(richiestaCreazioneGruppoCreate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/richieste-creazione-gruppo').send({ nomeProposto: 'Gruppo Alfa' });
expect(response.status).toBe(401);
expect(richiestaCreazioneGruppoCreate).not.toHaveBeenCalled();
});
});
describe('GET /richieste-creazione-gruppo', () => {
test('restituisce le richieste PENDING per admin', async () => {
richiestaCreazioneGruppoFindMany.mockResolvedValueOnce([richiestaFixture()]);
const response = await request(app)
.get('/richieste-creazione-gruppo')
.set('Authorization', `Bearer ${adminCentraleToken()}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([
{
id: 'richiesta-1',
email: 'nuovo@example.com',
nomeProposto: 'Gruppo Alfa',
regione: 'Lombardia',
createdAt: ORA.toISOString(),
},
]);
expect(richiestaCreazioneGruppoFindMany).toHaveBeenCalledWith({
where: { stato: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
});
test('risponde 403 se il ruolo non è admin', async () => {
const response = await request(app)
.get('/richieste-creazione-gruppo')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
expect(response.status).toBe(403);
expect(richiestaCreazioneGruppoFindMany).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/richieste-creazione-gruppo');
expect(response.status).toBe(401);
expect(richiestaCreazioneGruppoFindMany).not.toHaveBeenCalled();
});
});
describe('PUT /richieste-creazione-gruppo/:id', () => {
test('approva la richiesta: crea il gruppo, rende il richiedente capo-gruppo e aggiorna stato=APPROVATA', async () => {
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
createOrganization.mockResolvedValueOnce({ orgId: 'org-nuovo' });
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
addMemberToOrganization.mockResolvedValueOnce(undefined);
gruppoScoutCreate.mockResolvedValueOnce({});
assignUserToGroup.mockResolvedValueOnce(undefined);
assignRealmRoleToUser.mockResolvedValueOnce(undefined);
richiestaCreazioneGruppoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-1')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ esito: 'approvata' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'APPROVATA' });
expect(createOrganization).toHaveBeenCalledWith('Gruppo Alfa');
expect(gruppoScoutCreate).toHaveBeenCalledWith({
data: { orgId: 'org-nuovo', nome: 'Gruppo Alfa', regione: 'Lombardia' },
});
expect(addMemberToOrganization).toHaveBeenCalledWith('org-nuovo', 'user-nuovo');
expect(assignUserToGroup).toHaveBeenCalledWith('user-nuovo', 'capo-gruppo');
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-nuovo', 'capo-gruppo');
expect(richiestaCreazioneGruppoUpdate).toHaveBeenCalledWith({
where: { id: 'richiesta-1' },
data: { stato: 'APPROVATA' },
});
});
test("risponde 502 e NON marca la richiesta come approvata se l'assegnazione del ruolo capo-gruppo fallisce", async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
createOrganization.mockResolvedValueOnce({ orgId: 'org-nuovo' });
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
addMemberToOrganization.mockResolvedValueOnce(undefined);
gruppoScoutCreate.mockResolvedValueOnce({});
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-1')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ esito: 'approvata' });
expect(response.status).toBe(502);
expect(richiestaCreazioneGruppoUpdate).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 5'), expect.anything());
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('richiesta-1'), expect.anything());
consoleErrorSpy.mockRestore();
});
test('risponde 502 e NON marca la richiesta come approvata se la creazione del gruppo fallisce', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
createOrganization.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-1')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ esito: 'approvata' });
expect(response.status).toBe(502);
expect(richiestaCreazioneGruppoUpdate).not.toHaveBeenCalled();
expect(assignUserToGroup).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
test('rifiuta la richiesta senza creare alcun gruppo e aggiorna stato=RIFIUTATA', async () => {
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
richiestaCreazioneGruppoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'RIFIUTATA' }));
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-1')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'RIFIUTATA' });
expect(createOrganization).not.toHaveBeenCalled();
expect(richiestaCreazioneGruppoUpdate).toHaveBeenCalledWith({
where: { id: 'richiesta-1' },
data: { stato: 'RIFIUTATA' },
});
});
test('risponde 409 se la richiesta non è più PENDING', async () => {
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-1')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(409);
expect(richiestaCreazioneGruppoUpdate).not.toHaveBeenCalled();
});
test('risponde 404 se la richiesta non esiste', async () => {
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-inesistente')
.set('Authorization', `Bearer ${adminCentraleToken()}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(404);
});
test('risponde 403 se il ruolo non è admin', async () => {
const response = await request(app)
.put('/richieste-creazione-gruppo/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'approvata' });
expect(response.status).toBe(403);
expect(richiestaCreazioneGruppoFindUnique).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,301 @@
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 richiestaIngressoFindFirst = jest.fn();
const richiestaIngressoCreate = jest.fn();
const richiestaIngressoFindMany = jest.fn();
const richiestaIngressoFindUnique = jest.fn();
const richiestaIngressoUpdate = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
richiestaIngresso: {
findFirst: (...args: unknown[]) => richiestaIngressoFindFirst(...args),
create: (...args: unknown[]) => richiestaIngressoCreate(...args),
findMany: (...args: unknown[]) => richiestaIngressoFindMany(...args),
findUnique: (...args: unknown[]) => richiestaIngressoFindUnique(...args),
update: (...args: unknown[]) => richiestaIngressoUpdate(...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: [] } });
}
const ORA = new Date();
function richiestaFixture(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'richiesta-1',
userId: 'user-nuovo',
email: 'nuovo@example.com',
orgId: 'org-1',
stato: 'PENDING',
origine: 'PROFILO',
createdAt: ORA,
updatedAt: ORA,
...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/richieste-ingresso', () => {
test('crea una richiesta PENDING con origine=PROFILO e risponde 201', async () => {
richiestaIngressoFindFirst.mockResolvedValueOnce(null);
richiestaIngressoCreate.mockResolvedValueOnce({ id: 'richiesta-1' });
const response = await request(app)
.post('/gruppi/org-1/richieste-ingresso')
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
.send({});
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: 'PROFILO',
stato: 'PENDING',
},
});
});
test("risponde 409 se l'utente ha già una richiesta PENDING per lo stesso gruppo", async () => {
richiestaIngressoFindFirst.mockResolvedValueOnce(richiestaFixture());
const response = await request(app)
.post('/gruppi/org-1/richieste-ingresso')
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
.send({});
expect(response.status).toBe(409);
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/gruppi/org-1/richieste-ingresso').send({});
expect(response.status).toBe(401);
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
});
});
describe('GET /gruppi/:orgId/richieste-ingresso', () => {
test('restituisce le richieste PENDING del gruppo per capo-gruppo della propria org', async () => {
richiestaIngressoFindMany.mockResolvedValueOnce([
richiestaFixture({ id: 'richiesta-1' }),
richiestaFixture({ id: 'richiesta-2', userId: 'user-due', email: 'due@example.com', origine: 'LINK' }),
]);
const response = await request(app)
.get('/gruppi/org-1/richieste-ingresso')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([
{ id: 'richiesta-1', userId: 'user-nuovo', email: 'nuovo@example.com', origine: 'PROFILO', createdAt: ORA.toISOString() },
{ id: 'richiesta-2', userId: 'user-due', email: 'due@example.com', origine: 'LINK', createdAt: ORA.toISOString() },
]);
expect(richiestaIngressoFindMany).toHaveBeenCalledWith({
where: { orgId: 'org-1', stato: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
});
test('admin può leggere le richieste anche di un altro gruppo', async () => {
richiestaIngressoFindMany.mockResolvedValueOnce([]);
const response = await request(app)
.get('/gruppi/org-altrui/richieste-ingresso')
.set('Authorization', `Bearer ${adminCentraleToken()}`);
expect(response.status).toBe(200);
expect(richiestaIngressoFindMany).toHaveBeenCalledWith({
where: { orgId: 'org-altrui', stato: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
});
test("risponde 403 se il capo gruppo prova a leggere le richieste di un'altra organizzazione", async () => {
const response = await request(app)
.get('/gruppi/org-1/richieste-ingresso')
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`);
expect(response.status).toBe(403);
expect(richiestaIngressoFindMany).not.toHaveBeenCalled();
});
});
describe('PUT /gruppi/:orgId/richieste-ingresso/:id', () => {
test('approva la richiesta: assegna gruppo/ruolo su Keycloak e aggiorna stato=APPROVATA', async () => {
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture());
addMemberToOrganization.mockResolvedValueOnce(undefined);
assignUserToGroup.mockResolvedValueOnce(undefined);
assignRealmRoleToUser.mockResolvedValueOnce(undefined);
richiestaIngressoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
const response = await request(app)
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'approvata', ruolo: 'Capi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'APPROVATA' });
expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-nuovo');
expect(assignUserToGroup).toHaveBeenCalledWith('user-nuovo', 'Capi');
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-nuovo', 'Capi');
expect(richiestaIngressoUpdate).toHaveBeenCalledWith({
where: { id: 'richiesta-1' },
data: { stato: 'APPROVATA' },
});
});
test('risponde 502 e NON marca la richiesta come approvata se Keycloak fallisce', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture());
addMemberToOrganization.mockResolvedValueOnce(undefined);
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'approvata', ruolo: 'Capi' });
expect(response.status).toBe(502);
expect(richiestaIngressoUpdate).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('richiesta-1'), expect.anything());
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("PENDING"), expect.anything());
consoleErrorSpy.mockRestore();
});
test('rifiuta la richiesta senza toccare Keycloak e aggiorna stato=RIFIUTATA', async () => {
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture());
richiestaIngressoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'RIFIUTATA' }));
const response = await request(app)
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'RIFIUTATA' });
expect(addMemberToOrganization).not.toHaveBeenCalled();
expect(assignUserToGroup).not.toHaveBeenCalled();
expect(richiestaIngressoUpdate).toHaveBeenCalledWith({
where: { id: 'richiesta-1' },
data: { stato: 'RIFIUTATA' },
});
});
test("risponde 400 se esito='approvata' senza il campo ruolo", async () => {
const response = await request(app)
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'approvata' });
expect(response.status).toBe(400);
expect(richiestaIngressoFindUnique).not.toHaveBeenCalled();
});
test('risponde 409 se la richiesta non è più PENDING', async () => {
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
const response = await request(app)
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(409);
expect(richiestaIngressoUpdate).not.toHaveBeenCalled();
});
test('risponde 404 se la richiesta non esiste', async () => {
richiestaIngressoFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.put('/gruppi/org-1/richieste-ingresso/richiesta-inesistente')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(404);
});
test("risponde 403 se il capo gruppo prova ad approvare una richiesta di un'altra organizzazione", async () => {
const response = await request(app)
.put('/gruppi/org-2/richieste-ingresso/richiesta-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ esito: 'rifiutata' });
expect(response.status).toBe(403);
expect(richiestaIngressoFindUnique).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,71 @@
import { Request, Response } from 'express';
import { requireOrgAccess } from '../../../src/middleware/requireOrgAccess';
function buildReqRes(auth: Partial<Request['auth']> | undefined, orgId: string) {
const req = { auth, params: { orgId } } as unknown as Request;
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const res = { status } as unknown as Response;
const next = jest.fn();
return { req, res, next, status, json };
}
describe('requireOrgAccess', () => {
test('lascia passare admin anche su un orgId diverso dal proprio', () => {
const { req, res, next, status } = buildReqRes(
{ userId: 'u1', email: null, organizationId: 'org-mio', roles: ['admin'] },
'org-altrui',
);
requireOrgAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(status).not.toHaveBeenCalled();
});
test('lascia passare capo-gruppo sulla propria organizzazione', () => {
const { req, res, next, status } = buildReqRes(
{ userId: 'u1', email: null, organizationId: 'org-1', roles: ['capo-gruppo'] },
'org-1',
);
requireOrgAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(status).not.toHaveBeenCalled();
});
test('risponde 403 se capo-gruppo prova ad accedere a un altro orgId', () => {
const { req, res, next, status, json } = buildReqRes(
{ userId: 'u1', email: null, organizationId: 'org-1', roles: ['capo-gruppo'] },
'org-2',
);
requireOrgAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(403);
expect(json).toHaveBeenCalled();
});
test('risponde 403 se manca il ruolo capo-gruppo (e non è admin)', () => {
const { req, res, next, status } = buildReqRes(
{ userId: 'u1', email: null, organizationId: 'org-1', roles: ['censito'] },
'org-1',
);
requireOrgAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(403);
});
test('risponde 403 se req.auth è assente', () => {
const { req, res, next, status } = buildReqRes(undefined, 'org-1');
requireOrgAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(403);
});
});