Fix scouthub-home-be
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# 8082, non 8081: la porta 8081 è quella su cui il Keycloak del
|
# 8000, non 6999: la porta 6999 e' quella su cui il Keycloak del
|
||||||
# docker-compose alla radice del progetto è esposto sull'host.
|
# docker-compose alla radice del progetto e' esposto sull'host.
|
||||||
PORT=8082
|
PORT=8000
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_home?schema=public
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_home?schema=public
|
||||||
FRONTEND_BASE_URL=http://localhost:4200
|
FRONTEND_BASE_URL=http://localhost:7000
|
||||||
|
|
||||||
KEYCLOAK_BASE_URL=http://localhost:8081
|
KEYCLOAK_BASE_URL=http://localhost:6999
|
||||||
KEYCLOAK_REALM=scouthub
|
KEYCLOAK_REALM=scouthub
|
||||||
KEYCLOAK_ORG_SERVICE_CLIENT_ID=scouthub-home-be
|
KEYCLOAK_ORG_SERVICE_CLIENT_ID=scouthub-home-be
|
||||||
KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT
|
KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ interamente `src/keycloak-admin` e `src/db/prisma`.
|
|||||||
Crea un nuovo gruppo scout: organizzazione Keycloak, un gruppo Keycloak per
|
Crea un nuovo gruppo scout: organizzazione Keycloak, un gruppo Keycloak per
|
||||||
ciascun ruolo di default, e la riga corrispondente in `gruppo_scout`.
|
ciascun ruolo di default, e la riga corrispondente in `gruppo_scout`.
|
||||||
|
|
||||||
- Richiede autenticazione + ruolo `admin-centrale` (temporaneo, vedi
|
- Richiede autenticazione + ruolo `admin` (temporaneo, vedi
|
||||||
`src/routes/gruppi.routes.ts`)
|
`src/routes/gruppi.routes.ts`)
|
||||||
- Body: `{ nome: string, regione?: string, ruoliDefault?: string[] }`
|
- Body: `{ nome: string, regione?: string, ruoliDefault?: string[] }`
|
||||||
(`ruoliDefault` di default `["Capi", "Aiuto capi", "Censiti"]`)
|
(`ruoliDefault` di default `["Capi", "Aiuto capi", "Censiti"]`)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Baseline migration representing the schema already present in the database
|
||||||
|
-- before migration history was introduced (created via `prisma db push`).
|
||||||
|
CREATE TABLE "gruppo_scout" (
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"nome" TEXT NOT NULL,
|
||||||
|
"regione" TEXT,
|
||||||
|
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "gruppo_scout_pkey" PRIMARY KEY ("org_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "invito" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"ruolo" TEXT NOT NULL,
|
||||||
|
"scadenza" TIMESTAMP(3) NOT NULL,
|
||||||
|
"stato" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "invito_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "invito_token_key" ON "invito"("token");
|
||||||
|
|
||||||
|
ALTER TABLE "invito" ADD CONSTRAINT "invito_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "gruppo_scout"("org_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StatoRichiesta" AS ENUM ('PENDING', 'APPROVATA', 'RIFIUTATA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrigineRichiesta" AS ENUM ('LINK', 'PROFILO');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "richiesta_ingresso" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"stato" "StatoRichiesta" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"origine" "OrigineRichiesta" NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "richiesta_ingresso_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "link_ingresso" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"attivo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"creato_da" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "link_ingresso_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "richiesta_creazione_gruppo" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"nome_proposto" TEXT NOT NULL,
|
||||||
|
"regione" TEXT,
|
||||||
|
"stato" "StatoRichiesta" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "richiesta_creazione_gruppo_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "richiesta_ingresso_email_idx" ON "richiesta_ingresso"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "link_ingresso_token_key" ON "link_ingresso"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "richiesta_creazione_gruppo_email_idx" ON "richiesta_creazione_gruppo"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "richiesta_ingresso" ADD CONSTRAINT "richiesta_ingresso_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "gruppo_scout"("org_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "link_ingresso" ADD CONSTRAINT "link_ingresso_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "gruppo_scout"("org_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -13,7 +13,9 @@ model GruppoScout {
|
|||||||
regione String?
|
regione String?
|
||||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||||
|
|
||||||
inviti Invito[]
|
inviti Invito[]
|
||||||
|
richiesteIngresso RichiestaIngresso[]
|
||||||
|
linkIngresso LinkIngresso[]
|
||||||
|
|
||||||
@@map("gruppo_scout")
|
@@map("gruppo_scout")
|
||||||
}
|
}
|
||||||
@@ -31,3 +33,57 @@ model Invito {
|
|||||||
|
|
||||||
@@map("invito")
|
@@map("invito")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StatoRichiesta {
|
||||||
|
PENDING
|
||||||
|
APPROVATA
|
||||||
|
RIFIUTATA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrigineRichiesta {
|
||||||
|
LINK
|
||||||
|
PROFILO
|
||||||
|
}
|
||||||
|
|
||||||
|
model RichiestaIngresso {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
email String
|
||||||
|
orgId String @map("org_id")
|
||||||
|
stato StatoRichiesta @default(PENDING)
|
||||||
|
origine OrigineRichiesta
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
gruppoScout GruppoScout @relation(fields: [orgId], references: [orgId])
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@map("richiesta_ingresso")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LinkIngresso {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
token String @unique
|
||||||
|
orgId String @map("org_id")
|
||||||
|
attivo Boolean @default(true)
|
||||||
|
creatoDa String @map("creato_da")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
gruppoScout GruppoScout @relation(fields: [orgId], references: [orgId])
|
||||||
|
|
||||||
|
@@map("link_ingresso")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RichiestaCreazioneGruppo {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
email String
|
||||||
|
nomeProposto String @map("nome_proposto")
|
||||||
|
regione String?
|
||||||
|
stato StatoRichiesta @default(PENDING)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@map("richiesta_creazione_gruppo")
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { healthRouter } from './routes/health.routes';
|
|||||||
import { gruppiRouter } from './routes/gruppi.routes';
|
import { gruppiRouter } from './routes/gruppi.routes';
|
||||||
import { invitiRouter } from './routes/inviti.routes';
|
import { invitiRouter } from './routes/inviti.routes';
|
||||||
import { membriRouter } from './routes/membri.routes';
|
import { membriRouter } from './routes/membri.routes';
|
||||||
|
import { linkIngressoRouter } from './routes/linkIngresso.routes';
|
||||||
|
import { richiesteIngressoRouter } from './routes/richiesteIngresso.routes';
|
||||||
|
import { richiesteCreazioneGruppoRouter } from './routes/richiesteCreazioneGruppo.routes';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
@@ -15,6 +18,9 @@ app.use(healthRouter);
|
|||||||
app.use(gruppiRouter);
|
app.use(gruppiRouter);
|
||||||
app.use(invitiRouter);
|
app.use(invitiRouter);
|
||||||
app.use(membriRouter);
|
app.use(membriRouter);
|
||||||
|
app.use(linkIngressoRouter);
|
||||||
|
app.use(richiesteIngressoRouter);
|
||||||
|
app.use(richiesteCreazioneGruppoRouter);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ message: 'not found' });
|
res.status(404).json({ message: 'not found' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { createGruppo } from '../services/gruppi.service';
|
import { createGruppo, listGruppi, listGruppiPubblico } from '../services/gruppi.service';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
interface PostGruppoBody {
|
interface PostGruppoBody {
|
||||||
@@ -40,3 +40,21 @@ export async function postGruppo(req: Request, res: Response, next: NextFunction
|
|||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGruppi(_req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const gruppi = await listGruppi();
|
||||||
|
res.status(200).json(gruppi);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGruppiElencoPubblico(_req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const gruppi = await listGruppiPubblico();
|
||||||
|
res.status(200).json(gruppi);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ function parseCreaInvitoBody(body: PostInvitoBody): { email: string; ruolo: stri
|
|||||||
export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
|
export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
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 { email, ruolo } = parseCreaInvitoBody(req.body ?? {});
|
||||||
const result = await creaInvito({ orgId, email, ruolo });
|
const result = await creaInvito({ orgId, email, ruolo });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { creaOTrovaLinkIngresso, getLinkIngressoPubblico, richiediIngresso } from '../services/linkIngresso.service';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
|
export async function postLinkIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { orgId } = req.params;
|
||||||
|
const { giaEsistente, ...result } = await creaOTrovaLinkIngresso(orgId, req.auth!.userId);
|
||||||
|
res.status(giaEsistente ? 200 : 201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinkIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { token } = req.params;
|
||||||
|
const link = await getLinkIngressoPubblico(token);
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new HttpError(404, 'Link di ingresso non trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(link);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postRichiediIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { token } = req.params;
|
||||||
|
const result = await richiediIngresso(token, {
|
||||||
|
userId: req.auth!.userId,
|
||||||
|
email: req.auth!.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { listaMembri, cambiaRuoloMembro, rimuoviMembro } from '../services/membri.service';
|
import { listaMembri, cambiaRuoloMembro, rimuoviMembro, aggiungiMembro } from '../services/membri.service';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
function checkOrgAccess(req: Request): string {
|
interface PostMembroBody {
|
||||||
const { orgId } = req.params;
|
email?: unknown;
|
||||||
if (req.auth?.organizationId !== orgId) {
|
ruolo?: unknown;
|
||||||
throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua");
|
}
|
||||||
|
|
||||||
|
function parseAggiungiMembroBody(body: PostMembroBody): { 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 postMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { orgId } = req.params;
|
||||||
|
const { email, ruolo } = parseAggiungiMembroBody(req.body ?? {});
|
||||||
|
const result = await aggiungiMembro({ orgId, email, ruolo });
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
}
|
}
|
||||||
return orgId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> {
|
export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const orgId = checkOrgAccess(req);
|
const { orgId } = req.params;
|
||||||
const membri = await listaMembri(orgId);
|
const membri = await listaMembri(orgId);
|
||||||
res.json(membri);
|
res.json(membri);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -22,8 +40,7 @@ export async function getMembri(req: Request, res: Response, next: NextFunction)
|
|||||||
|
|
||||||
export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const orgId = checkOrgAccess(req);
|
const { orgId, userId } = req.params;
|
||||||
const { userId } = req.params;
|
|
||||||
const { ruolo } = req.body ?? {};
|
const { ruolo } = req.body ?? {};
|
||||||
|
|
||||||
if (typeof ruolo !== 'string' || ruolo.trim().length === 0) {
|
if (typeof ruolo !== 'string' || ruolo.trim().length === 0) {
|
||||||
@@ -39,8 +56,7 @@ export async function putRuoloMembro(req: Request, res: Response, next: NextFunc
|
|||||||
|
|
||||||
export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const orgId = checkOrgAccess(req);
|
const { orgId, userId } = req.params;
|
||||||
const { userId } = req.params;
|
|
||||||
|
|
||||||
await rimuoviMembro(orgId, userId);
|
await rimuoviMembro(orgId, userId);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import {
|
||||||
|
creaRichiestaCreazioneGruppo,
|
||||||
|
listaRichiestePending,
|
||||||
|
approvaRichiesta,
|
||||||
|
rifiutaRichiesta,
|
||||||
|
} from '../services/richiesteCreazioneGruppo.service';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
|
interface PostRichiestaBody {
|
||||||
|
nomeProposto?: unknown;
|
||||||
|
regione?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePostBody(body: PostRichiestaBody): { nomeProposto: string; regione?: string } {
|
||||||
|
if (typeof body.nomeProposto !== 'string' || body.nomeProposto.trim().length === 0) {
|
||||||
|
throw new HttpError(400, "Il campo 'nomeProposto' è 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nomeProposto: body.nomeProposto, regione: body.regione as string | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postRichiestaCreazioneGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { nomeProposto, regione } = parsePostBody(req.body ?? {});
|
||||||
|
const result = await creaRichiestaCreazioneGruppo({
|
||||||
|
userId: req.auth!.userId,
|
||||||
|
email: req.auth!.email,
|
||||||
|
nomeProposto,
|
||||||
|
regione,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRichiesteCreazioneGruppo(_req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const richieste = await listaRichiestePending();
|
||||||
|
res.json(richieste);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PutRichiestaBody {
|
||||||
|
esito?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePutBody(body: PutRichiestaBody): 'approvata' | 'rifiutata' {
|
||||||
|
if (body.esito !== 'approvata' && body.esito !== 'rifiutata') {
|
||||||
|
throw new HttpError(400, "Il campo 'esito' deve essere 'approvata' o 'rifiutata'");
|
||||||
|
}
|
||||||
|
return body.esito;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putRichiestaCreazioneGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const esito = parsePutBody(req.body ?? {});
|
||||||
|
|
||||||
|
const result = esito === 'approvata' ? await approvaRichiesta(id) : await rifiutaRichiesta(id);
|
||||||
|
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import {
|
||||||
|
creaRichiestaIngresso,
|
||||||
|
listaRichiestePending,
|
||||||
|
approvaRichiesta,
|
||||||
|
rifiutaRichiesta,
|
||||||
|
ORIGINE_RICHIESTA,
|
||||||
|
} from '../services/richiesteIngresso.service';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
|
export async function postRichiestaIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { orgId } = req.params;
|
||||||
|
const result = await creaRichiestaIngresso(
|
||||||
|
orgId,
|
||||||
|
{ userId: req.auth!.userId, email: req.auth!.email },
|
||||||
|
ORIGINE_RICHIESTA.PROFILO,
|
||||||
|
);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRichiesteIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { orgId } = req.params;
|
||||||
|
const richieste = await listaRichiestePending(orgId);
|
||||||
|
res.json(richieste);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PutRichiestaBody {
|
||||||
|
esito?: unknown;
|
||||||
|
ruolo?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePutBody(body: PutRichiestaBody): { esito: 'approvata' | 'rifiutata'; ruolo?: string } {
|
||||||
|
if (body.esito !== 'approvata' && body.esito !== 'rifiutata') {
|
||||||
|
throw new HttpError(400, "Il campo 'esito' deve essere 'approvata' o 'rifiutata'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.esito === 'approvata') {
|
||||||
|
if (typeof body.ruolo !== 'string' || body.ruolo.trim().length === 0) {
|
||||||
|
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio quando esito è 'approvata'");
|
||||||
|
}
|
||||||
|
return { esito: body.esito, ruolo: body.ruolo };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { esito: body.esito };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putRichiestaIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { orgId, id } = req.params;
|
||||||
|
const { esito, ruolo } = parsePutBody(req.body ?? {});
|
||||||
|
|
||||||
|
const result =
|
||||||
|
esito === 'approvata' ? await approvaRichiesta(orgId, id, ruolo!) : await rifiutaRichiesta(orgId, id);
|
||||||
|
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import { keycloakAdminHttp } from './httpClient';
|
||||||
|
import { GRUPPO_PADRE_PREFIX } from './organizations';
|
||||||
|
|
||||||
export async function assignUserToGroup(userId: string, groupId: string): Promise<void> {
|
export async function assignUserToGroup(userId: string, groupId: string): Promise<void> {
|
||||||
// TODO: PUT {adminBaseUrl}/users/{userId}/groups/{groupId}
|
await keycloakAdminHttp.put(`/users/${userId}/groups/${groupId}`);
|
||||||
throw new Error('Not implemented');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> {
|
export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> {
|
||||||
// TODO: DELETE {adminBaseUrl}/users/{userId}/groups/{groupId}
|
await keycloakAdminHttp.delete(`/users/${userId}/groups/${groupId}`);
|
||||||
throw new Error('Not implemented');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserGroup {
|
export interface UserGroup {
|
||||||
@@ -14,8 +15,16 @@ export interface UserGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> {
|
export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> {
|
||||||
// TODO: GET {adminBaseUrl}/users/{userId}/groups, filtrati ai gruppi che
|
const response = await keycloakAdminHttp.get<Array<{ id: string; name: string; path: string }>>(
|
||||||
// appartengono all'albero dei gruppi dell'organizzazione orgId (o endpoint
|
`/users/${userId}/groups`,
|
||||||
// dedicato, da verificare in base alla versione di Keycloak).
|
);
|
||||||
throw new Error('Not implemented');
|
|
||||||
|
// I gruppi ruolo di un'organizzazione vivono tutti sotto il gruppo padre
|
||||||
|
// "org-{orgId}" (vedi organizations.ts): filtriamo per path invece che
|
||||||
|
// interrogare Keycloak org per org, perché l'endpoint utente non supporta
|
||||||
|
// un filtro nativo per organizzazione.
|
||||||
|
const prefix = `/${GRUPPO_PADRE_PREFIX}${orgId}/`;
|
||||||
|
return response.data
|
||||||
|
.filter((group) => group.path.startsWith(prefix))
|
||||||
|
.map((group) => ({ groupId: group.id, nome: group.name }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ export {
|
|||||||
} from './organizations';
|
} from './organizations';
|
||||||
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
|
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
|
||||||
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
|
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
|
||||||
export { findUserByEmail, createUser } from './users';
|
export { findUserByEmail } from './users';
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function createOrganization(nome: string): Promise<{ orgId: string
|
|||||||
// dell'organizzazione (non elencato da GET /groups, ma verificato contro
|
// dell'organizzazione (non elencato da GET /groups, ma verificato contro
|
||||||
// un'istanza reale: un POST /groups con quel nome esatto risponde comunque
|
// un'istanza reale: un POST /groups con quel nome esatto risponde comunque
|
||||||
// 409 "already exists"). Va quindi evitato un nome che collida con quello.
|
// 409 "already exists"). Va quindi evitato un nome che collida con quello.
|
||||||
const GRUPPO_PADRE_PREFIX = 'org-';
|
export const GRUPPO_PADRE_PREFIX = 'org-';
|
||||||
|
|
||||||
export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> {
|
export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> {
|
||||||
// Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo
|
// Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
|
import { keycloakAdminHttp } from './httpClient';
|
||||||
|
|
||||||
|
interface RealmRoleRepresentation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruoli assegnati automaticamente da Keycloak a ogni utente (non gestiti da
|
||||||
|
// questa app): il composite "default-roles-{realm}" e i ruoli che include.
|
||||||
|
const RUOLI_DEFAULT_KEYCLOAK = new Set(['offline_access', 'uma_authorization']);
|
||||||
|
|
||||||
|
function isRuoloDefaultKeycloak(nome: string): boolean {
|
||||||
|
return RUOLI_DEFAULT_KEYCLOAK.has(nome) || nome.startsWith('default-roles-');
|
||||||
|
}
|
||||||
|
|
||||||
export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> {
|
export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> {
|
||||||
// TODO: POST {adminBaseUrl}/users/{userId}/role-mappings/realm con il
|
const { data: ruoloRepresentation } = await keycloakAdminHttp.get<RealmRoleRepresentation>(`/roles/${ruolo}`);
|
||||||
// rappresentante del ruolo (richiede prima GET {adminBaseUrl}/roles/{ruolo}
|
await keycloakAdminHttp.post(`/users/${userId}/role-mappings/realm`, [ruoloRepresentation]);
|
||||||
// per ottenerne id e name).
|
|
||||||
throw new Error('Not implemented');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> {
|
export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> {
|
||||||
// TODO: DELETE {adminBaseUrl}/users/{userId}/role-mappings/realm con il
|
const { data: ruoloRepresentation } = await keycloakAdminHttp.get<RealmRoleRepresentation>(`/roles/${ruolo}`);
|
||||||
// rappresentante del ruolo.
|
await keycloakAdminHttp.delete(`/users/${userId}/role-mappings/realm`, { data: [ruoloRepresentation] });
|
||||||
throw new Error('Not implemented');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserRealmRoles(userId: string): Promise<string[]> {
|
export async function getUserRealmRoles(userId: string): Promise<string[]> {
|
||||||
// TODO: GET {adminBaseUrl}/users/{userId}/role-mappings/realm. Il contratto
|
const response = await keycloakAdminHttp.get<RealmRoleRepresentation[]>(`/users/${userId}/role-mappings/realm`);
|
||||||
// di questa funzione è di restituire solo i ruoli scout "custom" (es. le
|
return response.data.map((ruolo) => ruolo.name).filter((nome) => !isRuoloDefaultKeycloak(nome));
|
||||||
// voci di ruoliDefault create in POST /gruppi), escludendo i ruoli di
|
|
||||||
// default di Keycloak (offline_access, uma_authorization, ecc.).
|
|
||||||
throw new Error('Not implemented');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
|
import { keycloakAdminHttp } from './httpClient';
|
||||||
import { KeycloakUserSummary } from './types';
|
import { KeycloakUserSummary } from './types';
|
||||||
|
|
||||||
export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> {
|
export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> {
|
||||||
// TODO: GET {adminBaseUrl}/users?email={email}&exact=true, restituire il
|
const response = await keycloakAdminHttp.get<Array<{ id: string }>>('/users', {
|
||||||
// primo risultato mappato a { id } oppure null se l'array è vuoto.
|
params: { email, exact: true },
|
||||||
throw new Error('Not implemented');
|
});
|
||||||
}
|
const [utente] = response.data;
|
||||||
|
return utente ? { id: utente.id } : null;
|
||||||
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,20 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
// admin opera su qualsiasi organizzazione; capo-gruppo resta
|
||||||
|
// vincolato alla propria (req.auth.organizationId deve coincidere con
|
||||||
|
// req.params.orgId).
|
||||||
|
export function requireOrgAccess(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const roles = req.auth?.roles ?? [];
|
||||||
|
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.includes('capo-gruppo') && req.auth?.organizationId === req.params.orgId) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({ message: "Non sei autorizzato ad operare su questa organizzazione" });
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../middleware/authenticate';
|
import { authenticate } from '../middleware/authenticate';
|
||||||
import { requireRole } from '../middleware/requireRole';
|
import { requireRole } from '../middleware/requireRole';
|
||||||
import { postGruppo } from '../controllers/gruppi.controller';
|
import { postGruppo, getGruppi, getGruppiElencoPubblico } from '../controllers/gruppi.controller';
|
||||||
|
|
||||||
export const gruppiRouter = Router();
|
export const gruppiRouter = Router();
|
||||||
|
|
||||||
// TODO: "admin-centrale" è temporaneo, in attesa di definire i ruoli reali
|
// "admin" è la porta d'accesso diretta e definitiva a queste due route: un
|
||||||
// abilitati alla creazione di un nuovo gruppo scout.
|
// utente normale non le chiama mai a mano, ma passa dal flusso self-service
|
||||||
gruppiRouter.post('/gruppi', authenticate, requireRole('admin-centrale'), postGruppo);
|
// di richiesta creazione gruppo (POST /richieste-creazione-gruppo), che alla
|
||||||
|
// review positiva invoca createGruppo() internamente (vedi
|
||||||
|
// richiesteCreazioneGruppo.service.ts:98), bypassando questo controllo.
|
||||||
|
gruppiRouter.post('/gruppi', authenticate, requireRole('admin'), postGruppo);
|
||||||
|
gruppiRouter.get('/gruppi', authenticate, requireRole('admin'), getGruppi);
|
||||||
|
|
||||||
|
// Elenco minimale (orgId/nome) aperto a qualsiasi utente autenticato, usato
|
||||||
|
// dal percorso "richiedi di entrare in un gruppo esistente" del profilo.
|
||||||
|
gruppiRouter.get('/gruppi/elenco-pubblico', authenticate, getGruppiElencoPubblico);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../middleware/authenticate';
|
import { authenticate } from '../middleware/authenticate';
|
||||||
import { requireRole } from '../middleware/requireRole';
|
import { requireOrgAccess } from '../middleware/requireOrgAccess';
|
||||||
import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller';
|
import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller';
|
||||||
|
|
||||||
export const invitiRouter = Router();
|
export const invitiRouter = Router();
|
||||||
|
|
||||||
invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireRole('capo-gruppo'), postInvito);
|
invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireOrgAccess, postInvito);
|
||||||
|
|
||||||
invitiRouter.get('/inviti/:token', getInvito);
|
invitiRouter.get('/inviti/:token', getInvito);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authenticate';
|
||||||
|
import { requireOrgAccess } from '../middleware/requireOrgAccess';
|
||||||
|
import { postLinkIngresso, getLinkIngresso, postRichiediIngresso } from '../controllers/linkIngresso.controller';
|
||||||
|
|
||||||
|
export const linkIngressoRouter = Router();
|
||||||
|
|
||||||
|
linkIngressoRouter.post('/gruppi/:orgId/link-ingresso', authenticate, requireOrgAccess, postLinkIngresso);
|
||||||
|
|
||||||
|
linkIngressoRouter.get('/link-ingresso/:token', getLinkIngresso);
|
||||||
|
|
||||||
|
linkIngressoRouter.post('/link-ingresso/:token/richiedi', authenticate, postRichiediIngresso);
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../middleware/authenticate';
|
import { authenticate } from '../middleware/authenticate';
|
||||||
|
import { requireOrgAccess } from '../middleware/requireOrgAccess';
|
||||||
import { requireRole } from '../middleware/requireRole';
|
import { requireRole } from '../middleware/requireRole';
|
||||||
import { getMembri, putRuoloMembro, deleteMembro } from '../controllers/membri.controller';
|
import { getMembri, putRuoloMembro, deleteMembro, postMembro } from '../controllers/membri.controller';
|
||||||
|
|
||||||
export const membriRouter = Router();
|
export const membriRouter = Router();
|
||||||
|
|
||||||
membriRouter.get('/gruppi/:orgId/membri', authenticate, requireRole('capo-gruppo'), getMembri);
|
// Riservato ad admin: aggiunge un membro già registrato senza
|
||||||
membriRouter.put('/gruppi/:orgId/membri/:userId/ruolo', authenticate, requireRole('capo-gruppo'), putRuoloMembro);
|
// passare dal flusso di invito, su qualsiasi organizzazione.
|
||||||
membriRouter.delete('/gruppi/:orgId/membri/:userId', authenticate, requireRole('capo-gruppo'), deleteMembro);
|
membriRouter.post('/gruppi/:orgId/membri', authenticate, requireRole('admin'), postMembro);
|
||||||
|
|
||||||
|
membriRouter.get('/gruppi/:orgId/membri', authenticate, requireOrgAccess, getMembri);
|
||||||
|
membriRouter.put('/gruppi/:orgId/membri/:userId/ruolo', authenticate, requireOrgAccess, putRuoloMembro);
|
||||||
|
membriRouter.delete('/gruppi/:orgId/membri/:userId', authenticate, requireOrgAccess, deleteMembro);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authenticate';
|
||||||
|
import { requireRole } from '../middleware/requireRole';
|
||||||
|
import {
|
||||||
|
postRichiestaCreazioneGruppo,
|
||||||
|
getRichiesteCreazioneGruppo,
|
||||||
|
putRichiestaCreazioneGruppo,
|
||||||
|
} from '../controllers/richiesteCreazioneGruppo.controller';
|
||||||
|
|
||||||
|
export const richiesteCreazioneGruppoRouter = Router();
|
||||||
|
|
||||||
|
// Chiunque sia autenticato può proporre la creazione di un nuovo gruppo scout.
|
||||||
|
richiesteCreazioneGruppoRouter.post('/richieste-creazione-gruppo', authenticate, postRichiestaCreazioneGruppo);
|
||||||
|
|
||||||
|
richiesteCreazioneGruppoRouter.get(
|
||||||
|
'/richieste-creazione-gruppo',
|
||||||
|
authenticate,
|
||||||
|
requireRole('admin'),
|
||||||
|
getRichiesteCreazioneGruppo,
|
||||||
|
);
|
||||||
|
richiesteCreazioneGruppoRouter.put(
|
||||||
|
'/richieste-creazione-gruppo/:id',
|
||||||
|
authenticate,
|
||||||
|
requireRole('admin'),
|
||||||
|
putRichiestaCreazioneGruppo,
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authenticate';
|
||||||
|
import { requireOrgAccess } from '../middleware/requireOrgAccess';
|
||||||
|
import {
|
||||||
|
postRichiestaIngresso,
|
||||||
|
getRichiesteIngresso,
|
||||||
|
putRichiestaIngresso,
|
||||||
|
} from '../controllers/richiesteIngresso.controller';
|
||||||
|
|
||||||
|
export const richiesteIngressoRouter = Router();
|
||||||
|
|
||||||
|
// Chiunque sia autenticato può chiedere di entrare in un gruppo esistente.
|
||||||
|
richiesteIngressoRouter.post('/gruppi/:orgId/richieste-ingresso', authenticate, postRichiestaIngresso);
|
||||||
|
|
||||||
|
richiesteIngressoRouter.get(
|
||||||
|
'/gruppi/:orgId/richieste-ingresso',
|
||||||
|
authenticate,
|
||||||
|
requireOrgAccess,
|
||||||
|
getRichiesteIngresso,
|
||||||
|
);
|
||||||
|
richiesteIngressoRouter.put(
|
||||||
|
'/gruppi/:orgId/richieste-ingresso/:id',
|
||||||
|
authenticate,
|
||||||
|
requireOrgAccess,
|
||||||
|
putRichiestaIngresso,
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin';
|
||||||
|
|
||||||
|
// "ruolo" è usato qui anche come identificativo del gruppo Keycloak, in
|
||||||
|
// attesa che createOrganizationGroup persista una mappa ruolo -> groupId
|
||||||
|
// reale da risolvere in questo punto (vedi gruppi.service.ts).
|
||||||
|
export async function assegnaGruppoERuolo(userId: string, ruolo: string): Promise<void> {
|
||||||
|
await assignUserToGroup(userId, ruolo);
|
||||||
|
await assignRealmRoleToUser(userId, ruolo);
|
||||||
|
}
|
||||||
@@ -17,6 +17,35 @@ export interface CreateGruppoResult {
|
|||||||
gruppiCreati: string[];
|
gruppiCreati: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GruppoListItem {
|
||||||
|
orgId: string;
|
||||||
|
nome: string;
|
||||||
|
regione: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGruppi(): Promise<GruppoListItem[]> {
|
||||||
|
return prisma.gruppoScout.findMany({
|
||||||
|
select: { orgId: true, nome: true, regione: true },
|
||||||
|
orderBy: { nome: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GruppoPubblicoListItem {
|
||||||
|
orgId: string;
|
||||||
|
nome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elenco minimale (solo orgId/nome, niente regione) esposto a qualsiasi utente
|
||||||
|
// autenticato: serve al percorso "richiedi di entrare in un gruppo esistente"
|
||||||
|
// del profilo utente, che a differenza di GET /gruppi non può richiedere il
|
||||||
|
// ruolo admin.
|
||||||
|
export async function listGruppiPubblico(): Promise<GruppoPubblicoListItem[]> {
|
||||||
|
return prisma.gruppoScout.findMany({
|
||||||
|
select: { orgId: true, nome: true },
|
||||||
|
orderBy: { nome: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isKeycloakConflict(err: unknown): boolean {
|
function isKeycloakConflict(err: unknown): boolean {
|
||||||
return axios.isAxiosError(err) && err.response?.status === 409;
|
return axios.isAxiosError(err) && err.response?.status === 409;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
import { addMemberToOrganization, assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin';
|
import { addMemberToOrganization } from '../keycloak-admin';
|
||||||
|
import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
|
||||||
@@ -99,11 +100,7 @@ export async function accettaInvito(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await addMemberToOrganization(invito.orgId, utente.userId);
|
await addMemberToOrganization(invito.orgId, utente.userId);
|
||||||
// TODO: "ruolo" è usato qui anche come identificativo del gruppo Keycloak,
|
await assegnaGruppoERuolo(utente.userId, invito.ruolo);
|
||||||
// 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) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[inviti] fallita l'assegnazione Keycloak per l'accettazione dell'invito token="${token}", ` +
|
`[inviti] fallita l'assegnazione Keycloak per l'accettazione dell'invito token="${token}", ` +
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { creaRichiestaIngresso, ORIGINE_RICHIESTA, CreaRichiestaIngressoResult } from './richiesteIngresso.service';
|
||||||
|
|
||||||
|
function buildUrl(token: string): string {
|
||||||
|
return `${env.frontendBaseUrl}/ingresso/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreaLinkIngressoResult {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
giaEsistente: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function creaOTrovaLinkIngresso(orgId: string, userId: string): Promise<CreaLinkIngressoResult> {
|
||||||
|
const esistente = await prisma.linkIngresso.findFirst({
|
||||||
|
where: { orgId, attivo: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (esistente) {
|
||||||
|
return { token: esistente.token, url: buildUrl(esistente.token), giaEsistente: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
await prisma.linkIngresso.create({
|
||||||
|
data: { token, orgId, attivo: true, creatoDa: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, url: buildUrl(token), giaEsistente: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkIngressoPubblico {
|
||||||
|
nomeGruppo: string;
|
||||||
|
gruppoId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinkIngressoPubblico(token: string): Promise<LinkIngressoPubblico | null> {
|
||||||
|
const link = await prisma.linkIngresso.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { gruppoScout: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link || !link.attivo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nomeGruppo: link.gruppoScout.nome, gruppoId: link.orgId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function richiediIngresso(
|
||||||
|
token: string,
|
||||||
|
utente: { userId: string; email: string | null },
|
||||||
|
): Promise<CreaRichiestaIngressoResult> {
|
||||||
|
const link = await prisma.linkIngresso.findUnique({ where: { token } });
|
||||||
|
|
||||||
|
if (!link || !link.attivo) {
|
||||||
|
throw new HttpError(404, 'Link di ingresso non trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
return creaRichiestaIngresso(link.orgId, utente, ORIGINE_RICHIESTA.LINK);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
listOrganizationMembers,
|
listOrganizationMembers,
|
||||||
removeMemberFromOrganization,
|
removeMemberFromOrganization,
|
||||||
assignUserToGroup,
|
|
||||||
removeUserFromGroup,
|
removeUserFromGroup,
|
||||||
getUserGroupsInOrganization,
|
getUserGroupsInOrganization,
|
||||||
assignRealmRoleToUser,
|
|
||||||
removeRealmRoleFromUser,
|
removeRealmRoleFromUser,
|
||||||
getUserRealmRoles,
|
getUserRealmRoles,
|
||||||
|
addMemberToOrganization,
|
||||||
|
findUserByEmail,
|
||||||
} from '../keycloak-admin';
|
} from '../keycloak-admin';
|
||||||
|
import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
export interface MembroOrganization {
|
export interface MembroOrganization {
|
||||||
@@ -43,16 +44,13 @@ export async function cambiaRuoloMembro(orgId: string, userId: string, nuovoRuol
|
|||||||
for (const gruppo of gruppiAttuali) {
|
for (const gruppo of gruppiAttuali) {
|
||||||
await removeUserFromGroup(userId, gruppo.groupId);
|
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);
|
const ruoliAttuali = await getUserRealmRoles(userId);
|
||||||
for (const ruolo of ruoliAttuali) {
|
for (const ruolo of ruoliAttuali) {
|
||||||
await removeRealmRoleFromUser(userId, ruolo);
|
await removeRealmRoleFromUser(userId, ruolo);
|
||||||
}
|
}
|
||||||
await assignRealmRoleToUser(userId, nuovoRuolo);
|
|
||||||
|
await assegnaGruppoERuolo(userId, nuovoRuolo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[membri] cambio ruolo fallito per orgId="${orgId}", userId="${userId}", nuovoRuolo="${nuovoRuolo}". ` +
|
`[membri] cambio ruolo fallito per orgId="${orgId}", userId="${userId}", nuovoRuolo="${nuovoRuolo}". ` +
|
||||||
@@ -72,3 +70,54 @@ export async function rimuoviMembro(orgId: string, userId: string): Promise<void
|
|||||||
throw new HttpError(502, "Impossibile rimuovere il membro dall'organizzazione su Keycloak");
|
throw new HttpError(502, "Impossibile rimuovere il membro dall'organizzazione su Keycloak");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AggiungiMembroInput {
|
||||||
|
orgId: string;
|
||||||
|
email: string;
|
||||||
|
ruolo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggiungiMembroResult {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
ruolo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiunge direttamente un utente già registrato su Keycloak a un'organizzazione,
|
||||||
|
// senza passare dal flusso di invito (riservato ad admin).
|
||||||
|
export async function aggiungiMembro(input: AggiungiMembroInput): Promise<AggiungiMembroResult> {
|
||||||
|
const utente = await findUserByEmail(input.email);
|
||||||
|
if (!utente) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
`Nessun utente registrato con email "${input.email}": invita questa persona tramite il flusso di invito`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: membership nell'organizzazione.
|
||||||
|
try {
|
||||||
|
await addMemberToOrganization(input.orgId, utente.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[membri] STEP 1 (addMemberToOrganization) fallito per orgId="${input.orgId}", userId="${utente.id}". ` +
|
||||||
|
"Nessuna risorsa creata: il retry può ripartire dall'inizio.",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
throw new HttpError(502, "Impossibile aggiungere l'utente all'organizzazione su Keycloak");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: gruppo + ruolo realm.
|
||||||
|
try {
|
||||||
|
await assegnaGruppoERuolo(utente.id, input.ruolo);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[membri] STEP 2 (assegnazione gruppo/ruolo) fallito per orgId="${input.orgId}", userId="${utente.id}", ` +
|
||||||
|
`ruolo="${input.ruolo}". L'utente è già stato aggiunto all'organizzazione su Keycloak: per il retry ` +
|
||||||
|
"manuale non richiamare addMemberToOrganization, ma solo l'assegnazione di gruppo/ruolo mancante.",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
throw new HttpError(502, "Impossibile completare l'assegnazione di gruppo e ruolo su Keycloak");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: utente.id, email: input.email, ruolo: input.ruolo };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
import { createGruppo } from './gruppi.service';
|
||||||
|
import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo';
|
||||||
|
|
||||||
|
const STATO_RICHIESTA = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
APPROVATA: 'APPROVATA',
|
||||||
|
RIFIUTATA: 'RIFIUTATA',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const RUOLO_CAPO_GRUPPO = 'capo-gruppo';
|
||||||
|
|
||||||
|
export interface CreaRichiestaCreazioneGruppoInput {
|
||||||
|
userId: string;
|
||||||
|
email: string | null;
|
||||||
|
nomeProposto: string;
|
||||||
|
regione?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreaRichiestaCreazioneGruppoResult {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function creaRichiestaCreazioneGruppo(
|
||||||
|
input: CreaRichiestaCreazioneGruppoInput,
|
||||||
|
): Promise<CreaRichiestaCreazioneGruppoResult> {
|
||||||
|
const esistente = await prisma.richiestaCreazioneGruppo.findFirst({
|
||||||
|
where: { userId: input.userId, stato: STATO_RICHIESTA.PENDING },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (esistente) {
|
||||||
|
throw new HttpError(409, 'Hai già una richiesta di creazione gruppo in attesa');
|
||||||
|
}
|
||||||
|
|
||||||
|
const richiesta = await prisma.richiestaCreazioneGruppo.create({
|
||||||
|
data: {
|
||||||
|
userId: input.userId,
|
||||||
|
email: input.email ?? '',
|
||||||
|
nomeProposto: input.nomeProposto,
|
||||||
|
regione: input.regione,
|
||||||
|
stato: STATO_RICHIESTA.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: richiesta.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichiestaCreazioneGruppoPending {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
nomeProposto: string;
|
||||||
|
regione: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listaRichiestePending(): Promise<RichiestaCreazioneGruppoPending[]> {
|
||||||
|
const richieste = await prisma.richiestaCreazioneGruppo.findMany({
|
||||||
|
where: { stato: STATO_RICHIESTA.PENDING },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return richieste.map((richiesta) => ({
|
||||||
|
id: richiesta.id,
|
||||||
|
email: richiesta.email,
|
||||||
|
nomeProposto: richiesta.nomeProposto,
|
||||||
|
regione: richiesta.regione,
|
||||||
|
createdAt: richiesta.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RisolviRichiestaResult {
|
||||||
|
id: string;
|
||||||
|
stato: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trovaRichiestaPending(id: string) {
|
||||||
|
const richiesta = await prisma.richiestaCreazioneGruppo.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!richiesta) {
|
||||||
|
throw new HttpError(404, 'Richiesta di creazione gruppo non trovata');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (richiesta.stato !== STATO_RICHIESTA.PENDING) {
|
||||||
|
throw new HttpError(409, 'La richiesta non è più in attesa di approvazione');
|
||||||
|
}
|
||||||
|
|
||||||
|
return richiesta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approvaRichiesta(id: string): Promise<RisolviRichiestaResult> {
|
||||||
|
const richiesta = await trovaRichiestaPending(id);
|
||||||
|
|
||||||
|
// Step 1-4: stessa logica di POST /gruppi (organizzazione, gruppi ruolo,
|
||||||
|
// membership del richiedente, riga locale). In caso di fallimento
|
||||||
|
// createGruppo lancia già il proprio HttpError con il dettaglio dello step,
|
||||||
|
// e la richiesta resta 'PENDING' perché non viene ancora aggiornata qui.
|
||||||
|
const { orgId } = await createGruppo({
|
||||||
|
nome: richiesta.nomeProposto,
|
||||||
|
regione: richiesta.regione ?? undefined,
|
||||||
|
userId: richiesta.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: il richiedente diventa capo-gruppo del gruppo appena creato.
|
||||||
|
try {
|
||||||
|
await assegnaGruppoERuolo(richiesta.userId, RUOLO_CAPO_GRUPPO);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[richieste-creazione-gruppo] STEP 5 (assegnazione ruolo "${RUOLO_CAPO_GRUPPO}") fallito per ` +
|
||||||
|
`richiestaId="${id}", orgId="${orgId}", userId="${richiesta.userId}". Il gruppo scout è già stato ` +
|
||||||
|
'creato su Keycloak e localmente: per il retry manuale non richiamare createGruppo, ma solo ' +
|
||||||
|
`l'assegnazione del ruolo "${RUOLO_CAPO_GRUPPO}" all'utente userId="${richiesta.userId}".`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
throw new HttpError(502, "Impossibile completare l'assegnazione del ruolo capo-gruppo su Keycloak");
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggiornata = await prisma.richiestaCreazioneGruppo.update({
|
||||||
|
where: { id },
|
||||||
|
data: { stato: STATO_RICHIESTA.APPROVATA },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: aggiornata.id, stato: aggiornata.stato };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rifiutaRichiesta(id: string): Promise<RisolviRichiestaResult> {
|
||||||
|
const richiesta = await trovaRichiestaPending(id);
|
||||||
|
|
||||||
|
const aggiornata = await prisma.richiestaCreazioneGruppo.update({
|
||||||
|
where: { id: richiesta.id },
|
||||||
|
data: { stato: STATO_RICHIESTA.RIFIUTATA },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: aggiornata.id, stato: aggiornata.stato };
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
import { addMemberToOrganization } from '../keycloak-admin';
|
||||||
|
import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo';
|
||||||
|
|
||||||
|
export const STATO_RICHIESTA = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
APPROVATA: 'APPROVATA',
|
||||||
|
RIFIUTATA: 'RIFIUTATA',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ORIGINE_RICHIESTA = {
|
||||||
|
LINK: 'LINK',
|
||||||
|
PROFILO: 'PROFILO',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OrigineRichiesta = (typeof ORIGINE_RICHIESTA)[keyof typeof ORIGINE_RICHIESTA];
|
||||||
|
|
||||||
|
export interface CreaRichiestaIngressoResult {
|
||||||
|
richiestaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comune ai flussi "link" e "profilo": una richiesta PENDING per lo stesso
|
||||||
|
// utente/gruppo non va duplicata, a prescindere da come è stata originata.
|
||||||
|
export async function creaRichiestaIngresso(
|
||||||
|
orgId: string,
|
||||||
|
utente: { userId: string; email: string | null },
|
||||||
|
origine: OrigineRichiesta,
|
||||||
|
): Promise<CreaRichiestaIngressoResult> {
|
||||||
|
const esistente = await prisma.richiestaIngresso.findFirst({
|
||||||
|
where: { orgId, userId: utente.userId, stato: STATO_RICHIESTA.PENDING },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (esistente) {
|
||||||
|
throw new HttpError(409, 'Hai già una richiesta di ingresso in attesa per questo gruppo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const richiesta = await prisma.richiestaIngresso.create({
|
||||||
|
data: {
|
||||||
|
userId: utente.userId,
|
||||||
|
email: utente.email ?? '',
|
||||||
|
orgId,
|
||||||
|
origine,
|
||||||
|
stato: STATO_RICHIESTA.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { richiestaId: richiesta.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichiestaIngressoPending {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
origine: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listaRichiestePending(orgId: string): Promise<RichiestaIngressoPending[]> {
|
||||||
|
const richieste = await prisma.richiestaIngresso.findMany({
|
||||||
|
where: { orgId, stato: STATO_RICHIESTA.PENDING },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return richieste.map((richiesta) => ({
|
||||||
|
id: richiesta.id,
|
||||||
|
userId: richiesta.userId,
|
||||||
|
email: richiesta.email,
|
||||||
|
origine: richiesta.origine,
|
||||||
|
createdAt: richiesta.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RisolviRichiestaResult {
|
||||||
|
id: string;
|
||||||
|
stato: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trovaRichiestaPending(orgId: string, richiestaId: string) {
|
||||||
|
const richiesta = await prisma.richiestaIngresso.findUnique({ where: { id: richiestaId } });
|
||||||
|
|
||||||
|
if (!richiesta || richiesta.orgId !== orgId) {
|
||||||
|
throw new HttpError(404, 'Richiesta di ingresso non trovata');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (richiesta.stato !== STATO_RICHIESTA.PENDING) {
|
||||||
|
throw new HttpError(409, 'La richiesta non è più in attesa di approvazione');
|
||||||
|
}
|
||||||
|
|
||||||
|
return richiesta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approvaRichiesta(orgId: string, richiestaId: string, ruolo: string): Promise<RisolviRichiestaResult> {
|
||||||
|
const richiesta = await trovaRichiestaPending(orgId, richiestaId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addMemberToOrganization(orgId, richiesta.userId);
|
||||||
|
await assegnaGruppoERuolo(richiesta.userId, ruolo);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[richieste-ingresso] approvazione fallita per richiestaId="${richiestaId}", orgId="${orgId}", ` +
|
||||||
|
`userId="${richiesta.userId}", ruolo="${ruolo}". Lo stato resta 'PENDING' e nessuna modifica è stata ` +
|
||||||
|
"salvata localmente: verificare manualmente l'eventuale assegnazione parziale su Keycloak prima di " +
|
||||||
|
"ritentare l'approvazione.",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
throw new HttpError(502, "Impossibile completare l'assegnazione su Keycloak");
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggiornata = await prisma.richiestaIngresso.update({
|
||||||
|
where: { id: richiestaId },
|
||||||
|
data: { stato: STATO_RICHIESTA.APPROVATA },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: aggiornata.id, stato: aggiornata.stato };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rifiutaRichiesta(orgId: string, richiestaId: string): Promise<RisolviRichiestaResult> {
|
||||||
|
const richiesta = await trovaRichiestaPending(orgId, richiestaId);
|
||||||
|
|
||||||
|
const aggiornata = await prisma.richiestaIngresso.update({
|
||||||
|
where: { id: richiesta.id },
|
||||||
|
data: { stato: STATO_RICHIESTA.RIFIUTATA },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: aggiornata.id, stato: aggiornata.stato };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const createOrganization = jest.fn();
|
|||||||
const createOrganizationGroup = jest.fn();
|
const createOrganizationGroup = jest.fn();
|
||||||
const addMemberToOrganization = jest.fn();
|
const addMemberToOrganization = jest.fn();
|
||||||
const gruppoScoutCreate = jest.fn();
|
const gruppoScoutCreate = jest.fn();
|
||||||
|
const gruppoScoutFindMany = jest.fn();
|
||||||
|
|
||||||
jest.mock('../../src/keycloak-admin', () => ({
|
jest.mock('../../src/keycloak-admin', () => ({
|
||||||
createOrganization: (...args: unknown[]) => createOrganization(...args),
|
createOrganization: (...args: unknown[]) => createOrganization(...args),
|
||||||
@@ -25,6 +26,7 @@ jest.mock('../../src/db/prisma', () => ({
|
|||||||
prisma: {
|
prisma: {
|
||||||
gruppoScout: {
|
gruppoScout: {
|
||||||
create: (...args: unknown[]) => gruppoScoutCreate(...args),
|
create: (...args: unknown[]) => gruppoScoutCreate(...args),
|
||||||
|
findMany: (...args: unknown[]) => gruppoScoutFindMany(...args),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -47,7 +49,7 @@ function signToken(roles: string[]): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminToken = () => signToken(['admin-centrale']);
|
const adminToken = () => signToken(['admin']);
|
||||||
const nonAdminToken = () => signToken(['capo-gruppo']);
|
const nonAdminToken = () => signToken(['capo-gruppo']);
|
||||||
|
|
||||||
function conflictError(): Error {
|
function conflictError(): Error {
|
||||||
@@ -166,7 +168,7 @@ describe('POST /gruppi', () => {
|
|||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('risponde 403 se il ruolo non è admin-centrale', async () => {
|
test('risponde 403 se il ruolo non è admin', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/gruppi')
|
.post('/gruppi')
|
||||||
.set('Authorization', `Bearer ${nonAdminToken()}`)
|
.set('Authorization', `Bearer ${nonAdminToken()}`)
|
||||||
@@ -193,3 +195,68 @@ describe('POST /gruppi', () => {
|
|||||||
expect(createOrganization).not.toHaveBeenCalled();
|
expect(createOrganization).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /gruppi', () => {
|
||||||
|
test('restituisce 200 con la lista dei gruppi scout per admin', async () => {
|
||||||
|
gruppoScoutFindMany.mockResolvedValueOnce([
|
||||||
|
{ orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' },
|
||||||
|
{ orgId: 'org-2', nome: 'Gruppo Beta', regione: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/gruppi').set('Authorization', `Bearer ${adminToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([
|
||||||
|
{ orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' },
|
||||||
|
{ orgId: 'org-2', nome: 'Gruppo Beta', regione: null },
|
||||||
|
]);
|
||||||
|
expect(gruppoScoutFindMany).toHaveBeenCalledWith({
|
||||||
|
select: { orgId: true, nome: true, regione: true },
|
||||||
|
orderBy: { nome: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se il ruolo non è admin', async () => {
|
||||||
|
const response = await request(app).get('/gruppi').set('Authorization', `Bearer ${nonAdminToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(gruppoScoutFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).get('/gruppi');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(gruppoScoutFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /gruppi/elenco-pubblico', () => {
|
||||||
|
test('restituisce 200 con solo orgId/nome, accessibile a un utente non admin', async () => {
|
||||||
|
gruppoScoutFindMany.mockResolvedValueOnce([
|
||||||
|
{ orgId: 'org-1', nome: 'Gruppo Alfa' },
|
||||||
|
{ orgId: 'org-2', nome: 'Gruppo Beta' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/gruppi/elenco-pubblico')
|
||||||
|
.set('Authorization', `Bearer ${nonAdminToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([
|
||||||
|
{ orgId: 'org-1', nome: 'Gruppo Alfa' },
|
||||||
|
{ orgId: 'org-2', nome: 'Gruppo Beta' },
|
||||||
|
]);
|
||||||
|
expect(gruppoScoutFindMany).toHaveBeenCalledWith({
|
||||||
|
select: { orgId: true, nome: true },
|
||||||
|
orderBy: { nome: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).get('/gruppi/elenco-pubblico');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(gruppoScoutFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ function utenteToken(email: string): string {
|
|||||||
return signToken({ sub: 'user-invitato', email, realm_access: { roles: [] } });
|
return signToken({ sub: 'user-invitato', email, realm_access: { roles: [] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adminCentraleToken(): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
realm_access: { roles: ['admin'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ORA = Date.now();
|
const ORA = Date.now();
|
||||||
function invitoFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
function invitoFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -133,6 +141,25 @@ describe('POST /gruppi/:orgId/inviti', () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(invitoCreate).not.toHaveBeenCalled();
|
expect(invitoCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("admin può creare un invito anche in un'organizzazione che non è la propria", async () => {
|
||||||
|
invitoCreate.mockResolvedValueOnce({ id: 'invito-nuovo', scadenza: new Date(ORA + 7 * 24 * 60 * 60 * 1000) });
|
||||||
|
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-altrui/inviti')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ email: 'nuovo@example.com', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(invitoCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ orgId: 'org-altrui' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /inviti/:token', () => {
|
describe('GET /inviti/:token', () => {
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { generateKeyPairSync } from 'crypto';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
|
||||||
|
process.env.KEYCLOAK_REALM = 'scouthub';
|
||||||
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_ID = 'test-client';
|
||||||
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_home_test';
|
||||||
|
process.env.FRONTEND_BASE_URL = 'http://localhost:4200';
|
||||||
|
|
||||||
|
const linkIngressoFindFirst = jest.fn();
|
||||||
|
const linkIngressoCreate = jest.fn();
|
||||||
|
const linkIngressoFindUnique = jest.fn();
|
||||||
|
const richiestaIngressoFindFirst = jest.fn();
|
||||||
|
const richiestaIngressoCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../src/db/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
linkIngresso: {
|
||||||
|
findFirst: (...args: unknown[]) => linkIngressoFindFirst(...args),
|
||||||
|
create: (...args: unknown[]) => linkIngressoCreate(...args),
|
||||||
|
findUnique: (...args: unknown[]) => linkIngressoFindUnique(...args),
|
||||||
|
},
|
||||||
|
richiestaIngresso: {
|
||||||
|
findFirst: (...args: unknown[]) => richiestaIngressoFindFirst(...args),
|
||||||
|
create: (...args: unknown[]) => richiestaIngressoCreate(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from '../../src/app';
|
||||||
|
|
||||||
|
const KEYCLOAK_HOST = 'http://keycloak.test';
|
||||||
|
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
|
||||||
|
const KID = 'test-kid';
|
||||||
|
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||||
|
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
|
||||||
|
|
||||||
|
function signToken(payload: object): string {
|
||||||
|
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function capoGruppoToken(orgId: string): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-capo',
|
||||||
|
email: 'capo@example.com',
|
||||||
|
realm_access: { roles: ['capo-gruppo'] },
|
||||||
|
organization: { alfa: { id: orgId, roles: [] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminCentraleToken(): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
realm_access: { roles: ['admin'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function utenteToken(userId: string, email: string): string {
|
||||||
|
return signToken({ sub: userId, email, realm_access: { roles: [] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkIngressoFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'link-1',
|
||||||
|
token: 'token-abc',
|
||||||
|
orgId: 'org-1',
|
||||||
|
attivo: true,
|
||||||
|
creatoDa: 'user-capo',
|
||||||
|
createdAt: new Date(),
|
||||||
|
gruppoScout: { nome: 'Gruppo Alfa' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||||
|
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /gruppi/:orgId/link-ingresso', () => {
|
||||||
|
test('crea un nuovo link attivo e risponde 201 se non ne esiste già uno', async () => {
|
||||||
|
linkIngressoFindFirst.mockResolvedValueOnce(null);
|
||||||
|
linkIngressoCreate.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/link-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.token).toEqual(expect.any(String));
|
||||||
|
expect(response.body.url).toBe(`http://localhost:4200/ingresso/${response.body.token}`);
|
||||||
|
|
||||||
|
expect(linkIngressoCreate).toHaveBeenCalledWith({
|
||||||
|
data: { token: response.body.token, orgId: 'org-1', attivo: true, creatoDa: 'user-capo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('è idempotente: restituisce 200 con il link già attivo senza crearne uno nuovo', async () => {
|
||||||
|
linkIngressoFindFirst.mockResolvedValueOnce(linkIngressoFixture());
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/link-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ token: 'token-abc', url: 'http://localhost:4200/ingresso/token-abc' });
|
||||||
|
expect(linkIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin può generare il link anche per un altro gruppo', async () => {
|
||||||
|
linkIngressoFindFirst.mockResolvedValueOnce(null);
|
||||||
|
linkIngressoCreate.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-altrui/link-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(linkIngressoFindFirst).toHaveBeenCalledWith({ where: { orgId: 'org-altrui', attivo: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 403 se il capo gruppo prova a generare il link per un'altra organizzazione", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/link-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(linkIngressoFindFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).post('/gruppi/org-1/link-ingresso');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(linkIngressoFindFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /link-ingresso/:token', () => {
|
||||||
|
test('è pubblico e restituisce nomeGruppo e gruppoId per un link attivo', async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture());
|
||||||
|
|
||||||
|
const response = await request(app).get('/link-ingresso/token-abc');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ nomeGruppo: 'Gruppo Alfa', gruppoId: 'org-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 404 se il token non esiste', async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app).get('/link-ingresso/token-inesistente');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 404 se il link non è più attivo', async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture({ attivo: false }));
|
||||||
|
|
||||||
|
const response = await request(app).get('/link-ingresso/token-abc');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /link-ingresso/:token/richiedi', () => {
|
||||||
|
test('crea una richiesta PENDING e risponde 201', async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture());
|
||||||
|
richiestaIngressoFindFirst.mockResolvedValueOnce(null);
|
||||||
|
richiestaIngressoCreate.mockResolvedValueOnce({ id: 'richiesta-1' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/link-ingresso/token-abc/richiedi')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual({ richiestaId: 'richiesta-1' });
|
||||||
|
|
||||||
|
expect(richiestaIngressoCreate).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: 'user-nuovo',
|
||||||
|
email: 'nuovo@example.com',
|
||||||
|
orgId: 'org-1',
|
||||||
|
origine: 'LINK',
|
||||||
|
stato: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 404 se il token non esiste', async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/link-ingresso/token-inesistente/richiedi')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 404 se il link non è più attivo', async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture({ attivo: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/link-ingresso/token-abc/richiedi')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 409 se l'utente ha già una richiesta PENDING per lo stesso gruppo", async () => {
|
||||||
|
linkIngressoFindUnique.mockResolvedValueOnce(linkIngressoFixture());
|
||||||
|
richiestaIngressoFindFirst.mockResolvedValueOnce({ id: 'richiesta-esistente' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/link-ingresso/token-abc/richiedi')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token di autenticazione', async () => {
|
||||||
|
const response = await request(app).post('/link-ingresso/token-abc/richiedi');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ const getUserGroupsInOrganization = jest.fn();
|
|||||||
const assignRealmRoleToUser = jest.fn();
|
const assignRealmRoleToUser = jest.fn();
|
||||||
const removeRealmRoleFromUser = jest.fn();
|
const removeRealmRoleFromUser = jest.fn();
|
||||||
const getUserRealmRoles = jest.fn();
|
const getUserRealmRoles = jest.fn();
|
||||||
|
const addMemberToOrganization = jest.fn();
|
||||||
|
const findUserByEmail = jest.fn();
|
||||||
|
|
||||||
jest.mock('../../src/keycloak-admin', () => ({
|
jest.mock('../../src/keycloak-admin', () => ({
|
||||||
listOrganizationMembers: (...args: unknown[]) => listOrganizationMembers(...args),
|
listOrganizationMembers: (...args: unknown[]) => listOrganizationMembers(...args),
|
||||||
@@ -28,6 +30,8 @@ jest.mock('../../src/keycloak-admin', () => ({
|
|||||||
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
|
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
|
||||||
removeRealmRoleFromUser: (...args: unknown[]) => removeRealmRoleFromUser(...args),
|
removeRealmRoleFromUser: (...args: unknown[]) => removeRealmRoleFromUser(...args),
|
||||||
getUserRealmRoles: (...args: unknown[]) => getUserRealmRoles(...args),
|
getUserRealmRoles: (...args: unknown[]) => getUserRealmRoles(...args),
|
||||||
|
addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args),
|
||||||
|
findUserByEmail: (...args: unknown[]) => findUserByEmail(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { app } from '../../src/app';
|
import { app } from '../../src/app';
|
||||||
@@ -62,6 +66,14 @@ function nonCapoToken(orgId: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adminCentraleToken(): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
realm_access: { roles: ['admin'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||||
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||||
@@ -76,6 +88,68 @@ beforeEach(() => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /gruppi/:orgId/membri', () => {
|
||||||
|
test('aggiunge un utente già registrato con successo e risponde 201', async () => {
|
||||||
|
findUserByEmail.mockResolvedValueOnce({ id: 'user-1' });
|
||||||
|
addMemberToOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
assignUserToGroup.mockResolvedValueOnce(undefined);
|
||||||
|
assignRealmRoleToUser.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/membri')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ email: 'gia-registrato@example.com', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual({ userId: 'user-1', email: 'gia-registrato@example.com', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(findUserByEmail).toHaveBeenCalledWith('gia-registrato@example.com');
|
||||||
|
expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-1');
|
||||||
|
expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Capi');
|
||||||
|
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-1', 'Capi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 404 se non esiste un utente registrato con quell'email", async () => {
|
||||||
|
findUserByEmail.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/membri')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ email: 'sconosciuto@example.com', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(addMemberToOrganization).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 502 se una chiamata Keycloak fallisce a metà sequenza', async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
findUserByEmail.mockResolvedValueOnce({ id: 'user-1' });
|
||||||
|
addMemberToOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/membri')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ email: 'gia-registrato@example.com', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(502);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 2'), expect.anything());
|
||||||
|
expect(assignRealmRoleToUser).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se chiamato da capo-gruppo, anche sulla propria organizzazione', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/membri')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ email: 'gia-registrato@example.com', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(findUserByEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /gruppi/:orgId/membri', () => {
|
describe('GET /gruppi/:orgId/membri', () => {
|
||||||
test('restituisce la lista membri con ruolo e gruppo interno', async () => {
|
test('restituisce la lista membri con ruolo e gruppo interno', async () => {
|
||||||
listOrganizationMembers.mockResolvedValueOnce([
|
listOrganizationMembers.mockResolvedValueOnce([
|
||||||
@@ -116,6 +190,19 @@ describe('GET /gruppi/:orgId/membri', () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(listOrganizationMembers).not.toHaveBeenCalled();
|
expect(listOrganizationMembers).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("admin accede con successo anche a un'organizzazione che non è la propria", async () => {
|
||||||
|
listOrganizationMembers.mockResolvedValueOnce([{ userId: 'user-1', email: 'uno@example.com' }]);
|
||||||
|
getUserGroupsInOrganization.mockResolvedValueOnce([]);
|
||||||
|
getUserRealmRoles.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/gruppi/org-altrui/membri')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(listOrganizationMembers).toHaveBeenCalledWith('org-altrui');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => {
|
describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => {
|
||||||
@@ -173,6 +260,19 @@ describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => {
|
|||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("admin può cambiare ruolo anche in un'organizzazione che non è la propria", async () => {
|
||||||
|
getUserGroupsInOrganization.mockResolvedValueOnce([]);
|
||||||
|
getUserRealmRoles.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-altrui/membri/user-1/ruolo')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ ruolo: 'Aiuto capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Aiuto capi');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DELETE /gruppi/:orgId/membri/:userId', () => {
|
describe('DELETE /gruppi/:orgId/membri/:userId', () => {
|
||||||
@@ -209,4 +309,15 @@ describe('DELETE /gruppi/:orgId/membri/:userId', () => {
|
|||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("admin può rimuovere un membro anche in un'organizzazione che non è la propria", async () => {
|
||||||
|
removeMemberFromOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/gruppi/org-altrui/membri/user-1')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(removeMemberFromOrganization).toHaveBeenCalledWith('org-altrui', 'user-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { generateKeyPairSync } from 'crypto';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
|
||||||
|
process.env.KEYCLOAK_REALM = 'scouthub';
|
||||||
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_ID = 'test-client';
|
||||||
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_home_test';
|
||||||
|
process.env.FRONTEND_BASE_URL = 'http://localhost:4200';
|
||||||
|
|
||||||
|
const createOrganization = jest.fn();
|
||||||
|
const createOrganizationGroup = jest.fn();
|
||||||
|
const addMemberToOrganization = jest.fn();
|
||||||
|
const assignUserToGroup = jest.fn();
|
||||||
|
const assignRealmRoleToUser = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../src/keycloak-admin', () => ({
|
||||||
|
createOrganization: (...args: unknown[]) => createOrganization(...args),
|
||||||
|
createOrganizationGroup: (...args: unknown[]) => createOrganizationGroup(...args),
|
||||||
|
addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args),
|
||||||
|
assignUserToGroup: (...args: unknown[]) => assignUserToGroup(...args),
|
||||||
|
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const richiestaCreazioneGruppoFindFirst = jest.fn();
|
||||||
|
const richiestaCreazioneGruppoCreate = jest.fn();
|
||||||
|
const richiestaCreazioneGruppoFindMany = jest.fn();
|
||||||
|
const richiestaCreazioneGruppoFindUnique = jest.fn();
|
||||||
|
const richiestaCreazioneGruppoUpdate = jest.fn();
|
||||||
|
const gruppoScoutCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../src/db/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
richiestaCreazioneGruppo: {
|
||||||
|
findFirst: (...args: unknown[]) => richiestaCreazioneGruppoFindFirst(...args),
|
||||||
|
create: (...args: unknown[]) => richiestaCreazioneGruppoCreate(...args),
|
||||||
|
findMany: (...args: unknown[]) => richiestaCreazioneGruppoFindMany(...args),
|
||||||
|
findUnique: (...args: unknown[]) => richiestaCreazioneGruppoFindUnique(...args),
|
||||||
|
update: (...args: unknown[]) => richiestaCreazioneGruppoUpdate(...args),
|
||||||
|
},
|
||||||
|
gruppoScout: {
|
||||||
|
create: (...args: unknown[]) => gruppoScoutCreate(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from '../../src/app';
|
||||||
|
|
||||||
|
const KEYCLOAK_HOST = 'http://keycloak.test';
|
||||||
|
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
|
||||||
|
const KID = 'test-kid';
|
||||||
|
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||||
|
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
|
||||||
|
|
||||||
|
function signToken(payload: object): string {
|
||||||
|
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function utenteToken(userId: string, email: string): string {
|
||||||
|
return signToken({ sub: userId, email, realm_access: { roles: [] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminCentraleToken(): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
realm_access: { roles: ['admin'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function capoGruppoToken(orgId: string): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-capo',
|
||||||
|
email: 'capo@example.com',
|
||||||
|
realm_access: { roles: ['capo-gruppo'] },
|
||||||
|
organization: { alfa: { id: orgId, roles: [] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORA = new Date();
|
||||||
|
function richiestaFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'richiesta-1',
|
||||||
|
userId: 'user-nuovo',
|
||||||
|
email: 'nuovo@example.com',
|
||||||
|
nomeProposto: 'Gruppo Alfa',
|
||||||
|
regione: 'Lombardia',
|
||||||
|
stato: 'PENDING',
|
||||||
|
createdAt: ORA,
|
||||||
|
updatedAt: ORA,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||||
|
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /richieste-creazione-gruppo', () => {
|
||||||
|
test('crea una richiesta PENDING e risponde 201', async () => {
|
||||||
|
richiestaCreazioneGruppoFindFirst.mockResolvedValueOnce(null);
|
||||||
|
richiestaCreazioneGruppoCreate.mockResolvedValueOnce({ id: 'richiesta-1' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/richieste-creazione-gruppo')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
|
||||||
|
.send({ nomeProposto: 'Gruppo Alfa', regione: 'Lombardia' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual({ id: 'richiesta-1' });
|
||||||
|
|
||||||
|
expect(richiestaCreazioneGruppoCreate).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: 'user-nuovo',
|
||||||
|
email: 'nuovo@example.com',
|
||||||
|
nomeProposto: 'Gruppo Alfa',
|
||||||
|
regione: 'Lombardia',
|
||||||
|
stato: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 409 se l'utente ha già una richiesta PENDING", async () => {
|
||||||
|
richiestaCreazioneGruppoFindFirst.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/richieste-creazione-gruppo')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
|
||||||
|
.send({ nomeProposto: 'Gruppo Beta' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(richiestaCreazioneGruppoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 400 se manca il campo 'nomeProposto'", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/richieste-creazione-gruppo')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
|
||||||
|
.send({ regione: 'Lombardia' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(richiestaCreazioneGruppoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).post('/richieste-creazione-gruppo').send({ nomeProposto: 'Gruppo Alfa' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(richiestaCreazioneGruppoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /richieste-creazione-gruppo', () => {
|
||||||
|
test('restituisce le richieste PENDING per admin', async () => {
|
||||||
|
richiestaCreazioneGruppoFindMany.mockResolvedValueOnce([richiestaFixture()]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/richieste-creazione-gruppo')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([
|
||||||
|
{
|
||||||
|
id: 'richiesta-1',
|
||||||
|
email: 'nuovo@example.com',
|
||||||
|
nomeProposto: 'Gruppo Alfa',
|
||||||
|
regione: 'Lombardia',
|
||||||
|
createdAt: ORA.toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(richiestaCreazioneGruppoFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { stato: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se il ruolo non è admin', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/richieste-creazione-gruppo')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(richiestaCreazioneGruppoFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).get('/richieste-creazione-gruppo');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(richiestaCreazioneGruppoFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /richieste-creazione-gruppo/:id', () => {
|
||||||
|
test('approva la richiesta: crea il gruppo, rende il richiedente capo-gruppo e aggiorna stato=APPROVATA', async () => {
|
||||||
|
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
createOrganization.mockResolvedValueOnce({ orgId: 'org-nuovo' });
|
||||||
|
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
|
||||||
|
addMemberToOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
gruppoScoutCreate.mockResolvedValueOnce({});
|
||||||
|
assignUserToGroup.mockResolvedValueOnce(undefined);
|
||||||
|
assignRealmRoleToUser.mockResolvedValueOnce(undefined);
|
||||||
|
richiestaCreazioneGruppoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ esito: 'approvata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'APPROVATA' });
|
||||||
|
|
||||||
|
expect(createOrganization).toHaveBeenCalledWith('Gruppo Alfa');
|
||||||
|
expect(gruppoScoutCreate).toHaveBeenCalledWith({
|
||||||
|
data: { orgId: 'org-nuovo', nome: 'Gruppo Alfa', regione: 'Lombardia' },
|
||||||
|
});
|
||||||
|
expect(addMemberToOrganization).toHaveBeenCalledWith('org-nuovo', 'user-nuovo');
|
||||||
|
expect(assignUserToGroup).toHaveBeenCalledWith('user-nuovo', 'capo-gruppo');
|
||||||
|
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-nuovo', 'capo-gruppo');
|
||||||
|
expect(richiestaCreazioneGruppoUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'richiesta-1' },
|
||||||
|
data: { stato: 'APPROVATA' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 502 e NON marca la richiesta come approvata se l'assegnazione del ruolo capo-gruppo fallisce", async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
createOrganization.mockResolvedValueOnce({ orgId: 'org-nuovo' });
|
||||||
|
createOrganizationGroup.mockResolvedValue({ groupId: 'irrelevant' });
|
||||||
|
addMemberToOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
gruppoScoutCreate.mockResolvedValueOnce({});
|
||||||
|
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ esito: 'approvata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(502);
|
||||||
|
expect(richiestaCreazioneGruppoUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 5'), expect.anything());
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('richiesta-1'), expect.anything());
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 502 e NON marca la richiesta come approvata se la creazione del gruppo fallisce', async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
createOrganization.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ esito: 'approvata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(502);
|
||||||
|
expect(richiestaCreazioneGruppoUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(assignUserToGroup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rifiuta la richiesta senza creare alcun gruppo e aggiorna stato=RIFIUTATA', async () => {
|
||||||
|
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
richiestaCreazioneGruppoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'RIFIUTATA' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'RIFIUTATA' });
|
||||||
|
|
||||||
|
expect(createOrganization).not.toHaveBeenCalled();
|
||||||
|
expect(richiestaCreazioneGruppoUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'richiesta-1' },
|
||||||
|
data: { stato: 'RIFIUTATA' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 409 se la richiesta non è più PENDING', async () => {
|
||||||
|
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(richiestaCreazioneGruppoUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 404 se la richiesta non esiste', async () => {
|
||||||
|
richiestaCreazioneGruppoFindUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-inesistente')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se il ruolo non è admin', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/richieste-creazione-gruppo/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'approvata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(richiestaCreazioneGruppoFindUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { generateKeyPairSync } from 'crypto';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
|
||||||
|
process.env.KEYCLOAK_REALM = 'scouthub';
|
||||||
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_ID = 'test-client';
|
||||||
|
process.env.KEYCLOAK_ORG_SERVICE_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_home_test';
|
||||||
|
process.env.FRONTEND_BASE_URL = 'http://localhost:4200';
|
||||||
|
|
||||||
|
const addMemberToOrganization = jest.fn();
|
||||||
|
const assignUserToGroup = jest.fn();
|
||||||
|
const assignRealmRoleToUser = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../src/keycloak-admin', () => ({
|
||||||
|
addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args),
|
||||||
|
assignUserToGroup: (...args: unknown[]) => assignUserToGroup(...args),
|
||||||
|
assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const richiestaIngressoFindFirst = jest.fn();
|
||||||
|
const richiestaIngressoCreate = jest.fn();
|
||||||
|
const richiestaIngressoFindMany = jest.fn();
|
||||||
|
const richiestaIngressoFindUnique = jest.fn();
|
||||||
|
const richiestaIngressoUpdate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../src/db/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
richiestaIngresso: {
|
||||||
|
findFirst: (...args: unknown[]) => richiestaIngressoFindFirst(...args),
|
||||||
|
create: (...args: unknown[]) => richiestaIngressoCreate(...args),
|
||||||
|
findMany: (...args: unknown[]) => richiestaIngressoFindMany(...args),
|
||||||
|
findUnique: (...args: unknown[]) => richiestaIngressoFindUnique(...args),
|
||||||
|
update: (...args: unknown[]) => richiestaIngressoUpdate(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from '../../src/app';
|
||||||
|
|
||||||
|
const KEYCLOAK_HOST = 'http://keycloak.test';
|
||||||
|
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
|
||||||
|
const KID = 'test-kid';
|
||||||
|
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||||
|
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
|
||||||
|
|
||||||
|
function signToken(payload: object): string {
|
||||||
|
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function capoGruppoToken(orgId: string): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-capo',
|
||||||
|
email: 'capo@example.com',
|
||||||
|
realm_access: { roles: ['capo-gruppo'] },
|
||||||
|
organization: { alfa: { id: orgId, roles: [] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminCentraleToken(): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
realm_access: { roles: ['admin'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function utenteToken(userId: string, email: string): string {
|
||||||
|
return signToken({ sub: userId, email, realm_access: { roles: [] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORA = new Date();
|
||||||
|
function richiestaFixture(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'richiesta-1',
|
||||||
|
userId: 'user-nuovo',
|
||||||
|
email: 'nuovo@example.com',
|
||||||
|
orgId: 'org-1',
|
||||||
|
stato: 'PENDING',
|
||||||
|
origine: 'PROFILO',
|
||||||
|
createdAt: ORA,
|
||||||
|
updatedAt: ORA,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||||
|
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /gruppi/:orgId/richieste-ingresso', () => {
|
||||||
|
test('crea una richiesta PENDING con origine=PROFILO e risponde 201', async () => {
|
||||||
|
richiestaIngressoFindFirst.mockResolvedValueOnce(null);
|
||||||
|
richiestaIngressoCreate.mockResolvedValueOnce({ id: 'richiesta-1' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/richieste-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual({ richiestaId: 'richiesta-1' });
|
||||||
|
|
||||||
|
expect(richiestaIngressoCreate).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: 'user-nuovo',
|
||||||
|
email: 'nuovo@example.com',
|
||||||
|
orgId: 'org-1',
|
||||||
|
origine: 'PROFILO',
|
||||||
|
stato: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 409 se l'utente ha già una richiesta PENDING per lo stesso gruppo", async () => {
|
||||||
|
richiestaIngressoFindFirst.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/gruppi/org-1/richieste-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${utenteToken('user-nuovo', 'nuovo@example.com')}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).post('/gruppi/org-1/richieste-ingresso').send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(richiestaIngressoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /gruppi/:orgId/richieste-ingresso', () => {
|
||||||
|
test('restituisce le richieste PENDING del gruppo per capo-gruppo della propria org', async () => {
|
||||||
|
richiestaIngressoFindMany.mockResolvedValueOnce([
|
||||||
|
richiestaFixture({ id: 'richiesta-1' }),
|
||||||
|
richiestaFixture({ id: 'richiesta-2', userId: 'user-due', email: 'due@example.com', origine: 'LINK' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/gruppi/org-1/richieste-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([
|
||||||
|
{ id: 'richiesta-1', userId: 'user-nuovo', email: 'nuovo@example.com', origine: 'PROFILO', createdAt: ORA.toISOString() },
|
||||||
|
{ id: 'richiesta-2', userId: 'user-due', email: 'due@example.com', origine: 'LINK', createdAt: ORA.toISOString() },
|
||||||
|
]);
|
||||||
|
expect(richiestaIngressoFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { orgId: 'org-1', stato: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin può leggere le richieste anche di un altro gruppo', async () => {
|
||||||
|
richiestaIngressoFindMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/gruppi/org-altrui/richieste-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${adminCentraleToken()}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(richiestaIngressoFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { orgId: 'org-altrui', stato: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 403 se il capo gruppo prova a leggere le richieste di un'altra organizzazione", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/gruppi/org-1/richieste-ingresso')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-2')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(richiestaIngressoFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /gruppi/:orgId/richieste-ingresso/:id', () => {
|
||||||
|
test('approva la richiesta: assegna gruppo/ruolo su Keycloak e aggiorna stato=APPROVATA', async () => {
|
||||||
|
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
addMemberToOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
assignUserToGroup.mockResolvedValueOnce(undefined);
|
||||||
|
assignRealmRoleToUser.mockResolvedValueOnce(undefined);
|
||||||
|
richiestaIngressoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'approvata', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'APPROVATA' });
|
||||||
|
|
||||||
|
expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-nuovo');
|
||||||
|
expect(assignUserToGroup).toHaveBeenCalledWith('user-nuovo', 'Capi');
|
||||||
|
expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-nuovo', 'Capi');
|
||||||
|
expect(richiestaIngressoUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'richiesta-1' },
|
||||||
|
data: { stato: 'APPROVATA' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 502 e NON marca la richiesta come approvata se Keycloak fallisce', async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
addMemberToOrganization.mockResolvedValueOnce(undefined);
|
||||||
|
assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'approvata', ruolo: 'Capi' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(502);
|
||||||
|
expect(richiestaIngressoUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('richiesta-1'), expect.anything());
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("PENDING"), expect.anything());
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rifiuta la richiesta senza toccare Keycloak e aggiorna stato=RIFIUTATA', async () => {
|
||||||
|
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture());
|
||||||
|
richiestaIngressoUpdate.mockResolvedValueOnce(richiestaFixture({ stato: 'RIFIUTATA' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ id: 'richiesta-1', stato: 'RIFIUTATA' });
|
||||||
|
|
||||||
|
expect(addMemberToOrganization).not.toHaveBeenCalled();
|
||||||
|
expect(assignUserToGroup).not.toHaveBeenCalled();
|
||||||
|
expect(richiestaIngressoUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'richiesta-1' },
|
||||||
|
data: { stato: 'RIFIUTATA' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 400 se esito='approvata' senza il campo ruolo", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'approvata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(richiestaIngressoFindUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 409 se la richiesta non è più PENDING', async () => {
|
||||||
|
richiestaIngressoFindUnique.mockResolvedValueOnce(richiestaFixture({ stato: 'APPROVATA' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-1/richieste-ingresso/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(richiestaIngressoUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 404 se la richiesta non esiste', async () => {
|
||||||
|
richiestaIngressoFindUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-1/richieste-ingresso/richiesta-inesistente')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 403 se il capo gruppo prova ad approvare una richiesta di un'altra organizzazione", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/gruppi/org-2/richieste-ingresso/richiesta-1')
|
||||||
|
.set('Authorization', `Bearer ${capoGruppoToken('org-1')}`)
|
||||||
|
.send({ esito: 'rifiutata' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(richiestaIngressoFindUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { requireOrgAccess } from '../../../src/middleware/requireOrgAccess';
|
||||||
|
|
||||||
|
function buildReqRes(auth: Partial<Request['auth']> | undefined, orgId: string) {
|
||||||
|
const req = { auth, params: { orgId } } as unknown as Request;
|
||||||
|
const json = jest.fn();
|
||||||
|
const status = jest.fn().mockReturnValue({ json });
|
||||||
|
const res = { status } as unknown as Response;
|
||||||
|
const next = jest.fn();
|
||||||
|
return { req, res, next, status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireOrgAccess', () => {
|
||||||
|
test('lascia passare admin anche su un orgId diverso dal proprio', () => {
|
||||||
|
const { req, res, next, status } = buildReqRes(
|
||||||
|
{ userId: 'u1', email: null, organizationId: 'org-mio', roles: ['admin'] },
|
||||||
|
'org-altrui',
|
||||||
|
);
|
||||||
|
|
||||||
|
requireOrgAccess(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lascia passare capo-gruppo sulla propria organizzazione', () => {
|
||||||
|
const { req, res, next, status } = buildReqRes(
|
||||||
|
{ userId: 'u1', email: null, organizationId: 'org-1', roles: ['capo-gruppo'] },
|
||||||
|
'org-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
requireOrgAccess(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se capo-gruppo prova ad accedere a un altro orgId', () => {
|
||||||
|
const { req, res, next, status, json } = buildReqRes(
|
||||||
|
{ userId: 'u1', email: null, organizationId: 'org-1', roles: ['capo-gruppo'] },
|
||||||
|
'org-2',
|
||||||
|
);
|
||||||
|
|
||||||
|
requireOrgAccess(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
expect(json).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se manca il ruolo capo-gruppo (e non è admin)', () => {
|
||||||
|
const { req, res, next, status } = buildReqRes(
|
||||||
|
{ userId: 'u1', email: null, organizationId: 'org-1', roles: ['censito'] },
|
||||||
|
'org-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
requireOrgAccess(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 403 se req.auth è assente', () => {
|
||||||
|
const { req, res, next, status } = buildReqRes(undefined, 'org-1');
|
||||||
|
|
||||||
|
requireOrgAccess(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user