Add scouthub-home-be

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:36:55 +02:00
parent cfd2de4ca3
commit 566754f825
45 changed files with 8233 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# 8082, non 8081: la porta 8081 è quella su cui il Keycloak del
# docker-compose alla radice del progetto è esposto sull'host.
PORT=8082
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_home?schema=public
FRONTEND_BASE_URL=http://localhost:4200
KEYCLOAK_BASE_URL=http://localhost:8081
KEYCLOAK_REALM=scouthub
KEYCLOAK_ORG_SERVICE_CLIENT_ID=scouthub-home-be
KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
coverage/
/home-be.iml
/.idea/
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
EXPOSE 8082
# Niente migrazioni ancora create (prisma/migrations assente): db push sincronizza
# lo schema direttamente, coerente con lo stato early-stage del progetto.
CMD ["sh", "-c", "npx prisma db push --skip-generate && node dist/server.js"]
+164
View File
@@ -0,0 +1,164 @@
# scouthub-home-be
Backend Node.js/TypeScript/Express per la gestione organizzativa di Scouthub
(gruppi scout, inviti), integrato con Keycloak per l'autenticazione/gestione
delle organizzazioni. Affianca `scouthub-attivita-be` nello stesso ecosistema.
Stato: creazione gruppi, flusso di invito e gestione membri implementati
(vedi sezione Endpoint); l'integrazione reale con le Admin REST API di
Keycloak (`src/keycloak-admin/`) resta invece solo abbozzata (vedi sotto).
## Struttura
```
src/
config/ configurazione da variabili d'ambiente (dotenv)
routes/ definizione degli endpoint Express
controllers/ gestione richieste/risposte HTTP
services/ logica applicativa
keycloak-admin/ client verso le Admin REST API di Keycloak (token client-credentials + operazioni org/utenti/ruoli)
db/ client Prisma
middleware/ middleware Express (autenticazione JWT, autorizzazione per ruolo, error handling)
prisma/
schema.prisma modelli GruppoScout e Invito
```
## Prerequisiti
- Node.js 20+
- Un'istanza PostgreSQL raggiungibile (locale o remota)
- Un realm Keycloak con un client service-account per la gestione organizzazioni
## Variabili d'ambiente
Copiare `.env.example` in `.env` e valorizzare:
- `PORT` — porta di ascolto del server (default 8082 — 8081 è già usata dal
Keycloak del `docker-compose.yml` alla radice del progetto)
- `DATABASE_URL` — connection string PostgreSQL per Prisma
- `FRONTEND_BASE_URL` — URL base del frontend, usato per comporre il link di invito (`{FRONTEND_BASE_URL}/inviti/{token}`)
- `KEYCLOAK_BASE_URL` — URL base dell'istanza Keycloak
- `KEYCLOAK_REALM` — realm Keycloak usato da Scouthub
- `KEYCLOAK_ORG_SERVICE_CLIENT_ID` — client id del service account per la gestione organizzazioni
- `KEYCLOAK_ORG_SERVICE_CLIENT_SECRET` — client secret corrispondente
## Sviluppo locale
```bash
npm install
npx prisma migrate dev --name init
npm run dev
```
Il server si avvia sulla porta definita da `PORT` e risponde su `GET /health`
(verifica anche la connessione al database).
## Build
```bash
npm run build
npm start
```
## Test
```bash
npm test
```
Suite Jest, nessun database richiesto: le chiamate verso Keycloak (endpoint
token e JWKS) sono mockate con `nock`, e i test di integrazione mockano
interamente `src/keycloak-admin` e `src/db/prisma`.
## Endpoint
### `POST /gruppi`
Crea un nuovo gruppo scout: organizzazione Keycloak, un gruppo Keycloak per
ciascun ruolo di default, e la riga corrispondente in `gruppo_scout`.
- Richiede autenticazione + ruolo `admin-centrale` (temporaneo, vedi
`src/routes/gruppi.routes.ts`)
- Body: `{ nome: string, regione?: string, ruoliDefault?: string[] }`
(`ruoliDefault` di default `["Capi", "Aiuto capi", "Censiti"]`)
- Risposta `201`: `{ orgId, gruppiCreati: string[] }`
- `409` se Keycloak segnala un nome di organizzazione duplicato (nessuna riga
orfana viene creata in locale)
- In caso di errore a metà sequenza (es. fallisce la creazione di un gruppo
ruolo dopo che l'organizzazione è già stata creata), `src/services/gruppi.service.ts`
logga con `console.error` esattamente a quale step si è fermato e cosa è già
stato creato, per permettere un retry manuale mirato invece di ripartire da zero.
### Flusso di invito
- **`POST /gruppi/:orgId/inviti`** — richiede autenticazione + ruolo
`capo-gruppo`, e che `req.auth.organizationId` coincida con `:orgId` (un
capo gruppo non può invitare in un'altra organizzazione, 403 altrimenti).
Body `{ email: string, ruolo: string }`. Genera un token casuale
(`crypto.randomBytes(32)`), salva l'invito con stato `pending` e scadenza a
7 giorni, e per ora si limita a loggare in console il link
`{FRONTEND_BASE_URL}/inviti/{token}` (nessun servizio email reale).
Risposta `201`: `{ invitoId, scadenza }`.
- **`GET /inviti/:token`** — pubblico. Risponde `404` se il token non esiste,
altrimenti `{ email, nomeGruppo, ruolo, valido }` (`valido: false` se
l'invito è scaduto o già accettato), usato dal frontend per la schermata di
accettazione prima del redirect a Keycloak.
- **`POST /inviti/:token/accetta`** — richiede autenticazione (l'utente deve
essersi già autenticato/registrato su Keycloak). Verifica in ordine: invito
esistente (`404`), non già accettato (`409`), non scaduto (`410`), email del
JWT corrispondente a quella dell'invito (`403` altrimenti — impedisce che un
altro utente autenticato accetti l'invito di qualcun altro). Poi chiama in
sequenza `addMemberToOrganization``assignUserToGroup`
`assignRealmRoleToUser` e marca l'invito come `accettato`. Risposta `200`:
`{ organizationId, ruolo }`.
⚠️ `assignUserToGroup` riceve `invito.ruolo` (un nome, es. `"Capi"`) al
posto di un vero `groupId` di Keycloak: `createOrganizationGroup` non
persiste ancora una mappa ruolo → groupId, quindi questa risoluzione è
solo un placeholder (vedi `TODO` in `src/services/inviti.service.ts`).
### Gestione membri
Tutti e tre gli endpoint richiedono autenticazione + ruolo `capo-gruppo`, e
che `req.auth.organizationId` coincida con `:orgId` (403 altrimenti — un capo
gruppo non può gestire membri di un'altra organizzazione).
- **`GET /gruppi/:orgId/membri`** — Risposta `200` con un array di
`{ userId, email, ruolo, gruppoInterno }` (entrambi `null` se non
risolvibili). Per ciascun membro restituito da `listOrganizationMembers`
interroga `getUserGroupsInOrganization` e `getUserRealmRoles`, prendendo il
primo risultato di ciascuna (il modello attuale assume un solo gruppo/ruolo
scout per membro, coerente con `POST /gruppi/:orgId/inviti`).
- **`PUT /gruppi/:orgId/membri/:userId/ruolo`** — Body `{ ruolo: string }`.
Rimuove tutti i gruppi/ruoli realm correnti del membro all'interno
dell'organizzazione e assegna il nuovo gruppo/ruolo (`removeUserFromGroup`
+ `assignUserToGroup`, `removeRealmRoleFromUser` + `assignRealmRoleToUser`).
Risposta `200`: `{ userId, ruolo }`. `502` se una delle chiamate Keycloak
fallisce (loggato con `console.error`, stato potenzialmente parziale su
Keycloak da verificare manualmente).
- **`DELETE /gruppi/:orgId/membri/:userId`** — Rimuove solo la membership
dell'Organization (`removeMemberFromOrganization`), **non** elimina
l'utente da Keycloak. Risposta `204`.
## Autenticazione e autorizzazione
- `src/middleware/authenticate.ts` — estrae il Bearer token, ne valida la
firma contro le chiavi pubbliche JWKS di Keycloak (`jsonwebtoken` +
`jwks-rsa`, con caching delle chiavi) e popola `req.auth` con
`{ userId, email, organizationId, roles }` (ruoli realm + ruoli
dell'organizzazione attiva). Risponde 401 se il token manca o non è valido.
- `src/middleware/requireRole.ts``requireRole(...ruoli)` verifica che
`req.auth.roles` contenga almeno uno dei ruoli richiesti, altrimenti 403.
Va usato **dopo** `authenticate` nella catena dei middleware.
- `src/keycloak-admin/` — client verso le Admin REST API di Keycloak. Il
recupero/rinnovo del token via client-credentials (`tokenManager.ts`) è già
funzionante; tutte le operazioni usate dagli endpoint applicativi
(`createOrganization`, `createOrganizationGroup`, `addMemberToOrganization`,
`removeMemberFromOrganization`, `listOrganizationMembers`,
`assignUserToGroup`, `removeUserFromGroup`, `getUserGroupsInOrganization`,
`assignRealmRoleToUser`, `removeRealmRoleFromUser`, `getUserRealmRoles`)
sono già collegate ma il loro corpo è ancora solo un `TODO` (`throw new
Error('Not implemented')`) — vanno implementate prima che gli endpoint
funzionino contro un Keycloak reale (nei test sono sempre mockate).
`findUserByEmail` e `createUser` restano firme tipizzate non ancora usate
da nessun endpoint.
+14
View File
@@ -0,0 +1,14 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/tests/unit/**/*.test.ts', '<rootDir>/tests/integration/**/*.test.ts'],
testTimeout: 15000,
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }],
},
};
export default config;
+6092
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "scouthub-home-be",
"version": "1.0.0",
"private": true,
"description": "Backend Node.js/TypeScript per la gestione organizzativa (gruppi scout, inviti) integrata con Keycloak",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"test": "jest --runInBand --passWithNoTests",
"test:watch": "jest --watch --runInBand"
},
"dependencies": {
"@prisma/client": "^5.16.1",
"axios": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.2.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.14.12",
"@types/supertest": "^7.2.1",
"jest": "^29.7.0",
"nock": "^13.5.4",
"prisma": "^5.16.1",
"supertest": "^7.2.2",
"ts-jest": "^29.2.3",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
}
}
+33
View File
@@ -0,0 +1,33 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model GruppoScout {
orgId String @id @map("org_id")
nome String
regione String?
dataCreazione DateTime @default(now()) @map("data_creazione")
inviti Invito[]
@@map("gruppo_scout")
}
model Invito {
id String @id @default(uuid())
token String @unique
email String
orgId String @map("org_id")
ruolo String
scadenza DateTime
stato String
gruppoScout GruppoScout @relation(fields: [orgId], references: [orgId])
@@map("invito")
}
+23
View File
@@ -0,0 +1,23 @@
import express from 'express';
import cors from 'cors';
import { healthRouter } from './routes/health.routes';
import { gruppiRouter } from './routes/gruppi.routes';
import { invitiRouter } from './routes/inviti.routes';
import { membriRouter } from './routes/membri.routes';
import { errorHandler } from './middleware/errorHandler';
export const app = express();
app.use(express.json());
app.use(cors());
app.use(healthRouter);
app.use(gruppiRouter);
app.use(invitiRouter);
app.use(membriRouter);
app.use((req, res) => {
res.status(404).json({ message: 'not found' });
});
app.use(errorHandler);
+23
View File
@@ -0,0 +1,23 @@
import dotenv from 'dotenv';
dotenv.config();
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Variabile d'ambiente mancante: ${name}`);
}
return value;
}
export const env = {
port: Number(process.env.PORT) || 8082,
databaseUrl: requireEnv('DATABASE_URL'),
frontendBaseUrl: requireEnv('FRONTEND_BASE_URL'),
keycloak: {
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
realm: requireEnv('KEYCLOAK_REALM'),
orgServiceClientId: requireEnv('KEYCLOAK_ORG_SERVICE_CLIENT_ID'),
orgServiceClientSecret: requireEnv('KEYCLOAK_ORG_SERVICE_CLIENT_SECRET'),
},
};
@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { createGruppo } from '../services/gruppi.service';
import { HttpError } from '../errors';
interface PostGruppoBody {
nome?: unknown;
regione?: unknown;
ruoliDefault?: unknown;
}
function parseBody(body: PostGruppoBody): { nome: string; regione?: string; ruoliDefault?: string[] } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (body.regione !== undefined && typeof body.regione !== 'string') {
throw new HttpError(400, "Il campo 'regione', se presente, deve essere una stringa");
}
if (body.ruoliDefault !== undefined) {
const isArrayOfStrings = Array.isArray(body.ruoliDefault) && body.ruoliDefault.every((r) => typeof r === 'string');
if (!isArrayOfStrings) {
throw new HttpError(400, "Il campo 'ruoliDefault', se presente, deve essere un array di stringhe");
}
}
return {
nome: body.nome,
regione: body.regione as string | undefined,
ruoliDefault: body.ruoliDefault as string[] | undefined,
};
}
export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseBody(req.body ?? {});
const result = await createGruppo({ ...input, userId: req.auth!.userId });
res.status(201).json(result);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import { healthService } from '../services/health.service';
export async function getHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await healthService.checkDatabase();
res.json({ status: 'ok', database: 'up' });
} catch (err) {
next(err);
}
}
@@ -0,0 +1,65 @@
import { Request, Response, NextFunction } from 'express';
import { creaInvito, getInvitoPubblico, accettaInvito } from '../services/inviti.service';
import { HttpError } from '../errors';
interface PostInvitoBody {
email?: unknown;
ruolo?: unknown;
}
function parseCreaInvitoBody(body: PostInvitoBody): { email: string; ruolo: string } {
if (typeof body.email !== 'string' || body.email.trim().length === 0) {
throw new HttpError(400, "Il campo 'email' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.ruolo !== 'string' || body.ruolo.trim().length === 0) {
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
}
return { email: body.email, ruolo: body.ruolo };
}
export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId } = req.params;
// Un capo gruppo può invitare solo all'interno della propria organization.
if (req.auth?.organizationId !== orgId) {
throw new HttpError(403, "Non puoi invitare persone in un'organizzazione diversa dalla tua");
}
const { email, ruolo } = parseCreaInvitoBody(req.body ?? {});
const result = await creaInvito({ orgId, email, ruolo });
res.status(201).json(result);
} catch (err) {
next(err);
}
}
export async function getInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.params;
const invito = await getInvitoPubblico(token);
if (!invito) {
throw new HttpError(404, 'Invito non trovato');
}
res.json(invito);
} catch (err) {
next(err);
}
}
export async function postAccettaInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.params;
const result = await accettaInvito(token, {
userId: req.auth!.userId,
email: req.auth!.email,
});
res.status(200).json(result);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,50 @@
import { Request, Response, NextFunction } from 'express';
import { listaMembri, cambiaRuoloMembro, rimuoviMembro } from '../services/membri.service';
import { HttpError } from '../errors';
function checkOrgAccess(req: Request): string {
const { orgId } = req.params;
if (req.auth?.organizationId !== orgId) {
throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua");
}
return orgId;
}
export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const orgId = checkOrgAccess(req);
const membri = await listaMembri(orgId);
res.json(membri);
} catch (err) {
next(err);
}
}
export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const orgId = checkOrgAccess(req);
const { userId } = req.params;
const { ruolo } = req.body ?? {};
if (typeof ruolo !== 'string' || ruolo.trim().length === 0) {
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
}
await cambiaRuoloMembro(orgId, userId, ruolo);
res.status(200).json({ userId, ruolo });
} catch (err) {
next(err);
}
}
export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const orgId = checkOrgAccess(req);
const { userId } = req.params;
await rimuoviMembro(orgId, userId);
res.status(204).send();
} catch (err) {
next(err);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma = global.__prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}
+9
View File
@@ -0,0 +1,9 @@
export class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
this.name = 'HttpError';
}
}
@@ -0,0 +1,21 @@
export async function assignUserToGroup(userId: string, groupId: string): Promise<void> {
// TODO: PUT {adminBaseUrl}/users/{userId}/groups/{groupId}
throw new Error('Not implemented');
}
export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> {
// TODO: DELETE {adminBaseUrl}/users/{userId}/groups/{groupId}
throw new Error('Not implemented');
}
export interface UserGroup {
groupId: string;
nome: string;
}
export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> {
// TODO: GET {adminBaseUrl}/users/{userId}/groups, filtrati ai gruppi che
// appartengono all'albero dei gruppi dell'organizzazione orgId (o endpoint
// dedicato, da verificare in base alla versione di Keycloak).
throw new Error('Not implemented');
}
@@ -0,0 +1,13 @@
import axios from 'axios';
import { env } from '../config/env';
import { getAccessToken } from './tokenManager';
export const keycloakAdminHttp = axios.create({
baseURL: `${env.keycloak.baseUrl}/admin/realms/${env.keycloak.realm}`,
});
keycloakAdminHttp.interceptors.request.use(async (config) => {
const token = await getAccessToken();
config.headers.set('Authorization', `Bearer ${token}`);
return config;
});
@@ -0,0 +1,12 @@
export { getAccessToken } from './tokenManager';
export { keycloakAdminHttp } from './httpClient';
export {
createOrganization,
createOrganizationGroup,
addMemberToOrganization,
listOrganizationMembers,
removeMemberFromOrganization,
} from './organizations';
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
export { findUserByEmail, createUser } from './users';
@@ -0,0 +1,101 @@
import axios from 'axios';
import { keycloakAdminHttp } from './httpClient';
// Le risposte 201 di Keycloak restituiscono l'id della risorsa creata solo
// nell'header Location (es. ".../organizations/3f2b...").
function extractIdFromLocation(location: string | undefined): string {
if (!location) {
throw new Error("Risposta Keycloak priva dell'header Location");
}
const id = location.split('/').pop();
if (!id) {
throw new Error(`Impossibile estrarre l'id dall'header Location: "${location}"`);
}
return id;
}
// L'alias dell'organizzazione è vincolato da Keycloak a un formato URL-safe:
// verificato contro un'istanza reale che uno spazio nel nome (comunissimo nei
// nomi di gruppo scout, es. "Milano 1") viene rifiutato con 400 "Empty Space
// not allowed", a differenza del campo "name" che accetta testo libero.
function toAlias(nome: string): string {
return nome
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export async function createOrganization(nome: string): Promise<{ orgId: string }> {
const response = await keycloakAdminHttp.post('/organizations', {
name: nome,
alias: toAlias(nome),
enabled: true,
domains: [],
});
return { orgId: extractIdFromLocation(response.headers.location) };
}
// Prefisso per il gruppo realm padre di ogni organizzazione. Keycloak riserva
// internamente un gruppo top-level il cui nome coincide con l'id
// dell'organizzazione (non elencato da GET /groups, ma verificato contro
// un'istanza reale: un POST /groups con quel nome esatto risponde comunque
// 409 "already exists"). Va quindi evitato un nome che collida con quello.
const GRUPPO_PADRE_PREFIX = 'org-';
export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> {
// Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo
// ruolo": usiamo un gruppo realm nidificato sotto un gruppo padre dedicato
// all'organizzazione, per isolare i gruppi ruolo di organizzazioni diverse
// anche se condividono lo stesso nome di ruolo (es. "Capi"). Il gruppo
// padre viene creato al primo ruolo e riusato per gli altri: la 409 sul
// secondo tentativo è quindi attesa, non un errore.
const nomeGruppoPadre = `${GRUPPO_PADRE_PREFIX}${orgId}`;
let parentGroupId: string;
try {
const parentResponse = await keycloakAdminHttp.post('/groups', { name: nomeGruppoPadre });
parentGroupId = extractIdFromLocation(parentResponse.headers.location);
} catch (err) {
if (!axios.isAxiosError(err) || err.response?.status !== 409) {
throw err;
}
const searchResponse = await keycloakAdminHttp.get<Array<{ id: string; name: string }>>('/groups', {
params: { search: nomeGruppoPadre, exact: true },
});
const existing = searchResponse.data.find((g) => g.name === nomeGruppoPadre);
if (!existing) {
throw new Error(`Gruppo padre "${nomeGruppoPadre}" segnalato come duplicato ma non trovato nella ricerca`);
}
parentGroupId = existing.id;
}
const childResponse = await keycloakAdminHttp.post(`/groups/${parentGroupId}/children`, { name: nomeGruppo });
return { groupId: extractIdFromLocation(childResponse.headers.location) };
}
export async function addMemberToOrganization(orgId: string, userId: string): Promise<void> {
// Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede
// Content-Type application/json con il solo id utente come stringa JSON nel
// body (non un oggetto, e non text/plain: entrambi rispondono 415).
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
headers: { 'Content-Type': 'application/json' },
});
}
export interface OrganizationMember {
userId: string;
email: string;
}
export async function listOrganizationMembers(orgId: string): Promise<OrganizationMember[]> {
const response = await keycloakAdminHttp.get<Array<{ id: string; email: string }>>(
`/organizations/${orgId}/members`,
);
return response.data.map((member) => ({ userId: member.id, email: member.email }));
}
export async function removeMemberFromOrganization(orgId: string, userId: string): Promise<void> {
await keycloakAdminHttp.delete(`/organizations/${orgId}/members/${userId}`);
}
@@ -0,0 +1,20 @@
export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> {
// TODO: POST {adminBaseUrl}/users/{userId}/role-mappings/realm con il
// rappresentante del ruolo (richiede prima GET {adminBaseUrl}/roles/{ruolo}
// per ottenerne id e name).
throw new Error('Not implemented');
}
export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> {
// TODO: DELETE {adminBaseUrl}/users/{userId}/role-mappings/realm con il
// rappresentante del ruolo.
throw new Error('Not implemented');
}
export async function getUserRealmRoles(userId: string): Promise<string[]> {
// TODO: GET {adminBaseUrl}/users/{userId}/role-mappings/realm. Il contratto
// di questa funzione è di restituire solo i ruoli scout "custom" (es. le
// voci di ruoliDefault create in POST /gruppi), escludendo i ruoli di
// default di Keycloak (offline_access, uma_authorization, ecc.).
throw new Error('Not implemented');
}
@@ -0,0 +1,60 @@
import axios from 'axios';
import { env } from '../config/env';
import { TokenResponse } from './types';
// Margine di sicurezza sotto il quale consideriamo il token "in scadenza"
// e ne richiediamo uno nuovo, invece di rischiare di usarne uno già scaduto
// a causa della latenza della chiamata Admin API successiva.
const EXPIRY_SAFETY_MARGIN_MS = 10_000;
interface CachedToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: CachedToken | null = null;
let pendingRefresh: Promise<string> | null = null;
function tokenEndpoint(): string {
return `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/token`;
}
async function requestNewToken(): Promise<string> {
const response = await axios.post<TokenResponse>(
tokenEndpoint(),
new URLSearchParams({
grant_type: 'client_credentials',
client_id: env.keycloak.orgServiceClientId,
client_secret: env.keycloak.orgServiceClientSecret,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
const { access_token, expires_in } = response.data;
cachedToken = {
accessToken: access_token,
expiresAt: Date.now() + expires_in * 1000,
};
return cachedToken.accessToken;
}
export async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt - EXPIRY_SAFETY_MARGIN_MS > Date.now()) {
return cachedToken.accessToken;
}
// Se un rinnovo è già in corso, tutte le chiamate concorrenti aspettano
// lo stesso risultato invece di richiedere ciascuna un nuovo token.
if (!pendingRefresh) {
pendingRefresh = requestNewToken().finally(() => {
pendingRefresh = null;
});
}
return pendingRefresh;
}
// Esportata solo per i test: azzera lo stato in-memory del modulo tra un test e l'altro.
export function resetTokenCache(): void {
cachedToken = null;
pendingRefresh = null;
}
@@ -0,0 +1,9 @@
export interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
export interface KeycloakUserSummary {
id: string;
}
@@ -0,0 +1,13 @@
import { KeycloakUserSummary } from './types';
export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> {
// TODO: GET {adminBaseUrl}/users?email={email}&exact=true, restituire il
// primo risultato mappato a { id } oppure null se l'array è vuoto.
throw new Error('Not implemented');
}
export async function createUser(email: string, datiProfilo: object): Promise<{ userId: string }> {
// TODO: POST {adminBaseUrl}/users con { email, ...datiProfilo, enabled: true },
// leggere l'id dall'header Location della risposta.
throw new Error('Not implemented');
}
@@ -0,0 +1,6 @@
export interface AuthContext {
userId: string;
email: string | null;
organizationId: string | null;
roles: string[];
}
@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
import { AuthContext } from './auth.types';
const client = jwksClient({
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
});
interface KeycloakTokenPayload extends JwtPayload {
sub: string;
email?: string;
realm_access?: { roles?: string[] };
// Claim iniettato dalla feature "organizations" di Keycloak: mappa alias
// organizzazione -> { id, roles dell'utente in quella organizzazione }.
// Un token porta al più un'organizzazione attiva per volta.
organization?: Record<string, { id: string; roles?: string[] }>;
}
function extractBearerToken(req: Request): string | null {
const header = req.headers.authorization;
if (!header) {
return null;
}
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return null;
}
return token;
}
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
const realmRoles = payload.realm_access?.roles ?? [];
const [organization] = payload.organization ? Object.values(payload.organization) : [];
const orgRoles = organization?.roles ?? [];
return {
userId: payload.sub,
email: payload.email ?? null,
organizationId: organization?.id ?? null,
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
};
}
export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractBearerToken(req);
if (!token) {
res.status(401).json({ message: 'Token mancante' });
return;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error("Header del token privo di 'kid'");
}
const signingKey = await client.getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
if (typeof payload === 'string') {
throw new Error('Payload del token non valido');
}
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
next();
} catch {
res.status(401).json({ message: 'Token non valido' });
}
}
@@ -0,0 +1,10 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
const hasStatusCode =
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
res.status(statusCode).json({ message });
}
@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
export function requireRole(...ruoliAmmessi: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const roles = req.auth?.roles ?? [];
const autorizzato = ruoliAmmessi.some((ruolo) => roles.includes(ruolo));
if (!autorizzato) {
res.status(403).json({ message: 'Ruolo non autorizzato' });
return;
}
next();
};
}
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole';
import { postGruppo } from '../controllers/gruppi.controller';
export const gruppiRouter = Router();
// TODO: "admin-centrale" è temporaneo, in attesa di definire i ruoli reali
// abilitati alla creazione di un nuovo gruppo scout.
gruppiRouter.post('/gruppi', authenticate, requireRole('admin-centrale'), postGruppo);
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { getHealth } from '../controllers/health.controller';
export const healthRouter = Router();
healthRouter.get('/health', getHealth);
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole';
import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller';
export const invitiRouter = Router();
invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireRole('capo-gruppo'), postInvito);
invitiRouter.get('/inviti/:token', getInvito);
invitiRouter.post('/inviti/:token/accetta', authenticate, postAccettaInvito);
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole';
import { getMembri, putRuoloMembro, deleteMembro } from '../controllers/membri.controller';
export const membriRouter = Router();
membriRouter.get('/gruppi/:orgId/membri', authenticate, requireRole('capo-gruppo'), getMembri);
membriRouter.put('/gruppi/:orgId/membri/:userId/ruolo', authenticate, requireRole('capo-gruppo'), putRuoloMembro);
membriRouter.delete('/gruppi/:orgId/membri/:userId', authenticate, requireRole('capo-gruppo'), deleteMembro);
+6
View File
@@ -0,0 +1,6 @@
import { app } from './app';
import { env } from './config/env';
app.listen(env.port, () => {
console.log(`Server avviato su porta ${env.port}`);
});
@@ -0,0 +1,93 @@
import axios from 'axios';
import { prisma } from '../db/prisma';
import { createOrganization, createOrganizationGroup, addMemberToOrganization } from '../keycloak-admin';
import { HttpError } from '../errors';
export const RUOLI_DEFAULT = ['Capi', 'Aiuto capi', 'Censiti'];
export interface CreateGruppoInput {
nome: string;
userId: string;
regione?: string;
ruoliDefault?: string[];
}
export interface CreateGruppoResult {
orgId: string;
gruppiCreati: string[];
}
function isKeycloakConflict(err: unknown): boolean {
return axios.isAxiosError(err) && err.response?.status === 409;
}
export async function createGruppo(input: CreateGruppoInput): Promise<CreateGruppoResult> {
const ruoliDefault = input.ruoliDefault && input.ruoliDefault.length > 0 ? input.ruoliDefault : RUOLI_DEFAULT;
// Step 1: organizzazione Keycloak.
let orgId: string;
try {
({ orgId } = await createOrganization(input.nome));
} catch (err) {
if (isKeycloakConflict(err)) {
throw new HttpError(409, `Esiste già un gruppo scout con nome "${input.nome}"`);
}
console.error(
`[gruppi] STEP 1 (createOrganization) fallito per nome="${input.nome}". Nessuna risorsa creata: ` +
'il retry può ripartire dall\'inizio.',
err,
);
throw new HttpError(502, "Impossibile creare l'organizzazione su Keycloak");
}
// Step 2: un gruppo Keycloak per ciascun ruolo di default.
const gruppiCreati: string[] = [];
for (const ruolo of ruoliDefault) {
try {
await createOrganizationGroup(orgId, ruolo);
gruppiCreati.push(ruolo);
} catch (err) {
console.error(
`[gruppi] STEP 2 (createOrganizationGroup) fallito per orgId="${orgId}", ruolo="${ruolo}". ` +
`Gruppi già creati con successo su Keycloak: [${gruppiCreati.join(', ')}]. ` +
`L'organizzazione orgId="${orgId}" esiste già su Keycloak: per il retry manuale non richiamare ` +
'createOrganization, ma solo i gruppi ruolo mancanti seguiti dal salvataggio locale.',
err,
);
throw new HttpError(502, 'Impossibile creare uno dei gruppi ruolo su Keycloak');
}
}
// Step 3: il creatore diventa membro dell'organizzazione appena creata.
// Senza questo passaggio il claim "organization" non comparirebbe mai nel
// suo token, e la auth guard del FE lo rimanderebbe sempre su "crea gruppo"
// anche a creazione riuscita.
try {
await addMemberToOrganization(orgId, input.userId);
} catch (err) {
console.error(
`[gruppi] STEP 3 (addMemberToOrganization) fallito per orgId="${orgId}", userId="${input.userId}". ` +
`Organizzazione e gruppi [${gruppiCreati.join(', ')}] già creati su Keycloak: per il retry manuale ` +
`non richiamare le API di creazione, ma solo l'aggiunta del membro con orgId="${orgId}".`,
err,
);
throw new HttpError(502, "Impossibile aggiungere l'utente all'organizzazione su Keycloak");
}
// Step 4: riga locale in gruppo_scout.
try {
await prisma.gruppoScout.create({
data: { orgId, nome: input.nome, regione: input.regione },
});
} catch (err) {
console.error(
`[gruppi] STEP 4 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` +
`Organizzazione, gruppi [${gruppiCreati.join(', ')}] e membership già creati su Keycloak: per il ` +
`retry manuale non richiamare le API Keycloak, ma solo il salvataggio locale con orgId="${orgId}".`,
err,
);
throw new HttpError(500, 'Impossibile salvare il gruppo scout localmente');
}
return { orgId, gruppiCreati };
}
@@ -0,0 +1,10 @@
import { prisma } from '../db/prisma';
export class HealthService {
async checkDatabase(): Promise<boolean> {
await prisma.$queryRaw`SELECT 1`;
return true;
}
}
export const healthService = new HealthService();
@@ -0,0 +1,123 @@
import crypto from 'crypto';
import { prisma } from '../db/prisma';
import { addMemberToOrganization, assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin';
import { HttpError } from '../errors';
import { env } from '../config/env';
export const STATO_INVITO = {
PENDING: 'pending',
ACCETTATO: 'accettato',
} as const;
const DURATA_INVITO_MS = 7 * 24 * 60 * 60 * 1000;
export interface CreaInvitoInput {
orgId: string;
email: string;
ruolo: string;
}
export interface CreaInvitoResult {
invitoId: string;
scadenza: Date;
}
export async function creaInvito(input: CreaInvitoInput): Promise<CreaInvitoResult> {
const token = crypto.randomBytes(32).toString('hex');
const scadenza = new Date(Date.now() + DURATA_INVITO_MS);
const invito = await prisma.invito.create({
data: {
token,
email: input.email,
orgId: input.orgId,
ruolo: input.ruolo,
scadenza,
stato: STATO_INVITO.PENDING,
},
});
// TODO: sostituire con un vero invio email quando sarà disponibile un servizio dedicato.
console.log(`[inviti] invito per ${input.email} (ruolo "${input.ruolo}"): ${env.frontendBaseUrl}/inviti/${token}`);
return { invitoId: invito.id, scadenza };
}
export interface InvitoPubblico {
email: string;
nomeGruppo: string;
ruolo: string;
valido: boolean;
}
export async function getInvitoPubblico(token: string): Promise<InvitoPubblico | null> {
const invito = await prisma.invito.findUnique({
where: { token },
include: { gruppoScout: true },
});
if (!invito) {
return null;
}
const valido = invito.stato === STATO_INVITO.PENDING && invito.scadenza.getTime() > Date.now();
return {
email: invito.email,
nomeGruppo: invito.gruppoScout.nome,
ruolo: invito.ruolo,
valido,
};
}
export interface AccettaInvitoResult {
organizationId: string;
ruolo: string;
}
export async function accettaInvito(
token: string,
utente: { userId: string; email: string | null },
): Promise<AccettaInvitoResult> {
const invito = await prisma.invito.findUnique({ where: { token } });
if (!invito) {
throw new HttpError(404, 'Invito non trovato');
}
if (invito.stato !== STATO_INVITO.PENDING) {
throw new HttpError(409, "L'invito è già stato accettato");
}
if (invito.scadenza.getTime() <= Date.now()) {
throw new HttpError(410, 'Invito scaduto');
}
if (!utente.email || utente.email.toLowerCase() !== invito.email.toLowerCase()) {
throw new HttpError(403, "L'utente autenticato non corrisponde al destinatario dell'invito");
}
try {
await addMemberToOrganization(invito.orgId, utente.userId);
// TODO: "ruolo" è usato qui anche come identificativo del gruppo Keycloak,
// in attesa che createOrganizationGroup persista una mappa ruolo -> groupId
// reale da risolvere in questo punto.
await assignUserToGroup(utente.userId, invito.ruolo);
await assignRealmRoleToUser(utente.userId, invito.ruolo);
} catch (err) {
console.error(
`[inviti] fallita l'assegnazione Keycloak per l'accettazione dell'invito token="${token}", ` +
`userId="${utente.userId}", orgId="${invito.orgId}". Nessun cambio di stato locale: ` +
"l'invito resta 'pending' e l'utente può ritentare l'accettazione.",
err,
);
throw new HttpError(502, "Impossibile completare l'assegnazione su Keycloak");
}
await prisma.invito.update({
where: { token },
data: { stato: STATO_INVITO.ACCETTATO },
});
return { organizationId: invito.orgId, ruolo: invito.ruolo };
}
@@ -0,0 +1,74 @@
import {
listOrganizationMembers,
removeMemberFromOrganization,
assignUserToGroup,
removeUserFromGroup,
getUserGroupsInOrganization,
assignRealmRoleToUser,
removeRealmRoleFromUser,
getUserRealmRoles,
} from '../keycloak-admin';
import { HttpError } from '../errors';
export interface MembroOrganization {
userId: string;
email: string;
ruolo: string | null;
gruppoInterno: string | null;
}
export async function listaMembri(orgId: string): Promise<MembroOrganization[]> {
const membri = await listOrganizationMembers(orgId);
return Promise.all(
membri.map(async (membro) => {
const [gruppi, ruoli] = await Promise.all([
getUserGroupsInOrganization(orgId, membro.userId),
getUserRealmRoles(membro.userId),
]);
return {
userId: membro.userId,
email: membro.email,
gruppoInterno: gruppi[0]?.nome ?? null,
ruolo: ruoli[0] ?? null,
};
}),
);
}
export async function cambiaRuoloMembro(orgId: string, userId: string, nuovoRuolo: string): Promise<void> {
try {
const gruppiAttuali = await getUserGroupsInOrganization(orgId, userId);
for (const gruppo of gruppiAttuali) {
await removeUserFromGroup(userId, gruppo.groupId);
}
// TODO: come nel flusso di invito, "ruolo" è usato anche come
// identificativo del gruppo Keycloak, in attesa di una mappa
// ruolo -> groupId reale (vedi src/services/inviti.service.ts).
await assignUserToGroup(userId, nuovoRuolo);
const ruoliAttuali = await getUserRealmRoles(userId);
for (const ruolo of ruoliAttuali) {
await removeRealmRoleFromUser(userId, ruolo);
}
await assignRealmRoleToUser(userId, nuovoRuolo);
} catch (err) {
console.error(
`[membri] cambio ruolo fallito per orgId="${orgId}", userId="${userId}", nuovoRuolo="${nuovoRuolo}". ` +
"Lo stato su Keycloak potrebbe essere stato aggiornato solo parzialmente: verificare manualmente " +
'gruppo/ruoli correnti dell\'utente prima di ritentare.',
err,
);
throw new HttpError(502, "Impossibile completare l'aggiornamento del ruolo su Keycloak");
}
}
export async function rimuoviMembro(orgId: string, userId: string): Promise<void> {
try {
await removeMemberFromOrganization(orgId, userId);
} catch (err) {
console.error(`[membri] rimozione membro fallita per orgId="${orgId}", userId="${userId}".`, err);
throw new HttpError(502, "Impossibile rimuovere il membro dall'organizzazione su Keycloak");
}
}
+11
View File
@@ -0,0 +1,11 @@
import { AuthContext } from '../middleware/auth.types';
declare global {
namespace Express {
interface Request {
auth?: AuthContext;
}
}
}
export {};
@@ -0,0 +1,195 @@
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 gruppoScoutCreate = jest.fn();
jest.mock('../../src/keycloak-admin', () => ({
createOrganization: (...args: unknown[]) => createOrganization(...args),
createOrganizationGroup: (...args: unknown[]) => createOrganizationGroup(...args),
addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args),
}));
jest.mock('../../src/db/prisma', () => ({
prisma: {
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(roles: string[]): string {
return jwt.sign(
{ sub: 'user-1', realm_access: { roles } },
privateKeyPem,
{ algorithm: 'RS256', keyid: KID, expiresIn: '5m' },
);
}
const adminToken = () => signToken(['admin-centrale']);
const nonAdminToken = () => signToken(['capo-gruppo']);
function conflictError(): Error {
return Object.assign(new Error('Conflict'), { isAxiosError: true, response: { status: 409 } });
}
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', () => {
test('crea organizzazione, gruppi ruolo, membership e riga locale, rispondendo 201', async () => {
createOrganization.mockResolvedValueOnce({ orgId: 'org-1' });
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
addMemberToOrganization.mockResolvedValueOnce(undefined);
gruppoScoutCreate.mockResolvedValueOnce({});
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ nome: 'Gruppo Alfa', regione: 'Lombardia', ruoliDefault: ['Capi', 'Rover'] });
expect(response.status).toBe(201);
expect(response.body).toEqual({ orgId: 'org-1', gruppiCreati: ['Capi', 'Rover'] });
expect(createOrganization).toHaveBeenCalledWith('Gruppo Alfa');
expect(createOrganizationGroup).toHaveBeenCalledTimes(2);
expect(createOrganizationGroup).toHaveBeenNthCalledWith(1, 'org-1', 'Capi');
expect(createOrganizationGroup).toHaveBeenNthCalledWith(2, 'org-1', 'Rover');
expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-1');
expect(gruppoScoutCreate).toHaveBeenCalledWith({
data: { orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' },
});
});
test('usa i ruoli di default quando ruoliDefault non è specificato', async () => {
createOrganization.mockResolvedValueOnce({ orgId: 'org-2' });
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
addMemberToOrganization.mockResolvedValueOnce(undefined);
gruppoScoutCreate.mockResolvedValueOnce({});
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ nome: 'Gruppo Beta' });
expect(response.status).toBe(201);
expect(response.body.gruppiCreati).toEqual(['Capi', 'Aiuto capi', 'Censiti']);
expect(createOrganizationGroup).toHaveBeenCalledTimes(3);
expect(addMemberToOrganization).toHaveBeenCalledWith('org-2', 'user-1');
});
test('risponde 409 se Keycloak segnala un nome duplicato, senza creare la riga locale', async () => {
createOrganization.mockRejectedValueOnce(conflictError());
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ nome: 'Gruppo Già Esistente' });
expect(response.status).toBe(409);
expect(response.body.message).toMatch(/Gruppo Già Esistente/);
expect(createOrganizationGroup).not.toHaveBeenCalled();
expect(gruppoScoutCreate).not.toHaveBeenCalled();
});
test('se la creazione di un gruppo ruolo fallisce a metà, non salva la riga locale e logga il punto di arresto', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
createOrganization.mockResolvedValueOnce({ orgId: 'org-3' });
createOrganizationGroup
.mockResolvedValueOnce({ groupId: 'g-capi' })
.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ nome: 'Gruppo Gamma', ruoliDefault: ['Capi', 'Aiuto capi'] });
expect(response.status).toBe(502);
expect(gruppoScoutCreate).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 2'), expect.anything());
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('org-3'), expect.anything());
consoleErrorSpy.mockRestore();
});
test("se l'aggiunta del creatore all'organizzazione fallisce, non salva la riga locale e logga il punto di arresto", async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
createOrganization.mockResolvedValueOnce({ orgId: 'org-4' });
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
addMemberToOrganization.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ nome: 'Gruppo Delta due' });
expect(response.status).toBe(502);
expect(gruppoScoutCreate).not.toHaveBeenCalled();
expect(addMemberToOrganization).toHaveBeenCalledWith('org-4', 'user-1');
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 3'), expect.anything());
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('org-4'), expect.anything());
consoleErrorSpy.mockRestore();
});
test('risponde 403 se il ruolo non è admin-centrale', async () => {
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${nonAdminToken()}`)
.send({ nome: 'Gruppo Delta' });
expect(response.status).toBe(403);
expect(createOrganization).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/gruppi').send({ nome: 'Gruppo Epsilon' });
expect(response.status).toBe(401);
expect(createOrganization).not.toHaveBeenCalled();
});
test("risponde 400 se manca il campo 'nome'", async () => {
const response = await request(app)
.post('/gruppi')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ regione: 'Piemonte' });
expect(response.status).toBe(400);
expect(createOrganization).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,243 @@
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: [] } });
}
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();
});
});
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();
});
});
@@ -0,0 +1,212 @@
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 listOrganizationMembers = jest.fn();
const removeMemberFromOrganization = jest.fn();
const assignUserToGroup = jest.fn();
const removeUserFromGroup = jest.fn();
const getUserGroupsInOrganization = jest.fn();
const assignRealmRoleToUser = jest.fn();
const removeRealmRoleFromUser = jest.fn();
const getUserRealmRoles = jest.fn();
jest.mock('../../src/keycloak-admin', () => ({
listOrganizationMembers: (...args: unknown[]) => listOrganizationMembers(...args),
removeMemberFromOrganization: (...args: unknown[]) => removeMemberFromOrganization(...args),
assignUserToGroup: (...args: unknown[]) => assignUserToGroup(...args),
removeUserFromGroup: (...args: unknown[]) => removeUserFromGroup(...args),
getUserGroupsInOrganization: (...args: unknown[]) => getUserGroupsInOrganization(...args),
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
removeRealmRoleFromUser: (...args: unknown[]) => removeRealmRoleFromUser(...args),
getUserRealmRoles: (...args: unknown[]) => getUserRealmRoles(...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 nonCapoToken(orgId: string): string {
return signToken({
sub: 'user-censito',
email: 'censito@example.com',
realm_access: { roles: ['censito'] },
organization: { alfa: { id: orgId, roles: [] } },
});
}
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('GET /gruppi/:orgId/membri', () => {
test('restituisce la lista membri con ruolo e gruppo interno', async () => {
listOrganizationMembers.mockResolvedValueOnce([
{ userId: 'user-1', email: 'uno@example.com' },
{ userId: 'user-2', email: 'due@example.com' },
]);
getUserGroupsInOrganization.mockImplementation(async (_orgId: string, userId: string) =>
userId === 'user-1' ? [{ groupId: 'g-capi', nome: 'Capi' }] : [],
);
getUserRealmRoles.mockImplementation(async (userId: string) => (userId === 'user-1' ? ['Capi'] : []));
const response = await request(app)
.get('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([
{ userId: 'user-1', email: 'uno@example.com', ruolo: 'Capi', gruppoInterno: 'Capi' },
{ userId: 'user-2', email: 'due@example.com', ruolo: null, gruppoInterno: null },
]);
expect(listOrganizationMembers).toHaveBeenCalledWith('org-1');
});
test("risponde 403 se l'organization non è la propria", async () => {
const response = await request(app)
.get('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`);
expect(response.status).toBe(403);
expect(listOrganizationMembers).not.toHaveBeenCalled();
});
test('risponde 403 se manca il ruolo capo-gruppo', async () => {
const response = await request(app)
.get('/gruppi/org-1/membri')
.set('Authorization', `Bearer ${nonCapoToken('org-1')}`);
expect(response.status).toBe(403);
expect(listOrganizationMembers).not.toHaveBeenCalled();
});
});
describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => {
test('rimuove gruppo e ruolo correnti e assegna quelli nuovi', async () => {
getUserGroupsInOrganization.mockResolvedValueOnce([{ groupId: 'g-capi', nome: 'Capi' }]);
getUserRealmRoles.mockResolvedValueOnce(['Capi']);
const response = await request(app)
.put('/gruppi/org-1/membri/user-1/ruolo')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ ruolo: 'Aiuto capi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ userId: 'user-1', ruolo: 'Aiuto capi' });
expect(removeUserFromGroup).toHaveBeenCalledWith('user-1', 'g-capi');
expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Aiuto capi');
expect(removeRealmRoleFromUser).toHaveBeenCalledWith('user-1', 'Capi');
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-1', 'Aiuto capi');
});
test("risponde 403 se l'organization non è la propria", async () => {
const response = await request(app)
.put('/gruppi/org-1/membri/user-1/ruolo')
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`)
.send({ ruolo: 'Aiuto capi' });
expect(response.status).toBe(403);
expect(assignUserToGroup).not.toHaveBeenCalled();
});
test("risponde 400 se manca il campo 'ruolo'", async () => {
const response = await request(app)
.put('/gruppi/org-1/membri/user-1/ruolo')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({});
expect(response.status).toBe(400);
expect(assignUserToGroup).not.toHaveBeenCalled();
});
test('risponde 502 se una chiamata Keycloak fallisce', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
getUserGroupsInOrganization.mockResolvedValueOnce([]);
getUserRealmRoles.mockResolvedValueOnce([]);
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.put('/gruppi/org-1/membri/user-1/ruolo')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
.send({ ruolo: 'Aiuto capi' });
expect(response.status).toBe(502);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('DELETE /gruppi/:orgId/membri/:userId', () => {
test("rimuove il membro dall'organization e risponde 204", async () => {
removeMemberFromOrganization.mockResolvedValueOnce(undefined);
const response = await request(app)
.delete('/gruppi/org-1/membri/user-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
expect(response.status).toBe(204);
expect(removeMemberFromOrganization).toHaveBeenCalledWith('org-1', 'user-1');
});
test("risponde 403 se l'organization non è la propria", async () => {
const response = await request(app)
.delete('/gruppi/org-1/membri/user-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`);
expect(response.status).toBe(403);
expect(removeMemberFromOrganization).not.toHaveBeenCalled();
});
test('risponde 502 se Keycloak fallisce nel rimuovere il membro', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
removeMemberFromOrganization.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
const response = await request(app)
.delete('/gruppi/org-1/membri/user-1')
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
expect(response.status).toBe(502);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
@@ -0,0 +1,102 @@
import nock from 'nock';
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';
import { getAccessToken, resetTokenCache } from '../../../src/keycloak-admin/tokenManager';
const KEYCLOAK_HOST = 'http://keycloak.test';
const TOKEN_PATH = '/realms/scouthub/protocol/openid-connect/token';
function mockTokenEndpoint(accessToken: string, expiresInSeconds: number) {
return nock(KEYCLOAK_HOST)
.post(TOKEN_PATH)
.reply(200, { access_token: accessToken, expires_in: expiresInSeconds, token_type: 'Bearer' });
}
// Il modulo calcola la scadenza con Date.now(): per simulare il passare del
// tempo mockiamo solo Date.now (non i timer), così le richieste HTTP mockate
// da nock continuano a risolversi normalmente sull'event loop reale.
let nowSpy: jest.SpyInstance<number, []> | undefined;
function advanceTimeBy(ms: number): void {
const current = nowSpy ? (nowSpy.getMockImplementation()?.() ?? Date.now()) : Date.now();
nowSpy = jest.spyOn(Date, 'now').mockReturnValue(current + ms);
}
beforeEach(() => {
resetTokenCache();
});
afterEach(() => {
nowSpy?.mockRestore();
nowSpy = undefined;
nock.cleanAll();
});
describe('keycloak-admin tokenManager', () => {
test("ottiene un access token dall'endpoint client_credentials", async () => {
const scope = mockTokenEndpoint('token-1', 300);
const token = await getAccessToken();
expect(token).toBe('token-1');
expect(scope.isDone()).toBe(true);
});
test('riutilizza il token in cache invece di richiederne uno nuovo ad ogni chiamata', async () => {
const scope = mockTokenEndpoint('token-1', 300);
const first = await getAccessToken();
const second = await getAccessToken();
const third = await getAccessToken();
expect(first).toBe('token-1');
expect(second).toBe('token-1');
expect(third).toBe('token-1');
// Un solo interceptor registrato (consumato una volta sola): se il codice
// avesse richiesto un nuovo token per ogni chiamata, la seconda/terza
// sarebbero fallite per mancanza di un match su nock.
expect(scope.isDone()).toBe(true);
});
test('rinnova il token quando è vicino alla scadenza (entro il margine di sicurezza)', async () => {
mockTokenEndpoint('token-1', 300);
await getAccessToken();
// 295s dopo: mancano 5s alla scadenza reale, sotto al margine di sicurezza di 10s.
advanceTimeBy(295_000);
const renewalScope = mockTokenEndpoint('token-2', 300);
const renewed = await getAccessToken();
expect(renewed).toBe('token-2');
expect(renewalScope.isDone()).toBe(true);
});
test('non rinnova il token se la scadenza è ancora lontana', async () => {
mockTokenEndpoint('token-1', 300);
await getAccessToken();
advanceTimeBy(60_000); // ben dentro la validità dei 300s
const token = await getAccessToken();
expect(token).toBe('token-1');
});
test('richieste concorrenti senza token in cache generano una sola chiamata HTTP', async () => {
const scope = mockTokenEndpoint('token-1', 300);
const [a, b, c] = await Promise.all([getAccessToken(), getAccessToken(), getAccessToken()]);
expect(a).toBe('token-1');
expect(b).toBe('token-1');
expect(c).toBe('token-1');
expect(scope.isDone()).toBe(true);
});
});
@@ -0,0 +1,128 @@
import { generateKeyPairSync } from 'crypto';
import express from 'express';
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';
import { authenticate } from '../../../src/middleware/authenticate';
import { requireRole } from '../../../src/middleware/requireRole';
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;
// Keypair "estranea": usata per firmare token che non devono validare contro
// le chiavi pubblicate nel JWKS mockato (firma non corrispondente).
const { privateKey: rogueKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const rogueKeyPem = rogueKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function mockJwks(): void {
nock(KEYCLOAK_HOST)
.persist()
.get(CERTS_PATH)
.reply(200, { keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }] });
}
function signValidToken(payload: object, signOptions: jwt.SignOptions = {}): string {
return jwt.sign(payload, privateKeyPem, {
algorithm: 'RS256',
keyid: KID,
expiresIn: '5m',
...signOptions,
});
}
const CAPO_GRUPPO_PAYLOAD = {
sub: 'user-123',
email: 'capo@example.com',
realm_access: { roles: ['capo-gruppo', 'utente'] },
organization: {
'gruppo-alfa': { id: 'org-1', roles: ['owner'] },
},
};
function buildApp() {
const app = express();
app.get('/capo', authenticate, requireRole('capo-gruppo'), (req, res) => {
res.json({ auth: req.auth });
});
app.get('/solo-admin', authenticate, requireRole('super-admin'), (req, res) => {
res.json({ auth: req.auth });
});
return app;
}
beforeAll(() => {
mockJwks();
});
afterAll(() => {
nock.cleanAll();
});
describe('authenticate + requireRole', () => {
test('restituisce 401 se manca il token', async () => {
const app = buildApp();
const response = await request(app).get('/capo');
expect(response.status).toBe(401);
});
test('restituisce 401 se il token è scaduto', async () => {
const app = buildApp();
const token = signValidToken(CAPO_GRUPPO_PAYLOAD, { expiresIn: '-10s' });
const response = await request(app).get('/capo').set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(401);
});
test('restituisce 401 se il token ha una firma non valida', async () => {
const app = buildApp();
const token = jwt.sign(CAPO_GRUPPO_PAYLOAD, rogueKeyPem, {
algorithm: 'RS256',
keyid: KID,
expiresIn: '5m',
});
const response = await request(app).get('/capo').set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(401);
});
test('restituisce 403 se il token è valido ma manca il ruolo richiesto', async () => {
const app = buildApp();
const token = signValidToken(CAPO_GRUPPO_PAYLOAD);
const response = await request(app).get('/solo-admin').set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(403);
});
test('restituisce 200 e popola req.auth se il token è valido con il ruolo corretto', async () => {
const app = buildApp();
const token = signValidToken(CAPO_GRUPPO_PAYLOAD);
const response = await request(app).get('/capo').set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body.auth).toEqual({
userId: 'user-123',
email: 'capo@example.com',
organizationId: 'org-1',
roles: ['capo-gruppo', 'utente', 'owner'],
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"declaration": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}