Add scouthub-home-be
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
/home-be.iml
|
||||||
|
/.idea/
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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;
|
||||||
Generated
+6092
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user