From f8dd5c156b3b359dff6859e73c71ec0a2789c733 Mon Sep 17 00:00:00 2001 From: Guillaume Fay Date: Thu, 23 Apr 2026 15:48:35 +0200 Subject: [PATCH] feat: disable proconnect connexion for communes --- src/modules/proconnect/insee.service.ts | 8 +- .../proconnect/proconnect.controller.ts | 24 +++++- src/modules/proconnect/proconnect.service.ts | 18 +++- src/tests/proconnect.spec.ts | 82 ++++++++++++++++--- 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/modules/proconnect/insee.service.ts b/src/modules/proconnect/insee.service.ts index 24611d2..a121155 100644 --- a/src/modules/proconnect/insee.service.ts +++ b/src/modules/proconnect/insee.service.ts @@ -4,6 +4,8 @@ import { ConfigService } from '@nestjs/config'; export interface OrganizationInfo { nom: string; isPublic: boolean; + isCommune: boolean; + codeCommune: string | null; } @Injectable() @@ -57,8 +59,12 @@ export class InseeService { const categorieJuridique = uniteLegale?.categorieJuridiqueUniteLegale || ''; const isPublic = categorieJuridique.startsWith('7'); + // Catégorie juridique 7210 = Commune et commune nouvelle (mairie) + const isCommune = categorieJuridique === '7210'; + const codeCommune = + etablissement?.adresseEtablissement?.codeCommuneEtablissement || null; - return { nom, isPublic }; + return { nom, isPublic, isCommune, codeCommune }; } catch (error) { this.logger.error( `Error fetching organization info for SIRET ${siret}: ${error.message}`, diff --git a/src/modules/proconnect/proconnect.controller.ts b/src/modules/proconnect/proconnect.controller.ts index 0d4d0ed..259343e 100644 --- a/src/modules/proconnect/proconnect.controller.ts +++ b/src/modules/proconnect/proconnect.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Query, Res } from '@nestjs/common'; +import { + Controller, + Get, + HttpException, + HttpStatus, + Query, + Res, +} from '@nestjs/common'; import { Response } from 'express'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; @@ -60,9 +67,24 @@ export class ProConnectController { res.redirect(`${frontendUrl}/#/proconnect-callback?${params.toString()}`); } catch (error) { console.error('ProConnect login callback error:', error); + const frontendUrl = this.configService.get( 'MES_SIGNALEMENTS_URL', ); + + if (typeof error === 'object' && error instanceof HttpException) { + const status = error.getStatus(); + const response = error.getResponse(); + + if (status === HttpStatus.FORBIDDEN) { + const { error, errorLink } = response as any; + res.redirect( + `${frontendUrl}/#/proconnect-callback?error=${encodeURIComponent(error)}&errorLink=${encodeURIComponent(errorLink)}`, + ); + return; + } + } + res.redirect( `${frontendUrl}/#/proconnect-callback?error=${encodeURIComponent('Authentication failed')}`, ); diff --git a/src/modules/proconnect/proconnect.service.ts b/src/modules/proconnect/proconnect.service.ts index 7dd8f53..9e916ed 100644 --- a/src/modules/proconnect/proconnect.service.ts +++ b/src/modules/proconnect/proconnect.service.ts @@ -145,7 +145,7 @@ export class ProConnectService { if (!userInfo.siret) { throw new HttpException( - 'No SIRET found in user info', + 'Aucun SIRET trouvé dans les informations utilisateur', HttpStatus.BAD_REQUEST, ); } @@ -156,14 +156,26 @@ export class ProConnectService { if (!organizationInfo) { throw new HttpException( - `No organization found for SIRET ${userInfo.siret}`, + `Aucune organisation trouvée pour le SIRET ${userInfo.siret}`, HttpStatus.BAD_REQUEST, ); } if (!organizationInfo.isPublic) { throw new HttpException( - `Organization with SIRET ${userInfo.siret} is not a public organism`, + { + error: 'ORGANIZATION_NOT_PUBLIC', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (organizationInfo.isCommune) { + throw new HttpException( + { + error: `COMMUNE_NOT_ALLOWED`, + errorLink: `${this.configService.get('MES_ADRESSES_URL')}/new?commune=${organizationInfo.codeCommune}`, + }, HttpStatus.FORBIDDEN, ); } diff --git a/src/tests/proconnect.spec.ts b/src/tests/proconnect.spec.ts index ac6c959..fcd2881 100644 --- a/src/tests/proconnect.spec.ts +++ b/src/tests/proconnect.spec.ts @@ -27,6 +27,7 @@ import { InseeService } from '../modules/proconnect/insee.service'; import { ProConnectModule } from '../modules/proconnect/proconnect.module'; const MES_SIGNALEMENTS_URL = 'http://localhost:7777'; +const MES_ADRESSES_URL = 'http://localhost:8888'; jest.setTimeout(60000); @@ -60,6 +61,7 @@ describe('ProConnect module', () => { load: [ () => ({ MES_SIGNALEMENTS_URL, + MES_ADRESSES_URL, API_SIGNALEMENT_URL: 'http://localhost:5005', PROCONNECT_CLIENT_SECRET: 'test-secret', }), @@ -252,9 +254,12 @@ describe('ProConnect module', () => { jest .spyOn(proConnectService, 'getClient') .mockResolvedValue(mockClient as any); - jest - .spyOn(inseeService, 'getOrganizationInfo') - .mockResolvedValue({ nom: 'Mairie Existante', isPublic: true }); + jest.spyOn(inseeService, 'getOrganizationInfo').mockResolvedValue({ + nom: 'Mairie Existante', + isPublic: true, + isCommune: false, + codeCommune: null, + }); const result = await proConnectService.handleCallback( 'code', @@ -285,9 +290,12 @@ describe('ProConnect module', () => { jest .spyOn(proConnectService, 'getClient') .mockResolvedValue(mockClient as any); - jest - .spyOn(inseeService, 'getOrganizationInfo') - .mockResolvedValue({ nom: 'Commune Nouvelle', isPublic: true }); + jest.spyOn(inseeService, 'getOrganizationInfo').mockResolvedValue({ + nom: 'Commune Nouvelle', + isPublic: true, + isCommune: false, + codeCommune: null, + }); const result = await proConnectService.handleCallback( 'code', @@ -334,7 +342,7 @@ describe('ProConnect module', () => { await expect( proConnectService.handleCallback('code', signedState), - ).rejects.toThrow('No SIRET found in user info'); + ).rejects.toThrow('Aucun SIRET trouvé dans les informations utilisateur'); }); it('should throw when organization is not a public organism', async () => { @@ -355,15 +363,19 @@ describe('ProConnect module', () => { jest .spyOn(proConnectService, 'getClient') .mockResolvedValue(mockClient as any); - jest - .spyOn(inseeService, 'getOrganizationInfo') - .mockResolvedValue({ nom: 'Entreprise Privée SAS', isPublic: false }); + jest.spyOn(inseeService, 'getOrganizationInfo').mockResolvedValue({ + nom: 'Entreprise Privée SAS', + isPublic: false, + isCommune: false, + codeCommune: null, + }); await expect( proConnectService.handleCallback('code', signedState), - ).rejects.toThrow( - 'Organization with SIRET 55566677788899 is not a public organism', - ); + ).rejects.toMatchObject({ + status: HttpStatus.FORBIDDEN, + response: { error: 'ORGANIZATION_NOT_PUBLIC' }, + }); // Verify no source was created const source = await sourceRepository.findOne({ @@ -371,5 +383,49 @@ describe('ProConnect module', () => { }); expect(source).toBeNull(); }); + + describe('mairie (commune)', () => { + const mairieUserInfo: ProConnectUserInfo = { + sub: 'user-mairie', + email: 'agent@mairie-test.fr', + given_name: 'Marie', + usual_name: 'Mairie', + siret: '21330063500017', + organizational_unit: 'Mairie', + }; + + const buildMockClient = () => ({ + issuer: { metadata: { issuer: 'https://test-issuer' } }, + callback: jest.fn().mockResolvedValue({ access_token: 'at' }), + userinfo: jest.fn().mockResolvedValue(mairieUserInfo), + }); + + it('should throw COMMUNE_NOT_ALLOWED with a Mes Adresses link for the commune', async () => { + jest + .spyOn(proConnectService, 'getClient') + .mockResolvedValue(buildMockClient() as any); + jest.spyOn(inseeService, 'getOrganizationInfo').mockResolvedValue({ + nom: 'Mairie de Test', + isPublic: true, + isCommune: true, + codeCommune: '33063', + }); + + await expect( + proConnectService.handleCallback('code', signedState), + ).rejects.toMatchObject({ + status: HttpStatus.FORBIDDEN, + response: { + error: 'COMMUNE_NOT_ALLOWED', + errorLink: `${MES_ADRESSES_URL}/new?commune=33063`, + }, + }); + + const source = await sourceRepository.findOne({ + where: { siret: '21330063500017' }, + }); + expect(source).toBeNull(); + }); + }); }); });