From 5b7c82d359206ec1b43f7e71211b7b4d06b80ff5 Mon Sep 17 00:00:00 2001 From: Lorenzo Sanesi Date: Sat, 25 Jul 2026 12:09:12 +0200 Subject: [PATCH] Fix scouthub-home-be --- scouthub-home-be/.env.example | 10 +- scouthub-home-be/README.md | 2 +- .../migrations/0000_baseline/migration.sql | 26 ++ .../migration.sql | 60 ++++ .../prisma/migrations/migration_lock.toml | 3 + scouthub-home-be/prisma/schema.prisma | 58 ++- scouthub-home-be/src/app.ts | 6 + .../src/controllers/gruppi.controller.ts | 20 +- .../src/controllers/inviti.controller.ts | 6 - .../controllers/linkIngresso.controller.ts | 42 +++ .../src/controllers/membri.controller.ts | 38 +- .../richiesteCreazioneGruppo.controller.ts | 74 ++++ .../richiesteIngresso.controller.ts | 67 ++++ scouthub-home-be/src/keycloak-admin/groups.ts | 25 +- scouthub-home-be/src/keycloak-admin/index.ts | 2 +- .../src/keycloak-admin/organizations.ts | 2 +- scouthub-home-be/src/keycloak-admin/roles.ts | 33 +- scouthub-home-be/src/keycloak-admin/users.ts | 15 +- .../src/middleware/requireOrgAccess.ts | 20 ++ scouthub-home-be/src/routes/gruppi.routes.ts | 16 +- scouthub-home-be/src/routes/inviti.routes.ts | 4 +- .../src/routes/linkIngresso.routes.ts | 12 + scouthub-home-be/src/routes/membri.routes.ts | 13 +- .../routes/richiesteCreazioneGruppo.routes.ts | 26 ++ .../src/routes/richiesteIngresso.routes.ts | 26 ++ .../src/services/assegnazioneGruppoRuolo.ts | 9 + .../src/services/gruppi.service.ts | 29 ++ .../src/services/inviti.service.ts | 9 +- .../src/services/linkIngresso.service.ts | 63 ++++ .../src/services/membri.service.ts | 63 +++- .../richiesteCreazioneGruppo.service.ts | 135 +++++++ .../src/services/richiesteIngresso.service.ts | 127 +++++++ .../tests/integration/gruppi.endpoint.test.ts | 71 +++- .../tests/integration/inviti.endpoint.test.ts | 27 ++ .../integration/linkIngresso.endpoint.test.ts | 245 +++++++++++++ .../tests/integration/membri.endpoint.test.ts | 111 ++++++ .../richiesteCreazioneGruppo.endpoint.test.ts | 330 ++++++++++++++++++ .../richiesteIngresso.endpoint.test.ts | 301 ++++++++++++++++ .../unit/middleware/requireOrgAccess.test.ts | 71 ++++ 39 files changed, 2116 insertions(+), 81 deletions(-) create mode 100644 scouthub-home-be/prisma/migrations/0000_baseline/migration.sql create mode 100644 scouthub-home-be/prisma/migrations/20260723201327_aggiungi_richieste_e_link_ingresso/migration.sql create mode 100644 scouthub-home-be/prisma/migrations/migration_lock.toml create mode 100644 scouthub-home-be/src/controllers/linkIngresso.controller.ts create mode 100644 scouthub-home-be/src/controllers/richiesteCreazioneGruppo.controller.ts create mode 100644 scouthub-home-be/src/controllers/richiesteIngresso.controller.ts create mode 100644 scouthub-home-be/src/middleware/requireOrgAccess.ts create mode 100644 scouthub-home-be/src/routes/linkIngresso.routes.ts create mode 100644 scouthub-home-be/src/routes/richiesteCreazioneGruppo.routes.ts create mode 100644 scouthub-home-be/src/routes/richiesteIngresso.routes.ts create mode 100644 scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts create mode 100644 scouthub-home-be/src/services/linkIngresso.service.ts create mode 100644 scouthub-home-be/src/services/richiesteCreazioneGruppo.service.ts create mode 100644 scouthub-home-be/src/services/richiesteIngresso.service.ts create mode 100644 scouthub-home-be/tests/integration/linkIngresso.endpoint.test.ts create mode 100644 scouthub-home-be/tests/integration/richiesteCreazioneGruppo.endpoint.test.ts create mode 100644 scouthub-home-be/tests/integration/richiesteIngresso.endpoint.test.ts create mode 100644 scouthub-home-be/tests/unit/middleware/requireOrgAccess.test.ts diff --git a/scouthub-home-be/.env.example b/scouthub-home-be/.env.example index cf968cf..8489000 100644 --- a/scouthub-home-be/.env.example +++ b/scouthub-home-be/.env.example @@ -1,10 +1,10 @@ -# 8082, non 8081: la porta 8081 è quella su cui il Keycloak del -# docker-compose alla radice del progetto è esposto sull'host. -PORT=8082 +# 8000, non 6999: la porta 6999 e' quella su cui il Keycloak del +# docker-compose alla radice del progetto e' esposto sull'host. +PORT=8000 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_ORG_SERVICE_CLIENT_ID=scouthub-home-be KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT diff --git a/scouthub-home-be/README.md b/scouthub-home-be/README.md index 36c2052..8b3f221 100644 --- a/scouthub-home-be/README.md +++ b/scouthub-home-be/README.md @@ -77,7 +77,7 @@ interamente `src/keycloak-admin` e `src/db/prisma`. Crea un nuovo gruppo scout: organizzazione Keycloak, un gruppo Keycloak per ciascun ruolo di default, e la riga corrispondente in `gruppo_scout`. -- Richiede autenticazione + ruolo `admin-centrale` (temporaneo, vedi +- Richiede autenticazione + ruolo `admin` (temporaneo, vedi `src/routes/gruppi.routes.ts`) - Body: `{ nome: string, regione?: string, ruoliDefault?: string[] }` (`ruoliDefault` di default `["Capi", "Aiuto capi", "Censiti"]`) diff --git a/scouthub-home-be/prisma/migrations/0000_baseline/migration.sql b/scouthub-home-be/prisma/migrations/0000_baseline/migration.sql new file mode 100644 index 0000000..124a69e --- /dev/null +++ b/scouthub-home-be/prisma/migrations/0000_baseline/migration.sql @@ -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; diff --git a/scouthub-home-be/prisma/migrations/20260723201327_aggiungi_richieste_e_link_ingresso/migration.sql b/scouthub-home-be/prisma/migrations/20260723201327_aggiungi_richieste_e_link_ingresso/migration.sql new file mode 100644 index 0000000..c7463f4 --- /dev/null +++ b/scouthub-home-be/prisma/migrations/20260723201327_aggiungi_richieste_e_link_ingresso/migration.sql @@ -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; diff --git a/scouthub-home-be/prisma/migrations/migration_lock.toml b/scouthub-home-be/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/scouthub-home-be/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/scouthub-home-be/prisma/schema.prisma b/scouthub-home-be/prisma/schema.prisma index ce9bc0a..a6cacb2 100644 --- a/scouthub-home-be/prisma/schema.prisma +++ b/scouthub-home-be/prisma/schema.prisma @@ -13,7 +13,9 @@ model GruppoScout { regione String? dataCreazione DateTime @default(now()) @map("data_creazione") - inviti Invito[] + inviti Invito[] + richiesteIngresso RichiestaIngresso[] + linkIngresso LinkIngresso[] @@map("gruppo_scout") } @@ -31,3 +33,57 @@ model 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") +} diff --git a/scouthub-home-be/src/app.ts b/scouthub-home-be/src/app.ts index 7574801..294c4f6 100644 --- a/scouthub-home-be/src/app.ts +++ b/scouthub-home-be/src/app.ts @@ -4,6 +4,9 @@ import { healthRouter } from './routes/health.routes'; import { gruppiRouter } from './routes/gruppi.routes'; import { invitiRouter } from './routes/inviti.routes'; import { membriRouter } from './routes/membri.routes'; +import { linkIngressoRouter } from './routes/linkIngresso.routes'; +import { richiesteIngressoRouter } from './routes/richiesteIngresso.routes'; +import { richiesteCreazioneGruppoRouter } from './routes/richiesteCreazioneGruppo.routes'; import { errorHandler } from './middleware/errorHandler'; export const app = express(); @@ -15,6 +18,9 @@ app.use(healthRouter); app.use(gruppiRouter); app.use(invitiRouter); app.use(membriRouter); +app.use(linkIngressoRouter); +app.use(richiesteIngressoRouter); +app.use(richiesteCreazioneGruppoRouter); app.use((req, res) => { res.status(404).json({ message: 'not found' }); diff --git a/scouthub-home-be/src/controllers/gruppi.controller.ts b/scouthub-home-be/src/controllers/gruppi.controller.ts index 0d265c4..c47ccd5 100644 --- a/scouthub-home-be/src/controllers/gruppi.controller.ts +++ b/scouthub-home-be/src/controllers/gruppi.controller.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from 'express'; -import { createGruppo } from '../services/gruppi.service'; +import { createGruppo, listGruppi, listGruppiPubblico } from '../services/gruppi.service'; import { HttpError } from '../errors'; interface PostGruppoBody { @@ -40,3 +40,21 @@ export async function postGruppo(req: Request, res: Response, next: NextFunction next(err); } } + +export async function getGruppi(_req: Request, res: Response, next: NextFunction): Promise { + 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 { + try { + const gruppi = await listGruppiPubblico(); + res.status(200).json(gruppi); + } catch (err) { + next(err); + } +} diff --git a/scouthub-home-be/src/controllers/inviti.controller.ts b/scouthub-home-be/src/controllers/inviti.controller.ts index a40677a..d041f58 100644 --- a/scouthub-home-be/src/controllers/inviti.controller.ts +++ b/scouthub-home-be/src/controllers/inviti.controller.ts @@ -20,12 +20,6 @@ function parseCreaInvitoBody(body: PostInvitoBody): { email: string; ruolo: stri export async function postInvito(req: Request, res: Response, next: NextFunction): Promise { try { const { orgId } = req.params; - - // Un capo gruppo può invitare solo all'interno della propria organization. - if (req.auth?.organizationId !== orgId) { - throw new HttpError(403, "Non puoi invitare persone in un'organizzazione diversa dalla tua"); - } - const { email, ruolo } = parseCreaInvitoBody(req.body ?? {}); const result = await creaInvito({ orgId, email, ruolo }); diff --git a/scouthub-home-be/src/controllers/linkIngresso.controller.ts b/scouthub-home-be/src/controllers/linkIngresso.controller.ts new file mode 100644 index 0000000..09d0e8f --- /dev/null +++ b/scouthub-home-be/src/controllers/linkIngresso.controller.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/scouthub-home-be/src/controllers/membri.controller.ts b/scouthub-home-be/src/controllers/membri.controller.ts index b89ad51..4e59383 100644 --- a/scouthub-home-be/src/controllers/membri.controller.ts +++ b/scouthub-home-be/src/controllers/membri.controller.ts @@ -1,18 +1,36 @@ 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'; -function checkOrgAccess(req: Request): string { - const { orgId } = req.params; - if (req.auth?.organizationId !== orgId) { - throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua"); +interface PostMembroBody { + email?: unknown; + ruolo?: unknown; +} + +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 { + 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 { try { - const orgId = checkOrgAccess(req); + const { orgId } = req.params; const membri = await listaMembri(orgId); res.json(membri); } 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 { try { - const orgId = checkOrgAccess(req); - const { userId } = req.params; + const { orgId, userId } = req.params; const { ruolo } = req.body ?? {}; 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 { try { - const orgId = checkOrgAccess(req); - const { userId } = req.params; + const { orgId, userId } = req.params; await rimuoviMembro(orgId, userId); res.status(204).send(); diff --git a/scouthub-home-be/src/controllers/richiesteCreazioneGruppo.controller.ts b/scouthub-home-be/src/controllers/richiesteCreazioneGruppo.controller.ts new file mode 100644 index 0000000..0feceb5 --- /dev/null +++ b/scouthub-home-be/src/controllers/richiesteCreazioneGruppo.controller.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/scouthub-home-be/src/controllers/richiesteIngresso.controller.ts b/scouthub-home-be/src/controllers/richiesteIngresso.controller.ts new file mode 100644 index 0000000..b979ead --- /dev/null +++ b/scouthub-home-be/src/controllers/richiesteIngresso.controller.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/scouthub-home-be/src/keycloak-admin/groups.ts b/scouthub-home-be/src/keycloak-admin/groups.ts index a7cd07d..3c5f3ed 100644 --- a/scouthub-home-be/src/keycloak-admin/groups.ts +++ b/scouthub-home-be/src/keycloak-admin/groups.ts @@ -1,11 +1,12 @@ +import { keycloakAdminHttp } from './httpClient'; +import { GRUPPO_PADRE_PREFIX } from './organizations'; + export async function assignUserToGroup(userId: string, groupId: string): Promise { - // TODO: PUT {adminBaseUrl}/users/{userId}/groups/{groupId} - throw new Error('Not implemented'); + await keycloakAdminHttp.put(`/users/${userId}/groups/${groupId}`); } export async function removeUserFromGroup(userId: string, groupId: string): Promise { - // TODO: DELETE {adminBaseUrl}/users/{userId}/groups/{groupId} - throw new Error('Not implemented'); + await keycloakAdminHttp.delete(`/users/${userId}/groups/${groupId}`); } export interface UserGroup { @@ -14,8 +15,16 @@ export interface UserGroup { } export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise { - // TODO: GET {adminBaseUrl}/users/{userId}/groups, filtrati ai gruppi che - // appartengono all'albero dei gruppi dell'organizzazione orgId (o endpoint - // dedicato, da verificare in base alla versione di Keycloak). - throw new Error('Not implemented'); + const response = await keycloakAdminHttp.get>( + `/users/${userId}/groups`, + ); + + // 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 })); } diff --git a/scouthub-home-be/src/keycloak-admin/index.ts b/scouthub-home-be/src/keycloak-admin/index.ts index f3a922d..c65ccb6 100644 --- a/scouthub-home-be/src/keycloak-admin/index.ts +++ b/scouthub-home-be/src/keycloak-admin/index.ts @@ -9,4 +9,4 @@ export { } from './organizations'; export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups'; export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles'; -export { findUserByEmail, createUser } from './users'; +export { findUserByEmail } from './users'; diff --git a/scouthub-home-be/src/keycloak-admin/organizations.ts b/scouthub-home-be/src/keycloak-admin/organizations.ts index bc00a7e..8aa3a97 100644 --- a/scouthub-home-be/src/keycloak-admin/organizations.ts +++ b/scouthub-home-be/src/keycloak-admin/organizations.ts @@ -43,7 +43,7 @@ export async function createOrganization(nome: string): Promise<{ orgId: string // dell'organizzazione (non elencato da GET /groups, ma verificato contro // un'istanza reale: un POST /groups con quel nome esatto risponde comunque // 409 "already exists"). Va quindi evitato un nome che collida con quello. -const GRUPPO_PADRE_PREFIX = 'org-'; +export const GRUPPO_PADRE_PREFIX = 'org-'; export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> { // Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo diff --git a/scouthub-home-be/src/keycloak-admin/roles.ts b/scouthub-home-be/src/keycloak-admin/roles.ts index 8d5aabe..d56ed61 100644 --- a/scouthub-home-be/src/keycloak-admin/roles.ts +++ b/scouthub-home-be/src/keycloak-admin/roles.ts @@ -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 { - // TODO: POST {adminBaseUrl}/users/{userId}/role-mappings/realm con il - // rappresentante del ruolo (richiede prima GET {adminBaseUrl}/roles/{ruolo} - // per ottenerne id e name). - throw new Error('Not implemented'); + const { data: ruoloRepresentation } = await keycloakAdminHttp.get(`/roles/${ruolo}`); + await keycloakAdminHttp.post(`/users/${userId}/role-mappings/realm`, [ruoloRepresentation]); } export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise { - // TODO: DELETE {adminBaseUrl}/users/{userId}/role-mappings/realm con il - // rappresentante del ruolo. - throw new Error('Not implemented'); + const { data: ruoloRepresentation } = await keycloakAdminHttp.get(`/roles/${ruolo}`); + await keycloakAdminHttp.delete(`/users/${userId}/role-mappings/realm`, { data: [ruoloRepresentation] }); } export async function getUserRealmRoles(userId: string): Promise { - // TODO: GET {adminBaseUrl}/users/{userId}/role-mappings/realm. Il contratto - // di questa funzione è di restituire solo i ruoli scout "custom" (es. le - // voci di ruoliDefault create in POST /gruppi), escludendo i ruoli di - // default di Keycloak (offline_access, uma_authorization, ecc.). - throw new Error('Not implemented'); + const response = await keycloakAdminHttp.get(`/users/${userId}/role-mappings/realm`); + return response.data.map((ruolo) => ruolo.name).filter((nome) => !isRuoloDefaultKeycloak(nome)); } diff --git a/scouthub-home-be/src/keycloak-admin/users.ts b/scouthub-home-be/src/keycloak-admin/users.ts index c3b2128..392f012 100644 --- a/scouthub-home-be/src/keycloak-admin/users.ts +++ b/scouthub-home-be/src/keycloak-admin/users.ts @@ -1,13 +1,10 @@ +import { keycloakAdminHttp } from './httpClient'; import { KeycloakUserSummary } from './types'; export async function findUserByEmail(email: string): Promise { - // TODO: GET {adminBaseUrl}/users?email={email}&exact=true, restituire il - // primo risultato mappato a { id } oppure null se l'array è vuoto. - throw new Error('Not implemented'); -} - -export async function createUser(email: string, datiProfilo: object): Promise<{ userId: string }> { - // TODO: POST {adminBaseUrl}/users con { email, ...datiProfilo, enabled: true }, - // leggere l'id dall'header Location della risposta. - throw new Error('Not implemented'); + const response = await keycloakAdminHttp.get>('/users', { + params: { email, exact: true }, + }); + const [utente] = response.data; + return utente ? { id: utente.id } : null; } diff --git a/scouthub-home-be/src/middleware/requireOrgAccess.ts b/scouthub-home-be/src/middleware/requireOrgAccess.ts new file mode 100644 index 0000000..4494df3 --- /dev/null +++ b/scouthub-home-be/src/middleware/requireOrgAccess.ts @@ -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" }); +} diff --git a/scouthub-home-be/src/routes/gruppi.routes.ts b/scouthub-home-be/src/routes/gruppi.routes.ts index 12f8384..f9b19b7 100644 --- a/scouthub-home-be/src/routes/gruppi.routes.ts +++ b/scouthub-home-be/src/routes/gruppi.routes.ts @@ -1,10 +1,18 @@ import { Router } from 'express'; import { authenticate } from '../middleware/authenticate'; import { requireRole } from '../middleware/requireRole'; -import { postGruppo } from '../controllers/gruppi.controller'; +import { postGruppo, getGruppi, getGruppiElencoPubblico } from '../controllers/gruppi.controller'; export const gruppiRouter = Router(); -// TODO: "admin-centrale" è temporaneo, in attesa di definire i ruoli reali -// abilitati alla creazione di un nuovo gruppo scout. -gruppiRouter.post('/gruppi', authenticate, requireRole('admin-centrale'), postGruppo); +// "admin" è la porta d'accesso diretta e definitiva a queste due route: un +// utente normale non le chiama mai a mano, ma passa dal flusso self-service +// 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); diff --git a/scouthub-home-be/src/routes/inviti.routes.ts b/scouthub-home-be/src/routes/inviti.routes.ts index 7f8d18f..064e862 100644 --- a/scouthub-home-be/src/routes/inviti.routes.ts +++ b/scouthub-home-be/src/routes/inviti.routes.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; import { authenticate } from '../middleware/authenticate'; -import { requireRole } from '../middleware/requireRole'; +import { requireOrgAccess } from '../middleware/requireOrgAccess'; import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller'; 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); diff --git a/scouthub-home-be/src/routes/linkIngresso.routes.ts b/scouthub-home-be/src/routes/linkIngresso.routes.ts new file mode 100644 index 0000000..2ccb168 --- /dev/null +++ b/scouthub-home-be/src/routes/linkIngresso.routes.ts @@ -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); diff --git a/scouthub-home-be/src/routes/membri.routes.ts b/scouthub-home-be/src/routes/membri.routes.ts index 2c7b22a..602c41f 100644 --- a/scouthub-home-be/src/routes/membri.routes.ts +++ b/scouthub-home-be/src/routes/membri.routes.ts @@ -1,10 +1,15 @@ import { Router } from 'express'; import { authenticate } from '../middleware/authenticate'; +import { requireOrgAccess } from '../middleware/requireOrgAccess'; 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(); -membriRouter.get('/gruppi/:orgId/membri', authenticate, requireRole('capo-gruppo'), getMembri); -membriRouter.put('/gruppi/:orgId/membri/:userId/ruolo', authenticate, requireRole('capo-gruppo'), putRuoloMembro); -membriRouter.delete('/gruppi/:orgId/membri/:userId', authenticate, requireRole('capo-gruppo'), deleteMembro); +// Riservato ad admin: aggiunge un membro già registrato senza +// passare dal flusso di invito, su qualsiasi organizzazione. +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); diff --git a/scouthub-home-be/src/routes/richiesteCreazioneGruppo.routes.ts b/scouthub-home-be/src/routes/richiesteCreazioneGruppo.routes.ts new file mode 100644 index 0000000..478cc69 --- /dev/null +++ b/scouthub-home-be/src/routes/richiesteCreazioneGruppo.routes.ts @@ -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, +); diff --git a/scouthub-home-be/src/routes/richiesteIngresso.routes.ts b/scouthub-home-be/src/routes/richiesteIngresso.routes.ts new file mode 100644 index 0000000..3f6dbee --- /dev/null +++ b/scouthub-home-be/src/routes/richiesteIngresso.routes.ts @@ -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, +); diff --git a/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts b/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts new file mode 100644 index 0000000..db2b52e --- /dev/null +++ b/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts @@ -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 { + await assignUserToGroup(userId, ruolo); + await assignRealmRoleToUser(userId, ruolo); +} diff --git a/scouthub-home-be/src/services/gruppi.service.ts b/scouthub-home-be/src/services/gruppi.service.ts index c4c896c..559cc2f 100644 --- a/scouthub-home-be/src/services/gruppi.service.ts +++ b/scouthub-home-be/src/services/gruppi.service.ts @@ -17,6 +17,35 @@ export interface CreateGruppoResult { gruppiCreati: string[]; } +export interface GruppoListItem { + orgId: string; + nome: string; + regione: string | null; +} + +export async function listGruppi(): Promise { + 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 { + return prisma.gruppoScout.findMany({ + select: { orgId: true, nome: true }, + orderBy: { nome: 'asc' }, + }); +} + function isKeycloakConflict(err: unknown): boolean { return axios.isAxiosError(err) && err.response?.status === 409; } diff --git a/scouthub-home-be/src/services/inviti.service.ts b/scouthub-home-be/src/services/inviti.service.ts index 1c115e8..56e5233 100644 --- a/scouthub-home-be/src/services/inviti.service.ts +++ b/scouthub-home-be/src/services/inviti.service.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; 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 { env } from '../config/env'; @@ -99,11 +100,7 @@ export async function accettaInvito( try { await addMemberToOrganization(invito.orgId, utente.userId); - // TODO: "ruolo" è usato qui anche come identificativo del gruppo Keycloak, - // in attesa che createOrganizationGroup persista una mappa ruolo -> groupId - // reale da risolvere in questo punto. - await assignUserToGroup(utente.userId, invito.ruolo); - await assignRealmRoleToUser(utente.userId, invito.ruolo); + await assegnaGruppoERuolo(utente.userId, invito.ruolo); } catch (err) { console.error( `[inviti] fallita l'assegnazione Keycloak per l'accettazione dell'invito token="${token}", ` + diff --git a/scouthub-home-be/src/services/linkIngresso.service.ts b/scouthub-home-be/src/services/linkIngresso.service.ts new file mode 100644 index 0000000..5c96315 --- /dev/null +++ b/scouthub-home-be/src/services/linkIngresso.service.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/scouthub-home-be/src/services/membri.service.ts b/scouthub-home-be/src/services/membri.service.ts index 5de5f16..baa8d8a 100644 --- a/scouthub-home-be/src/services/membri.service.ts +++ b/scouthub-home-be/src/services/membri.service.ts @@ -1,13 +1,14 @@ import { listOrganizationMembers, removeMemberFromOrganization, - assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization, - assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles, + addMemberToOrganization, + findUserByEmail, } from '../keycloak-admin'; +import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo'; import { HttpError } from '../errors'; export interface MembroOrganization { @@ -43,16 +44,13 @@ export async function cambiaRuoloMembro(orgId: string, userId: string, nuovoRuol for (const gruppo of gruppiAttuali) { await removeUserFromGroup(userId, gruppo.groupId); } - // TODO: come nel flusso di invito, "ruolo" è usato anche come - // identificativo del gruppo Keycloak, in attesa di una mappa - // ruolo -> groupId reale (vedi src/services/inviti.service.ts). - await assignUserToGroup(userId, nuovoRuolo); const ruoliAttuali = await getUserRealmRoles(userId); for (const ruolo of ruoliAttuali) { await removeRealmRoleFromUser(userId, ruolo); } - await assignRealmRoleToUser(userId, nuovoRuolo); + + await assegnaGruppoERuolo(userId, nuovoRuolo); } catch (err) { console.error( `[membri] cambio ruolo fallito per orgId="${orgId}", userId="${userId}", nuovoRuolo="${nuovoRuolo}". ` + @@ -72,3 +70,54 @@ export async function rimuoviMembro(orgId: string, userId: string): Promise { + 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 }; +} diff --git a/scouthub-home-be/src/services/richiesteCreazioneGruppo.service.ts b/scouthub-home-be/src/services/richiesteCreazioneGruppo.service.ts new file mode 100644 index 0000000..7211122 --- /dev/null +++ b/scouthub-home-be/src/services/richiesteCreazioneGruppo.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/scouthub-home-be/src/services/richiesteIngresso.service.ts b/scouthub-home-be/src/services/richiesteIngresso.service.ts new file mode 100644 index 0000000..97d7b24 --- /dev/null +++ b/scouthub-home-be/src/services/richiesteIngresso.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/scouthub-home-be/tests/integration/gruppi.endpoint.test.ts b/scouthub-home-be/tests/integration/gruppi.endpoint.test.ts index 032d487..31e971f 100644 --- a/scouthub-home-be/tests/integration/gruppi.endpoint.test.ts +++ b/scouthub-home-be/tests/integration/gruppi.endpoint.test.ts @@ -14,6 +14,7 @@ const createOrganization = jest.fn(); const createOrganizationGroup = jest.fn(); const addMemberToOrganization = jest.fn(); const gruppoScoutCreate = jest.fn(); +const gruppoScoutFindMany = jest.fn(); jest.mock('../../src/keycloak-admin', () => ({ createOrganization: (...args: unknown[]) => createOrganization(...args), @@ -25,6 +26,7 @@ jest.mock('../../src/db/prisma', () => ({ prisma: { gruppoScout: { create: (...args: unknown[]) => gruppoScoutCreate(...args), + findMany: (...args: unknown[]) => gruppoScoutFindMany(...args), }, }, })); @@ -47,7 +49,7 @@ function signToken(roles: string[]): string { ); } -const adminToken = () => signToken(['admin-centrale']); +const adminToken = () => signToken(['admin']); const nonAdminToken = () => signToken(['capo-gruppo']); function conflictError(): Error { @@ -166,7 +168,7 @@ describe('POST /gruppi', () => { consoleErrorSpy.mockRestore(); }); - test('risponde 403 se il ruolo non è admin-centrale', async () => { + test('risponde 403 se il ruolo non è admin', async () => { const response = await request(app) .post('/gruppi') .set('Authorization', `Bearer ${nonAdminToken()}`) @@ -193,3 +195,68 @@ describe('POST /gruppi', () => { expect(createOrganization).not.toHaveBeenCalled(); }); }); + +describe('GET /gruppi', () => { + test('restituisce 200 con la lista dei gruppi scout per admin', async () => { + gruppoScoutFindMany.mockResolvedValueOnce([ + { orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' }, + { orgId: 'org-2', nome: 'Gruppo Beta', regione: null }, + ]); + + const response = await request(app).get('/gruppi').set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual([ + { orgId: 'org-1', nome: 'Gruppo Alfa', regione: 'Lombardia' }, + { orgId: 'org-2', nome: 'Gruppo Beta', regione: null }, + ]); + expect(gruppoScoutFindMany).toHaveBeenCalledWith({ + select: { orgId: true, nome: true, regione: true }, + orderBy: { nome: 'asc' }, + }); + }); + + test('risponde 403 se il ruolo non è admin', async () => { + const response = await request(app).get('/gruppi').set('Authorization', `Bearer ${nonAdminToken()}`); + + expect(response.status).toBe(403); + expect(gruppoScoutFindMany).not.toHaveBeenCalled(); + }); + + test('risponde 401 senza token', async () => { + const response = await request(app).get('/gruppi'); + + expect(response.status).toBe(401); + expect(gruppoScoutFindMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /gruppi/elenco-pubblico', () => { + test('restituisce 200 con solo orgId/nome, accessibile a un utente non admin', async () => { + gruppoScoutFindMany.mockResolvedValueOnce([ + { orgId: 'org-1', nome: 'Gruppo Alfa' }, + { orgId: 'org-2', nome: 'Gruppo Beta' }, + ]); + + const response = await request(app) + .get('/gruppi/elenco-pubblico') + .set('Authorization', `Bearer ${nonAdminToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual([ + { orgId: 'org-1', nome: 'Gruppo Alfa' }, + { orgId: 'org-2', nome: 'Gruppo Beta' }, + ]); + expect(gruppoScoutFindMany).toHaveBeenCalledWith({ + select: { orgId: true, nome: true }, + orderBy: { nome: 'asc' }, + }); + }); + + test('risponde 401 senza token', async () => { + const response = await request(app).get('/gruppi/elenco-pubblico'); + + expect(response.status).toBe(401); + expect(gruppoScoutFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/scouthub-home-be/tests/integration/inviti.endpoint.test.ts b/scouthub-home-be/tests/integration/inviti.endpoint.test.ts index 1c435ec..6c01c8c 100644 --- a/scouthub-home-be/tests/integration/inviti.endpoint.test.ts +++ b/scouthub-home-be/tests/integration/inviti.endpoint.test.ts @@ -61,6 +61,14 @@ function utenteToken(email: string): string { return signToken({ sub: 'user-invitato', email, realm_access: { roles: [] } }); } +function adminCentraleToken(): string { + return signToken({ + sub: 'user-admin', + email: 'admin@example.com', + realm_access: { roles: ['admin'] }, + }); +} + const ORA = Date.now(); function invitoFixture(overrides: Partial> = {}) { return { @@ -133,6 +141,25 @@ describe('POST /gruppi/:orgId/inviti', () => { expect(response.status).toBe(403); expect(invitoCreate).not.toHaveBeenCalled(); }); + + test("admin può creare un invito anche in un'organizzazione che non è la propria", async () => { + invitoCreate.mockResolvedValueOnce({ id: 'invito-nuovo', scadenza: new Date(ORA + 7 * 24 * 60 * 60 * 1000) }); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + const response = await request(app) + .post('/gruppi/org-altrui/inviti') + .set('Authorization', `Bearer ${adminCentraleToken()}`) + .send({ email: 'nuovo@example.com', ruolo: 'Capi' }); + + expect(response.status).toBe(201); + expect(invitoCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ orgId: 'org-altrui' }), + }), + ); + + consoleLogSpy.mockRestore(); + }); }); describe('GET /inviti/:token', () => { diff --git a/scouthub-home-be/tests/integration/linkIngresso.endpoint.test.ts b/scouthub-home-be/tests/integration/linkIngresso.endpoint.test.ts new file mode 100644 index 0000000..1706b01 --- /dev/null +++ b/scouthub-home-be/tests/integration/linkIngresso.endpoint.test.ts @@ -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; +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> = {}) { + 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(); + }); +}); diff --git a/scouthub-home-be/tests/integration/membri.endpoint.test.ts b/scouthub-home-be/tests/integration/membri.endpoint.test.ts index 93c8a8a..32c6fea 100644 --- a/scouthub-home-be/tests/integration/membri.endpoint.test.ts +++ b/scouthub-home-be/tests/integration/membri.endpoint.test.ts @@ -18,6 +18,8 @@ const getUserGroupsInOrganization = jest.fn(); const assignRealmRoleToUser = jest.fn(); const removeRealmRoleFromUser = jest.fn(); const getUserRealmRoles = jest.fn(); +const addMemberToOrganization = jest.fn(); +const findUserByEmail = jest.fn(); jest.mock('../../src/keycloak-admin', () => ({ listOrganizationMembers: (...args: unknown[]) => listOrganizationMembers(...args), @@ -28,6 +30,8 @@ jest.mock('../../src/keycloak-admin', () => ({ assignRealmRoleToUser: (...args: unknown[]) => assignRealmRoleToUser(...args), removeRealmRoleFromUser: (...args: unknown[]) => removeRealmRoleFromUser(...args), getUserRealmRoles: (...args: unknown[]) => getUserRealmRoles(...args), + addMemberToOrganization: (...args: unknown[]) => addMemberToOrganization(...args), + findUserByEmail: (...args: unknown[]) => findUserByEmail(...args), })); import { app } from '../../src/app'; @@ -62,6 +66,14 @@ function nonCapoToken(orgId: string): string { }); } +function adminCentraleToken(): string { + return signToken({ + sub: 'user-admin', + email: 'admin@example.com', + realm_access: { roles: ['admin'] }, + }); +} + beforeAll(() => { nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, { keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }], @@ -76,6 +88,68 @@ beforeEach(() => { jest.clearAllMocks(); }); +describe('POST /gruppi/:orgId/membri', () => { + test('aggiunge un utente già registrato con successo e risponde 201', async () => { + findUserByEmail.mockResolvedValueOnce({ id: 'user-1' }); + addMemberToOrganization.mockResolvedValueOnce(undefined); + assignUserToGroup.mockResolvedValueOnce(undefined); + assignRealmRoleToUser.mockResolvedValueOnce(undefined); + + const response = await request(app) + .post('/gruppi/org-1/membri') + .set('Authorization', `Bearer ${adminCentraleToken()}`) + .send({ email: 'gia-registrato@example.com', ruolo: 'Capi' }); + + expect(response.status).toBe(201); + expect(response.body).toEqual({ userId: 'user-1', email: 'gia-registrato@example.com', ruolo: 'Capi' }); + + expect(findUserByEmail).toHaveBeenCalledWith('gia-registrato@example.com'); + expect(addMemberToOrganization).toHaveBeenCalledWith('org-1', 'user-1'); + expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Capi'); + expect(assignRealmRoleToUser).toHaveBeenCalledWith('user-1', 'Capi'); + }); + + test("risponde 404 se non esiste un utente registrato con quell'email", async () => { + findUserByEmail.mockResolvedValueOnce(null); + + const response = await request(app) + .post('/gruppi/org-1/membri') + .set('Authorization', `Bearer ${adminCentraleToken()}`) + .send({ email: 'sconosciuto@example.com', ruolo: 'Capi' }); + + expect(response.status).toBe(404); + expect(addMemberToOrganization).not.toHaveBeenCalled(); + }); + + test('risponde 502 se una chiamata Keycloak fallisce a metà sequenza', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + findUserByEmail.mockResolvedValueOnce({ id: 'user-1' }); + addMemberToOrganization.mockResolvedValueOnce(undefined); + assignUserToGroup.mockRejectedValueOnce(new Error('Keycloak non raggiungibile')); + + const response = await request(app) + .post('/gruppi/org-1/membri') + .set('Authorization', `Bearer ${adminCentraleToken()}`) + .send({ email: 'gia-registrato@example.com', ruolo: 'Capi' }); + + expect(response.status).toBe(502); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('STEP 2'), expect.anything()); + expect(assignRealmRoleToUser).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + test('risponde 403 se chiamato da capo-gruppo, anche sulla propria organizzazione', async () => { + const response = await request(app) + .post('/gruppi/org-1/membri') + .set('Authorization', `Bearer ${capoGruppoToken('org-1')}`) + .send({ email: 'gia-registrato@example.com', ruolo: 'Capi' }); + + expect(response.status).toBe(403); + expect(findUserByEmail).not.toHaveBeenCalled(); + }); +}); + describe('GET /gruppi/:orgId/membri', () => { test('restituisce la lista membri con ruolo e gruppo interno', async () => { listOrganizationMembers.mockResolvedValueOnce([ @@ -116,6 +190,19 @@ describe('GET /gruppi/:orgId/membri', () => { expect(response.status).toBe(403); expect(listOrganizationMembers).not.toHaveBeenCalled(); }); + + test("admin accede con successo anche a un'organizzazione che non è la propria", async () => { + listOrganizationMembers.mockResolvedValueOnce([{ userId: 'user-1', email: 'uno@example.com' }]); + getUserGroupsInOrganization.mockResolvedValueOnce([]); + getUserRealmRoles.mockResolvedValueOnce([]); + + const response = await request(app) + .get('/gruppi/org-altrui/membri') + .set('Authorization', `Bearer ${adminCentraleToken()}`); + + expect(response.status).toBe(200); + expect(listOrganizationMembers).toHaveBeenCalledWith('org-altrui'); + }); }); describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => { @@ -173,6 +260,19 @@ describe('PUT /gruppi/:orgId/membri/:userId/ruolo', () => { consoleErrorSpy.mockRestore(); }); + + test("admin può cambiare ruolo anche in un'organizzazione che non è la propria", async () => { + getUserGroupsInOrganization.mockResolvedValueOnce([]); + getUserRealmRoles.mockResolvedValueOnce([]); + + const response = await request(app) + .put('/gruppi/org-altrui/membri/user-1/ruolo') + .set('Authorization', `Bearer ${adminCentraleToken()}`) + .send({ ruolo: 'Aiuto capi' }); + + expect(response.status).toBe(200); + expect(assignUserToGroup).toHaveBeenCalledWith('user-1', 'Aiuto capi'); + }); }); describe('DELETE /gruppi/:orgId/membri/:userId', () => { @@ -209,4 +309,15 @@ describe('DELETE /gruppi/:orgId/membri/:userId', () => { consoleErrorSpy.mockRestore(); }); + + test("admin può rimuovere un membro anche in un'organizzazione che non è la propria", async () => { + removeMemberFromOrganization.mockResolvedValueOnce(undefined); + + const response = await request(app) + .delete('/gruppi/org-altrui/membri/user-1') + .set('Authorization', `Bearer ${adminCentraleToken()}`); + + expect(response.status).toBe(204); + expect(removeMemberFromOrganization).toHaveBeenCalledWith('org-altrui', 'user-1'); + }); }); diff --git a/scouthub-home-be/tests/integration/richiesteCreazioneGruppo.endpoint.test.ts b/scouthub-home-be/tests/integration/richiesteCreazioneGruppo.endpoint.test.ts new file mode 100644 index 0000000..2e729d3 --- /dev/null +++ b/scouthub-home-be/tests/integration/richiesteCreazioneGruppo.endpoint.test.ts @@ -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; +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> = {}) { + 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(); + }); +}); diff --git a/scouthub-home-be/tests/integration/richiesteIngresso.endpoint.test.ts b/scouthub-home-be/tests/integration/richiesteIngresso.endpoint.test.ts new file mode 100644 index 0000000..55a9ace --- /dev/null +++ b/scouthub-home-be/tests/integration/richiesteIngresso.endpoint.test.ts @@ -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; +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> = {}) { + 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(); + }); +}); diff --git a/scouthub-home-be/tests/unit/middleware/requireOrgAccess.test.ts b/scouthub-home-be/tests/unit/middleware/requireOrgAccess.test.ts new file mode 100644 index 0000000..0f313bb --- /dev/null +++ b/scouthub-home-be/tests/unit/middleware/requireOrgAccess.test.ts @@ -0,0 +1,71 @@ +import { Request, Response } from 'express'; +import { requireOrgAccess } from '../../../src/middleware/requireOrgAccess'; + +function buildReqRes(auth: Partial | 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); + }); +});