diff --git a/admin/app/components/administration/certification/sco-whitelist-configuration.gjs b/admin/app/components/administration/certification/sco-whitelist-configuration.gjs index 490807fd067..a7482718ee4 100644 --- a/admin/app/components/administration/certification/sco-whitelist-configuration.gjs +++ b/admin/app/components/administration/certification/sco-whitelist-configuration.gjs @@ -1,8 +1,10 @@ +import PixButton from '@1024pix/pix-ui/components/pix-button'; import PixButtonUpload from '@1024pix/pix-ui/components/pix-button-upload'; import PixMessage from '@1024pix/pix-ui/components/pix-message'; import { action } from '@ember/object'; import { service } from '@ember/service'; import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { t } from 'ember-intl'; import ENV from 'pix-admin/config/environment'; @@ -12,6 +14,9 @@ export default class ScoWhitelistConfiguration extends Component { @service intl; @service session; @service pixToast; + @service fileSaver; + + @tracked isExportLoading = false; @action async importScoWhitelist(files) { @@ -33,31 +38,57 @@ export default class ScoWhitelistConfiguration extends Component { message: this.intl.t('pages.administration.certification.sco-whitelist.import.success'), }); } else { + const responseJson = await response.json(); + const errorKey = responseJson?.errors[0]?.code || 'error'; + this.pixToast.sendErrorNotification({ - message: this.intl.t('pages.administration.certification.sco-whitelist.import.error'), + message: this.intl.t(`pages.administration.certification.sco-whitelist.import.${errorKey}`), }); } } catch (error) { this.pixToast.sendErrorNotification({ message: this.intl.t('pages.administration.certification.sco-whitelist.import.error'), }); + } + } + + @action + async exportWhitelist() { + try { + this.isExportLoading = true; + const url = `${ENV.APP.API_HOST}/api/admin/sco-whitelist`; + const fileName = 'sco-whitelist.csv'; + const token = this.session.data.authenticated.access_token; + await this.fileSaver.save({ url, fileName, token }); + } catch (error) { + this.pixToast.sendErrorNotification({ + message: this.intl.t('pages.administration.certification.sco-whitelist.export.error'), + }); } finally { - this.isLoading = false; + this.isExportLoading = false; } } } diff --git a/admin/app/styles/app.scss b/admin/app/styles/app.scss index 76304462039..6c9063a269d 100644 --- a/admin/app/styles/app.scss +++ b/admin/app/styles/app.scss @@ -32,6 +32,7 @@ @import 'components/administration/certification/certification-scoring-configuration'; @import 'components/administration/certification/competence-scoring-configuration'; @import 'components/administration/certification/flash-algorithm-configuration-form'; +@import 'components/administration/certification/sco-whitelist-configuration'; @import 'components/administration/certification/scoring-simulator'; @import 'components/autonomous-courses/details'; @import 'components/autonomous-courses/form'; diff --git a/admin/app/styles/components/administration/certification/sco-whitelist-configuration.scss b/admin/app/styles/components/administration/certification/sco-whitelist-configuration.scss new file mode 100644 index 00000000000..7612e19c97b --- /dev/null +++ b/admin/app/styles/components/administration/certification/sco-whitelist-configuration.scss @@ -0,0 +1,8 @@ +.sco-whitelist-configuration { + &__actions { + display: flex; + flex-direction: row; + gap: var(--pix-spacing-6x); + margin-top: var(--pix-spacing-6x); + } +} diff --git a/admin/tests/integration/components/administration/certification/sco-whitelist-configuration-test.gjs b/admin/tests/integration/components/administration/certification/sco-whitelist-configuration-test.gjs index 8f50f19bfb4..858b79341ca 100644 --- a/admin/tests/integration/components/administration/certification/sco-whitelist-configuration-test.gjs +++ b/admin/tests/integration/components/administration/certification/sco-whitelist-configuration-test.gjs @@ -59,26 +59,58 @@ module('Integration | Component | administration/certification/sco-whitelist-con }); module('when import fails', function () { - test('it displays an error notification', async function (assert) { - // given - fetchStub - .withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'text/csv', - Accept: 'application/json', - }, - method: 'POST', - body: file, - }) - .rejects(); - // when - const screen = await render(); - const input = await screen.findByLabelText(t('pages.administration.certification.sco-whitelist.import.button')); - await triggerEvent(input, 'change', { files: [file] }); + module('when it is a generic error', function () { + test('it displays an error notification', async function (assert) { + // given + fetchStub + .withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'text/csv', + Accept: 'application/json', + }, + method: 'POST', + body: file, + }) + .rejects(); + // when + const screen = await render(); + const input = await screen.findByLabelText(t('pages.administration.certification.sco-whitelist.import.button')); + await triggerEvent(input, 'change', { files: [file] }); - // then - assert.ok(await screen.findByText(t('pages.administration.certification.sco-whitelist.import.error'))); + // then + assert.ok(await screen.findByText(t('pages.administration.certification.sco-whitelist.import.error'))); + }); + }); + + module('when it is a CERTIFICATION_INVALID_SCO_WHITELIST_ERROR', function () { + test('it displays an error notification', async function (assert) { + // given + fetchStub + .withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'text/csv', + Accept: 'application/json', + }, + method: 'POST', + body: file, + }) + .resolves( + fetchResponse({ body: { errors: [{ code: 'CERTIFICATION_INVALID_SCO_WHITELIST_ERROR' }] }, status: 422 }), + ); + // when + const screen = await render(); + const input = await screen.findByLabelText(t('pages.administration.certification.sco-whitelist.import.button')); + await triggerEvent(input, 'change', { files: [file] }); + + // then + assert.ok( + await screen.findByText( + t('pages.administration.certification.sco-whitelist.import.CERTIFICATION_INVALID_SCO_WHITELIST_ERROR'), + ), + ); + }); }); }); }); diff --git a/admin/tests/integration/components/administration/certification/sco-whitelist-configuration_test.gjs b/admin/tests/integration/components/administration/certification/sco-whitelist-configuration_test.gjs new file mode 100644 index 00000000000..94e6ad1051c --- /dev/null +++ b/admin/tests/integration/components/administration/certification/sco-whitelist-configuration_test.gjs @@ -0,0 +1,138 @@ +import { render } from '@1024pix/ember-testing-library'; +import PixToastContainer from '@1024pix/pix-ui/components/pix-toast-container'; +import Service from '@ember/service'; +import { triggerEvent } from '@ember/test-helpers'; +import { t } from 'ember-intl/test-support'; +import ScoWhitelistConfiguration from 'pix-admin/components/administration/certification/sco-whitelist-configuration'; +import ENV from 'pix-admin/config/environment'; +import { module, test } from 'qunit'; +import sinon from 'sinon'; + +import setupIntlRenderingTest from '../../../../helpers/setup-intl-rendering'; + +const accessToken = 'An access token'; +const fileContent = 'foo'; +const file = new Blob([fileContent], { type: `valid-file` }); + +module('Integration | Component | administration/certification/sco-whitelist-configuration', function (hooks) { + setupIntlRenderingTest(hooks); + + let fetchStub, fileSaverStub; + + hooks.beforeEach(function () { + class SessionService extends Service { + data = { authenticated: { access_token: accessToken } }; + } + this.owner.register('service:session', SessionService); + + class FileSaver extends Service { + save = fileSaverStub; + } + + this.owner.register('service:file-saver', FileSaver); + + fetchStub = sinon.stub(window, 'fetch'); + fileSaverStub = sinon.stub(); + }); + + hooks.afterEach(function () { + window.fetch.restore(); + }); + + module('Export', function () { + module('when export succeeds', function () { + test('it succeeds', async function (assert) { + // given + fileSaverStub.resolves(); + // when + const screen = await render(); + const input = await screen.findByText(t('pages.administration.certification.sco-whitelist.export.button')); + await triggerEvent(input, 'click'); + + // then + assert + .dom(await screen.queryByText(t('pages.administration.certification.sco-whitelist.export.error'))) + .doesNotExist(); + }); + }); + + module('when export fails', function () { + test('it displays an error notification', async function (assert) { + // given + fileSaverStub.rejects(); + // when + const screen = await render(); + const input = await screen.findByText(t('pages.administration.certification.sco-whitelist.export.button')); + await triggerEvent(input, 'click'); + + // then + assert.dom(await screen.getByText(t('pages.administration.certification.sco-whitelist.export.error'))).exists(); + }); + }); + }); + + module('Import', function () { + module('when import succeeds', function (hooks) { + hooks.beforeEach(function () { + fetchStub + .withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'text/csv', + Accept: 'application/json', + }, + method: 'POST', + body: file, + }) + .resolves(fetchResponse({ status: 201 })); + }); + + test('it displays a success notification', async function (assert) { + // when + const screen = await render(); + const input = await screen.getByLabelText(t('pages.administration.certification.sco-whitelist.import.button')); + await triggerEvent(input, 'change', { files: [file] }); + + // then + assert + .dom(await screen.getByText(t('pages.administration.certification.sco-whitelist.import.success'))) + .exists(); + }); + }); + + module('when import fails', function () { + test('it displays an error notification', async function (assert) { + // given + fetchStub + .withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'text/csv', + Accept: 'application/json', + }, + method: 'POST', + body: file, + }) + .rejects(); + // when + const screen = await render(); + const input = await screen.findByLabelText(t('pages.administration.certification.sco-whitelist.import.button')); + await triggerEvent(input, 'change', { files: [file] }); + + // then + assert.dom(await screen.getByText(t('pages.administration.certification.sco-whitelist.import.error'))).exists(); + }); + }); + }); +}); + +function fetchResponse({ body, status }) { + const mockResponse = new window.Response(JSON.stringify(body), { + status, + headers: { + 'Content-type': 'application/json', + }, + }); + + return mockResponse; +} diff --git a/admin/translations/en.json b/admin/translations/en.json index 3a289b75e85..f7914508f3a 100644 --- a/admin/translations/en.json +++ b/admin/translations/en.json @@ -413,11 +413,17 @@ }, "sco-whitelist": { "title": "SCO whitelist", + "export": { + "button": "Export whitelist (CSV)", + "error": "Could not download SCO whitelist." + }, "import": { - "button": "Import new CSV file as whitelist", + "CERTIFICATION_INVALID_SCO_WHITELIST_ERROR": "An error has occurred. Please verify externalIds in csv.", + "button": "Import file as whitelist (CSV)", "error": "Could not save SCO whitelist", - "success": "SCO whitelist saved" - } + "success": "SCO whitelist saved." + }, + "instructions": "Recommended: make an export, modify the export, and import the modified version." }, "scoring-simulator": { "title": "Scoring simulator", diff --git a/admin/translations/fr.json b/admin/translations/fr.json index a30970f1b4b..a10f639ad7e 100644 --- a/admin/translations/fr.json +++ b/admin/translations/fr.json @@ -423,11 +423,17 @@ }, "sco-whitelist": { "title": "Liste blanche centres SCO", + "export": { + "button": "Exporter la liste blanche (format CSV)", + "error": "Échec de la récupération de la liste blanche." + }, "import": { - "button": "Importer une nouvelle liste blanche au format CSV", + "CERTIFICATION_INVALID_SCO_WHITELIST_ERROR": "Une erreur est survenue. Veuillez vérifier les externalId renseignés.", + "button": "Importer une nouvelle liste blanche (format CSV)", "error": "Échec de l'enregistrement de la liste blanche", - "success": "Liste blanche enregistrée" - } + "success": "Liste blanche enregistrée." + }, + "instructions": "Recommandation: exporter la liste, modifier l'export, et envoyer la version modifiée." }, "scoring-simulator": { "title": "Simulateur de scoring", diff --git a/api/src/certification/configuration/application/http-error-mapper-configuration.js b/api/src/certification/configuration/application/http-error-mapper-configuration.js new file mode 100644 index 00000000000..eb79f60979b --- /dev/null +++ b/api/src/certification/configuration/application/http-error-mapper-configuration.js @@ -0,0 +1,10 @@ +import { HttpErrors } from '../../../shared/application/http-errors.js'; +import { DomainErrorMappingConfiguration } from '../../../shared/application/models/domain-error-mapping-configuration.js'; +import { InvalidScoWhitelistError } from '../domain/errors.js'; + +export const configurationDomainErrorMappingConfiguration = [ + { + name: InvalidScoWhitelistError.name, + httpErrorFn: (error) => new HttpErrors.UnprocessableEntityError(error.message, error.code, error.meta), + }, +].map((domainErrorMappingConfiguration) => new DomainErrorMappingConfiguration(domainErrorMappingConfiguration)); diff --git a/api/src/certification/configuration/application/sco-whitelist-controller.js b/api/src/certification/configuration/application/sco-whitelist-controller.js index 9d14cb0fd7b..1d08a0a2bbf 100644 --- a/api/src/certification/configuration/application/sco-whitelist-controller.js +++ b/api/src/certification/configuration/application/sco-whitelist-controller.js @@ -1,5 +1,6 @@ import { usecases } from '../domain/usecases/index.js'; import { extractExternalIds } from '../infrastructure/serializers/csv/sco-whitelist-csv-parser.js'; +import { serialize } from '../infrastructure/serializers/csv/sco-whitelist-csv-serializer.js'; const importScoWhitelist = async function (request, h, dependencies = { extractExternalIds }) { const externalIds = await dependencies.extractExternalIds(request.payload.path); @@ -7,6 +8,17 @@ const importScoWhitelist = async function (request, h, dependencies = { extractE return h.response().created(); }; +const exportScoWhitelist = async function (request, h) { + const whitelist = await usecases.exportScoWhitelist(); + + return h + .response(await serialize({ centers: whitelist })) + .header('Content-Type', 'text/csv; charset=utf-8') + .header('content-disposition', 'filename=sco-whitelist') + .code(200); +}; + export const scoWhitelistController = { importScoWhitelist, + exportScoWhitelist, }; diff --git a/api/src/certification/configuration/application/sco-whitelist-route.js b/api/src/certification/configuration/application/sco-whitelist-route.js index e1c8d881e96..42274b779ab 100644 --- a/api/src/certification/configuration/application/sco-whitelist-route.js +++ b/api/src/certification/configuration/application/sco-whitelist-route.js @@ -40,6 +40,24 @@ const register = async function (server) { ], }, }, + { + method: 'GET', + path: '/api/admin/sco-whitelist', + config: { + pre: [ + { + method: securityPreHandlers.checkAdminMemberHasRoleSuperAdmin, + assign: 'hasRoleSuperAdmin', + }, + ], + handler: scoWhitelistController.exportScoWhitelist, + tags: ['api', 'admin'], + notes: [ + 'Cette route est restreinte aux utilisateurs authentifiés avec le rôle Super Admin', + 'Elle permet de récupérer la liste blanche des centres SCO.', + ], + }, + }, ]); }; diff --git a/api/src/certification/configuration/domain/errors.js b/api/src/certification/configuration/domain/errors.js new file mode 100644 index 00000000000..1ac7823993a --- /dev/null +++ b/api/src/certification/configuration/domain/errors.js @@ -0,0 +1,7 @@ +import { DomainError } from '../../../shared/domain/errors.js'; + +export class InvalidScoWhitelistError extends DomainError { + constructor(meta) { + super('La liste blanche contient des données invalides.', 'CERTIFICATION_INVALID_SCO_WHITELIST_ERROR', meta); + } +} diff --git a/api/src/certification/configuration/domain/usecases/export-sco-whitelist.js b/api/src/certification/configuration/domain/usecases/export-sco-whitelist.js new file mode 100644 index 00000000000..01a9eadaea6 --- /dev/null +++ b/api/src/certification/configuration/domain/usecases/export-sco-whitelist.js @@ -0,0 +1,13 @@ +/** + * @typedef {import ('./index.js').CenterRepository} CenterRepository + * @typedef {import ('../models/Center.js').Center} Center + */ + +/** + * @param {Object} params + * @param {CenterRepository} params.centerRepository + * @returns {Promise>} + */ +export const exportScoWhitelist = async ({ centerRepository }) => { + return centerRepository.getWhitelist(); +}; diff --git a/api/src/certification/configuration/domain/usecases/import-sco-whitelist.js b/api/src/certification/configuration/domain/usecases/import-sco-whitelist.js index 22a8ecf1ecb..8b8b6402740 100644 --- a/api/src/certification/configuration/domain/usecases/import-sco-whitelist.js +++ b/api/src/certification/configuration/domain/usecases/import-sco-whitelist.js @@ -3,6 +3,7 @@ */ import { withTransaction } from '../../../../shared/domain/DomainTransaction.js'; +import { InvalidScoWhitelistError } from '../errors.js'; export const importScoWhitelist = withTransaction( /** @@ -11,6 +12,13 @@ export const importScoWhitelist = withTransaction( */ async ({ externalIds = [], centerRepository }) => { await centerRepository.resetWhitelist(); - return centerRepository.addToWhitelistByExternalIds({ externalIds }); + const numberOfUpdatedLines = await centerRepository.addToWhitelistByExternalIds({ externalIds }); + + if (externalIds.length !== numberOfUpdatedLines) { + throw new InvalidScoWhitelistError({ + numberOfExternalIdsInInput: externalIds.length, + numberOfValidExternalIds: numberOfUpdatedLines, + }); + } }, ); diff --git a/api/src/certification/configuration/domain/usecases/index.js b/api/src/certification/configuration/domain/usecases/index.js index ec90539052b..18ad1c4879f 100644 --- a/api/src/certification/configuration/domain/usecases/index.js +++ b/api/src/certification/configuration/domain/usecases/index.js @@ -16,7 +16,7 @@ import * as centerRepository from '../../infrastructure/repositories/center-repo * @typedef {complementaryCertificationRepository} ComplementaryCertificationRepository * @typedef {attachableTargetProfileRepository} AttachableTargetProfileRepository * @typedef {centerPilotFeaturesRepository} CenterPilotFeaturesRepository - * @typedef {centerRepository} CentersRepository + * @typedef {centerRepository} CenterRepository * @typedef {candidateRepository} CandidateRepository **/ const dependencies = { diff --git a/api/src/certification/configuration/infrastructure/repositories/center-repository.js b/api/src/certification/configuration/infrastructure/repositories/center-repository.js index a3e8ff2c744..57441af7c76 100644 --- a/api/src/certification/configuration/infrastructure/repositories/center-repository.js +++ b/api/src/certification/configuration/infrastructure/repositories/center-repository.js @@ -1,17 +1,20 @@ import { DomainTransaction } from '../../../../shared/domain/DomainTransaction.js'; +import { Center } from '../../domain/models/Center.js'; import { CenterTypes } from '../../domain/models/CenterTypes.js'; /** * @param {Object} params * @param {Array} params.externalIds - * @returns {Promise} + * @returns {Promise} - number of rows affected */ export const addToWhitelistByExternalIds = async ({ externalIds }) => { const knexConn = DomainTransaction.getConnection(); - return knexConn('certification-centers') + const numberOfUpdatedLines = knexConn('certification-centers') .update({ isScoBlockedAccessWhitelist: true, updatedAt: knexConn.fn.now() }) .where({ type: CenterTypes.SCO }) .whereIn('externalId', externalIds); + + return numberOfUpdatedLines || 0; }; /** @@ -23,3 +26,19 @@ export const resetWhitelist = async () => { .update({ isScoBlockedAccessWhitelist: false, updatedAt: knexConn.fn.now() }) .where({ type: CenterTypes.SCO }); }; + +/** + * @returns {Promise>} + */ +export const getWhitelist = async () => { + const knexConn = DomainTransaction.getConnection(); + const data = await knexConn('certification-centers') + .select('id', 'type', 'externalId') + .where({ isScoBlockedAccessWhitelist: true }); + + return data.map(_toDomain); +}; + +const _toDomain = ({ id, externalId, type }) => { + return new Center({ id, externalId, type }); +}; diff --git a/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-parser.js b/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-parser.js index 8d3cacf082c..8ec13573961 100644 --- a/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-parser.js +++ b/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-parser.js @@ -1,12 +1,20 @@ +import { createReadStream } from 'node:fs'; import * as fs from 'node:fs/promises'; +import { FileValidationError } from '../../../../../shared/domain/errors.js'; import { CsvParser } from '../../../../../shared/infrastructure/serializers/csv/csv-parser.js'; +import { getDataBuffer } from '../../../../../shared/infrastructure/utils/buffer.js'; +import { logger } from '../../../../../shared/infrastructure/utils/logger.js'; import { ScoWhitelistCsvHeader } from './sco-whitelist-csv-header.js'; export const extractExternalIds = async (file) => { - const buffer = await fs.readFile(file); + const stream = createReadStream(file); + const buffer = await getDataBuffer(stream); try { return _extractIds(buffer).map(({ externalId }) => externalId.trim()); + } catch (error) { + logger.error(error); + throw new FileValidationError(); } finally { fs.unlink(file); } diff --git a/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-serializer.js b/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-serializer.js new file mode 100644 index 00000000000..e10ed4571c1 --- /dev/null +++ b/api/src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-serializer.js @@ -0,0 +1,10 @@ +import { getCsvContent } from '../../../../../shared/infrastructure/utils/csv/write-csv-utils.js'; + +/** + * @param {Object} params + * @param {Array
} params.centers + * @returns {Promise} + */ +export const serialize = async ({ centers }) => { + return getCsvContent({ data: centers, fileHeaders: [{ label: 'externalId', value: 'externalId' }] }); +}; diff --git a/api/src/certification/shared/application/http-error-mapper-configuration.js b/api/src/certification/shared/application/http-error-mapper-configuration.js index 7c750dc3044..f74ecb62ecf 100644 --- a/api/src/certification/shared/application/http-error-mapper-configuration.js +++ b/api/src/certification/shared/application/http-error-mapper-configuration.js @@ -1,5 +1,6 @@ import { HttpErrors } from '../../../shared/application/http-errors.js'; import { DomainErrorMappingConfiguration } from '../../../shared/application/models/domain-error-mapping-configuration.js'; +import { configurationDomainErrorMappingConfiguration } from '../../configuration/application/http-error-mapper-configuration.js'; import { enrolmentDomainErrorMappingConfiguration } from '../../enrolment/application/http-error-mapper-configuration.js'; import { resultsDomainErrorMappingConfiguration } from '../../results/application/http-error-mapper-configuration.js'; import { sessionDomainErrorMappingConfiguration } from '../../session-management/application/http-error-mapper-configuration.js'; @@ -22,5 +23,6 @@ certificationDomainErrorMappingConfiguration.push( ...resultsDomainErrorMappingConfiguration, ...enrolmentDomainErrorMappingConfiguration, ...sessionDomainErrorMappingConfiguration, + ...configurationDomainErrorMappingConfiguration, ); export { certificationDomainErrorMappingConfiguration }; diff --git a/api/tests/certification/configuration/acceptance/application/sco-whitelist-route_test.js b/api/tests/certification/configuration/acceptance/application/sco-whitelist-route_test.js index fc209fc7bef..4a8469466b5 100644 --- a/api/tests/certification/configuration/acceptance/application/sco-whitelist-route_test.js +++ b/api/tests/certification/configuration/acceptance/application/sco-whitelist-route_test.js @@ -1,3 +1,4 @@ +import { CenterTypes } from '../../../../../src/certification/configuration/domain/models/CenterTypes.js'; import { CERTIFICATION_CENTER_TYPES } from '../../../../../src/shared/domain/constants.js'; import { createServer, @@ -16,7 +17,7 @@ describe('Certification | Configuration | Acceptance | API | sco-whitelist-route }); describe('POST /api/admin/sco-whitelist', function () { - it('should return 200 HTTP status code', async function () { + it('should return 201 HTTP status code', async function () { // given const superAdmin = await insertUserWithRoleSuperAdmin(); const buffer = 'externalId\next1\next2'; @@ -58,5 +59,93 @@ describe('Certification | Configuration | Acceptance | API | sco-whitelist-route .pluck('externalId'); expect(whitelist).to.deep.equal(['ext1', 'ext2']); }); + + it('should rollback if invalid whitelist given', async function () { + // given + const thisExternalIdCannotBeWhitelisted = 'NOT_A_SCO_EXTERNAL_ID'; + const superAdmin = await insertUserWithRoleSuperAdmin(); + const buffer = `externalId\next1\n${thisExternalIdCannotBeWhitelisted}`; + const options = { + method: 'POST', + url: '/api/admin/sco-whitelist', + headers: { + authorization: generateValidRequestAuthorizationHeader(superAdmin.id), + }, + payload: buffer, + }; + databaseBuilder.factory.buildCertificationCenter({ + isV3Pilot: true, + type: CERTIFICATION_CENTER_TYPES.SCO, + externalId: 'ext1', + isScoBlockedAccessWhitelist: false, + }); + databaseBuilder.factory.buildCertificationCenter({ + isV3Pilot: true, + type: CERTIFICATION_CENTER_TYPES.PRO, + externalId: thisExternalIdCannotBeWhitelisted, + isScoBlockedAccessWhitelist: false, + }); + const whitelistRollbackedToThis = databaseBuilder.factory.buildCertificationCenter({ + isV3Pilot: true, + type: CERTIFICATION_CENTER_TYPES.SCO, + externalId: 'ext3', + isScoBlockedAccessWhitelist: true, + }); + await databaseBuilder.commit(); + + // when + const response = await server.inject(options); + + // then + expect(response.statusCode).to.equal(422); + expect(response.result.errors[0]).to.deep.equal({ + status: '422', + code: 'CERTIFICATION_INVALID_SCO_WHITELIST_ERROR', + title: 'Unprocessable entity', + detail: 'La liste blanche contient des données invalides.', + meta: { + numberOfExternalIdsInInput: 2, + numberOfValidExternalIds: 1, + }, + }); + const whitelist = await knex('certification-centers') + .where({ isScoBlockedAccessWhitelist: true }) + .pluck('externalId'); + expect(whitelist).to.deep.equal([whitelistRollbackedToThis.externalId]); + }); + }); + + describe('GET /api/admin/sco-whitelist', function () { + it('should return 200 HTTP status code and whitelist as CSV', async function () { + // given + const BOM_CHAR = '\ufeff'; + const superAdmin = await insertUserWithRoleSuperAdmin(); + const options = { + method: 'GET', + url: '/api/admin/sco-whitelist', + headers: { + authorization: generateValidRequestAuthorizationHeader(superAdmin.id), + }, + }; + + databaseBuilder.factory.buildCertificationCenter({ + type: CenterTypes.SCO, + isScoBlockedAccessWhitelist: true, + externalId: 'I_AM_WHITELISTED', + }); + databaseBuilder.factory.buildCertificationCenter({ + type: CenterTypes.SCO, + isScoBlockedAccessWhitelist: false, + externalId: 'I_AM_NOT_WHITELISTED', + }); + await databaseBuilder.commit(); + + // when + const response = await server.inject(options); + + // then + expect(response.statusCode).to.equal(200); + expect(response.payload).to.equal(`${BOM_CHAR}"externalId"\n"I_AM_WHITELISTED"`); + }); }); }); diff --git a/api/tests/certification/configuration/integration/infrastructure/repositories/center-repository_test.js b/api/tests/certification/configuration/integration/infrastructure/repositories/center-repository_test.js index 0c86f3d53c0..32bc0f12b12 100644 --- a/api/tests/certification/configuration/integration/infrastructure/repositories/center-repository_test.js +++ b/api/tests/certification/configuration/integration/infrastructure/repositories/center-repository_test.js @@ -1,6 +1,6 @@ import * as centerRepository from '../../../../../../src/certification/configuration/infrastructure/repositories/center-repository.js'; import { CenterTypes } from '../../../../../../src/certification/enrolment/domain/models/CenterTypes.js'; -import { databaseBuilder, expect, knex } from '../../../../../test-helper.js'; +import { databaseBuilder, domainBuilder, expect, knex } from '../../../../../test-helper.js'; describe('Certification | Configuration | Integration | Repository | center-repository', function () { describe('#addToWhitelistByExternalIds', function () { @@ -23,11 +23,12 @@ describe('Certification | Configuration | Integration | Repository | center-repo await databaseBuilder.commit(); // when - await centerRepository.addToWhitelistByExternalIds({ + const numberOfUpdatedLines = await centerRepository.addToWhitelistByExternalIds({ externalIds: [whitelistedExternalId1, whitelistedExternalId2], }); // then + expect(numberOfUpdatedLines).to.equal(2); const updatedCenter1 = await knex('certification-centers').where({ id: center1BeforeUpdate.id }).first(); expect(updatedCenter1.isScoBlockedAccessWhitelist).to.be.true; expect(updatedCenter1.updatedAt).to.be.above(center1BeforeUpdate.updatedAt); @@ -95,4 +96,36 @@ describe('Certification | Configuration | Integration | Repository | center-repo expect(updatedCenter.isScoBlockedAccessWhitelist).to.be.true; }); }); + + describe('#getWhitelist', function () { + it('should get whitelisted centers', async function () { + // given + const whitelistedCenter = databaseBuilder.factory.buildCertificationCenter({ + type: CenterTypes.SCO, + isScoBlockedAccessWhitelist: true, + externalId: 'IN_WHITELIST', + updatedAt: new Date('2024-09-24'), + }); + databaseBuilder.factory.buildCertificationCenter({ + type: CenterTypes.SCO, + isScoBlockedAccessWhitelist: false, + externalId: 'NOT_IN_WHITELIST', + updatedAt: new Date('2024-09-24'), + }); + await databaseBuilder.commit(); + + // when + const results = await centerRepository.getWhitelist(); + + // then + expect(results).to.have.lengthOf(1); + expect(results[0]).to.deepEqualInstance( + domainBuilder.certification.configuration.buildCenter({ + id: whitelistedCenter.id, + type: CenterTypes.SCO, + externalId: 'IN_WHITELIST', + }), + ); + }); + }); }); diff --git a/api/tests/certification/configuration/unit/application/sco-whitelist-route_test.js b/api/tests/certification/configuration/unit/application/sco-whitelist-route_test.js index d819b4e55a0..ed0b7b5865e 100644 --- a/api/tests/certification/configuration/unit/application/sco-whitelist-route_test.js +++ b/api/tests/certification/configuration/unit/application/sco-whitelist-route_test.js @@ -24,4 +24,25 @@ describe('Certification | Configuration | Unit | Application | Router | sco-whit }); }); }); + + describe('GET /api/admin/sco-whitelist', function () { + describe('when the user authenticated has no role', function () { + it('should return 403 HTTP status code', async function () { + // given + sinon + .stub(securityPreHandlers, 'hasAtLeastOneAccessOf') + .returns((request, h) => h.response().code(403).takeover()); + sinon.stub(scoWhitelistController, 'exportScoWhitelist').returns('ok'); + const httpTestServer = new HttpTestServer(); + await httpTestServer.register(moduleUnderTest); + + // when + const response = await httpTestServer.request('GET', '/api/admin/sco-whitelist'); + + // then + expect(response.statusCode).to.equal(403); + sinon.assert.notCalled(scoWhitelistController.exportScoWhitelist); + }); + }); + }); }); diff --git a/api/tests/certification/configuration/unit/domain/usecases/export-sco-whitelist_test.js b/api/tests/certification/configuration/unit/domain/usecases/export-sco-whitelist_test.js new file mode 100644 index 00000000000..a233fb942fc --- /dev/null +++ b/api/tests/certification/configuration/unit/domain/usecases/export-sco-whitelist_test.js @@ -0,0 +1,29 @@ +import { CenterTypes } from '../../../../../../src/certification/configuration/domain/models/CenterTypes.js'; +import { exportScoWhitelist } from '../../../../../../src/certification/configuration/domain/usecases/export-sco-whitelist.js'; +import { domainBuilder, expect, sinon } from '../../../../../test-helper.js'; + +describe('Certification | Configuration | Unit | UseCase | export-sco-whitelist', function () { + let centerRepository; + + beforeEach(function () { + centerRepository = { + getWhitelist: sinon.stub().throws(new Error('bad arguments')), + }; + }); + + it('should whitelist a center', async function () { + // given + const whitelistedCenter = domainBuilder.certification.configuration.buildCenter({ + type: CenterTypes.SCO, + externalId: 'IN_WHITELIST', + }); + centerRepository.getWhitelist.resolves([whitelistedCenter]); + + // when + const results = await exportScoWhitelist({ centerRepository }); + + // then + expect(centerRepository.getWhitelist).to.have.been.calledOnce; + expect(results).to.deep.equal([whitelistedCenter]); + }); +}); diff --git a/api/tests/certification/configuration/unit/domain/usecases/import-sco-whitelist_test.js b/api/tests/certification/configuration/unit/domain/usecases/import-sco-whitelist_test.js index 15bb25989e6..230e7f72863 100644 --- a/api/tests/certification/configuration/unit/domain/usecases/import-sco-whitelist_test.js +++ b/api/tests/certification/configuration/unit/domain/usecases/import-sco-whitelist_test.js @@ -1,6 +1,7 @@ import { DomainTransaction } from '../../../../../../lib/infrastructure/DomainTransaction.js'; +import { InvalidScoWhitelistError } from '../../../../../../src/certification/configuration/domain/errors.js'; import { importScoWhitelist } from '../../../../../../src/certification/configuration/domain/usecases/import-sco-whitelist.js'; -import { expect, sinon } from '../../../../../test-helper.js'; +import { catchErr, expect, sinon } from '../../../../../test-helper.js'; describe('Certification | Configuration | Unit | UseCase | import-sco-whitelist', function () { let centerRepository; @@ -19,7 +20,7 @@ describe('Certification | Configuration | Unit | UseCase | import-sco-whitelist' it('should whitelist a center', async function () { // given centerRepository.resetWhitelist.resolves(); - centerRepository.addToWhitelistByExternalIds.resolves(); + centerRepository.addToWhitelistByExternalIds.resolves(1); // when await importScoWhitelist({ @@ -31,4 +32,22 @@ describe('Certification | Configuration | Unit | UseCase | import-sco-whitelist' expect(centerRepository.resetWhitelist).to.have.been.calledOnce; expect(centerRepository.addToWhitelistByExternalIds).to.have.been.calledOnceWithExactly({ externalIds: [12] }); }); + + it('should reject new whitelist when not valid', async function () { + // given + centerRepository.resetWhitelist.resolves(); + centerRepository.addToWhitelistByExternalIds.resolves(1); + + // when + const error = await catchErr((externalIds) => + importScoWhitelist({ + externalIds, + centerRepository, + }), + )([11, 12]); + + // then + expect(error).to.be.instanceOf(InvalidScoWhitelistError); + expect(error.message).to.equal('La liste blanche contient des données invalides.'); + }); }); diff --git a/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-parser_test.js b/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-parser_test.js index 735f9b8b9b1..d56094fc814 100644 --- a/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-parser_test.js +++ b/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-parser_test.js @@ -1,5 +1,5 @@ import { extractExternalIds } from '../../../../../../../src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-parser.js'; -import { CsvImportError } from '../../../../../../../src/shared/domain/errors.js'; +import { FileValidationError } from '../../../../../../../src/shared/domain/errors.js'; import { catchErr, createTempFile, expect, removeTempFile } from '../../../../../../test-helper.js'; describe('Integration | Serializer | CSV | Certification | Configuration | sco-whitelist-csv-parser', function () { @@ -26,8 +26,7 @@ describe('Integration | Serializer | CSV | Certification | Configuration | sco-w const data = 'RendLesDonnées\n1'; const filePath = await createTempFile(file, data); const error = await catchErr(extractExternalIds)(filePath); - expect(error).to.be.an.instanceOf(CsvImportError); - expect(error.code).to.equal('ENCODING_NOT_SUPPORTED'); + expect(error).to.be.an.instanceOf(FileValidationError); }); }); }); diff --git a/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-serializer_test.js b/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-serializer_test.js new file mode 100644 index 00000000000..4f26c51b14c --- /dev/null +++ b/api/tests/certification/configuration/unit/infrastructure/serializers/csv/sco-whitelist-csv-serializer_test.js @@ -0,0 +1,20 @@ +import { CenterTypes } from '../../../../../../../src/certification/configuration/domain/models/CenterTypes.js'; +import { serialize } from '../../../../../../../src/certification/configuration/infrastructure/serializers/csv/sco-whitelist-csv-serializer.js'; +import { domainBuilder, expect } from '../../../../../../test-helper.js'; + +describe('Integration | Serializer | CSV | Certification | Configuration | sco-whitelist-csv-serializer', function () { + it('returns all external ids as a string', async function () { + // given + const center = domainBuilder.certification.configuration.buildCenter({ + type: CenterTypes.SCO, + externalId: 'SERIALIZED_EXTERNAL_ID', + }); + + // when + const results = await serialize({ centers: [center] }); + + // then + const expectedResult = '\uFEFF' + '"externalId"\n' + '"SERIALIZED_EXTERNAL_ID"'; + expect(results).to.equal(expectedResult); + }); +});