diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8d43bbb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +# editorconfig-tools is unable to ignore longs strings or urls +max_line_length = off + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be30c8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env +.env.mcm + +# gatsby files +.cache/ + +# Mac files +.DS_Store + +# Yarn +yarn-error.log +.pnp/ +.pnp.js +# Yarn Integrity file +.yarn-integrity +/.idea/ + +# MCM +.secrets +.scannerwork +.env.bat + +### IDE ### +.vscode/ +.terminals.json +.launch.json +.package-lock.json + +# test and debug file +api-manifest.yml +idp-manifest.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..8acd150 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,63 @@ +#### WORKFLOW #### +workflow: + rules: + - if: $CI_COMMIT_TAG + when: never + - if: $CI_PIPELINE_SOURCE == 'merge_request_event' && '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH' + when: never + - when: always + +#### STAGES #### +stages: + # Prepare context for build stages: fetch thirdparty source code, compile build tools, etc. + - prepare + # Compile, generally turn source code into derived objects + - pre-image + # Build source code + - build + # Build, tag and push container images + - image + # # First level of testing e.g. unit tests + - test + # Prepare additional deployment descriptors e.g. K8s manifests, Helm charts, etc. + #- bundle + - verify + - verify-all + # Deploy to development environment, from feature branch, time-limited (24 hrs) + - deploy-preview + # Deploy to testing environment, from feature branch, time-limited (24 hrs) + - deploy-testing + - smoke-test + - integration-test + - functional-test + # Stage to clean data, sast, rollback version or play api documentation + - utils + # Remove not wanted files or directory for publication + - publication + # Push build images from RC + - helm-push-image + # Push gitlab source to cloud + - helm-gitlab + # Package the helm + - helm-package + # Publish to public repo + - cleanup + +#### INCLUDES #### +include: + - local: "commons/.gitlab-ci.yml" + - local: "api/.gitlab-ci.yml" + - local: "idp/.gitlab-ci.yml" + - local: "administration/.gitlab-ci.yml" + - local: "s3/.gitlab-ci.yml" + - local: "vault/.gitlab-ci.yml" + - local: "analytics/.gitlab-ci.yml" + - local: "test/.gitlab-ci.yml" + - local: "website/.gitlab-ci.yml" + - local: "simulation-maas/.gitlab-ci.yml" + - local: "antivirus/.gitlab-ci.yml" + - local: "mailhog/.gitlab-ci.yml" + - local: "bus/.gitlab-ci.yml" + - local: "publication/.gitlab-ci.yml" + # rules: + # - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fdee2..fed1747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,26 +6,28 @@ Le versionning des releases suit le [semantic versioning](http://semver.org). ### 1.10.0 - [X] Version production octobre 2022 - #### Identification / Authentification + #### Identification / Authentification du Citoyen - [X] Création de compte Citoyen - [X] Authentification sur le site - [X] Gestion du consentement (partage des données, demande de portabilité) -- [X] Affiliations salariés / employeurs financeurs -- [X] Gestion des communautés financeur -- [X] Création de compte Financeur (gestionnaire / superviseur) -- [X] ... +- [X] Association du compte MaaS au compte moB +- [X] Affiliation salarié à une entreprise financeur #### Dispositifs d'incitation (i.e. Aides) -- [X] Creation / Edition des aides -- [X] Visualiser les aides disponibles (nationales / territoriales / employeur) -- [X] ... +- [X] Creation / Edition des aides du catalogue, qu'elles soient souscriptibles en externe ou directement dans moB +- [X] Visualisation des aides disponibles (nationales / territoriales / employeur) #### Souscriptions -- [X] Réceptionner les données de souscription à une aide du citoyen +- [X] Souscription à une aide à la mobilité en fournissant les justificatifs demandés ou communiqués +- [X] Réception des données de souscription à une aide du citoyen - [X] Traitement de pièces justificatives attachés à une souscription - [X] Reconstitution de justificatifs à partir de données de facture de fournisseur de service de mobilités -- [X] ... - #### Reportings -- [X] Tableau de bord financeur sur le nombre de souscriptions -- [X] Tableau de bord citoyen +- [X] Notification des différents états de traitement de la souscription par e-mail et sur le tableau de bord citoyen + #### Back-office Financeur +- [X] Création de compte Financeur (gestionnaire / superviseur) +- [X] Validation des demandes d'affiliations des citoyens salariés +- [X] Consultation et traitement par le gestionnaire financeur des souscriptions +- [X] Gestion des communautés +- [X] Tableau de bord financeur sur le nombre de souscriptions validées et par aide, et d'autres indicateurs de suivi de pilotage de la politique de mobilité +- [X] Intégration du Système d'Information Ressources Humaines pour permettre le traitement des souscriptions dans l'outil existant du financeur #### Autres fonctionnalités - [X] Publication Opensource - [X] Audit du code et de l'infrastructure diff --git a/README.md b/README.md index c2e3402..066901b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ > **Note** : La publication du code sera échelonnée en plusieurs parties et accompagné de documents permettant à l’usager d’appréhender facilement les éléments partagés. Basé un mode de fonctionnement agile, les éléments constitutifs du projet sont susceptibles d’évoluer au fil de l’eau, en amont de tout déploiement éventuel, du fait : des retours des tests, de la prise en compte de l'ensemble des exigences en matière d'accessibilité, de la précision des éléments légaux, de tout autre élément susceptible de conduire à des modifications. # Rappel du contexte -Le programme Mon Compte Mobilité, porté par Capgemini Invent et la Fabrique des Mobilité, est une plateforme de services neutre qui facilite les relations entre citoyens, employeurs, collectivités et opérateurs de mobilité autour d’un compte personnel de mobilité et d'une passerelle (i.e. Gateway) d'échanges de services standardisés à destination des MaaS. Son ambition est d’accélérer les mutations des mobilités pour réduire massivement l’autosolisme et encourager l’utilisation des mobilités douces. +Le programme Mon Compte Mobilité, porté par Capgemini Invent et la Fabrique des Mobilité, est une plateforme de services neutre qui facilite les relations entre citoyens, employeurs, collectivités et opérateurs de mobilité autour d’un compte personnel de mobilité et d'une passerelle (i.e. Gateway) d'échanges de services standardisés à destination des MaaS. Son ambition est d’accélérer les mutations des mobilités pour réduire massivement l’autosolisme et encourager l’utilisation des mobilités douces. Ce programme répond parfaitement à une des propositions de la convention citoyenne : mettre en place un portail unique permettant de savoir à tout moment, rapidement et simplement, quels sont les moyens et dispositifs existants sur un territoire pour se déplacer. Le projet Mon Compte Mobilité est lauréat de l’appel à projet pour des programmes de Certificats d’économie d’énergie par l’arrêté du 27 février 2020, et publié au journal officiel le 8 mars 2020. Mon Compte Mobilité (ou **moB**) est un compte unique pour chaque utilisateur qui permet : -- A chaque citoyen de visualiser les dispositifs d’incitation nationaux, de sa collectivité ou son employeur pour en bénéficier comme il le souhaite auprès des différentes offres de mobilité et de gérer son consentement à la portabilité de ses données personnelles -- A chaque entreprise de paramétrer et mettre en œuvre la politique de mobilité qu’elle souhaite pour ses collaborateurs +- A chaque citoyen de visualiser les dispositifs d’incitation nationaux, de sa collectivité ou son employeur pour en bénéficier comme il le souhaite auprès des différentes offres de mobilité et de gérer son consentement à la portabilité de ses données personnelles +- A chaque entreprise de paramétrer et mettre en œuvre la politique de mobilité qu’elle souhaite pour ses collaborateurs - A chaque AOM (Autorité Organisatrice de la Mobilité) de créer et piloter ses politiques d’incitation pour encourager l’utilisation de modes de mobilité plus durables sur son territoire - A chaque opérateur de mobilité (MSP, Mobility Service Provider) de mettre en visibilité ses offres et de faciliter l’utilisation des incitatifs sur celles-ci, et de contribuer à la politique incitative de mobilité durable des territoires @@ -49,7 +49,7 @@ Merci de vous référer au fichier dédié : [LICENSE.txt](https://github.com/fa # Périmètre de la publication -La présente publication sera complétée le 18 novembre 2022. Seront alors publiées les fonctionnalités de Mon Compte Mobilité ayant pour vocation de +La présente publication sera complétée le 18 novembre 2022. Seront alors publiées les fonctionnalités de Mon Compte Mobilité ayant pour vocation de Permettre à l’Utilisateur non-authentifié : - D’accéder au catalogue d’aides publiques proposées par les Territoires partenaires diff --git a/administration/.eslintignore b/administration/.eslintignore new file mode 100644 index 0000000..06d792d --- /dev/null +++ b/administration/.eslintignore @@ -0,0 +1,2 @@ +*.css +*.svg diff --git a/administration/.eslintrc.js b/administration/.eslintrc.js new file mode 100644 index 0000000..e62a310 --- /dev/null +++ b/administration/.eslintrc.js @@ -0,0 +1,86 @@ +module.exports = { + extends: [ + 'airbnb-typescript', + 'airbnb/hooks', + 'plugin:@typescript-eslint/recommended', + 'plugin:jest/recommended', + 'prettier', + 'plugin:prettier/recommended', + ], + plugins: ['react', '@typescript-eslint', 'jest', 'react-hooks'], + env: { + browser: true, + es2021: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 12, + sourceType: 'module', + project: ['./tsconfig.json'], + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-asserted-optional-chain" : "off", + "@typescript-eslint/no-non-null-assertion" : "off", + 'max-len': 0, + 'react/prop-types': 0, + 'prettier/prettier': [ + 'error', + { endOfLine: 'auto' }, + { singleQuote: true }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.tsx'] }], + 'react/jsx-one-expression-per-line': 0, + '@typescript-eslint/ban-ts-comment': 'off', + 'react/jsx-props-no-spreading': 'off', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['error'], + 'react/jsx-filename-extension': [ + 'warn', + { + extensions: ['.tsx'], + }, + ], + 'import/extensions': [ + 'error', + 'ignorePackages', + { + ts: 'never', + tsx: 'never', + }, + ], + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: ['**/*.test.tsx', '*/jest-configs/*'], + }, + ], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error'], + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowExpressions: true, + }, + ], + 'max-len': [ + 'warn', + { + code: 80, + }, + ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'import/prefer-default-export': 'off', + 'react/prop-types': 'off', + }, + settings: { + 'import/resolver': { + typescript: {}, + }, + }, +}; diff --git a/administration/.gitignore b/administration/.gitignore new file mode 100644 index 0000000..48c9383 --- /dev/null +++ b/administration/.gitignore @@ -0,0 +1,24 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +/public/keycloak.json + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/administration/.gitlab-ci.yml b/administration/.gitlab-ci.yml new file mode 100644 index 0000000..32a193a --- /dev/null +++ b/administration/.gitlab-ci.yml @@ -0,0 +1,58 @@ +include: + - local: 'administration/.gitlab-ci/preview.yml' + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: 'administration/.gitlab-ci/testing.yml' + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + - local: 'administration/.gitlab-ci/helm.yml' + rules: + - if: $CI_PIPELINE_SOURCE == "trigger" + +# Initialisation of specifique administration variable +.admin-base: + variables: + MODULE_NAME: admin + MODULE_PATH: administration + ADMIN_BASE_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/nginx:1.21 + ADMIN_IMAGE_NAME: ${REGISTRY_BASE_NAME}/${MODULE_NAME}:${IMAGE_TAG_NAME} + only: + changes: + - '*' + - 'commons/**/*' + - 'administration/**/*' + +# Build of testing environement image and creation of the cache +.admin_build_script: &admin_build_script | + yarn install + export REACT_APP_ADMIN_ACCES_ROLE=${MCM_CMS_ACCESS_ROLE} + export REACT_APP_PACKAGE_VERSION=${PACKAGE_VERSION} + npm version ${PACKAGE_VERSION} + yarn build --production=true + +admin_build: + extends: + - .build-job + - .admin-base + script: + - *admin_build_script + cache: + key: ${MODULE_PATH}-${CI_COMMIT_REF_SLUG} + policy: push + paths: + - ${MODULE_PATH}/node_modules/ + - ${MODULE_PATH}/yarn.lock + artifacts: + paths: + - ${MODULE_PATH}/build + - ${MODULE_PATH}/node_modules/ + - ${MODULE_PATH}/yarn.lock + expire_in: 5 days + +# Static Application Security Testing for known vulnerabilities +admin_sast: + extends: + - .sast-job + - .build-n-sast-job-tags + - .admin-base + needs: ['admin_build'] \ No newline at end of file diff --git a/administration/.gitlab-ci/helm.yml b/administration/.gitlab-ci/helm.yml new file mode 100644 index 0000000..3188851 --- /dev/null +++ b/administration/.gitlab-ci/helm.yml @@ -0,0 +1,4 @@ +admin_image_push: + extends: + - .helm-push-image-job + - .admin-base diff --git a/administration/.gitlab-ci/preview.yml b/administration/.gitlab-ci/preview.yml new file mode 100644 index 0000000..117f03a --- /dev/null +++ b/administration/.gitlab-ci/preview.yml @@ -0,0 +1,18 @@ +admin_image_build: + extends: + - .preview-image-job + - .admin-base + needs: ['admin_build'] + +admin_preview_deploy: + extends: + - .preview-deploy-job + - .admin-base + needs: ['admin_image_build'] + environment: + on_stop: admin_preview_cleanup + +admin_preview_cleanup: + extends: + - .commons_preview_cleanup + - .admin-base diff --git a/administration/.gitlab-ci/testing.yml b/administration/.gitlab-ci/testing.yml new file mode 100644 index 0000000..79b7f56 --- /dev/null +++ b/administration/.gitlab-ci/testing.yml @@ -0,0 +1,11 @@ +admin_testing_image_build: + extends: + - .testing-image-job + - .admin-base + needs: ['admin_build'] + +admin_testing_deploy: + extends: + - .testing-deploy-job + - .admin-base + needs: ['admin_testing_image_build'] diff --git a/administration/.prettierignore b/administration/.prettierignore new file mode 100644 index 0000000..6012ab2 --- /dev/null +++ b/administration/.prettierignore @@ -0,0 +1,12 @@ +node_modules + +# Ignore artifacts: +build +coverage + +.cache +.gitlab-ci.yml +.scannerwork +public/ +gatsby-config.js +.eslintrc.js diff --git a/administration/.prettierrc.json b/administration/.prettierrc.json new file mode 100644 index 0000000..a574763 --- /dev/null +++ b/administration/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "always", + "endOfLine": "auto" +} diff --git a/administration/Chart.yaml b/administration/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/administration/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/administration/README.md b/administration/README.md new file mode 100644 index 0000000..e6060f5 --- /dev/null +++ b/administration/README.md @@ -0,0 +1,57 @@ +# Description + +Le service administration se base sur le framework **[React-Admin](https://marmelab.com/react-admin/)** + +⚠ La version utilisée est la v3.19.11 de React-Admin + +Ce service permet, pour un administrateur fonctionnel, de gérer et de contribuer sur différents types de contenus (Aides, entreprises, collectivités, communautés ...) proposés et/ou à gérer dans la solution. + +# Installation en local + +Modifier le fichier json présent dans le dossier public avec les variables mentionnées ci-dessous + +```sh +yarn install && yarn start +``` + +## Variables + +| Variables | Description | Obligatoire | +| ----------------------- | ----------------------------------------- | ----------- | +| IDP_FQDN | Url de l'IDP | Oui | +| API_FQDN | Url de l'api | Oui | +| IDP_MCM_REALM | Nom du realm mcm | Oui | +| IDP_MCM_ADMIN_CLIENT_ID | Client id du client public administration | Oui | + +## URL / Port + +- URL : localhost +- Port : 3001 + +# Précisions pipelines + +L'image de l'administration est basée sur nginx. + +La variable PACKAGE_VERSION (voir commons) permet de repérer la dernière version publiée + +## Preview + +Pas de précisions nécéssaires pour ce service + +## Testing + +Pas de précisions nécéssaires pour ce service + +# Relation avec les autres services + +Comme présenté dans le schéma global de l'architecture ci-dessous + +![technicalArchitecture](../docs/assets/MOB-CME_Archi_technique_detaillee.png) + +React Admin est lié à l'api grâce à l'implémentation du dataProvider. Ainsi certains endpoints permettant la contribution sont ouverts à ce portail d'administration. + +Il est aussi lié à l'idp pour l'accès au portail soit par un compte dédié, soit par une liaison identity provider avec notre Azure AD. + +# Tests Unitaires + +Pas de tests unitaires nécéssaires pour ce service diff --git a/administration/admin-dockerfile.yml b/administration/admin-dockerfile.yml new file mode 100644 index 0000000..dba436f --- /dev/null +++ b/administration/admin-dockerfile.yml @@ -0,0 +1,5 @@ +ARG ADMIN_BASE_IMAGE_NAME +FROM ${ADMIN_BASE_IMAGE_NAME} + +COPY config-nginx/ /etc/nginx/conf.d +COPY build/ /usr/share/nginx/html/ diff --git a/administration/administration-testing-values.yaml b/administration/administration-testing-values.yaml new file mode 100644 index 0000000..e217b16 --- /dev/null +++ b/administration/administration-testing-values.yaml @@ -0,0 +1,122 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: admin-keycloak-config + data: + keycloak.json: 'administration/keycloak.json' + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: admin + name: admin + spec: + ports: + - name: '8081' + port: 8081 + targetPort: 8081 + selector: + io.kompose.service: admin + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: admin + name: admin + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: admin + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/web-nw: 'true' + io.kompose.service: admin + spec: + containers: + env: + API_FQDN: ${API_FQDN} + IDP_FQDN: ${IDP_FQDN} + MCM_IDP_CLIENTID_ADMIN: ${IDP_MCM_ADMIN_CLIENT_ID} + MCM_IDP_REALM: ${IDP_MCM_REALM} + image: ${ADMIN_IMAGE_NAME} + name: admin + ports: + - containerPort: 8081 + resources: {} + volumeMounts: + - mountPath: /usr/share/nginx/html/keycloak.json + name: keycloak-config + subPath: keycloak.json + imagePullSecrets: + - name: ${GITLAB_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: admin-keycloak-config + name: keycloak-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: 'true' + podSelector: + matchLabels: + io.kompose.network/web-nw: 'true' + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: admin + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${ADMIN_FQDN}`) + services: + - kind: Service + name: admin + port: 8081 diff --git a/administration/config-nginx/default.conf b/administration/config-nginx/default.conf new file mode 100644 index 0000000..0555409 --- /dev/null +++ b/administration/config-nginx/default.conf @@ -0,0 +1,44 @@ +server { + listen 8081; + server_name localhost; + + #charset koi8-r; + #access_log /var/log/nginx/host.access.log main; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + } + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + + # proxy the PHP scripts to Apache listening on 127.0.0.1:80 + # + #location ~ \.php$ { + # proxy_pass http://127.0.0.1; + #} + + # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 + # + #location ~ \.php$ { + # root html; + # fastcgi_pass 127.0.0.1:9000; + # fastcgi_index index.php; + # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; + # include fastcgi_params; + #} + + # deny access to .htaccess files, if Apache's document root + # concurs with nginx's one + # + #location ~ /\.ht { + # deny all; + #} +} diff --git a/administration/kompose.yml b/administration/kompose.yml new file mode 100644 index 0000000..7d41570 --- /dev/null +++ b/administration/kompose.yml @@ -0,0 +1,24 @@ +version: '3' + +services: + admin: + image: ${ADMIN_IMAGE_NAME} + build: + context: . + dockerfile: ./admin-dockerfile.yml + args: + ADMIN_BASE_IMAGE_NAME: ${ADMIN_BASE_IMAGE_NAME} + networks: + - web-nw + ports: + - '8081' + environment: + - API_FQDN + - IDP_FQDN + - IDP_MCM_REALM + - IDP_MCM_ADMIN_CLIENT_ID + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' +networks: + web-nw: diff --git a/administration/overlays/admin-certificate.yml b/administration/overlays/admin-certificate.yml new file mode 100644 index 0000000..f788ff5 --- /dev/null +++ b/administration/overlays/admin-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: admin-cert +spec: + dnsNames: + - '*.${landscape_subdomain}' + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/administration/overlays/admin-deployment-namespace.yml b/administration/overlays/admin-deployment-namespace.yml new file mode 100644 index 0000000..777cdd2 --- /dev/null +++ b/administration/overlays/admin-deployment-namespace.yml @@ -0,0 +1,4 @@ +apiVersion: apps/v1 +kind: Namespace +metadata: + name: admin-test-override-namespace diff --git a/administration/overlays/admin-ingressroute.yml b/administration/overlays/admin-ingressroute.yml new file mode 100644 index 0000000..780f702 --- /dev/null +++ b/administration/overlays/admin-ingressroute.yml @@ -0,0 +1,22 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: admin +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${ADMIN_FQDN}`) + kind: Rule + services: + - kind: Service + name: admin + port: 8081 + # tls: + # secretName: ${SECRET_NAME} # admin-tls # cert-dev + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - '*.preview.${BASE_DOMAIN}' + # - '*.testing.${BASE_DOMAIN}' diff --git a/administration/overlays/admin_configmap_volumes.yml b/administration/overlays/admin_configmap_volumes.yml new file mode 100644 index 0000000..6760208 --- /dev/null +++ b/administration/overlays/admin_configmap_volumes.yml @@ -0,0 +1,19 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin +spec: + template: + spec: + containers: + - name: admin + volumeMounts: + - name: keycloak-config + mountPath: /usr/share/nginx/html/keycloak.json + subPath: keycloak.json + securityContext: + fsGroup: 1000 + volumes: + - name: keycloak-config + configMap: + name: admin-keycloak-config diff --git a/administration/overlays/config/keycloak.json b/administration/overlays/config/keycloak.json new file mode 100644 index 0000000..5661125 --- /dev/null +++ b/administration/overlays/config/keycloak.json @@ -0,0 +1,8 @@ +{ + "keycloakConfig": { + "url": "https://${IDP_FQDN}/auth", + "realm": "${IDP_MCM_REALM}", + "clientId": "${IDP_MCM_ADMIN_CLIENT_ID}" + }, + "apiConfig": "https://${API_FQDN}/" +} diff --git a/administration/overlays/kustomization.yaml b/administration/overlays/kustomization.yaml new file mode 100644 index 0000000..cba6e9b --- /dev/null +++ b/administration/overlays/kustomization.yaml @@ -0,0 +1,17 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - admin-ingressroute.yml + # - admin-certificate.yml + +patchesStrategicMerge: + - web_nw_networkpolicy_namespaceselector.yml + - admin_configmap_volumes.yml + +configMapGenerator: + - name: admin-keycloak-config + files: + - config/keycloak.json diff --git a/administration/overlays/web_nw_networkpolicy_namespaceselector.yml b/administration/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..32da467 --- /dev/null +++ b/administration/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: 'true' diff --git a/administration/package.json b/administration/package.json new file mode 100644 index 0000000..090cb5b --- /dev/null +++ b/administration/package.json @@ -0,0 +1,73 @@ +{ + "name": "administration", + "version": "1.10.1", + "author": "Mon Compte Mobilité", + "private": true, + "dependencies": { + "@material-ui/core": "4.12.4", + "@material-ui/icons": "4.11.3", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/react": "11.2.7", + "@testing-library/user-event": "12.8.3", + "@types/jest": "26.0.24", + "@types/node": "12.20.55", + "@types/react": "17.0.2", + "@types/react-dom": "17.0.2", + "axios": "0.21.4", + "keycloak-js": "16.1.1", + "lodash": "4.17.21", + "ra-i18n-polyglot": "3.19.11", + "ra-language-french": "3.19.11", + "react": "17.0.2", + "react-admin": "3.19.11", + "react-admin-lb4": "1.0.3", + "react-dom": "17.0.2", + "react-final-form": "6.5.9", + "react-query": "3.39.2", + "typescript": "4.1.2" + }, + "resolutions": { + "@types/react": "17.0.2", + "@types/react-dom": "17.0.2" + }, + "scripts": { + "start": "set PORT=3001 && react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "format": "npm run prettier:fix && npm run lint:fix", + "lint": "eslint src/* --ext js,ts,tsx", + "lint:fix": "npm run lint -- --fix", + "prettier": "prettier -c", + "prettier:fix": "npm run prettier -- --write" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "4.33.0", + "@typescript-eslint/parser": "4.33.0", + "eslint": "7.32.0", + "eslint-config-airbnb": "18.2.1", + "eslint-config-airbnb-typescript": "12.3.1", + "eslint-config-prettier": "8.5.0", + "eslint-import-resolver-alias": "1.1.2", + "eslint-import-resolver-typescript": "2.7.1", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-jsx-a11y": "6.6.1", + "eslint-plugin-prettier": "3.4.1", + "eslint-plugin-react": "7.31.10", + "eslint-plugin-react-hooks": "4.6.0", + "prettier": "2.7.1", + "react-scripts": "5.0.0" + } +} diff --git a/administration/public/index.html b/administration/public/index.html new file mode 100644 index 0000000..cc49e05 --- /dev/null +++ b/administration/public/index.html @@ -0,0 +1,37 @@ + + + + + + + + + + Administration | Mon Compte Mobilité + + + +
+ + + diff --git a/administration/public/mob-favicon.svg b/administration/public/mob-favicon.svg new file mode 100644 index 0000000..ce42712 --- /dev/null +++ b/administration/public/mob-favicon.svg @@ -0,0 +1 @@ + diff --git a/administration/public/robots.txt b/administration/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/administration/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/administration/src/App.tsx b/administration/src/App.tsx new file mode 100644 index 0000000..b243cf8 --- /dev/null +++ b/administration/src/App.tsx @@ -0,0 +1,80 @@ +/* eslint-disable */ +import { Admin, Resource, resolveBrowserLocale } from 'react-admin'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import frenchMessages from 'ra-language-french'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +import dataProvider from './api/provider/dataProvider'; +import AuthProvider from './modules/Auth/authProvider'; +import { KeycloakProviderInit } from './components/Keycloak/KeycloakProviderInit'; +import LogoutButton from './components/LoginForm/LogoutButton'; +import Dashboard from './components/Dashboard/Dashboard'; +import AccessRole from './components/Access/AccessRole'; +import EntrepriseForm from './components/Entreprises'; +import CollectiviteForm from './components/Collectivites/CollectiviteForm'; +import CollectiviteList from './components/Collectivites/CollectiviteList'; +import CommunateForm from './components/Communautes'; +import Aide from './components/Aide'; +import UtilisateurForm from './components/utilisateurs'; +import customTheme from './components/customTheme/customTheme'; +import TerritoryForm from './components/Territories'; + +const queryClient = new QueryClient(); + +const messages = { + fr: frenchMessages, +}; + +const i18nProvider = polyglotI18nProvider( + (locale) => (messages[locale] ? messages[locale] : messages.fr), + resolveBrowserLocale() +); + +function App(): JSX.Element { + return ( + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/administration/src/api/communautes.ts b/administration/src/api/communautes.ts new file mode 100644 index 0000000..f0406c4 --- /dev/null +++ b/administration/src/api/communautes.ts @@ -0,0 +1,15 @@ +import axios from 'axios'; +import { URL_API } from '../utils/constant'; +import { getAuthHeader } from '../utils/httpHeaders'; + +export const getFunderCommunityList = async ( + funderId: string +): Promise<{ id: string; name: string; funderId: string }[]> => { + const { data } = await axios.get( + `${await URL_API()}/funders/${funderId}/communities`, + { + headers: getAuthHeader(), + } + ); + return data; +}; diff --git a/administration/src/api/financeurs.ts b/administration/src/api/financeurs.ts new file mode 100644 index 0000000..e334e09 --- /dev/null +++ b/administration/src/api/financeurs.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; +import { URL_API } from '../utils/constant'; +import { getAuthHeader } from '../utils/httpHeaders'; + +export const getFunders = async (): Promise< + { + name: string; + id: string; + funderType: string; + emailFormat?: string[]; + }[] +> => { + const { data } = await axios.get(`${await URL_API()}/funders`, { + headers: getAuthHeader(), + }); + return data; +}; + +export const getClients = async (): Promise<{ + clientId: string; + id: string; +}> => { + const { data } = await axios.get(`${await URL_API()}/funders/clients`, { + headers: getAuthHeader(), + }); + return data; +}; diff --git a/administration/src/api/provider/dataProvider.ts b/administration/src/api/provider/dataProvider.ts new file mode 100644 index 0000000..be204c7 --- /dev/null +++ b/administration/src/api/provider/dataProvider.ts @@ -0,0 +1,42 @@ +/* eslint-disable */ + +import lb4Provider from 'react-admin-lb4'; +import { GET_LIST } from 'react-admin'; +import { URL_API } from '../../utils/constant'; + +import { getAuthHeader } from '../../utils/httpHeaders'; + +/** + * @param {string} type Request type, e.g GET_LIST, GET_ONE, DELETE, POST.. + * @param {string} resource Resource name, e.g. "communautes" + * @param {Object} params Request parameters. Depends on the request type + */ + +export default async (type: string, resource: string, params: any) => { + const data = await URL_API(); + const dataProvider = lb4Provider(data, getAuthHeader); + const url: string = resourceConverter(type, resource); + return dataProvider(type, url, params); +}; + +const resourceConverter = (type: string, resource: string): string => { + switch (resource) { + case 'collectivites': + return 'collectivities'; + case 'entreprises': + return 'enterprises'; + case 'aides': + return 'incentives'; + case 'communautes': + return 'funders/communities'; + case 'utilisateurs': + if (type === GET_LIST) { + return 'users/funders'; + } + return 'users'; + case 'territoires': + return 'territories'; + default: + return resource; + } +}; diff --git a/administration/src/api/roles.ts b/administration/src/api/roles.ts new file mode 100644 index 0000000..a1c0bde --- /dev/null +++ b/administration/src/api/roles.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; +import { URL_API } from '../utils/constant'; +import { getAuthHeader } from '../utils/httpHeaders'; + +export const getRoles = async (): Promise => { + const { data } = await axios.get(`${await URL_API()}/users/roles`, { + headers: getAuthHeader(), + }); + return data; +}; diff --git a/administration/src/api/territories.ts b/administration/src/api/territories.ts new file mode 100644 index 0000000..72d198a --- /dev/null +++ b/administration/src/api/territories.ts @@ -0,0 +1,13 @@ +import axios from 'axios'; +import { URL_API } from '../utils/constant'; +import { getAuthHeader } from '../utils/httpHeaders'; + +export const getTerritories = async (): Promise<{ + name: string; + id: string; +}> => { + const { data } = await axios.get(`${await URL_API()}/territories`, { + headers: getAuthHeader(), + }); + return data; +}; diff --git a/administration/src/assets/svg/spinner.svg b/administration/src/assets/svg/spinner.svg new file mode 100644 index 0000000..39fba0f --- /dev/null +++ b/administration/src/assets/svg/spinner.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/administration/src/components/Access/AccessRole.css b/administration/src/components/Access/AccessRole.css new file mode 100644 index 0000000..21247b5 --- /dev/null +++ b/administration/src/components/Access/AccessRole.css @@ -0,0 +1,5 @@ +.no-admin { + margin-top: 50px; + margin-left: 10%; + font-size: 30px; +} diff --git a/administration/src/components/Access/AccessRole.tsx b/administration/src/components/Access/AccessRole.tsx new file mode 100644 index 0000000..1eb15b2 --- /dev/null +++ b/administration/src/components/Access/AccessRole.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import './AccessRole.css'; +import { useSession } from '../Keycloak/KeycloakProviderInit'; + +/** + * Controle access role component + * @param props + * @constructor + */ +const AccessRole: React.FC = ({ children }) => { + const { keycloak } = useSession(); + + if ( + keycloak && + keycloak.realmAccess && + keycloak.realmAccess.roles.includes( + `${process.env.REACT_APP_ADMIN_ACCES_ROLE || 'content_editor'}` + ) + ) { + return <>{children}; + } + keycloak.logout(); + return ( +

Vous n êtes pas habilité.e à accéder à ce site

+ ); +}; + +export default AccessRole; diff --git a/administration/src/components/Aide/AideCreate.tsx b/administration/src/components/Aide/AideCreate.tsx new file mode 100644 index 0000000..ceaa66b --- /dev/null +++ b/administration/src/components/Aide/AideCreate.tsx @@ -0,0 +1,55 @@ +/* eslint-disable */ +import { FC } from 'react'; +import { + Create, + CreateProps, + SimpleForm, + useCreateContext, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; + +import AideCreateForm from './AideCreateForm'; +import AidesMessages from '../../utils/Aide/fr.json'; +import { errorFetching } from '../../utils/constant'; + +const AideCreate: FC = (props) => { + const { save, record } = useCreateContext(); + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onSuccess = ({ data }) => { + notify( + AidesMessages['aides.create.success'].replace('{aidTitle}', data.title), + 'success' + ); + redirect('/aides'); + refresh(); + }; + + const onFailure = ({ message }) => { + const result = + message !== errorFetching.messageApi + ? AidesMessages[message] + : errorFetching.messageToDisplay; + + notify(result, 'error'); + }; + + return ( + + + + + + ); +}; + +export default AideCreate; diff --git a/administration/src/components/Aide/AideCreateForm.tsx b/administration/src/components/Aide/AideCreateForm.tsx new file mode 100644 index 0000000..cd845be --- /dev/null +++ b/administration/src/components/Aide/AideCreateForm.tsx @@ -0,0 +1,302 @@ +/* eslint-disable */ +import React, { useEffect, useState } from 'react'; +import { + TextInput, + required, + DateInput, + SelectInput, + BooleanInput, + NumberInput, + AutocompleteArrayInput, + AutocompleteInput, + ArrayInput, + SimpleFormIterator, + FormDataConsumer, + useNotify, +} from 'react-admin'; +import { useForm } from 'react-final-form'; +import { CardContent, Box } from '@material-ui/core'; +import { useQuery } from 'react-query'; + +import { + TRANSPORT_CHOICE, + INCENTIVE_TYPE_CHOICE, + PROOF_CHOICE, + INPUT_FORMAT_CHOICE, +} from '../../utils/constant'; +import { getDate } from '../../utils/convertDateToString'; +import { + startDateMin, + dateMinValidation, +} from '../../utils/Aide/validityDateRules'; +import { validateUrl } from '../../utils/Aide/formHelper'; +import CustomAddButton from '../common/CustomAddButton'; +import { getFunders } from '../../api/financeurs'; +import FinanceurMessages from '../../utils/Financeur/fr.json'; + +import '../styles/DynamicForm.css'; +import TerritoriesDropDown from '../common/TerritoriesDropDown'; + +const AideCreateForm = (save, record) => { + const notify = useNotify(); + const form = useForm(); + + const [FUNDER_CHOICE, setFunderChoice] = useState< + { name: string; label: string }[] + >([]); + const [isMobilityChecked, setIsMobilityChecked] = React.useState(false); + + const { data: funders } = useQuery( + 'funders', + async (): Promise => { + return await getFunders(); + }, + { + onError: () => { + notify(FinanceurMessages['funders.error'], 'error'); + }, + enabled: true, + retry: false, + staleTime: Infinity, + } + ); + + useEffect(() => { + setFunderChoice( + funders && + funders.map((elt) => { + elt.label = elt.funderType + ? `${elt.name} (${elt.funderType})` + : elt.name; + return elt; + }) + ); + }, [funders]); + + const handleShowMobilityInputs = () => { + form.change('subscriptionLink', undefined); + form.change('specificFields', undefined); + setIsMobilityChecked(!isMobilityChecked); + }; + + return ( + + + + + + + + + + + { + if (value) { + const newFunder = { name: value, label: value }; + FUNDER_CHOICE.push(newFunder); + return newFunder; + } + }} + validate={[required()]} + choices={FUNDER_CHOICE} + optionText={(choice) => + choice ? + (choice.label ? choice.label : choice.name ) : + null + } + optionValue="name" + translateChoice={false} + /> + + + + + + { + if (value) { + const newProof = { id: value, name: value }; + PROOF_CHOICE.push(newProof); + return newProof; + } + }} + fullWidth + choices={PROOF_CHOICE} + /> + + + + + + + + + {!isMobilityChecked ? ( + + + + ) : ( + + + + } + > + + + + {({ + formData, // The whole form data + scopedFormData, // The data for this item of the ArrayInput + getSource, // A function to get the valid source inside an ArrayInput + ...rest + }) => + scopedFormData?.inputFormat === 'listeChoix' ? ( + <> + + + + + + + + ) : null + } + + + + + )} + + + + + + ); +}; + +export default AideCreateForm; diff --git a/administration/src/components/Aide/AideEdit.tsx b/administration/src/components/Aide/AideEdit.tsx new file mode 100644 index 0000000..b3d8d65 --- /dev/null +++ b/administration/src/components/Aide/AideEdit.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { FC } from 'react'; +import { + Edit, + EditProps, + Record, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; +import { errorFetching } from '../../utils/constant'; +import AideForm from './AideEditForm'; +import AidesMessages from '../../utils/Aide/fr.json'; + +const AideEdit: FC = (props) => { + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onSuccess = ({ data }): void => { + notify( + AidesMessages['aides.edit.success'].replace('{aidTitle}', data.title), + 'success' + ); + redirect('/aides'); + refresh(); + }; + + const onFailure = ({ message }): void => { + const result = + message !== errorFetching.messageApi + ? AidesMessages[message] + : errorFetching.messageToDisplay; + + notify(result, 'error'); + }; + + const transform = (data): Record => { + if (data.isMCMStaff) { + return { ...data, subscriptionLink: undefined }; + } + return { ...data, specificFields: undefined }; + }; + + return ( + + + + ); +}; + +export default AideEdit; diff --git a/administration/src/components/Aide/AideEditForm.tsx b/administration/src/components/Aide/AideEditForm.tsx new file mode 100644 index 0000000..db1552b --- /dev/null +++ b/administration/src/components/Aide/AideEditForm.tsx @@ -0,0 +1,312 @@ +/* eslint-disable */ +import React, { useEffect, useState } from 'react'; +import { find } from 'lodash'; +import { + TextInput, + required, + DateInput, + SelectInput, + BooleanInput, + AutocompleteArrayInput, + ArrayInput, + SimpleFormIterator, + useRecordContext, + FormWithRedirect, + FormDataConsumer, + NumberInput, + useEditContext, + Toolbar, + EditProps, +} from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; +import { + TRANSPORT_CHOICE, + INCENTIVE_TYPE_CHOICE, + PROOF_CHOICE, + INPUT_FORMAT_CHOICE, +} from '../../utils/constant'; +import { + startDateMin, + dateMinValidation, +} from '../../utils/Aide/validityDateRules'; +import { getDate } from '../../utils/convertDateToString'; +import { validateUrl } from '../../utils/Aide/formHelper'; +import CustomAddButton from '../common/CustomAddButton'; +import '../styles/DynamicForm.css'; +import TerritoriesDropDown from '../common/TerritoriesDropDown'; + +const AideEditForm = (props: EditProps) => { + const { save, record } = useEditContext(); + const recordContext = useRecordContext(); + const [isMobilityChecked, setIsMobilityChecked] = useState(false); + + // Check the isMobilityChecked at the willmount + useEffect(() => { + if (recordContext?.isMCMStaff) { + setIsMobilityChecked(true); + } else { + setIsMobilityChecked(false); + } + }, []); + // verify if each label Proof exist in options (PROOF_CHOICE) in this aides, else add option in Proof choice + if (recordContext?.attachments) { + recordContext.attachments.map((value) => { + if (!find(PROOF_CHOICE, ['id', value])) { + PROOF_CHOICE.push({ id: value, name: value }); + } + }); + } + + // Toogle of isMobilityChecked + const handleShowMobilityInputs = () => { + setIsMobilityChecked(!isMobilityChecked); + }; + + return ( + ( + + + +
+ + + + + + + + + + + + + + + { + if (value) { + const newProof = { id: value, name: value }; + PROOF_CHOICE.push(newProof); + return newProof; + } + }} + fullWidth + choices={PROOF_CHOICE} + /> + + + + + + + + + {!isMobilityChecked ? ( + + + + ) : ( + + + + } + > + + + + {({ + formData, // The whole form data + scopedFormData, // The data for this item of the ArrayInput + getSource, // A function to get the valid source inside an ArrayInput + ...rest + }) => + scopedFormData?.inputFormat === 'listeChoix' ? ( + <> + + + + + + + + ) : null + } + + + + + )} + + + + +
+
+
+ )} + /> + ); +}; + +export default AideEditForm; diff --git a/administration/src/components/Aide/AideList.tsx b/administration/src/components/Aide/AideList.tsx new file mode 100644 index 0000000..b1d8062 --- /dev/null +++ b/administration/src/components/Aide/AideList.tsx @@ -0,0 +1,35 @@ +/* eslint-disable */ +import React from 'react'; +import { + List, + Datagrid, + TextField, + EditButton, + DeleteButton, + FunctionField, +} from 'react-admin'; + +import { MAPPING_FUNDER_TYPE } from '../../utils/constant'; + +const AideList = (props) => { + return ( + + + + + + `${record.funderName} (${ + MAPPING_FUNDER_TYPE[record.incentiveType] + })` + } + /> + + + + + ); +}; + +export default AideList; diff --git a/administration/src/components/Aide/index.tsx b/administration/src/components/Aide/index.tsx new file mode 100644 index 0000000..06bb57c --- /dev/null +++ b/administration/src/components/Aide/index.tsx @@ -0,0 +1,9 @@ +import AideCreate from './AideCreate'; +import AideList from './AideList'; +import AideEdit from './AideEdit'; + +export default { + list: AideList, + create: AideCreate, + edit: AideEdit, +}; diff --git a/administration/src/components/Collectivites/CollectiviteCreateForm.tsx b/administration/src/components/Collectivites/CollectiviteCreateForm.tsx new file mode 100644 index 0000000..7d174f8 --- /dev/null +++ b/administration/src/components/Collectivites/CollectiviteCreateForm.tsx @@ -0,0 +1,43 @@ +/* eslint-disable */ +import { TextInput, required, NumberInput } from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; + +const Spacer = () => ; + +const CollectiviteCreateForm = (save, record) => { + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export default CollectiviteCreateForm; diff --git a/administration/src/components/Collectivites/CollectiviteForm.tsx b/administration/src/components/Collectivites/CollectiviteForm.tsx new file mode 100644 index 0000000..2a83550 --- /dev/null +++ b/administration/src/components/Collectivites/CollectiviteForm.tsx @@ -0,0 +1,50 @@ +/* eslint-disable */ +import CollectiviteCreateForm from './CollectiviteCreateForm'; +import { + Create, + SimpleForm, + useCreateContext, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; + +import CollectivitesMessages from '../../utils/Collectivite/fr.json'; +import { errorFetching } from '../../utils/constant'; + +const CollectiviteForm = (props) => { + const { save, record } = useCreateContext(); + + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onSuccess = ({ data }) => { + notify(`Création de la collectivité "${data.name}" avec succès`, 'success'); + redirect('/collectivites'); + refresh(); + }; + + const onFailure = ({ message }) => { + const result = + message !== errorFetching.messageApi + ? CollectivitesMessages[message] + : errorFetching.messageToDisplay; + notify(result, 'error'); + }; + + return ( + + + + + + ); +}; + +export default CollectiviteForm; diff --git a/administration/src/components/Collectivites/CollectiviteList.tsx b/administration/src/components/Collectivites/CollectiviteList.tsx new file mode 100644 index 0000000..fef12d5 --- /dev/null +++ b/administration/src/components/Collectivites/CollectiviteList.tsx @@ -0,0 +1,19 @@ +/* eslint-disable */ +import { List, Datagrid, TextField } from 'react-admin'; + +const CollectiviteList = (props) => { + return ( + + + + + + + + ); +}; + +export default CollectiviteList; diff --git a/administration/src/components/Communautes/CommunauteCreateForm.tsx b/administration/src/components/Communautes/CommunauteCreateForm.tsx new file mode 100644 index 0000000..969e351 --- /dev/null +++ b/administration/src/components/Communautes/CommunauteCreateForm.tsx @@ -0,0 +1,33 @@ +/* eslint-disable */ +import { TextInput, required } from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; + +import FinanceurDropDown from '../common/FinanceurDropDown'; + +const CommuniteCreateForm = (save, record) => { + return ( + + + + + + + + + + + + + + + + + ); +}; + +export default CommuniteCreateForm; diff --git a/administration/src/components/Communautes/CommunauteForm.tsx b/administration/src/components/Communautes/CommunauteForm.tsx new file mode 100644 index 0000000..3025015 --- /dev/null +++ b/administration/src/components/Communautes/CommunauteForm.tsx @@ -0,0 +1,55 @@ +/* eslint-disable */ +import { + Create, + SimpleForm, + useCreateContext, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; + +import { errorFetching } from '../../utils/constant'; +import CommunauteCreateForm from './CommunauteCreateForm'; +import CommunauteMessages from '../../utils/Communaute/fr.json'; + +const CommunauteForm = (props) => { + const { save, record } = useCreateContext(); + + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onSuccess = ({ data }) => { + notify(`Création de la communauté "${data.name}" avec succès`, 'success'); + redirect('/communautes'); + refresh(); + }; + + const onFailure = ({ message }) => { + const result = + message !== errorFetching.messageApi + ? CommunauteMessages[message] + : errorFetching.messageToDisplay; + notify(result, 'error'); + }; + + return ( + { + delete data.emailFormat; + delete data.hasManualAffiliation; + return data; + }} + > + + + + + ); +}; + +export default CommunauteForm; diff --git a/administration/src/components/Communautes/CommunauteList.tsx b/administration/src/components/Communautes/CommunauteList.tsx new file mode 100644 index 0000000..8bf36fc --- /dev/null +++ b/administration/src/components/Communautes/CommunauteList.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import { List, Datagrid, TextField } from 'react-admin'; + +const CommunauteList: FC = (props) => { + return ( + + + + + + + + ); +}; + +export default CommunauteList; diff --git a/administration/src/components/Communautes/index.tsx b/administration/src/components/Communautes/index.tsx new file mode 100644 index 0000000..6e10a9e --- /dev/null +++ b/administration/src/components/Communautes/index.tsx @@ -0,0 +1,7 @@ +import CommunauteForm from './CommunauteForm'; +import CommunauteList from './CommunauteList'; + +export default { + list: CommunauteList, + create: CommunauteForm, +}; diff --git a/administration/src/components/Dashboard/Dashboard.tsx b/administration/src/components/Dashboard/Dashboard.tsx new file mode 100644 index 0000000..a82a852 --- /dev/null +++ b/administration/src/components/Dashboard/Dashboard.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; + +const Dashboard: React.FC = () => ( + + + + Version : {process.env.REACT_APP_PACKAGE_VERSION || '1.0.0'} + + +); +export default Dashboard; diff --git a/administration/src/components/Entreprises/EntrepriseCreateForm.tsx b/administration/src/components/Entreprises/EntrepriseCreateForm.tsx new file mode 100644 index 0000000..1be3571 --- /dev/null +++ b/administration/src/components/Entreprises/EntrepriseCreateForm.tsx @@ -0,0 +1,197 @@ +/* eslint-disable */ +import { + TextInput, + required, + NumberInput, + BooleanInput, + AutocompleteArrayInput, + useNotify, + SelectInput, +} from 'react-admin'; +import { useForm } from 'react-final-form'; +import { Card, CardContent, Divider, Box } from '@material-ui/core'; +import { InfoRounded } from '@material-ui/icons'; + +import { isEmailFormatValid } from '../../utils/helpers'; + +import '../styles/DynamicForm.css'; +import { useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; +import { getClients } from '../../api/financeurs'; +import FinanceurMessages from '../../utils/Financeur/fr.json'; + +const EMAIL_TAB = [{ id: '@exemple.com', name: '@exemple.com' }]; + +type checkboxParams = { + source: string; + label: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const EntrepriseCreateForm = (save, record) => { + const notify = useNotify(); + const form = useForm(); + const [CLIENT_CHOICE, setClientChoice] = useState< + { clientId: string; id: string }[] + >([]); + + const { data: clients } = useQuery( + 'clients', + async (): Promise => { + return await getClients(); + }, + { + onError: () => { + notify(FinanceurMessages['funders.error.clients.list'], 'error'); + }, + enabled: true, + retry: false, + staleTime: Infinity, + } + ); + useEffect(() => { + setClientChoice( + clients && + clients.map((elt) => { + elt.name = elt.clientId; + return elt; + }) + ); + }, [clients]); + const checkBoxOptions: checkboxParams[] = [ + { + source: 'isHris', + label: 'SI RH', + }, + { + source: 'hasManualAffiliation', + label: 'Validation affiliation par le gestionnaire', + }, + ]; + + const uncheckOtherOptions = (checkedOption: string) => { + checkBoxOptions + .filter((el) => el.source !== checkedOption) + .forEach((option: checkboxParams) => form.change(option.source, false)); + }; + + return ( + + + + + + + + + + + + + + { + if (isEmailFormatValid(value)) { + const newFormat = { id: value, name: value }; + EMAIL_TAB.push(newFormat); + return newFormat; + } + EMAIL_TAB.push({ id: '', name: '' }); + }} + fullWidth + choices={EMAIL_TAB} + validate={[required(), validateFormatEmail]} + /> + + + + + + + + + + {CLIENT_CHOICE && ( + + + + )} + {checkBoxOptions.map((checkBoxOption: checkboxParams) => ( + + + uncheckOtherOptions(checkBoxOption.source) + } + defaultValue={false} + source={checkBoxOption.source} + label={checkBoxOption.label} + /> + + ))} + + +

+ + Vous ne pouvez pas activer les 2 options pour une + entreprise + +

+
+
+
+
+
+
+
+
+ ); +}; +const Spacer = () => ; + +const validateFormatEmail = (value) => { + return value.includes(undefined) || value.includes('@@ra-create') + ? 'Ne doit pas être nul' + : undefined; +}; + +export default EntrepriseCreateForm; diff --git a/administration/src/components/Entreprises/EntrepriseForm.tsx b/administration/src/components/Entreprises/EntrepriseForm.tsx new file mode 100644 index 0000000..d9846f8 --- /dev/null +++ b/administration/src/components/Entreprises/EntrepriseForm.tsx @@ -0,0 +1,54 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable */ + +import { + Create, + SimpleForm, + useCreateContext, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; + +import EntrepriseCreateForm from './EntrepriseCreateForm'; +import EntreprisesMessages from '../../utils/Entreprise/fr.json'; +import { errorFetching } from '../../utils/constant'; + +const EntrepriseForm = (props) => { + const { save, record } = useCreateContext(); + + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onFailure = ({ message }) => { + const result = + message !== errorFetching.messageApi + ? EntreprisesMessages[message] + : errorFetching.messageToDisplay; + notify(result, 'error'); + }; + + const onSuccess = ({ data }) => { + notify( + `Création du compte entreprise "${data.name}" avec succès`, + 'success' + ); + redirect('/entreprises'); + refresh(); + }; + + return ( + + + + + + ); +}; +export default EntrepriseForm; diff --git a/administration/src/components/Entreprises/EntrepriseList.tsx b/administration/src/components/Entreprises/EntrepriseList.tsx new file mode 100644 index 0000000..7668fa7 --- /dev/null +++ b/administration/src/components/Entreprises/EntrepriseList.tsx @@ -0,0 +1,25 @@ +/* eslint-disable */ +import { List, Datagrid, TextField, BooleanField } from 'react-admin'; + +const EntrepriseList = (props) => { + return ( + + + + + + + + + + + ); +}; + +export default EntrepriseList; diff --git a/administration/src/components/Entreprises/index.tsx b/administration/src/components/Entreprises/index.tsx new file mode 100644 index 0000000..b6f7db7 --- /dev/null +++ b/administration/src/components/Entreprises/index.tsx @@ -0,0 +1,8 @@ +/* eslint-disable import/no-anonymous-default-export */ +import EntrepriseForm from './EntrepriseForm'; +import EntrepriseList from './EntrepriseList'; + +export default { + create: EntrepriseForm, + list: EntrepriseList, +}; diff --git a/administration/src/components/Keycloak/KeycloakProviderInit.tsx b/administration/src/components/Keycloak/KeycloakProviderInit.tsx new file mode 100644 index 0000000..cd0b3df --- /dev/null +++ b/administration/src/components/Keycloak/KeycloakProviderInit.tsx @@ -0,0 +1,109 @@ +/* eslint-disable */ +import React, { useEffect, useState } from 'react'; +import Keycloak, { KeycloakConfig, KeycloakInstance } from 'keycloak-js'; + +import Loading from '../Loading/Loading'; + +interface IKeycloakContext { + keycloak?: KeycloakInstance; +} + +/** + * Create Context + */ +export const KeycloakContext = React.createContext< + IKeycloakContext | undefined +>(undefined); + +/** + * Keycloak wrapping component + * @param props + * @constructor + */ +const KeycloakProviderInit: React.FC = ({ children }) => { + // Keycloak config generated during CI/CD pipeline + const [keycloakInstance, setKeycloakInstance] = useState(); + const [isKCInit, setIsKCInit] = useState(false); + + /** + * Fetching of keycloak config (keycloak.json) generated during CI/CD pipeline + */ + const fetchKeycloakConfig = async () => { + const response = await fetch('/keycloak.json'); + const KeycloakConfig = await response.json(); + const data: KeycloakConfig = await KeycloakConfig.keycloakConfig; + if (Object.keys(data).length !== 0) { + const keycloak = Keycloak(data); + setKeycloakInstance(keycloak); + } + }; + + /** + * Initializes Keycloak instance and set the variable state if successfully initialized. + */ + const initKeycloak = async () => { + keycloakInstance + ?.init({ + onLoad: 'login-required', + }) + .then(() => { + window.localStorage.setItem('token', keycloakInstance?.token!); + setIsKCInit(true); + }) + .catch((err) => console.error(err)); + }; + + useEffect(() => { + fetchKeycloakConfig(); + }, []); + + useEffect(() => { + initKeycloak(); + }, [keycloakInstance]); + + useEffect(() => { + /** + * Call onTokenExpired when the access token is expired. + * If a refresh token is available, the token can be refreshed via the OAuth RefreshToken + * In order to transmit it on each call to the API. + * Otherwise logout the user. + */ + if (keycloakInstance) { + keycloakInstance!.onTokenExpired = () => { + keycloakInstance + ?.updateToken(5) + .then(() => { + window.localStorage.setItem('token', keycloakInstance?.token!); + }) + .catch(() => { + console.error('Failed to refresh token | the session is expired'); + keycloakInstance.logout(); + }); + }; + } + }, [keycloakInstance]); + + return ( + + {isKCInit ? children : } + + ); +}; + +/** + * useSession custom hook to get the provided values from KeycloakContext + */ +const useSession = () => { + const keycloakContext = React.useContext(KeycloakContext); + if (keycloakContext === undefined) { + throw new Error('useSession must be used within a KeycloakProvider'); + } + + return keycloakContext; +}; + +export { KeycloakProviderInit, useSession }; diff --git a/administration/src/components/Loading/Loading.css b/administration/src/components/Loading/Loading.css new file mode 100644 index 0000000..6278c96 --- /dev/null +++ b/administration/src/components/Loading/Loading.css @@ -0,0 +1,5 @@ +.loading { + position: fixed; + top: 50%; + left: 50%; +} diff --git a/administration/src/components/Loading/Loading.tsx b/administration/src/components/Loading/Loading.tsx new file mode 100644 index 0000000..8e99613 --- /dev/null +++ b/administration/src/components/Loading/Loading.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import loadingSpinner from '../../assets/svg/spinner.svg'; + +import './Loading.css'; + +const Loading = (): React.ReactElement => { + return spinner; +}; + +export default Loading; diff --git a/administration/src/components/LoginForm/LogoutButton.tsx b/administration/src/components/LoginForm/LogoutButton.tsx new file mode 100644 index 0000000..eaae409 --- /dev/null +++ b/administration/src/components/LoginForm/LogoutButton.tsx @@ -0,0 +1,23 @@ +/* eslint-disable */ +import * as React from 'react'; +import MenuItem from '@material-ui/core/MenuItem'; +import ExitIcon from '@material-ui/icons/PowerSettingsNew'; + +import { useSession } from '../Keycloak/KeycloakProviderInit'; + +const LogoutButton: React.FC = () => { + const { keycloak } = useSession(); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const handleClick = () => { + keycloak.logout(); + }; + + return ( + + Logout + + ); +}; + +export default LogoutButton; diff --git a/administration/src/components/Territories/TerritoryCreateForm.tsx b/administration/src/components/Territories/TerritoryCreateForm.tsx new file mode 100644 index 0000000..b051e46 --- /dev/null +++ b/administration/src/components/Territories/TerritoryCreateForm.tsx @@ -0,0 +1,23 @@ +/* eslint-disable */ +import { TextInput, required } from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; +import { checkNamesLength } from '../../utils/checkNamesLength'; + +const TerritoryCreateForm = (save, record) => { + return ( + + + + + + + + ); +}; + +export default TerritoryCreateForm; diff --git a/administration/src/components/Territories/TerritoryEdit.tsx b/administration/src/components/Territories/TerritoryEdit.tsx new file mode 100644 index 0000000..a147ffc --- /dev/null +++ b/administration/src/components/Territories/TerritoryEdit.tsx @@ -0,0 +1,60 @@ +/* eslint-disable */ +import { FC } from 'react'; +import { + Edit, + EditProps, + SaveButton, + SimpleForm, + Toolbar, + ToolbarProps, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; +import TerritoryEditForm from './TerritoryEditForm'; +import { errorFetching } from '../../utils/constant'; +import TerritoryMessages from '../../utils/Territory/fr.json'; + +const TerritoryEdit: FC = (props) => { + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onSuccess = (): void => { + notify(`Le terriotire a été modifié avec succes`, 'success'); + redirect('/territoires'); + refresh(); + }; + + const onFailure = ({ message }): void => { + const result: string = + message !== errorFetching.messageApi + ? TerritoryMessages[message] + : errorFetching.messageToDisplay; + notify(result, 'error'); + }; + + const EditToolbar = (props: ToolbarProps) => { + return ( + + + + ); + }; + + return ( + + }> + + + + ); +}; + +export default TerritoryEdit; diff --git a/administration/src/components/Territories/TerritoryEditForm.tsx b/administration/src/components/Territories/TerritoryEditForm.tsx new file mode 100644 index 0000000..090d424 --- /dev/null +++ b/administration/src/components/Territories/TerritoryEditForm.tsx @@ -0,0 +1,26 @@ +/* eslint-disable */ +import { TextInput, required } from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; +import { checkNamesLength } from '../../utils/checkNamesLength'; + +const TerritoryEditForm = () => { + return ( + + + + + + + + + + + ); +}; + +export default TerritoryEditForm; diff --git a/administration/src/components/Territories/TerritoryForm.tsx b/administration/src/components/Territories/TerritoryForm.tsx new file mode 100644 index 0000000..505043c --- /dev/null +++ b/administration/src/components/Territories/TerritoryForm.tsx @@ -0,0 +1,49 @@ +/* eslint-disable */ +import { + Create, + SimpleForm, + useCreateContext, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; + +import TerritoryCreateForm from './TerritoryCreateForm'; +import TerritoryMessages from '../../utils/Territory/fr.json'; +import { errorFetching } from '../../utils/constant'; + +const TerritoryForm = (props) => { + const { save, record } = useCreateContext(); + + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onFailure = ({ message }): void => { + const result: string = + message !== errorFetching.messageApi + ? TerritoryMessages[message] + : errorFetching.messageToDisplay; + notify(result, 'error'); + }; + + const onSuccess = ({ data }): void => { + notify(`Création du territoire ${data.name} avec succès`, 'success'); + redirect('/territoires'); + refresh(); + }; + + return ( + + + + + + ); +}; +export default TerritoryForm; diff --git a/administration/src/components/Territories/TerritoryList.tsx b/administration/src/components/Territories/TerritoryList.tsx new file mode 100644 index 0000000..8f8e93a --- /dev/null +++ b/administration/src/components/Territories/TerritoryList.tsx @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { ListProps } from '@material-ui/core'; +import { FC } from 'react'; +import { + List, + Datagrid, + TextField, + Filter, + SearchInput, + EditButton, +} from 'react-admin'; + +const TerritoryFilter: FC = (props) => ( + + + +); + +const TerritoryList: FC = (props) => { + return ( + } + sort={{ field: 'name', order: 'ASC' }} + bulkActionButtons={false} + > + + + + + + ); +}; + +export default TerritoryList; diff --git a/administration/src/components/Territories/index.tsx b/administration/src/components/Territories/index.tsx new file mode 100644 index 0000000..f2d96d6 --- /dev/null +++ b/administration/src/components/Territories/index.tsx @@ -0,0 +1,10 @@ +/* eslint-disable */ +import TerritoryForm from './TerritoryForm'; +import TerritoryList from './TerritoryList'; +import TerritoryEdit from './TerritoryEdit'; + +export default { + create: TerritoryForm, + list: TerritoryList, + edit: TerritoryEdit, +}; diff --git a/administration/src/components/common/CustomAddButton.tsx b/administration/src/components/common/CustomAddButton.tsx new file mode 100644 index 0000000..6dd2c27 --- /dev/null +++ b/administration/src/components/common/CustomAddButton.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Button } from '@material-ui/core'; + +const CustomAddButton = (props: { label: string }): JSX.Element => { + const { label } = props; + return ( + + ); +}; +export default CustomAddButton; diff --git a/administration/src/components/common/FinanceurDropDown.tsx b/administration/src/components/common/FinanceurDropDown.tsx new file mode 100644 index 0000000..a4f51fc --- /dev/null +++ b/administration/src/components/common/FinanceurDropDown.tsx @@ -0,0 +1,66 @@ +/* eslint-disable */ +import { required, useNotify, AutocompleteInput } from 'react-admin'; +import { useFormState } from 'react-final-form'; +import { useQuery } from 'react-query'; +import { getFunders } from '../../api/financeurs'; + +import FinanceurMessages from '../../utils/Financeur/fr.json'; + +interface FunderProps { + disabled?: boolean; +} + +export interface Enterprise { + id?: string; + emailFormat?: string[]; + funderType?: string; + name?: string; + hasManualAffiliation?: boolean; +} + +const FinanceurDropDown = ({ disabled }: FunderProps) => { + const notify = useNotify(); + const { values } = useFormState(); + + const { data: funders } = useQuery( + 'funders', + async (): Promise => { + return await getFunders(); + }, + { + onError: () => { + notify(FinanceurMessages['funders.error'], 'error'); + }, + enabled: true, + retry: false, + staleTime: Infinity, + } + ); + + return ( + ({ + id, + name: `${name} (${funderType})`, + emailFormat, + hasManualAffiliation, + }) + ) + } + onInputValueChange={(_, data) => + (values.emailFormat = data?.selectedItem?.emailFormat) && + (values.hasManualAffiliation = data?.selectedItem?.hasManualAffiliation) + } + /> + ); +}; + +export default FinanceurDropDown; diff --git a/administration/src/components/common/TerritoriesDropDown.tsx b/administration/src/components/common/TerritoriesDropDown.tsx new file mode 100644 index 0000000..d15225a --- /dev/null +++ b/administration/src/components/common/TerritoriesDropDown.tsx @@ -0,0 +1,115 @@ +/* eslint-disable */ +import { useEffect, useState, FC } from 'react'; +import { required, useNotify, AutocompleteInput } from 'react-admin'; +import { useFormState } from 'react-final-form'; +import { useQuery } from 'react-query'; +import { getTerritories } from '../../api/territories'; +import { checkNamesLength } from '../../utils/checkNamesLength'; +import { removeWhiteSpace } from '../../utils/helpers'; +import TerritoryMessages from '../../utils/Territory/fr.json'; + +interface TerritoryProps { + canCreate?: boolean; +} + +interface TerritoryOption { + id?: string; + name: string; + label: string; +} + +const TerritoriesDropDown: FC = ({ canCreate = true }) => { + const notify = useNotify(); + const { values } = useFormState(); + + const [territoriesList, setTerritoriesList] = useState([]); + const [selectedOption, setSelectedOption] = useState(null); + + const { data: territories } = useQuery( + 'territories', + async (): Promise => { + return await getTerritories(); + }, + { + onError: () => { + notify(TerritoryMessages['territory.error'], 'error'); + }, + enabled: true, + } + ); + + const checkExistingValue = (value: string): TerritoryOption | undefined => + territoriesList.find( + (choice) => choice?.name?.toLowerCase() === value?.toLowerCase() + ); + + const handleInputChanged = (data: TerritoryOption): void => { + setSelectedOption(data); + }; + + const handleOptionChange = (choice: TerritoryOption): string => + choice?.label ? choice.label : choice?.name; + + const handleCreateOption = (value: string): TerritoryOption => { + if (value) { + value = removeWhiteSpace(value); + const foundValue = checkExistingValue(value); + if (!foundValue) { + const newTerritory = { name: value, label: value }; + setTerritoriesList([...territoriesList, newTerritory]); + return newTerritory; + } + return foundValue; + } + }; + + let createProp = {}; + if (canCreate) { + createProp['onCreate'] = handleCreateOption; + } + + useEffect(() => { + setTerritoriesList( + territories && + territories.map((elt: TerritoryOption) => { + elt.label = `${elt.name} (créé)`; + return elt; + }) + ); + }, [territories]); + + useEffect(() => { + // ID GENERATED WHEN CREATING A NEW ITEM ON LIST CHOICES + if (selectedOption) { + if (selectedOption?.id === '@@ra-create') { + values.territoryName = values?.territory?.name; // TODO: REMOVING DEPRECATED territoryName. + const foundValue = checkExistingValue(values?.territory?.name); + if (foundValue) { + values.territory.id = foundValue?.id; + } + } else { + values.territory = { + id: selectedOption?.id, + name: selectedOption?.name, + }; + values.territoryName = selectedOption?.name; // TODO: REMOVING DEPRECATED territoryName. + } + } + }, [selectedOption, values]); + + return ( + + ); +}; + +export default TerritoriesDropDown; diff --git a/administration/src/components/customTheme/customTheme.tsx b/administration/src/components/customTheme/customTheme.tsx new file mode 100644 index 0000000..f63d775 --- /dev/null +++ b/administration/src/components/customTheme/customTheme.tsx @@ -0,0 +1,18 @@ +import { createTheme } from '@material-ui/core/styles'; +import { defaultTheme } from 'react-admin'; + +const customTheme = createTheme({ + ...defaultTheme, + palette: { + primary: { + main: '#464cd0', + contrastText: '#FFFFFF', + }, + secondary: { + main: '#01bf7d', + contrastText: '#FFFFFF', + }, + }, +}); + +export default customTheme; diff --git a/administration/src/components/styles/DynamicForm.css b/administration/src/components/styles/DynamicForm.css new file mode 100644 index 0000000..0ea219c --- /dev/null +++ b/administration/src/components/styles/DynamicForm.css @@ -0,0 +1,60 @@ +.css-2b097c-container { + z-index: 1000; +} + +.css-1hb7zxy-IndicatorsContainer { + display: none !important; +} + +.css-yk16xz-control { + background-color: hsl(0deg 0% 96%) !important; +} + +.css-g1d714-ValueContainer { + height: 48px !important; +} + +.ra-input-mde { + margin: 8px 0px 4px 0px; +} + +.simpleForm1 .MuiTypography-body1 { + visibility: hidden; +} + +.simpleForm1 { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} +.simpleForm1 > * { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 10px; + padding: 1em !important; + margin: 5px; +} + +.simpleForm1 > li:last-child { + min-width: 700px; + border: none; +} + +.simpleForm1 li { + flex-direction: column; + max-width: 300px; + width: 100%; + height: fit-content; +} + +.simpleForm2 li { + flex-direction: row; + justify-content: flex-start; +} + +.simpleForm2 > li:first-child span { + width: auto; +} + +.simpleForm1 p { + display: none; +} diff --git a/administration/src/components/utilisateurs/CommunauteCheckBox.tsx b/administration/src/components/utilisateurs/CommunauteCheckBox.tsx new file mode 100644 index 0000000..69ba573 --- /dev/null +++ b/administration/src/components/utilisateurs/CommunauteCheckBox.tsx @@ -0,0 +1,64 @@ +/* eslint-disable */ +import { useNotify, CheckboxGroupInput } from 'react-admin'; +import { useFormState } from 'react-final-form'; +import { useQuery } from 'react-query'; + +import CommunautesMessages from '../../utils/Communaute/fr.json'; +import { ROLES } from '../../utils/constant'; +import { getFunderCommunityList } from '../../api/communautes'; + +const CommunauteCheckBox = () => { + const notify = useNotify(); + const { values } = useFormState(); + + const enabled = + !!values && + !!values.funderId && + !!values.roles && + (typeof values.roles === 'string' + ? values.roles.split(' ; ').some((role) => role === ROLES.gestionnaires) + : values.roles.some((role) => role === ROLES.gestionnaires)); + const { data: communities } = useQuery( + `funders/${values.funderId}/communities`, + async (): Promise => { + return await getFunderCommunityList(values.funderId); + }, + { + onError: () => { + notify(CommunautesMessages['communautés.error'], 'error'); + }, + enabled, + staleTime: 300000, + } + ); + + const validateCommunities = (communityIds) => { + const condition = + communityIds && + communityIds.length > 0 && + communities && + communities.length > 0; + + return condition ? undefined : 'Il faut cocher au moins une communauté'; + }; + + if (enabled && communities && communities.length > 0) + return ( + + ); + if (enabled && communities) + return ( + + Aucune communauté n'est créée pour ce financeur. Les droits seront + appliqués pour l'ensemble du périmètre financeur + + ); + return null; +}; + +export default CommunauteCheckBox; diff --git a/administration/src/components/utilisateurs/RolesRadioButtons.tsx b/administration/src/components/utilisateurs/RolesRadioButtons.tsx new file mode 100644 index 0000000..807c0ca --- /dev/null +++ b/administration/src/components/utilisateurs/RolesRadioButtons.tsx @@ -0,0 +1,55 @@ +/* eslint-disable */ +import { useNotify, CheckboxGroupInput } from 'react-admin'; +import { RadioButtonChecked, RadioButtonUnchecked } from '@material-ui/icons'; +import { capitalize } from '@material-ui/core'; +import { useQuery } from 'react-query'; + +import UtilisateursMessages from '../../utils/Utilisateur/fr.json'; +import { getRoles } from '../../api/roles'; + +const RolesRadioButtons = () => { + const notify = useNotify(); + + const { data: roles } = useQuery( + 'roles', + async (): Promise => { + return await getRoles(); + }, + { + onError: () => { + notify(UtilisateursMessages['users.roles.error'], 'error'); + }, + enabled: true, + retry: false, + staleTime: Infinity, + } + ); + + const validateRoles = (roles) => { + return roles && roles.length > 0 + ? undefined + : 'Il faut cocher au moins un rôle'; + }; + + return ( + (typeof v === 'string' ? v.split(' ; ') : v)} + choices={ + roles && + roles.map((role) => ({ + id: role, + name: capitalize(role.replace(/s$/, '')), + })) + } + options={{ + checkedIcon: , + icon: , + }} + validate={[validateRoles]} + /> + ); +}; + +export default RolesRadioButtons; diff --git a/administration/src/components/utilisateurs/UtilisateurCreateForm.tsx b/administration/src/components/utilisateurs/UtilisateurCreateForm.tsx new file mode 100644 index 0000000..7d0bacc --- /dev/null +++ b/administration/src/components/utilisateurs/UtilisateurCreateForm.tsx @@ -0,0 +1,92 @@ +/* eslint-disable */ +import { useEffect } from 'react'; +import { TextInput, required, email, BooleanInput } from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; +import { useFormState } from 'react-final-form'; + +import FinanceurDropDown from '../common/FinanceurDropDown'; +import CompteMessages from '../../utils/Compte/fr.json'; +import CommunauteCheckBox from './CommunauteCheckBox'; +import RolesRadioButtons from './RolesRadioButtons'; +import { checkNamesLength } from '../../utils/checkNamesLength'; + +const UtilisateurCreateForm = (save, record) => { + const { values } = useFormState(); + + useEffect(() => { + !values.hasManualAffiliation + ? delete values?.canReceiveAffiliationMail + : null; + }, [values]); + + const Spacer = () => ; + + const checkPatternEmail = (email) => { + if ( + email && + values.emailFormat && + values.emailFormat.some((elt: string) => email.endsWith(elt)) !== true + ) { + return CompteMessages['email.error.emailFormat']; + } + return undefined; + }; + + const validateEmail = email(); + + return ( + + + + + + + + + + + + + + + + + {values?.hasManualAffiliation && ( + + + + )} + + + + + + + + + + + + ); +}; + +export default UtilisateurCreateForm; diff --git a/administration/src/components/utilisateurs/UtilisateurEdit.tsx b/administration/src/components/utilisateurs/UtilisateurEdit.tsx new file mode 100644 index 0000000..ceb702b --- /dev/null +++ b/administration/src/components/utilisateurs/UtilisateurEdit.tsx @@ -0,0 +1,49 @@ +/* eslint-disable */ +import { FC } from 'react'; +import { + Edit, + EditProps, + SimpleForm, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; +import UtilisateurEditForm from './UtilisateurEditForm'; +import { errorFetching } from '../../utils/constant'; + +const UtilisateurEdit: FC = (props) => { + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onSuccess = () => { + notify(`Le profil de l'utilisateur est modifié`, 'success'); + redirect('/utilisateurs'); + refresh(); + }; + + const onFailure = () => { + notify(errorFetching.messageToDisplay, 'error'); + }; + + return ( + { + delete data.emailFormat; + delete data.hasManualAffiliation; + return data; + }} + {...props} + > + + + + + ); +}; + +export default UtilisateurEdit; diff --git a/administration/src/components/utilisateurs/UtilisateurEditForm.tsx b/administration/src/components/utilisateurs/UtilisateurEditForm.tsx new file mode 100644 index 0000000..c9f5144 --- /dev/null +++ b/administration/src/components/utilisateurs/UtilisateurEditForm.tsx @@ -0,0 +1,71 @@ +/* eslint-disable */ +import { TextInput, required, BooleanInput } from 'react-admin'; +import { CardContent, Box } from '@material-ui/core'; +import { useFormState } from 'react-final-form'; + +import FinanceurDropDown from '../common/FinanceurDropDown'; +import CommunauteCheckBox from './CommunauteCheckBox'; +import RolesRadioButtons from './RolesRadioButtons'; +import { checkNamesLength } from '../../utils/checkNamesLength'; + +import '../styles/DynamicForm.css'; + +const UtilisateurEditForm = () => { + const Spacer = () => ; + + const { values } = useFormState(); + + return ( + + + + + + + + + + + + + + + + + + + + {values?.hasManualAffiliation && ( + + + + )} + + + + + + + + + + + + + ); +}; + +export default UtilisateurEditForm; diff --git a/administration/src/components/utilisateurs/UtilisateurForm.tsx b/administration/src/components/utilisateurs/UtilisateurForm.tsx new file mode 100644 index 0000000..e9d7eb9 --- /dev/null +++ b/administration/src/components/utilisateurs/UtilisateurForm.tsx @@ -0,0 +1,57 @@ +/* eslint-disable */ +import { + Create, + SimpleForm, + useCreateContext, + useNotify, + useRedirect, + useRefresh, +} from 'react-admin'; + +import UtilisateurCreateForm from './UtilisateurCreateForm'; +import UtilisateurMessages from '../../utils/Utilisateur/fr.json'; +import CompteMessages from '../../utils/Compte/fr.json'; +import { errorFetching } from '../../utils/constant'; + +const UtilisateurForm = (props) => { + const { save, record } = useCreateContext(); + + const notify = useNotify(); + const refresh = useRefresh(); + const redirect = useRedirect(); + + const onFailure = ({ message }) => { + const result = + message !== errorFetching.messageApi + ? UtilisateurMessages[message] + ? UtilisateurMessages[message] + : CompteMessages[message] + : errorFetching.messageToDisplay; + notify(result, 'error'); + }; + + const onSuccess = ({ data }) => { + notify(`Création de l'utilisateur ${data.email} avec succès`, 'success'); + redirect('/utilisateurs'); + refresh(); + }; + + return ( + { + delete data.emailFormat; + delete data.hasManualAffiliation; + return data; + }} + > + + + + + ); +}; +export default UtilisateurForm; diff --git a/administration/src/components/utilisateurs/UtilisateurList.tsx b/administration/src/components/utilisateurs/UtilisateurList.tsx new file mode 100644 index 0000000..440f8b3 --- /dev/null +++ b/administration/src/components/utilisateurs/UtilisateurList.tsx @@ -0,0 +1,56 @@ +/* eslint-disable */ +import { FC } from 'react'; +import { + List, + Datagrid, + TextField, + Filter, + SearchInput, + EditButton, + DeleteButton, + BooleanField, +} from 'react-admin'; + +const UsersFilter = (props) => ( + + + +); + +const UtilisateurList: FC = (props) => { + return ( + } + sort={{ field: 'lastName', order: 'ASC' }} + exporter={false} + > + + + + + + + + + + + + + + ); +}; + +export default UtilisateurList; diff --git a/administration/src/components/utilisateurs/index.tsx b/administration/src/components/utilisateurs/index.tsx new file mode 100644 index 0000000..7925dbc --- /dev/null +++ b/administration/src/components/utilisateurs/index.tsx @@ -0,0 +1,10 @@ +/* eslint-disable */ +import UtilisateurForm from './UtilisateurForm'; +import UtilisateurList from './UtilisateurList'; +import UtilisateurEdit from './UtilisateurEdit'; + +export default { + create: UtilisateurForm, + list: UtilisateurList, + edit: UtilisateurEdit, +}; diff --git a/administration/src/index.css b/administration/src/index.css new file mode 100644 index 0000000..ec2585e --- /dev/null +++ b/administration/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/administration/src/index.tsx b/administration/src/index.tsx new file mode 100644 index 0000000..395b749 --- /dev/null +++ b/administration/src/index.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/administration/src/modules/Auth/authProvider.ts b/administration/src/modules/Auth/authProvider.ts new file mode 100644 index 0000000..7162610 --- /dev/null +++ b/administration/src/modules/Auth/authProvider.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +const AuthProvider = async () => { + return Promise.resolve(); +}; + +export default AuthProvider; diff --git a/administration/src/react-app-env.d.ts b/administration/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/administration/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/administration/src/setupTests.ts b/administration/src/setupTests.ts new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/administration/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/administration/src/users.tsx b/administration/src/users.tsx new file mode 100644 index 0000000..b3801d3 --- /dev/null +++ b/administration/src/users.tsx @@ -0,0 +1,29 @@ +/* eslint-disable */ +import * as React from 'react'; +import { Theme, useMediaQuery } from '@material-ui/core'; +import { SimpleList, List, Datagrid, EmailField, TextField } from 'react-admin'; + +const UserList = (props) => { + const isSmall = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); + + return ( + + {isSmall ? ( + record.name} + secondaryText={(record) => record.username} + tertiaryText={(record) => record.email} + /> + ) : ( + + + + + + + )} + + ); +}; + +export default UserList; diff --git a/administration/src/utils/Aide/formHelper.ts b/administration/src/utils/Aide/formHelper.ts new file mode 100644 index 0000000..28e3ad4 --- /dev/null +++ b/administration/src/utils/Aide/formHelper.ts @@ -0,0 +1,7 @@ +/* eslint-disable */ +import { regex } from 'react-admin'; + +export const validateUrl = regex( + /^(?:http(s)?:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:%/?#[\]@!\$&'\(\)\*\+,;=.]+$/, + 'Entrez une url valide' +); diff --git a/administration/src/utils/Aide/fr.json b/administration/src/utils/Aide/fr.json new file mode 100644 index 0000000..adb0658 --- /dev/null +++ b/administration/src/utils/Aide/fr.json @@ -0,0 +1,13 @@ +{ + "incentives.error.required": "Attention, ce champ est obligatoire", + "incentives.error.title.minLength": "Le nom de l'aide doit faire plus de 3 caractères", + "incentives.error.validityDate.format": "La durée de validité doit être au format JJ/MM/AAAA", + "incentives.error.validityDate.minDate": "La durée de validité doit être supérieure à la date d'aujourd'hui", + "incentives.error.fundername.enterprise.notExist": "L'Entrepise renseignée n'existe pas", + "incentives.error.title.alreadyUsedForFunder": "Ce titre d'aide est déjà utilisé pour ce financeur", + "aides.edit.success": "L'aide \"{aidTitle}\" a été modifiée avec succès", + "aides.edit.title": "Modification d'une aide à la mobilité", + "aides.create.success": "L'aide \"{aidTitle}\" a été créée avec succès", + "aides.create.title": "Création d'une aide à la mobilité", + "territory.name.error.unique": "Attention, le nom de territoire est déjà utilisé. Veuillez le vérifier sur votre liste." +} diff --git a/administration/src/utils/Aide/fr.json.d.ts b/administration/src/utils/Aide/fr.json.d.ts new file mode 100644 index 0000000..e643e33 --- /dev/null +++ b/administration/src/utils/Aide/fr.json.d.ts @@ -0,0 +1,14 @@ +interface Fr { + 'incentives.error.required': string; + 'incentives.error.title.minLength': string; + 'incentives.error.validityDate.format': string; + 'incentives.error.validityDate.minDate': string; + 'incentives.error.fundername.enterprise.notExist': string; + 'incentives.error.title.alreadyUsedForFunder': string; + 'aides.edit.success': string; + 'aides.edit.title': string; + 'aides.create.success': string; + 'aides.create.title': string; +} +declare const value: Fr; +export = value; diff --git a/administration/src/utils/Aide/validityDateRules.ts b/administration/src/utils/Aide/validityDateRules.ts new file mode 100644 index 0000000..f3b32e8 --- /dev/null +++ b/administration/src/utils/Aide/validityDateRules.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +import { convertDateToString } from '../convertDateToString'; +import AidesMessages from './fr.json'; + +// display only date to select to input validityDate +export const startDateMin = () => { + var someDate = new Date(); + return convertDateToString( + new Date(someDate.setDate(someDate.getDate() + 1)) + ); +}; + +// add validation to input validityDate +export const dateMinValidation = (value) => { + if (value < startDateMin()) { + return AidesMessages['incentives.error.validityDate.minDate']; + } + return undefined; +}; diff --git a/administration/src/utils/Collectivite/fr.json b/administration/src/utils/Collectivite/fr.json new file mode 100644 index 0000000..e4cd29a --- /dev/null +++ b/administration/src/utils/Collectivite/fr.json @@ -0,0 +1,5 @@ +{ + "collectivities.error.required": "Attention, ce champ est obligatoire", + "collectivities.error.name.required": "Merci de renseigner le nom de la collectivité", + "collectivités.error.name.unique": "Attention, ce nom de collectivité est déjà utilisé" +} diff --git a/administration/src/utils/Collectivite/fr.json.d.ts b/administration/src/utils/Collectivite/fr.json.d.ts new file mode 100644 index 0000000..ffe626e --- /dev/null +++ b/administration/src/utils/Collectivite/fr.json.d.ts @@ -0,0 +1,12 @@ +interface Fr { + 'collectivities.error.required': string; + 'collectivities.error.name.required': string; + 'collectivités.error.name.unique': string; + 'collectivities.error.firstName': string; + 'collectivities.error.lastName': string; + 'collectivities.error.email': string; + 'collectivities.error.email.format': string; + 'collectivities.error.email.unique': string; +} +declare const value: Fr; +export = value; diff --git a/administration/src/utils/Communaute/fr.json b/administration/src/utils/Communaute/fr.json new file mode 100644 index 0000000..cc4425f --- /dev/null +++ b/administration/src/utils/Communaute/fr.json @@ -0,0 +1,5 @@ +{ + "communautés.error": "Problème durant la récupération des communautés", + "communities.error.name.required": "Merci de renseigner le nom de la communauté", + "communities.error.name.unique": "Attention, ce nom de communauté est déjà utilisé" +} diff --git a/administration/src/utils/Compte/fr.json b/administration/src/utils/Compte/fr.json new file mode 100644 index 0000000..8c1a7f2 --- /dev/null +++ b/administration/src/utils/Compte/fr.json @@ -0,0 +1,5 @@ +{ + "email.error.unique": "Attention, cette adresse mail est déjà utilisée par une autre personne", + "email.error.format": "Attention, le format de votre email est incorrect", + "email.error.emailFormat": "Attention, le format de votre email n'est pas adéquat avec le format d'adresse de votre employeur" +} diff --git a/administration/src/utils/Entreprise/fr.json b/administration/src/utils/Entreprise/fr.json new file mode 100644 index 0000000..cacb5f2 --- /dev/null +++ b/administration/src/utils/Entreprise/fr.json @@ -0,0 +1,6 @@ +{ + "enterprises.error.required": "Attention, ce champ est obligatoire", + "enterprises.error.name.required": "Merci de renseigner le nom d'entreprise", + "enterprises.error.name.unique": "Attention, ce nom d'entreprise est déjà utilisé", + "enterprises.error.emailFormat.type": "Attention, un format d'email est invalide (@exemple.com)" +} diff --git a/administration/src/utils/Entreprise/fr.json.d.ts b/administration/src/utils/Entreprise/fr.json.d.ts new file mode 100644 index 0000000..2e9222f --- /dev/null +++ b/administration/src/utils/Entreprise/fr.json.d.ts @@ -0,0 +1,15 @@ +interface Fr { + 'enterprises.error.required': string; + 'enterprises.error.name.required': string; + 'enterprises.error.name.unique': string; + 'enterprises.error.email.required': string; + 'enterprises.error.firstName': string; + 'enterprises.error.lastName': string; + 'enterprises.error.email': string; + 'enterprises.error.email.format': string; + 'enterprises.error.emailFormat.type': string; + 'enterprises.error.email.unique': string; +} + +declare const value: Fr; +export = value; diff --git a/administration/src/utils/Financeur/fr.json b/administration/src/utils/Financeur/fr.json new file mode 100644 index 0000000..32c653e --- /dev/null +++ b/administration/src/utils/Financeur/fr.json @@ -0,0 +1,4 @@ +{ + "funders.error": "Problème durant la récupération des financeurs", + "funders.error.clients.list": "Problème durant la récupération des clients" +} diff --git a/administration/src/utils/Territory/fr.json b/administration/src/utils/Territory/fr.json new file mode 100644 index 0000000..0dad841 --- /dev/null +++ b/administration/src/utils/Territory/fr.json @@ -0,0 +1,4 @@ +{ + "territory.name.error.unique": "Attention, ce nom de territoire est déjà utilisé. Veuillez le vérifier sur votre liste.", + "territory.error": "Problème durant la récupération des territoires" +} diff --git a/administration/src/utils/Utilisateur/fr.json b/administration/src/utils/Utilisateur/fr.json new file mode 100644 index 0000000..489b6e4 --- /dev/null +++ b/administration/src/utils/Utilisateur/fr.json @@ -0,0 +1,7 @@ +{ + "citizens.error.required": "Attention, ce champ est obligatoire", + "users.roles.error": "Problème durant la récupération des roles", + "users.error.roles.mismatch": "Les roles selectionnés ne sont pas adéquats", + "users.error.communities.mismatch": "Les communautés selectionnées ne sont pas adéquates", + "email.error.emailFormat": "L'email ne respecte pas les formats de l'entreprise" +} diff --git a/administration/src/utils/checkNamesLength.ts b/administration/src/utils/checkNamesLength.ts new file mode 100644 index 0000000..44f9b11 --- /dev/null +++ b/administration/src/utils/checkNamesLength.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +export const checkNamesLength = (name) => { + return name && name.length >= 2 + ? undefined + : 'Ce champ doit faire au moins 2 caractères'; +}; diff --git a/administration/src/utils/constant.ts b/administration/src/utils/constant.ts new file mode 100644 index 0000000..dc0d398 --- /dev/null +++ b/administration/src/utils/constant.ts @@ -0,0 +1,73 @@ +/* eslint-disable */ + +export const API_VERSION = 'v1'; + +export async function URL_API() { + const response = await (await fetch('/keycloak.json')).json(); + let API_FQDN = response.apiConfig; + return API_FQDN + API_VERSION; +} + +export const TRANSPORT_CHOICE = [ + { id: 'transportsCommun', name: 'Transports en commun' }, + { id: 'velo', name: 'Vélo' }, + { id: 'voiture', name: 'Voiture' }, + { id: 'libreService', name: '2 ou 3 roues en libre-service' }, + { id: 'electrique', name: '2 ou 3 roues électrique' }, + { id: 'autopartage', name: 'Autopartage' }, + { id: 'covoiturage', name: 'Covoiturage' }, +]; + +export const NATIONAL_INCENTIVE = 'AideNationale'; +export const TERRITORY_INCENTIVE = 'AideTerritoire'; +export const EMPLOYER_INCENTIVE = 'AideEmployeur'; + +export const INCENTIVE_TYPE_CHOICE = [ + { id: NATIONAL_INCENTIVE, name: 'Aide nationale' }, + { id: TERRITORY_INCENTIVE, name: 'Aide de mon territoire' }, + { id: EMPLOYER_INCENTIVE, name: 'Aide de mon employeur' }, +]; + +export const MAPPING_FUNDER_TYPE = { + [NATIONAL_INCENTIVE]: 'nationale', + [TERRITORY_INCENTIVE]: 'collectivité', + [EMPLOYER_INCENTIVE]: 'entreprise', +}; + +export const PROOF_CHOICE = [ + { id: 'identite', name: "Pièce d'Identité" }, + { + id: 'justificatifDomicile', + name: 'Justificatif de domicile de moins de 3 mois', + }, + { id: 'certificatMedical', name: 'Certificat médical' }, + { id: 'rib', name: 'RIB' }, + { id: 'attestationHonneur', name: "Attestation sur l'Honneur" }, + { id: 'factureAchat', name: "Facture d'achat" }, + { id: 'certificatImmatriculation', name: "Certificat d'immatriculation" }, + { id: 'justificatifEmancipation', name: "Justificatif d'émancipation" }, + { id: 'impositionRevenu', name: "Dernier avis d'imposition sur le revenu" }, + { + id: 'situationPoleEmploi', + name: 'Dernier relevé de situation Pôle Emploi', + }, + { id: 'certificatScolarite', name: 'Certificat de Scolarité' }, +]; + +export const INPUT_FORMAT_CHOICE = [ + { id: 'Texte', name: 'Texte' }, + { id: 'Date', name: 'Date' }, + { id: 'Numerique', name: 'Numérique' }, + { id: 'listeChoix', name: 'Sélection parmi une liste de choix' }, +]; + +export const errorFetching = { + messageApi: 'Failed to fetch', + messageToDisplay: + "Il semble qu'il y ait un problème, votre requête n'a pas pu aboutir. Merci de réessayer ultérieurement.", +}; + +export const ROLES = { + gestionnaires: 'gestionnaires', + superviseurs: 'superviseurs', +}; diff --git a/administration/src/utils/convertDateToString.ts b/administration/src/utils/convertDateToString.ts new file mode 100644 index 0000000..34f1056 --- /dev/null +++ b/administration/src/utils/convertDateToString.ts @@ -0,0 +1,40 @@ +/* eslint-disable */ +/** + * Convert Date object to String + * + * @param {Date} value value to convert + * @returns {String} A standardized date (yyyy-MM-dd), to be passed to an + */ +export const convertDateToString = (value: Date) => { + if (!(value instanceof Date) || isNaN(value.getDate())) return ''; + const pad = '00'; + const yyyy = value.getFullYear().toString(); + const MM = (value.getMonth() + 1).toString(); + const dd = value.getDate().toString(); + return `${yyyy}-${(pad + MM).slice(-2)}-${(pad + dd).slice(-2)}`; +}; + +export const getDate = (value: string | Date) => { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + // null, undefined and empty string values should not go through dateFormatter + // otherwise, it returns undefined and will make the input an uncontrolled one. + if (value == null || value === '') { + return undefined; + } + + if (value instanceof Date) { + return convertDateToString(value); + } + + // valid dates should not be converted + if (dateRegex.test(value)) { + return value; + } + return convertDateToString(new Date(value)); +}; + +export const startDateMin = () => { + return convertDateToString( + new Date(new Date().setDate(new Date().getDate() + 1)) + ); +}; diff --git a/administration/src/utils/helpers.ts b/administration/src/utils/helpers.ts new file mode 100644 index 0000000..c89933d --- /dev/null +++ b/administration/src/utils/helpers.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +export const isEmailFormatValid = (email) => { + const regex = new RegExp( + /^@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + ); + return email.match(regex); +}; + +/** + * Returns the string with white spaces removed + */ +export const removeWhiteSpace = (word: string): string => { + /** + * Regex for removing white spaces. + * Exemple : " Removing white spaces " returns "Removing white spaces". + */ + const removeSpacesRegex: RegExp = new RegExp('^\\s+|\\s+$|\\s+(?=\\s)', 'g'); + const newWord: string = word.replace(removeSpacesRegex, ''); + return newWord; +}; diff --git a/administration/src/utils/httpHeaders.ts b/administration/src/utils/httpHeaders.ts new file mode 100644 index 0000000..50b39cf --- /dev/null +++ b/administration/src/utils/httpHeaders.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +export const getAuthHeader = (): any => { + try { + let authHeader = null; + const token = localStorage.getItem('token'); + + if (token != null) { + authHeader = { Authorization: `Bearer ${token}` }; + } + return authHeader; + } catch (error) { + throw new Error(error); + } +}; diff --git a/administration/tsconfig.json b/administration/tsconfig.json new file mode 100644 index 0000000..1785ea1 --- /dev/null +++ b/administration/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["./src/**/*"] +} diff --git a/analytics/.gitignore b/analytics/.gitignore new file mode 100644 index 0000000..4cf73b6 --- /dev/null +++ b/analytics/.gitignore @@ -0,0 +1,2 @@ +# MCM temporary build assets +init.sql \ No newline at end of file diff --git a/analytics/.gitlab-ci.yml b/analytics/.gitlab-ci.yml new file mode 100644 index 0000000..310455e --- /dev/null +++ b/analytics/.gitlab-ci.yml @@ -0,0 +1,19 @@ +include: + - local: "analytics/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "analytics/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + +.analytics-base: + variables: + MODULE_NAME: analytics + MODULE_PATH: ${MODULE_NAME} + MATOMO_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/bitnami/matomo:4.8.0 + NEXUS_IMAGE_MARIADB: ${NEXUS_DOCKER_REPOSITORY_URL}/mariadb:10.6 + only: + changes: + - "*" + - "commons/**/*" + - "analytics/**/*" diff --git a/analytics/.gitlab-ci/preview.yml b/analytics/.gitlab-ci/preview.yml new file mode 100644 index 0000000..b53146e --- /dev/null +++ b/analytics/.gitlab-ci/preview.yml @@ -0,0 +1,12 @@ +analytics_preview_deploy: + extends: + - .preview-deploy-job + - .analytics-base + - .manual + environment: + on_stop: analytics_preview_cleanup + +analytics_preview_cleanup: + extends: + - .commons_preview_cleanup + - .analytics-base diff --git a/analytics/.gitlab-ci/testing.yml b/analytics/.gitlab-ci/testing.yml new file mode 100644 index 0000000..31acfb6 --- /dev/null +++ b/analytics/.gitlab-ci/testing.yml @@ -0,0 +1,4 @@ +analytics_testing_deploy: + extends: + - .testing-deploy-job + - .analytics-base diff --git a/analytics/Chart.yaml b/analytics/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/analytics/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/analytics/README.md b/analytics/README.md new file mode 100644 index 0000000..09ad9f0 --- /dev/null +++ b/analytics/README.md @@ -0,0 +1,33 @@ +# Description + +Le service analytics se base sur la brique logicielle **[Matomo](https://matomo.org/matomo-analytics-the-google-analytics-alternative-that-protects-your-data-variation/)** + +Elle nous permet de collecter les données utilisateur sur certaines pages tout en étant RGPD-compliant et ainsi de remonter des métriques d'audiences à des administrateurs fonctionnels. + +# Installation en local + +Pas d'installation prévue en local pour ce service + +## URL / Port + +Pas d'installation prévue en local pour ce service + +# Précisions pipelines + +## Preview + +Pas de précisions nécéssaires pour ce service + +## Testing + +Sur cet environnement, la bdd maria est hébergée sur Azure. Le paramétrage de la bdd est donc à faire en amont. + +# Relation avec les autres services + +Comme présenté dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png), les informations désignées pour le tracking sont envoyées à analytics en continu suite à la fréquentation et l'utilisation des services _website_ de la plateforme. + +Seulement certaines actions & pages sont trackées au niveau de website et KC. + +# Tests Unitaires + +Pas de tests unitaires nécéssaires pour ce service diff --git a/analytics/analytics-testing-values.yaml b/analytics/analytics-testing-values.yaml new file mode 100644 index 0000000..772906c --- /dev/null +++ b/analytics/analytics-testing-values.yaml @@ -0,0 +1,151 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: analytics + name: analytics + spec: + ports: + - name: "8082" + port: 8082 + targetPort: 8080 + selector: + io.kompose.service: analytics + type: ClusterIP + status: + loadBalancer: {} + +persistentVolumeClaim: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: matomo-data + name: matomo-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi + status: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: analytics + name: analytics + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: analytics + strategy: + type: Recreate + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/maria-matomo-nw: "true" + io.kompose.network/web-nw: "true" + io.kompose.service: analytics + spec: + containers: + env: + MATOMO_DATABASE_HOST: ${TESTING_MARIADB_SERVICE_NAME} + MATOMO_DATABASE_NAME: matomo_db + MATOMO_DATABASE_PASSWORD: ${TESTING_ANALYTICS_DB_DEV_PASSWORD} + MATOMO_DATABASE_PORT_NUMBER: 3306 + MATOMO_DATABASE_USER: ${TESTING_ANALYTICS_DB_DEV_USER} + MATOMO_EMAIL: ${TESTING_ANALYTICS_SUPER_EMAIL} + MATOMO_PASSWORD: ${TESTING_ANALYTICS_SUPER_PASSWORD} + MATOMO_USERNAME: ${TESTING_ANALYTICS_SUPER_USER} + MATOMO_WEBSITE_HOST: ${WEBSITE_FQDN} + MATOMO_WEBSITE_NAME: ${WEBSITE_FQDN} + image: ${MATOMO_IMAGE_NAME} + name: analytics + ports: + - containerPort: 8080 + resources: {} + volumeMounts: + - mountPath: /bitnami/matomo + name: matomo-data + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - name: matomo-data + persistentVolumeClaim: + claimName: matomo-data + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: analytics + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${MATOMO_FQDN}`) + middlewares: + - name: analytics-headers + services: + - kind: Service + name: analytics + port: 8082 + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: analytics-headers + spec: + headers: + customRequestHeaders: + X-Forwarded-Proto: https diff --git a/analytics/kompose.yml b/analytics/kompose.yml new file mode 100644 index 0000000..54be8c8 --- /dev/null +++ b/analytics/kompose.yml @@ -0,0 +1,56 @@ +version: "3" + +services: + mariadb: + image: ${NEXUS_IMAGE_MARIADB} + command: --max_allowed_packet=67108864 # Set max_allowed_packet to 64MB (default value is 16MB and Matomo need of 64MB min for performed) + environment: + - MYSQL_USER=${ANALYTICS_DB_DEV_USER} + - MYSQL_PASSWORD=${ANALYTICS_DB_DEV_PASSWORD} + - MYSQL_DATABASE=matomo_db + - MYSQL_ROOT_PASSWORD=${ANALYTICS_DB_ROOT_PASSWORD} + volumes: + - mariadb_data:/var/lib/mysql + networks: + - maria-matomo-nw + ports: + - "3006:3306" + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" + - "traefik.enable=false" + # - "kompose.volume.size=500Mi" + + analytics: + image: ${MATOMO_IMAGE_NAME} + environment: + - MATOMO_USERNAME=${ANALYTICS_SUPER_USER} + - MATOMO_PASSWORD=${ANALYTICS_SUPER_PASSWORD} + - MATOMO_EMAIL=${ANALYTICS_SUPER_EMAIL} + - MATOMO_DATABASE_PORT_NUMBER=3006 + - MATOMO_DATABASE_HOST=mariadb + - MATOMO_DATABASE_USER=${ANALYTICS_DB_DEV_USER} + - MATOMO_DATABASE_PASSWORD=${ANALYTICS_DB_DEV_PASSWORD} + - MATOMO_DATABASE_NAME=matomo_db + - MATOMO_WEBSITE_NAME=${WEBSITE_FQDN} + - MATOMO_WEBSITE_HOST=${WEBSITE_FQDN} + volumes: + - matomo_data:/bitnami/matomo + depends_on: + - mariadb + networks: + - maria-matomo-nw + - web-nw + ports: + - "8082:8080" + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" + +volumes: + mariadb_data: + matomo_data: + +networks: + web-nw: + maria-matomo-nw: diff --git a/analytics/overlays/analytics-certificate.yml b/analytics/overlays/analytics-certificate.yml new file mode 100644 index 0000000..7777315 --- /dev/null +++ b/analytics/overlays/analytics-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: analytics-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/analytics/overlays/analytics-ingressroute.yml b/analytics/overlays/analytics-ingressroute.yml new file mode 100644 index 0000000..5829e7e --- /dev/null +++ b/analytics/overlays/analytics-ingressroute.yml @@ -0,0 +1,26 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: analytics + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${MATOMO_FQDN}`) + kind: Rule + middlewares: + - name: analytics-headers + services: + - kind: Service + name: analytics + port: 8082 + # tls: + # secretName: ${SECRET_NAME} # analytics-tls # cert-dev + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - "*.preview.${BASE_DOMAIN}" + # - "*.testing.${BASE_DOMAIN}" diff --git a/analytics/overlays/analytics-middleware.yml b/analytics/overlays/analytics-middleware.yml new file mode 100644 index 0000000..5bcb66e --- /dev/null +++ b/analytics/overlays/analytics-middleware.yml @@ -0,0 +1,8 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: analytics-headers +spec: + headers: + customRequestHeaders: + X-Forwarded-Proto: "https" diff --git a/analytics/overlays/analytics_configmap.yml b/analytics/overlays/analytics_configmap.yml new file mode 100644 index 0000000..0281745 --- /dev/null +++ b/analytics/overlays/analytics_configmap.yml @@ -0,0 +1,18 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mariadb +spec: + template: + spec: + containers: + - name: mariadb + volumeMounts: + - name: dump-db + mountPath: /docker-entrypoint-initdb.d/mcm-matomo-dump.sql + securityContext: + fsGroup: 1000 + volumes: + - name: dump-db + configMap: + name: analytics-dump-db diff --git a/analytics/overlays/analytics_deployment_set_fsgroup.yml b/analytics/overlays/analytics_deployment_set_fsgroup.yml new file mode 100644 index 0000000..10b471b --- /dev/null +++ b/analytics/overlays/analytics_deployment_set_fsgroup.yml @@ -0,0 +1,9 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: analytics +spec: + template: + spec: + securityContext: + fsGroup: 1000 diff --git a/analytics/overlays/kustomization.yaml b/analytics/overlays/kustomization.yaml new file mode 100644 index 0000000..9066fb4 --- /dev/null +++ b/analytics/overlays/kustomization.yaml @@ -0,0 +1,19 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - analytics-ingressroute.yml + - analytics-middleware.yml + # - analytics-certificate.yml + +patchesStrategicMerge: + - analytics_deployment_set_fsgroup.yml + - web_nw_networkpolicy_namespaceselector.yml +# - analytics_configmap.yml + +# configMapGenerator: +# - name: analytics-dump-db +# files: +# - config/mcm-matomo-dump.sql diff --git a/analytics/overlays/web_nw_networkpolicy_namespaceselector.yml b/analytics/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..53e6193 --- /dev/null +++ b/analytics/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" diff --git a/antivirus/.gitlab-ci.yml b/antivirus/.gitlab-ci.yml new file mode 100644 index 0000000..12f5cad --- /dev/null +++ b/antivirus/.gitlab-ci.yml @@ -0,0 +1,18 @@ +include: + - local: "antivirus/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "antivirus/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + +.antivirus-base: + variables: + MODULE_NAME: antivirus + MODULE_PATH: ${MODULE_NAME} + ANTIVIRUS_IMAGE_NAME: ${NEXUS_DOCKER_REGISTRY_URL}/clamav/clamav:stable + only: + changes: + - "*" + - "commons/**/*" + - "antivirus/**/*" diff --git a/antivirus/.gitlab-ci/preview.yml b/antivirus/.gitlab-ci/preview.yml new file mode 100644 index 0000000..59ba008 --- /dev/null +++ b/antivirus/.gitlab-ci/preview.yml @@ -0,0 +1,13 @@ +antivirus_preview_deploy: + extends: + - .preview-deploy-job + - .antivirus-base + - .only-master + environment: + on_stop: antivirus_preview_cleanup + +antivirus_preview_cleanup: + extends: + - .commons_preview_cleanup + - .antivirus-base + - .only-master diff --git a/antivirus/.gitlab-ci/testing.yml b/antivirus/.gitlab-ci/testing.yml new file mode 100644 index 0000000..0df42ab --- /dev/null +++ b/antivirus/.gitlab-ci/testing.yml @@ -0,0 +1,4 @@ +antivirus_testing_deploy: + extends: + - .testing-deploy-job + - .antivirus-base diff --git a/antivirus/Chart.yaml b/antivirus/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/antivirus/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/antivirus/README.md b/antivirus/README.md new file mode 100644 index 0000000..06bfa0e --- /dev/null +++ b/antivirus/README.md @@ -0,0 +1,41 @@ +# Description + +Le service antivirus se base sur la brique logicielle **[Clamav](https://www.clamav.net/)** + +Elle permet de scanner les fichiers que moB peut recevoir lors de la souscription à une aide et ainsi de ne pas autoriser l'upload de fichiers vérolés dans le service de stockage. + +C'est la fonctionnalité INSTREAM de Clamav qui est utilisée pour scanner les fichiers. + +# Installation en local +```sh +docker run -d --name clamav -p 3310:3310 clamav/clamav:stable +``` +## URL / Port +- URL : localhost +- Port : 3310 + +# Précisions pipelines + +## Preview + +Pas de précisions nécéssaires pour ce service + +## Testing + +Pas de précisions nécéssaires pour ce service + +# Relation avec les autres services + +Comme présenté dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png), l'api effectue une requête TCP vers l'antivirus pour chaque fichier uploadé pour les souscriptions. + +L'api effectue une requête TCP via une fonction de scanStream permettant d'analyser le stream binaire des fichiers envoyés. Ainsi, ils ne sont pas stockés (vérolé ou non) dans l'antivirus. + +L'antivirus renvoie alors une réponse à l'api en précisant si le fichier est vérolé ou non. + +**Bilan des relations:** + +- Requête TCP de l'api vers l'antivirus + +# Tests Unitaires + +Pas de tests unitaires nécéssaires pour ce service diff --git a/antivirus/antivirus-testing-values.yaml b/antivirus/antivirus-testing-values.yaml new file mode 100644 index 0000000..8c95542 --- /dev/null +++ b/antivirus/antivirus-testing-values.yaml @@ -0,0 +1,65 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kompose.volume.size: 200Mi + creationTimestamp: null + labels: + io.kompose.service: clamav + name: clamav + spec: + ports: + - name: "3310" + port: 3310 + targetPort: 3310 + selector: + io.kompose.service: clamav + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kompose.volume.size: 200Mi + creationTimestamp: null + labels: + io.kompose.service: clamav + name: clamav + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: clamav + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.volume.size: 200Mi + kompose.service.type: clusterip + creationTimestamp: null + labels: + io.kompose.service: clamav + spec: + containers: + image: ${ANTIVIRUS_IMAGE_NAME} + name: clamav + ports: + - containerPort: 3310 + resources: + requests: + memory: 4Gi + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + status: {} \ No newline at end of file diff --git a/antivirus/kompose.yml b/antivirus/kompose.yml new file mode 100644 index 0000000..2783332 --- /dev/null +++ b/antivirus/kompose.yml @@ -0,0 +1,10 @@ +version: "3" + +services: + clamav: + image: ${ANTIVIRUS_IMAGE_NAME} + ports: + - "3310" + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..eb357c0 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +/dist +# Cache used by TypeScript's incremental build +*.tsbuildinfo +// fix \ No newline at end of file diff --git a/api/.eslintignore b/api/.eslintignore new file mode 100644 index 0000000..7b22c4a --- /dev/null +++ b/api/.eslintignore @@ -0,0 +1,7 @@ +mongo/ +node_modules/ +dist/ +coverage/ +.eslintrc.js +databaseConfig/*.js +openapi-maas.js diff --git a/api/.eslintrc.js b/api/.eslintrc.js new file mode 100644 index 0000000..b4ae679 --- /dev/null +++ b/api/.eslintrc.js @@ -0,0 +1,103 @@ +module.exports = { + extends: '@loopback/eslint-config', + env: { + node: true, + mocha: true, + es6: true, + }, + plugins: ['mocha'], + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', // fix + }, + overrides: [ + { + files: ['**/*.js', '**/*.ts'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', + }, + }, + ], + rules: { + 'comma-dangle': ['error', 'always-multiline'], + 'no-cond-assign': 'error', + 'no-console': 'off', + 'no-unused-expressions': 'error', + 'no-const-assign': 'error', + 'array-bracket-spacing': ['error', 'never'], + 'block-spacing': ['error', 'always'], + 'brace-style': ['error', '1tbs', {allowSingleLine: true}], + camelcase: 'off', + 'no-unused-expressions': 'off', + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-inferrable-types': 'off', + 'comma-spacing': ['error', {before: false, after: true}], + 'comma-style': ['error', 'last'], + 'computed-property-spacing': ['error', 'never'], + 'eol-last': ['error', 'unix'], + 'func-names': 0, + 'func-call-spacing': ['error', 'never'], + 'function-paren-newline': 'off', + 'key-spacing': ['error', {beforeColon: false, afterColon: true, mode: 'strict'}], + 'max-len': [ + 'error', + 110, + 8, + { + ignoreComments: true, + ignoreUrls: true, + ignorePattern: '^\\s*var\\s.+=\\s*require\\s*\\(', + }, + ], + 'mocha/handle-done-callback': 'error', + 'mocha/no-exclusive-tests': 'error', + 'mocha/no-identical-title': 'error', + 'mocha/no-nested-tests': 'error', + 'no-array-constructor': 2, + 'no-extra-semi': 'error', + 'no-multi-spaces': 'error', + 'no-multiple-empty-lines': ['error', {max: 1}], + 'no-redeclare': ['error'], + 'no-trailing-spaces': 2, + 'no-undef': 'error', + 'no-var': 'error', + 'object-curly-spacing': ['error', 'never'], + 'one-var': [ + 'error', + { + initialized: 'never', + uninitialized: 'always', + }, + ], + 'operator-linebreak': 'off', + 'padded-blocks': ['error', 'never'], + 'prefer-const': 'error', + 'semi-spacing': ['error', {before: false, after: true}], + semi: ['error', 'always'], + 'space-before-blocks': ['error', 'always'], + 'space-before-function-paren': 'off', + 'space-in-parens': ['error', 'never'], + 'space-infix-ops': ['error', {int32Hint: false}], + 'spaced-comment': [ + 'error', + 'always', + { + line: { + markers: ['/'], + exceptions: ['-'], + }, + block: { + balanced: true, + markers: ['!'], + exceptions: ['*'], + }, + }, + ], + strict: ['error', 'global'], + }, +}; diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..33ca215 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,68 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.bat +.env.mcm + +# Transpiled JavaScript files from Typescript +/dist + +# Cache used by TypeScript's incremental build +*.tsbuildinfo + +.scannerwork \ No newline at end of file diff --git a/api/.gitlab-ci.yml b/api/.gitlab-ci.yml new file mode 100644 index 0000000..1f3651f --- /dev/null +++ b/api/.gitlab-ci.yml @@ -0,0 +1,79 @@ +include: + - local: 'api/.gitlab-ci/preview.yml' + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: 'api/.gitlab-ci/testing.yml' + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + - local: 'api/.gitlab-ci/helm.yml' + rules: + - if: $CI_PIPELINE_SOURCE == "trigger" + +# Initialisation of specifique api variable +.api-base: + variables: + MODULE_NAME: api + MODULE_PATH: ${MODULE_NAME} + MONGO_SERVICE_NAME: mongo + NEXUS_IMAGE_MONGO: ${NEXUS_DOCKER_REPOSITORY_URL}/mongo:5.0.7 + API_IMAGE_NAME: ${REGISTRY_BASE_NAME}/${MODULE_NAME}:${IMAGE_TAG_NAME} + MONGO_IMAGE_NAME: ${REGISTRY_BASE_NAME}/mongo:${IMAGE_TAG_NAME} + only: + changes: + - '*' + - 'commons/**/*' + - 'api/**/*' + +api_build: + extends: + - .build-job + - .api-base + script: + - | + yarn install + cache: + key: ${MODULE_NAME}-${CI_COMMIT_REF_SLUG} + paths: + - ${MODULE_PATH}/node_modules/ + - ${MODULE_PATH}/yarn.lock + artifacts: + paths: + - ${MODULE_PATH}/node_modules/ + - ${MODULE_PATH}/yarn.lock + expire_in: 5 days + +.api_documentation_script: + script: + - | + yarn add curl + echo "this is api fqdn = https://${API_FQDN}" + export OPENAPI_MAAS=https://${API_FQDN}/openapi.json + echo "$(curl --insecure $OPENAPI_MAAS)" > openapi.json + chmod u+x api/openapi-maas.js + node ./api/openapi-maas.js + +.api-documentation-job: + extends: + - .api-base + - .except-all + - .manual + - .only-branches + - .no-dependencies + stage: utils + image: ${NEXUS_DOCKER_REPOSITORY_URL}/node:16.14.2-stretch + script: + - !reference [.api_documentation_script, script] + artifacts: + when: always + paths: + - openapi-maas.json + expire_in: 5 days + + +# Static Application Security Testing for known vulnerabilities +api_sast: + extends: + - .sast-job + - .build-n-sast-job-tags + - .api-base + needs: ['api_build'] \ No newline at end of file diff --git a/api/.gitlab-ci/helm.yml b/api/.gitlab-ci/helm.yml new file mode 100644 index 0000000..dd3fb3a --- /dev/null +++ b/api/.gitlab-ci/helm.yml @@ -0,0 +1,4 @@ +api_image_push: + extends: + - .helm-push-image-job + - .api-base \ No newline at end of file diff --git a/api/.gitlab-ci/preview.yml b/api/.gitlab-ci/preview.yml new file mode 100644 index 0000000..61f8067 --- /dev/null +++ b/api/.gitlab-ci/preview.yml @@ -0,0 +1,58 @@ +api_image_build: + extends: + - .preview-image-job + - .api-base + needs: ['api_build'] + +# Unit test of api with the yarn cache +.api_test_script: &api_test_script | + yarn test --reporter mocha-junit-reporter --reporter-options mochaFile=./junit.xml + yarn coverage + +api_test: + image: ${TEST_IMAGE_NAME} + extends: + - .test-job + - .api-base + script: + - *api_test_script + artifacts: + when: always + paths: + - ${MODULE_PATH}/coverage/lcov.info + expire_in: 5 days + needs: ['api_build'] + +# Quality of code test in sonarqube with verify-job from common +api_verify: + extends: + - .verify-job + - .api-base + variables: + SONAR_SOURCES: . + SONAR_EXCLUSIONS: 'sonar.exclusions=**/node_modules/**, dist/**, databaseConfig/**, public/**, coverage/**, **/__tests__/**, **.yml, **.json, **.md, eslintrc.js' + SONAR_CPD_EXCLUSIONS: '**/__tests__/**, src/datasources/**, src/models/**, src/repositories/**' + needs: ['sonarqube-verify-image-build', 'api_test'] + +api_preview_deploy: + extends: + - .preview-deploy-job + - .api-base + script: + - | + deploy + wait_pod mongo + config_volume mongo + needs: ['api_image_build'] + environment: + on_stop: api_preview_cleanup + +api_preview_documentation: + extends: + - .preview-env-vars + - .api-documentation-job + +api_preview_cleanup: + extends: + - .commons_preview_cleanup + - .api-base diff --git a/api/.gitlab-ci/testing.yml b/api/.gitlab-ci/testing.yml new file mode 100644 index 0000000..d8722c5 --- /dev/null +++ b/api/.gitlab-ci/testing.yml @@ -0,0 +1,17 @@ +api_testing_image_build: + extends: + - .testing-image-job + - .api-base + needs: ['api_build'] + +api_testing_deploy: + extends: + - .testing-deploy-job + - .api-base + needs: ['api_testing_image_build'] + +#UTILS +api_testing_documentation: + extends: + - .testing-env-vars + - .api-documentation-job diff --git a/api/.husky/pre-commit b/api/.husky/pre-commit new file mode 100644 index 0000000..4c2ca6a --- /dev/null +++ b/api/.husky/pre-commit @@ -0,0 +1,46 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +declare API_STAGED_FILES=$(git diff --name-only --cached | grep 'api/') +declare WEBSITE_STAGED_FILES=$(git diff --name-only --cached | grep 'website/') +declare ADMINISTRATION_STAGED_FILES=$(git diff --name-only --cached | grep 'administration/') +cd api && export $(grep -v '^#' .secrets | xargs) && export $(grep '^export CI_PROJECT_ID' .env.mcm | xargs) && cd ..; + +if [[ $API_STAGED_FILES ]]; +then + echo "API_STAGED_FILES :" + echo "$API_STAGED_FILES" + echo "Pre commit api" + cd api && yarn lint:fix && cd ..; + if [ $? -ne 0 ] + then + echo "API pre-commit hook failed" + exit 1 + fi +fi + +if [[ $WEBSITE_STAGED_FILES ]]; +then + echo "WEBSITE_STAGED_FILES :" + echo "$WEBSITE_STAGED_FILES" + echo "Pre commit website" + cd website && yarn lint:fix && cd ..; + if [ $? -ne 0 ] + then + echo "Website pre-commit hook failed" + exit 1 + fi +fi + +if [[ $ADMINISTRATION_STAGED_FILES ]]; +then + echo "ADMINISTRATION_STAGED_FILES :" + echo "$ADMINISTRATION_STAGED_FILES" + echo "Pre commit administration" + cd administration && yarn lint:fix && cd ..; + if [ $? -ne 0 ] + then + echo "Administration pre-commit hook failed" + exit 1 + fi +fi \ No newline at end of file diff --git a/api/.mocharc.json b/api/.mocharc.json new file mode 100644 index 0000000..7b523c3 --- /dev/null +++ b/api/.mocharc.json @@ -0,0 +1,5 @@ +{ + "exit": true, + "recursive": true, + "require": "source-map-support/register" +} diff --git a/api/.prettierignore b/api/.prettierignore new file mode 100644 index 0000000..46b020a --- /dev/null +++ b/api/.prettierignore @@ -0,0 +1,3 @@ +dist +*.json +mongo/databaseConfig/*.js diff --git a/api/.prettierrc b/api/.prettierrc new file mode 100644 index 0000000..95a8e60 --- /dev/null +++ b/api/.prettierrc @@ -0,0 +1,7 @@ +{ + "bracketSpacing": false, + "singleQuote": true, + "printWidth": 90, + "trailingComma": "all", + "arrowParens": "avoid" +} diff --git a/api/.yo-rc.json b/api/.yo-rc.json new file mode 100644 index 0000000..54319a3 --- /dev/null +++ b/api/.yo-rc.json @@ -0,0 +1,6 @@ +{ + "@loopback/cli": { + "packageManager": "npm", + "version": "2.21.0" + } +} diff --git a/api/Chart.yaml b/api/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/api/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..4c537d1 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,33 @@ +FROM node:16.14.2-alpine + +ENV PHANTOMJS_VERSION=2.1.1 +ENV PHANTOMJS_PATH=/usr/local/bin/phantomjs +RUN apk update && apk add --no-cache fontconfig ghostscript-fonts curl curl-dev && \ + cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/${PHANTOMJS_VERSION}/dockerized-phantomjs.tar.gz | tar xz && \ + cp -R lib lib64 / && \ + cp -R usr/lib/x86_64-linux-gnu /usr/lib && \ + cp -R usr/share /usr/share && \ + cp -R etc/fonts /etc && \ + curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-${PHANTOMJS_VERSION}-linux-x86_64.tar.bz2 | tar -jxf - && \ + cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs + +# Set to a non-root built-in user `node` +USER node + +# Create app directory (with user `node`) +RUN mkdir -p /home/node/app + +WORKDIR /home/node/app + +# Install app dependencies +COPY --chown=node package.json ./ + +RUN yarn install + +COPY --chown=node . . + +RUN rm .npmrc | true +RUN yarn run build + +EXPOSE 3000 +CMD [ "yarn", "start:watch" ] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..94ad5d7 --- /dev/null +++ b/api/README.md @@ -0,0 +1,136 @@ +# Description + +Le service api se base sur le framework **[Loopback 4](https://loopback.io/doc/en/lb4/)** + +Ce service contient la logique métier backend. Il est essentiel au reste de l'applicatif et permet de relier tous les services entre eux. +Il s'appuie sur une base de données orientée documents **[MongoDB](https://www.mongodb.com/docs/v5.0/)**. + +# Installation en local + +## MongoDB + +```sh +docker run -d --name mcm-mongo -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER} -e MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD} mongo +``` + +## LB4 + +Si vous voulez avoir plus de contrôle sur les variables de l'api, créez un fichier d'environnement à la racine (ex: .env) avec les variables mentionnées ci-dessous. Il faudra alors l'exécuter avant de lancer l'api. + +```sh +yarn install && yarn start:watch +``` + +ou + +`yarn install && yarn start` + +## Variables + +| Variables | Description | Obligatoire | +| ------------------------------ | ---------------------------------------------------------------------------- | ----------- | +| API_KEY | Api Key en header des requêtes pour l'api | Non | +| AFFILIATION_JWS_KEY | Clé jws utilisée pour signer le token d'affiliation citoyen à une entreprise | Non | +| WEBSITE_FQDN | Url du website | Non | +| LANDSCAPE | Nom de l'environnement (preview, testing ..) | Non | +| BASE_DOMAIN | Base domaine url | Non | +| IDP_FQDN | Url de l'idp | Non | +| API_FQDN | Url de l'api | Non | +| CLIENT_SECRET_KEY_KEYCLOAK_API | Secret key du client API | Non | +| PORT | Port de l'api | Non | +| HOST | Host de l'api | Non | +| CLAMAV_HOST | Host de l'antivirus | Non | +| CLAMAV_PORT | Port de l'antivirus | Non | +| MAILHOG_HOST | Host de mailhog | Non | +| MAILHOG_EMAIL_FROM | Email from 'mon compte mobilite' | Non | +| SENDGRID_HOST | Host sendgrid | Non | +| SENDGRID_USER | User sendgrid | Non | +| SENDGRID_API_KEY | Api key sendgrid | Non | +| SENDGRID_EMAIL_FROM | Email from 'mon compte mobilite' | Non | +| BUS_HOST | Host Rabbitmq | Non | +| BUS_MCM_CONSUME_USER | Username pour la réception des messages | Non | +| BUS_MCM_CONSUME_PASSWORD | Password pour la réception des messages | Non | +| BUS_MCM_HEADERS | Header d'échange de publication | Non | +| BUS_MCM_MESSAGE_TYPE | Message type d'échange de la publication | Non | +| BUS_CONSUMER_QUEUE | Nom de la queue pour le consumer | Non | +| S3_SERVICE_USER | User compte de service | Non | +| S3_SERVICE_PASSWORD | Password compte de service | Non | +| S3_HOST | Host minio | Non | +| S3_PORT | Port minio | Non | +| IDP_DB_HOST | Host bdd pgsql | Non | +| IDP_DB_PORT | Port bdd pgsql | Non | +| IDP_DB_SERVICE_USER | Username de service pgsql (readaccess) | Non | +| IDP_DB_SERVICE_PASSWORD | Password de service pgsql (readaccess) | Non | +| IDP_DB_DATABASE | Nom bdd pgsql | Non | +| PGSQL_FLEX_SSL_CERT | Certificat de connexion à la bdd pgsql en base 64 | Non | +| MONGO_SERVICE_USER | User service bdd mongo | Non | +| MONGO_SERVICE_PASSWORD | Password service bdd mongo | Non | +| MONGO_HOST | Host mongo | Non | +| MONGO_DATABASE | Nom de la bdd mongo | Non | +| MONGO_PORT | Port mongo | Non | +| MONGO_AUTH_SOURCE | Nom de la source d'authentification bdd mongo | Non | +| SENDGRID_EMAIL_CONTACT | Email contact | Non | +| LOG_LEVEL | Niveau de logs pour l'api | Non | + +## URL / Port + +- URL : localhost +- Port : 3000 + +# Précisions pipelines + +L'image de l'api est basée sur celle de nginx. + +La variable PACKAGE_VERSION (voir commons) permet de repérer la dernière version publiée. + +## Preview + +Deux scripts sont lancés au déploiement de la bdd mongo permettant de créer les collections et les index nécessaires et de créer l'utilisateur de service ayant seulement l'accès en lecture et écriture sur la bdd. (mongo/databaseConfig/createServiceUser.js & setup.js) + +## Testing + +Sur cet environnement, la bdd mongo est hébergée sur Azure. Le paramétrage de la bdd et des collections sont donc à faire en amont. + +# Relation avec les autres services + +Comme présenté dans le schéma global de l'architecture ci-dessous + +![technicalArchitecture](../docs/assets/MOB-CME_Archi_technique_detaillee.png) + +L'api est reliée à tous les services que nous avons mis en place. + +A son démarrage : + +- Elle peut exécuter des scripts de migrations de la bdd mongo. +- Elle lance un child process étant responsable de l'écoute et de la consommation des événements amqp sur la queue de dépôt +- Elle se connecte aux bdd configurées (mongo & pgsql) + +Comme mentionné, notre api a accès à la bdd pgsql de l'[idp](idp) en lecture seule permettant de requêter des informations plus rapidement & facilement que par appel API de l'IDP. + +L'api est reliée à : + +- _idp_ pour renforcer la sécurité et vérifier l'accès aux endpoints. +- _administration_ pour permettre la contribution de certains contenus. +- website pour la récupération des données permettant le bon fonctionnement de moB. +- _s3_ pour le download/upload des justificatifs +- simulation-maas pour la simulation de l'envoie des metadonnées. En effet, nos développements sont orientés open API et nos partenaires peuvent donc s'orienter sur une approche Full API de moB. +- _antivirus_ pour scanner les justificatifs qui nous sont envoyés. +- _bus_ pour l'échange de messages entre les SIRH et nous. + +* Requêtes HTTP avec l'administration +* Requêtes HTTP avec l'idp +* Requêtes HTTP avec website +* Requêtes HTTP avec s3 +* Requêtes HTTP avec simulation-maas +* Requêtes TCP avec antivirus +* Requêtes AMQP avec le bus + +# Tests Unitaires + +Les TU sont fait avec le framework de test Mocha. + +Pour lancer les tests unitaires + +```sh +yarn test +``` diff --git a/api/api-docker-compose.yml b/api/api-docker-compose.yml new file mode 100644 index 0000000..21a3df8 --- /dev/null +++ b/api/api-docker-compose.yml @@ -0,0 +1,31 @@ +version: '3.4' + +services: + api: + build: + context: . + network: host + container_name: api + environment: + IDP_URL: ${IDP_URL} + S3_URL: ${S3_URL} + IDP_DB_HOST: ${IDP_DB_HOST} + MONGO_HOST: ${MONGO_HOST} + BUS_HOST: ${BUS_HOST} + CLIENT_SECRET_KEY_KEYCLOAK_API: ${CLIENT_SECRET_KEY_KEYCLOAK_API} + ANTIVIRUS: ${ANTIVIRUS} + API_KEY: ${API_KEY} + PORT: ${PORT} + network_mode: host + volumes: + - ./src:/home/node/app/src + - node_modules:/home/node/app/node_modules + ports: + - 3000:3000 + +volumes: + node_modules: + +networks: + dev_web-nw: + external: true diff --git a/api/api-dockerfile.yml b/api/api-dockerfile.yml new file mode 100644 index 0000000..53181ce --- /dev/null +++ b/api/api-dockerfile.yml @@ -0,0 +1,57 @@ +ARG NODE_IMAGE_NAME +FROM ${NODE_IMAGE_NAME} + +RUN apk add curl + +ENV PHANTOMJS_VERSION=2.1.1 +ENV PHANTOMJS_PATH=/usr/local/bin/phantomjs +RUN apk update && apk add --no-cache fontconfig ghostscript-fonts curl curl-dev && \ + cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/${PHANTOMJS_VERSION}/dockerized-phantomjs.tar.gz | tar xz && \ + cp -R lib lib64 / && \ + cp -R usr/lib/x86_64-linux-gnu /usr/lib && \ + cp -R usr/share /usr/share && \ + cp -R etc/fonts /etc && \ + curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-${PHANTOMJS_VERSION}-linux-x86_64.tar.bz2 | tar -jxf - && \ + cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs + +# Set to a non-root built-in user `node` +USER node + +# Create app directory (with user `node`) +RUN mkdir -p /home/node/app + +WORKDIR /home/node/app + +# Install and build are supposed to be yet done +ARG CI_PROJECT_ID +ENV CI_PROJECT_ID=${CI_PROJECT_ID} +ARG GITLAB_REGISTRY_NPM_TOKEN +ENV GITLAB_REGISTRY_NPM_TOKEN=${GITLAB_REGISTRY_NPM_TOKEN} +ARG NEXUS_NPM_PROXY_TOKEN +ENV NEXUS_NPM_PROXY_TOKEN=${NEXUS_NPM_PROXY_TOKEN} +ARG PACKAGE_VERSION +ENV PACKAGE_VERSION=${PACKAGE_VERSION} + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY --chown=node package.json ./ +COPY --chown=node yarn.lock ./ +COPY --chown=node .npmrc ./ + +RUN yarn install +#COPY --chown=node node_modules ./node_modules + +# Bundle app source code +#COPY --chown=node src/ src +#COPY --chown=node tsconfig.json ./ +COPY --chown=node . . + +RUN npm version ${PACKAGE_VERSION} +RUN yarn run build + +# Bind to all network interfaces so that it can be mapped to the host OS +ENV HOST=0.0.0.0 PORT=3000 + +EXPOSE ${PORT} +CMD [ "node", "." ] diff --git a/api/api-testing-values.yaml b/api/api-testing-values.yaml new file mode 100644 index 0000000..2305fe6 --- /dev/null +++ b/api/api-testing-values.yaml @@ -0,0 +1,174 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: api + name: api + spec: + ports: + - name: '3000' + port: 3000 + targetPort: 3000 + selector: + io.kompose.service: api + type: ClusterIP + status: + loadBalancer: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: 'true' + podSelector: + matchLabels: + io.kompose.network/web-nw: 'true' + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: api + name: api + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: api + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/storage-nw: 'true' + io.kompose.network/web-nw: 'true' + io.kompose.service: api + spec: + containers: + env: + AFFILIATION_JWS_KEY: ${TESTING_AFFILIATION_JWS_KEY} + API_FQDN: ${API_FQDN} + API_KEY: ${TESTING_API_KEY} + BUS_HOST: bus.bus-${LANDSCAPE}.svc.cluster.local + BUS_MCM_HEADERS: ${BUS_MCM_HEADERS} + BUS_MCM_MESSAGE_TYPE: ${BUS_MCM_MESSAGE_TYPE} + BUS_CONSUMER_QUEUE: mob.subscriptions.status + BUS_MCM_CONSUME_PASSWORD: ${TESTING_BUS_MCM_CONSUME_PASSWORD} + BUS_MCM_CONSUME_USER: ${TESTING_BUS_MCM_CONSUME_USER} + CLIENT_SECRET_KEY_KEYCLOAK_API: ${TESTING_IDP_API_CLIENT_SECRET} + CLAMAV_HOST: clamav.antivirus-${LANDSCAPE}.svc.cluster.local + CLAMAV_PORT: '3310' + IDP_FQDN: ${IDP_FQDN} + IDP_DB_HOST: ${TESTING_PGSQL_FLEX_ADDRESS} + IDP_DB_PORT: 5432 + IDP_DB_AUTH_SOURCE: ${TESTING_PGSQL_NAME} + IDP_DB_DATABASE: ${TESTING_PGSQL_NAME} + IDP_DB_SERVICE_USER: ${TESTING_PGSQL_SERVICE_USER} + IDP_DB_SERVICE_PASSWORD: ${TESTING_PGSQL_SERVICE_PASSWORD} + LANDSCAPE: ${LANDSCAPE} + MONGO_AUTH_SOURCE: ${TESTING_MONGO_DB_NAME} + MONGO_HOST: ${TESTING_MONGO_HOST} + MONGO_PORT: 27017 + MONGO_SERVICE_USER: ${TESTING_MONGO_SERVICE_USER} + MONGO_SERVICE_PASSWORD: ${TESTING_MONGO_SERVICE_PASSWORD} + MONGO_DATABASE: ${TESTING_MONGO_DB_NAME} + S3_HOST: s3.s3-${LANDSCAPE}.svc.cluster.local + S3_PORT: '9000' + S3_SERVICE_PASSWORD: ${TESTING_S3_SERVICE_PASSWORD} + S3_SERVICE_USER: ${TESTING_S3_SERVICE_USER} + MAILHOG_EMAIL_FROM: ${MAILHOG_EMAIL_FROM} + MAILHOG_HOST: mailhog.mailhog-${LANDSCAPE}.svc.cluster.local + WEBSITE_FQDN: ${WEBSITE_FQDN} + PGSQL_FLEX_SSL_CERT: ${PGSQL_FLEX_SSL_CERT} + image: ${API_IMAGE_NAME} + name: api + ports: + - containerPort: 3000 + resources: {} + imagePullSecrets: + - name: ${GITLAB_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + status: {} + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: api-headers-middleware + spec: + headers: + customRequestHeaders: + X-Forwarded-Port: '443' + X-Forwarded-Proto: https + + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: api-inflightreq-middleware + spec: + inFlightReq: + amount: 100 + + # - metadata: + # annotations: + # app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + # app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + # kubernetes.io/ingress.class: traefik + # name: api-ratelimit-middleware + # spec: + # rateLimit: + # average: 30 + # burst: 50 + # period: 1s + # sourceCriterion: + # ipStrategy: + # depth: 2 + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: api + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${API_FQDN}`) + middlewares: + - name: api-headers-middleware + # - name: api-ratelimit-middleware + - name: api-inflightreq-middleware + services: + - kind: Service + name: api + port: 3000 diff --git a/api/explorer/index.html.ejs b/api/explorer/index.html.ejs new file mode 100644 index 0000000..0d27e9b --- /dev/null +++ b/api/explorer/index.html.ejs @@ -0,0 +1,63 @@ + + + + + + + <%- indexTemplateTitle %> + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/api/kompose.yml b/api/kompose.yml new file mode 100644 index 0000000..37777c8 --- /dev/null +++ b/api/kompose.yml @@ -0,0 +1,90 @@ +version: '3' + +services: + mongo: + image: ${MONGO_IMAGE_NAME} + build: + context: . + dockerfile: ./mongo-dockerfile.yml + args: + BASE_IMAGE_MONGO: ${NEXUS_IMAGE_MONGO} + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD} + - MONGO_INITDB_DATABASE=${MONGO_DB_NAME} + - MONGO_INITDB_NON_ROOT_USERNAME=${MONGO_SERVICE_USER} + - MONGO_INITDB_NON_ROOT_PASSWORD=${MONGO_SERVICE_PASSWORD} + volumes: + - mongo-data:/data/db + networks: + - storage-nw + ports: + - '27017' + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + - 'traefik.enable=false' + + api: + depends_on: + - mongo + image: ${API_IMAGE_NAME} + build: + context: . + dockerfile: ./api-dockerfile.yml + args: + NODE_IMAGE_NAME: ${BUILD_IMAGE_NAME} + CI_PROJECT_ID: ${CI_PROJECT_ID} + MCM_GITLAB_DEPLOY_NPM_TOKEN: ${GITLAB_REGISTRY_NPM_TOKEN} + NEXUS_NPM_PROXY_TOKEN: ${NEXUS_NPM_PROXY_TOKEN} + PACKAGE_VERSION: ${PACKAGE_VERSION} + environment: + - MONGO_AUTH_SOURCE=${MONGO_DB_NAME} + - MONGO_HOST=mongo + - MONGO_PORT=27017 + - MONGO_SERVICE_USER + - MONGO_SERVICE_PASSWORD + - MONGO_DATABASE=${MONGO_DB_NAME} + - IDP_DB_HOST=postgres-keycloak.idp-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local + - IDP_DB_PORT=5432 + - IDP_DB_AUTH_SOURCE=idp_db + - IDP_DB_SERVICE_USER=${PGSQL_SERVICE_USER} + - IDP_DB_SERVICE_PASSWORD=${PGSQL_SERVICE_PASSWORD} + - IDP_DB_DATABASE=idp_db + - CLIENT_SECRET_KEY_KEYCLOAK_API=${IDP_API_CLIENT_SECRET} + - IDP_FQDN + - API_FQDN + - S3_SERVICE_USER + - S3_SERVICE_PASSWORD + - S3_HOST=s3.s3-master-preview.svc.cluster.local + - S3_PORT=9000 + - AFFILIATION_JWS_KEY + - WEBSITE_FQDN + - CLAMAV_HOST=clamav.antivirus-master-preview.svc.cluster.local + - CLAMAV_PORT=3310 + - LANDSCAPE + - BASE_DOMAIN + - MAILHOG_EMAIL_FROM + - MAILHOG_HOST=mailhog.mailhog-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local + - BUS_HOST=bus.bus-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local + - BUS_CONSUMER_QUEUE + - BUS_MCM_CONSUME_USER + - BUS_MCM_CONSUME_PASSWORD + - BUS_MCM_HEADERS + - BUS_MCM_MESSAGE_TYPE + - API_KEY + networks: + - web-nw + - storage-nw + ports: + - '3000' + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + +volumes: + mongo-data: + +networks: + web-nw: + storage-nw: diff --git a/api/mongo-dockerfile.yml b/api/mongo-dockerfile.yml new file mode 100644 index 0000000..56d798f --- /dev/null +++ b/api/mongo-dockerfile.yml @@ -0,0 +1,5 @@ +# Check out https://hub.docker.com/_/mongo to select a new base image +ARG BASE_IMAGE_MONGO +FROM ${BASE_IMAGE_MONGO} + +COPY ./mongo/databaseConfig/*.js /docker-entrypoint-initdb.d/ diff --git a/api/mongo/databaseConfig/createServiceUser.js b/api/mongo/databaseConfig/createServiceUser.js new file mode 100644 index 0000000..0b1b1d2 --- /dev/null +++ b/api/mongo/databaseConfig/createServiceUser.js @@ -0,0 +1,12 @@ +/* eslint-disable */ +const username = _getEnv('MONGO_INITDB_NON_ROOT_USERNAME'); +const password = _getEnv('MONGO_INITDB_NON_ROOT_PASSWORD'); +const dbname = _getEnv('MONGO_INITDB_DATABASE'); + +db.createUser( + { + user: username, + pwd: password, + roles: [{ role: "readWrite", db: dbname }] + } +); \ No newline at end of file diff --git a/api/mongo/databaseConfig/setup.js b/api/mongo/databaseConfig/setup.js new file mode 100644 index 0000000..9decf09 --- /dev/null +++ b/api/mongo/databaseConfig/setup.js @@ -0,0 +1,27 @@ +/* eslint-disable */ +const res = [ + db.createCollection('Incentive'), + db.Incentive.createIndex( + { + '$**': 'text', + }, + { + unique: false, + name: 'fullText', + default_language: 'french', + }, + ), + db.Incentive.createIndex({funderId: 1}), + db.createCollection('Subscription'), + db.Subscription.createIndex({lastName: 1}), + db.Subscription.createIndex({funderId: 1}), + db.Subscription.createIndex({incentiveId: 1}), + db.Subscription.createIndex({incentiveType: 1}), + db.Subscription.createIndex({status: 1}), + db.createCollection('Citizen'), + db.Citizen.createIndex({'identity.lastName.value': 1}), + db.createCollection('CronJob'), + db.CronJob.createIndex({ "type": 1 }, { unique: true }), + db.Territory.createIndex({ "name": 1 }, { unique: true }) +]; +printjson(res); diff --git a/api/openapi-maas.js b/api/openapi-maas.js new file mode 100644 index 0000000..9217305 --- /dev/null +++ b/api/openapi-maas.js @@ -0,0 +1,91 @@ +const fs = require('fs'); + +const OPENAPI_FILEPATH = `./openapi.json`; +const OPENAPI_MAAS_FILENAME = `openapi-maas.json`; +const TAG_MAAS = 'MaaS'; + +function findAllByKey(obj, keyToFind) { + return Object.entries(obj).reduce( + (acc, [key, value]) => + key === keyToFind + ? acc.concat(value) + : typeof value === 'object' + ? acc.concat(findAllByKey(value, keyToFind)) + : acc, + [], + ); +} + +fs.readFile(OPENAPI_FILEPATH, 'utf8', (err, data) => { + if (err) { + console.error(err); + return; + } + + const parsedData = JSON.parse(data); + const maasPaths = Object.fromEntries( + Object.entries(parsedData.paths) + .map(([key, value]) => [ + key, + Object.fromEntries( + Object.entries(value) + .map(([key, value]) => [key, value]) + .filter(([key, value]) => { + return value.tags.includes(TAG_MAAS); + }) + .map(([key, value]) => { + value.tags = [TAG_MAAS]; + return [key, value]; + }), + ), + ]) + .filter(([key, value]) => Object.keys(value).length !== 0), + ); + + let finished = false; + let schemasList = findAllByKey(maasPaths, '$ref'); + let schemaNames = [...new Set(schemasList)].map( + value => value.split('#/components/schemas/')[1], + ); + let schemasCount = schemaNames.length; + let maasSchemas = {}; + + while (!finished) { + maasSchemas = Object.fromEntries( + Object.entries(parsedData.components.schemas).filter(([key]) => + schemaNames.includes(key), + ), + ); + + let nestedSchemas = findAllByKey(maasSchemas, '$ref'); + nestedSchemas = [...new Set(nestedSchemas)].map( + value => value.split('#/components/schemas/')[1], + ); + + schemaNames.push(...nestedSchemas); + schemaNames = [...new Set(schemaNames)]; + if (schemaNames.length === schemasCount) { + finished = true; + } else { + schemasCount = schemaNames.length; + } + } + + const openapiMaas = { + openapi: parsedData.openapi, + info: parsedData.info, + paths: maasPaths, + servers: parsedData.servers, + components: { + securitySchemes: parsedData.components.securitySchemes, + schemas: maasSchemas, + }, + }; + + fs.writeFile(OPENAPI_MAAS_FILENAME, JSON.stringify(openapiMaas), err => { + if (err) console.log(err); + else { + console.log('File written successfully\n'); + } + }); +}); diff --git a/api/overlays/api-certificate.yml b/api/overlays/api-certificate.yml new file mode 100644 index 0000000..933f69f --- /dev/null +++ b/api/overlays/api-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: api-cert +spec: + dnsNames: + - '*.${landscape_subdomain}' + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/api/overlays/api-headers-middleware.yml b/api/overlays/api-headers-middleware.yml new file mode 100644 index 0000000..c7d05ce --- /dev/null +++ b/api/overlays/api-headers-middleware.yml @@ -0,0 +1,9 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: api-headers-middleware +spec: + headers: + customRequestHeaders: + X-Forwarded-Proto: 'https' + X-Forwarded-Port: '443' diff --git a/api/overlays/api-inflightreq-middleware.yml b/api/overlays/api-inflightreq-middleware.yml new file mode 100644 index 0000000..5854881 --- /dev/null +++ b/api/overlays/api-inflightreq-middleware.yml @@ -0,0 +1,7 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: api-inflightreq-middleware +spec: + inFlightReq: + amount: 100 diff --git a/api/overlays/api-ingressroute.yml b/api/overlays/api-ingressroute.yml new file mode 100644 index 0000000..b6c90ba --- /dev/null +++ b/api/overlays/api-ingressroute.yml @@ -0,0 +1,26 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: api +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${API_FQDN}`) + middlewares: + - name: api-headers-middleware + # - name: api-ratelimit-middleware + - name: api-inflightreq-middleware + kind: Rule + services: + - kind: Service + name: api + port: 3000 + # tls: + # secretName: ${SECRET_NAME} # api-tls # cert-dev + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - '*.preview.${BASE_DOMAIN}' + # - '*.testing.${BASE_DOMAIN}' diff --git a/api/overlays/api-ipwhitelist-middleware.yml b/api/overlays/api-ipwhitelist-middleware.yml new file mode 100644 index 0000000..abeda1a --- /dev/null +++ b/api/overlays/api-ipwhitelist-middleware.yml @@ -0,0 +1,10 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: test-ipwhitelist +spec: + ipWhiteList: + sourceRange: + - 127.0.0.1/32 + # TO DO limite accès to FRONT ip + # and limite accès to diff --git a/api/overlays/api-ratelimit-middleware.yml b/api/overlays/api-ratelimit-middleware.yml new file mode 100644 index 0000000..e9ce427 --- /dev/null +++ b/api/overlays/api-ratelimit-middleware.yml @@ -0,0 +1,12 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: api-ratelimit-middleware +spec: + rateLimit: + period: 1s + average: 30 + burst: 50 + sourceCriterion: + ipStrategy: + depth: 2 diff --git a/api/overlays/kustomization.yaml b/api/overlays/kustomization.yaml new file mode 100644 index 0000000..8c07eee --- /dev/null +++ b/api/overlays/kustomization.yaml @@ -0,0 +1,14 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - api-ingressroute.yml + # - api-certificate.yml + - api-headers-middleware.yml + # - api-ratelimit-middleware.yml + - api-inflightreq-middleware.yml + +patchesStrategicMerge: + - web_nw_networkpolicy_namespaceselector.yml diff --git a/api/overlays/web_nw_networkpolicy_namespaceselector.yml b/api/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..53e6193 --- /dev/null +++ b/api/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..9cca1d8 --- /dev/null +++ b/api/package.json @@ -0,0 +1,115 @@ +{ + "name": "@mcm/mcm-api", + "version": "1.10.1", + "description": "", + "keywords": [ + "loopback-application", + "loopback" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=10.16" + }, + "scripts": { + "build": "lb-tsc", + "postbuild": "npm run copy-ejs-files", + "build:watch": "lb-tsc --watch", + "lint": "npm run eslint && npm run prettier:check", + "lint:fix": "npm run eslint:fix && npm run prettier:fix", + "prettier:cli": "lb-prettier \"**/*.ts\" \"**/*.js\"", + "prettier:check": "npm run prettier:cli -- -l", + "prettier:fix": "npm run prettier:cli -- --write", + "eslint": "lb-eslint --report-unused-disable-directives .", + "eslint:fix": "npm run eslint -- --fix", + "pretest": "npm run rebuild", + "test": "nyc lb-mocha --allow-console-logs \"dist/__tests__\"", + "posttest": "npm run lint", + "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", + "docker:build": "docker build -t api .", + "docker:run": "docker run -p 3000:3000 -d api", + "premigrate": "npm run build", + "migrate": "node ./dist/migrate", + "preopenapi-spec": "npm run build", + "openapi-spec": "node ./dist/openapi-spec", + "prestart": "npm run rebuild", + "start": "node -r source-map-support/register .", + "clean": "lb-clean dist *.tsbuildinfo .eslintcache", + "start:watch": "tsc-watch --target es2017 --outDir ./dist --onSuccess \"node .\"", + "rebuild": "npm run clean && npm run build", + "verify": "sonarqube-verify", + "coverage": "nyc report --reporter=lcovonly", + "copy-ejs-files": "copyfiles -u 1 src/**/**.ejs dist/", + "prepare": "cd .. && husky install api/.husky" + }, + "repository": { + "type": "git", + "url": "" + }, + "author": "Mon Compte Mobilité", + "license": "", + "files": [ + "README.md", + "dist", + "src", + "!*/__tests__" + ], + "dependencies": { + "@aws-sdk/client-s3": "3.24.0", + "@loopback/authentication": "9.0.4", + "@loopback/authorization": "0.12.4", + "@loopback/boot": "5.0.4", + "@loopback/build": "9.0.4", + "@loopback/core": "4.0.4", + "@loopback/cron": "0.9.4", + "@loopback/repository": "5.0.4", + "@loopback/rest": "12.0.4", + "@loopback/rest-explorer": "5.0.4", + "@loopback/service-proxy": "5.0.4", + "@types/amqplib": "0.8.2", + "@types/clamscan": "2.0.2", + "@types/ejs": "3.1.1", + "@types/express": "4.17.13", + "@types/jsonwebtoken": "8.5.8", + "@types/mocha": "10.0.0", + "@types/multer": "1.4.7", + "@types/node": "16.11.36", + "@types/nodemailer": "6.4.4", + "amqplib": "0.8.0", + "clamscan": "2.1.2", + "copyfiles": "2.4.1", + "date-fns": "2.28.0", + "ejs": "3.1.8", + "exceljs": "4.3.0", + "html-pdf": "3.0.1", + "jsonschema": "1.4.1", + "jsonwebtoken": "8.5.1", + "jwk-to-pem": "2.0.5", + "keycloak-admin": "1.14.22", + "loopback-connector-mongodb": "6.2.0", + "loopback-connector-postgresql": "5.5.1", + "loopback4-migration": "1.3.0", + "multer": "1.4.5-lts.1", + "nodemailer": "6.7.5", + "strong-error-handler": "4.0.0", + "tsc-watch": "4.6.2", + "tslib": "2.4.0", + "winston": "3.7.2" + }, + "devDependencies": { + "@loopback/eslint-config": "13.0.4", + "@loopback/testlab": "5.0.4", + "@types/html-pdf": "3.0.0", + "@types/jwk-to-pem": "2.0.1", + "date-fns-tz": "1.3.4", + "eslint": "8.25.0", + "husky": "7.0.4", + "mocha": "10.1.0", + "mocha-junit-reporter": "2.0.2", + "nyc": "15.1.0", + "regenerator-runtime": "0.13.9", + "sonarqube-verify": "1.0.2", + "source-map-support": "0.5.21", + "typescript": "4.8.4" + } +} diff --git a/api/public/images/logo-with-baseline.svg b/api/public/images/logo-with-baseline.svg new file mode 100644 index 0000000..b2ada22 --- /dev/null +++ b/api/public/images/logo-with-baseline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/public/images/mob-footer.svg b/api/public/images/mob-footer.svg new file mode 100644 index 0000000..364a607 --- /dev/null +++ b/api/public/images/mob-footer.svg @@ -0,0 +1 @@ + diff --git a/api/public/img/mob-favicon.svg b/api/public/img/mob-favicon.svg new file mode 100644 index 0000000..ce42712 --- /dev/null +++ b/api/public/img/mob-favicon.svg @@ -0,0 +1 @@ + diff --git a/api/public/index.html b/api/public/index.html new file mode 100644 index 0000000..8ac88e9 --- /dev/null +++ b/api/public/index.html @@ -0,0 +1,88 @@ + + + + + API | Mon Compte Mobilité + + + + + + + + + + +
+

moB

+

Version 1

+ +

OpenAPI spec: /openapi.json

+

API Explorer: /explorer

+
+ + + + + diff --git a/api/sonar-project.properties b/api/sonar-project.properties new file mode 100644 index 0000000..931830a --- /dev/null +++ b/api/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.sources=src +sonar.sourceEncoding=UTF-8 + +sonar.exclusions=sonar.exclusions=**/node_modules/**,dist/**,mongo/databaseConfig/**,public/**,coverage/**,**/__tests__/**,**.yml,**.json,**.md,eslintrc.js,openapi-maas.js +sonar.cpd.exclusions=**/__tests__/**,src/datasources/**,src/models/**,src/repositories/** +sonar.javascript.lcov.reportPaths=coverage/lcov.info diff --git a/api/src/__tests__/README.md b/api/src/__tests__/README.md new file mode 100644 index 0000000..a88f8a5 --- /dev/null +++ b/api/src/__tests__/README.md @@ -0,0 +1,3 @@ +# Tests + +Please place your tests in this folder. diff --git a/api/src/__tests__/controllers/citizen.controller.test.ts b/api/src/__tests__/controllers/citizen.controller.test.ts new file mode 100644 index 0000000..e534aef --- /dev/null +++ b/api/src/__tests__/controllers/citizen.controller.test.ts @@ -0,0 +1,739 @@ +import * as Excel from 'exceljs'; +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; +import {AnyObject} from '@loopback/repository'; + +import { + CitizenRepository, + EnterpriseRepository, + CommunityRepository, + UserRepository, + IncentiveRepository, + SubscriptionRepository, +} from '../../repositories'; +import {CitizenController} from '../../controllers'; +import { + CitizenService, + FunderService, + KeycloakService, + JwtService, + MailService, + SubscriptionService, +} from '../../services'; +import {ValidationError} from '../../validationError'; +import { + AFFILIATION_STATUS, + CITIZEN_STATUS, + ResourceName, + StatusCode, + SUBSCRIPTION_STATUS, + INCENTIVE_TYPE, + IUser, + GENDER, +} from '../../utils'; +import { + Enterprise, + Citizen, + User, + CitizenUpdate, + Subscription, + Affiliation, +} from '../../models'; + +describe('CitizenController (unit)', () => { + let citizenRepository: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor, + mailService: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + kcService: StubbedInstanceWithSinonAccessor, + funderService: StubbedInstanceWithSinonAccessor, + citizenService: StubbedInstanceWithSinonAccessor, + subscriptionService: StubbedInstanceWithSinonAccessor, + userRepository: StubbedInstanceWithSinonAccessor, + currentUserProfile: IUser, + jwtService: StubbedInstanceWithSinonAccessor, + subscriptionsRepository: StubbedInstanceWithSinonAccessor, + incentivesRepository: StubbedInstanceWithSinonAccessor, + controller: CitizenController; + + const salarie = Object.assign(new Citizen(), { + id: 'randomInputId', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + birthdate: '1991-11-17', + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: 'enterpriseEmail@gmail.com', + }), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }); + + const enterprise: Enterprise = new Enterprise({ + name: 'enterprise', + emailFormat: ['@gmail.com', 'rr'], + }); + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedService(); + controller = new CitizenController( + mailService, + citizenRepository, + communityRepository, + kcService, + funderService, + enterpriseRepository, + citizenService, + subscriptionService, + jwtService, + userRepository, + currentUserProfile, + subscriptionsRepository, + incentivesRepository, + ); + }); + + describe('CitizenController', () => { + it('CitizenController create salarie : successful', async () => { + citizenService.stubs.createCitizen.resolves({ + id: 'randomInputId', + }); + + const result = await controller.create(salarie); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + }); + + it('CitizenController findSalarie: error', async () => { + try { + citizenService.stubs.findEmployees.resolves({ + employees: [], + employeesCount: 0, + }); + await controller.findSalaries(AFFILIATION_STATUS.AFFILIATED); + } catch (err) { + expect(err).to.equal(true); + } + }); + + it('CitizenController findSalarie: successful', async () => { + citizenService.stubs.findEmployees.resolves({ + employees: [citizen], + employeesCount: 1, + }); + + const result = await controller.findSalaries(AFFILIATION_STATUS.AFFILIATED); + expect(result).to.deepEqual({employees: [citizen], employeesCount: 1}); + }); + + it('CitizenController findById : successful', async () => { + citizenRepository.stubs.findById.resolves(salarie); + const result = await controller.findById('randomInputId'); + + expect(result).to.deepEqual(salarie); + }); + + it('CitizenController findCitizenId : successful', async () => { + userRepository.stubs.findById.resolves(user); + citizenRepository.stubs.findById.resolves(mockCitizen); + const result = await controller.findCitizenId('randomInputId'); + + expect(result).to.deepEqual({lastName: 'Kenny', firstName: 'Gerard'}); + }); + + it('CitizenController validateAffiliation : successful', async () => { + const token = {token: 'montoken'}; + const citizen: any = { + id: 'randomId', + affiliation: { + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }, + }; + citizenRepository.stubs.findOne.resolves(citizen); + citizenService.stubs.checkAffiliation.resolves(citizen); + citizenRepository.stubs.updateById.resolves(); + + const result = await controller.validateAffiliation(citizen.id, token); + + expect(citizen.affiliation.affiliationStatus).to.equal( + AFFILIATION_STATUS.AFFILIATED, + ); + sinon.assert.calledOnceWithExactly(citizenRepository.stubs.updateById, citizen.id, { + affiliation: citizen.affiliation, + }); + + expect(citizenService.stubs.sendValidatedAffiliation.called).true(); + expect(result).to.Null; + }); + + it('CitizenController validateAffiliation : successful token', async () => { + const salarie: IUser = { + id: '', + clientName: 'testName-client', + token: '', + emailVerified: true, + roles: ['gestionnaires'], + [securityId]: 'testId', + }; + const controller = new CitizenController( + mailService, + citizenRepository, + communityRepository, + kcService, + funderService, + enterpriseRepository, + citizenService, + subscriptionService, + jwtService, + userRepository, + salarie, + subscriptionsRepository, + incentivesRepository, + ); + + const token = {token: 'montoken'}; + const citizen: any = { + id: 'randomId', + affiliation: { + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }, + }; + const citizenId: string = citizen.id; + citizenService.stubs.checkAffiliation.resolves(citizen); + citizenRepository.stubs.updateById.resolves(); + const result = await controller.validateAffiliation(citizenId, token); + + expect(citizen.affiliation.affiliationStatus).to.equal( + AFFILIATION_STATUS.AFFILIATED, + ); + sinon.assert.calledOnceWithExactly(citizenRepository.stubs.updateById, citizen.id, { + affiliation: citizen.affiliation, + }); + expect(result).to.Null; + }); + + it('CitizenController validateAffiliation no token : error', async () => { + const token: any = undefined; + const citizenId: string = ''; + const citizen: any = { + id: 'randomId', + affiliation: { + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }, + }; + try { + citizenRepository.stubs.findOne.resolves(citizen); + await controller.validateAffiliation(citizenId, token); + sinon.assert.fail(); + expect(citizenRepository.stubs.updateById.called).true(); + expect(citizenService.stubs.checkAffiliation.called).true(); + } catch (err) { + null; + } + }); + + it('CitizenController disaffiliation updateById : error', async () => { + try { + citizenRepository.stubs.findById.resolves( + new Citizen({ + id: 'citizenId', + affiliation: Object.assign({ + enterpriseEmail: 'user@example.com', + enterpriseId: 'id', + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), + }), + ); + citizenRepository.stubs.updateById.rejects(new Error('Error')); + await controller.disaffiliation('citizenId'); + } catch (error) { + expect(citizenService.stubs.sendDisaffiliationMail.notCalled).true(); + expect(error).to.deepEqual(new Error('Error')); + } + }); + + it('CitoyensController getCitizenWithDemandes : successful', () => { + subscriptionService.stubs.getCitizensWithSubscription.resolves(mockSubscriptions); + const citizensList = controller + .getCitizensWithSubscriptions('', 0) + .then(res => res) + .catch(err => err); + expect(citizensList).to.deepEqual(mockCitizens); + }); + + it('CitizenController disaffiliation : success', async () => { + citizenRepository.stubs.findById.resolves( + new Citizen({ + id: 'citizenId', + affiliation: Object.assign({ + enterpriseEmail: 'user@example.com', + enterpriseId: 'id', + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), + }), + ); + citizenRepository.stubs.updateById.resolves(); + citizenService.stubs.sendDisaffiliationMail.resolves(); + await controller.disaffiliation('citizenId'); + expect(citizenRepository.stubs.findById.called).true(); + expect(citizenRepository.stubs.updateById.called).true(); + expect(citizenService.stubs.sendDisaffiliationMail.called).true(); + }); + + it('CitizenController rejected affiliation : success and send rejection mail', async () => { + citizenRepository.stubs.findById.resolves( + new Citizen({ + id: 'citizenId', + affiliation: Object.assign({ + enterpriseEmail: 'user@example.com', + enterpriseId: 'id', + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), + }), + ); + citizenRepository.stubs.updateById.resolves(); + citizenService.stubs.sendRejectedAffiliation.resolves(); + await controller.disaffiliation('citizenId'); + expect(citizenRepository.stubs.findById.called).true(); + expect(citizenRepository.stubs.updateById.called).true(); + expect(citizenService.stubs.sendRejectedAffiliation.called).true(); + }); + + it('CitizenController updateById : successful', async () => { + const newUserData: CitizenUpdate = { + city: 'Paris', + postcode: '75010', + status: CITIZEN_STATUS.EMPLOYEE, + affiliation: Object.assign({ + enterpriseId: '', + enterpriseEmail: '', + }), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + citizenRepository.stubs.updateById.resolves(); + const result = await controller.updateById('randomInputId', newUserData); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + }); + + it('CitizenController updateById user with affiliation data : successful', async () => { + const newUserData: CitizenUpdate = { + city: 'Paris', + postcode: '75010', + status: CITIZEN_STATUS.EMPLOYEE, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: 'enterpriseEmail@gmail.com', + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + enterpriseRepository.stubs.findById.resolves(enterprise); + citizenRepository.stubs.findById.resolves(citizen); + citizenRepository.stubs.updateById.resolves(); + citizenService.stubs.sendAffiliationMail.resolves(); + + const result = await controller.updateById('randomInputId', newUserData); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + enterpriseRepository.stubs.findById.restore(); + citizenService.stubs.sendAffiliationMail.restore(); + }); + + it('CitizenController updateById user with enterprise email only : successful', async () => { + const newUserData: CitizenUpdate = { + city: 'Paris', + postcode: '75010', + status: CITIZEN_STATUS.EMPLOYEE, + affiliation: Object.assign({ + enterpriseId: '', + enterpriseEmail: 'enterpriseEmail@gmail.com', + }), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + citizenRepository.stubs.updateById.resolves(); + + const result = await controller.updateById('randomInputId', newUserData); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + }); + + it('CitizenController updateById user with enterprise id only : successful', async () => { + const newUserData: CitizenUpdate = { + city: 'Paris', + postcode: '75010', + status: CITIZEN_STATUS.EMPLOYEE, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: '', + }), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + citizenRepository.stubs.updateById.resolves(); + + const result = await controller.updateById('randomInputId', newUserData); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + }); + + it('CitoyensController generateUserRGPDExcelFile : successful', async () => { + const workbook = new Excel.Workbook(); + citizenService.stubs.generateExcelRGPD.resolves(await workbook.xlsx.writeBuffer()); + citizenRepository.stubs.findById.resolves(mockCitizen); + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + incentivesRepository.stubs.find.resolves([]); + subscriptionsRepository.stubs.find.resolves([]); + const response: any = { + status: function () { + return this; + }, + contentType: function () { + return this; + }, + send: (buffer: Buffer) => buffer, + }; + try { + const result = await controller.generateUserRGPDExcelFile( + 'randomInputId', + response, + ); + expect(result).to.be.instanceOf(Buffer); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('CitizenController deleteCitizenAccount : successful', async () => { + // Stub method + citizenRepository.stubs.findById.withArgs('randomInputId').resolves(mockCitizen); + citizenRepository.stubs.deleteById.resolves(); + kcService.stubs.deleteUserKc.resolves({ + id: 'randomInputId', + }); + subscriptionsRepository.stubs.find.resolves([subscription]); + subscriptionsRepository.stubs.updateById.resolves(); + citizenService.stubs.sendDeletionMail.resolves(); + const result = await controller.deleteCitizenAccount('randomInputId'); + // // Checks + expect(citizenRepository.stubs.findById.called).true(); + expect(citizenRepository.stubs.deleteById.called).true(); + expect(kcService.stubs.deleteUserKc.called).true(); + expect(citizenService.stubs.sendDeletionMail.called).true(); + expect(result).to.Null; + }); + }); + + it('CitizenController findConsentsById : successful', async () => { + const expected = [ + { + clientId: 'simulation-maas-client', + name: 'simulation maas client', + }, + { + clientId: 'mulhouse-maas-client', + name: 'mulhouse maas client', + }, + ]; + kcService.stubs.listConsents.resolves(consents); + citizenService.stubs.getClientList.resolves(clients); + + const result = await controller.findConsentsById('randomId'); + + expect(result).to.deepEqual(expected); + + kcService.stubs.listConsents.restore(); + citizenService.stubs.getClientList.restore(); + }); + + it('CitizenController deleteConsentById : successful', async () => { + kcService.stubs.deleteConsent.resolves(); + + await controller.deleteConsentById('randomId', 'randomClientId'); + + expect(kcService.stubs.deleteConsent.onCall(1)); + + kcService.stubs.deleteConsent.restore(); + }); + + it('CitizenController createCitizenFc salarie : successful', async () => { + citizenService.stubs.createCitizen.resolves({ + id: 'randomInputId', + }); + + const result = await controller.createCitizenFc('randomInputId', salarie); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + }); + + function givenStubbedRepository() { + citizenRepository = createStubInstance(CitizenRepository); + funderService = createStubInstance(FunderService); + enterpriseRepository = createStubInstance(EnterpriseRepository); + userRepository = createStubInstance(UserRepository); + incentivesRepository = createStubInstance(IncentiveRepository); + subscriptionsRepository = createStubInstance(SubscriptionRepository); + + currentUserProfile = { + id: 'idUser', + clientName: 'testName-client', + token: '', + funderName: 'funderName', + emailVerified: true, + roles: ['gestionnaires'], + [securityId]: 'testId', + }; + } + + function givenStubbedService() { + kcService = createStubInstance(KeycloakService); + citizenService = createStubInstance(CitizenService); + subscriptionService = createStubInstance(SubscriptionService); + } +}); + +const consents = [ + { + clientId: 'simulation-maas-client', + }, + { + clientId: 'mulhouse-maas-client', + }, +]; + +const clients = [ + { + name: 'simulation maas client', + clientId: 'simulation-maas-client', + }, + { + name: 'mulhouse maas client', + clientId: 'mulhouse-maas-client', + }, + { + name: 'paris maas client', + clientId: 'paris-maas-client', + }, +]; + +const user = new User({ + id: 'idUser', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'someFunderId', + roles: ['gestionnaires'], + communityIds: ['id1', 'id2'], +}); + +const citizen = new Citizen({ + identity: Object.assign({ + firstName: Object.assign({ + value: 'Xina', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Zhong', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), +}); + +const mockCitizen = new Citizen({ + id: 'randomInputId', + email: 'kennyg@gmail.com', + identity: Object.assign({ + firstName: Object.assign({ + value: 'Gerard', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Kenny', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '1994-02-18T00:00:00.000Z', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + city: 'Mulhouse', + postcode: '75000', + status: CITIZEN_STATUS.EMPLOYEE, + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'someFunderId', + enterpriseEmail: 'walid.housni@adevinta.com', + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), +}); + +const mockEnterprise = new Enterprise({ + id: 'randomInputIdEntreprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'nameEntreprise', + siretNumber: 50, + budgetAmount: 102, + employeesCount: 100, + hasManualAffiliation: true, +}); + +const mockCitizens: Promise = new Promise(() => { + return [ + { + citizensData: [ + { + citizenId: '260a6356-3261-4335-bca8-4c1f8257613d', + lastName: 'leYellow', + firstName: 'Bob', + }, + ], + totalCitizens: 1, + }, + ]; +}); + +const mockSubscriptions: Record[] = [ + { + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + incentiveTransportList: ['vélo'], + consent: true, + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }, + { + id: 'randomInputId1', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + incentiveTransportList: ['vélo'], + consent: true, + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }, +]; + +const subscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'randomInputId', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + incentiveTitle: 'Covoiturez à Mulhouse', + incentiveTransportList: ['covoiturage'], + citizenId: 'randomInputId', + lastName: 'térieur', + firstName: 'alain', + email: 'email.salarie@yopmail.com', + city: "L'eau Sangèles", + postcode: '99000', + birthdate: '1970-01-01T00:00:00.000Z', + communityId: 'randomInputId', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + attachments: undefined, + createdAt: new Date('2022-04-26T15:17:23.531Z'), + updatedAt: new Date('2022-04-26T15:17:30.672Z'), + funderId: 'randomInputId', + subscriptionValidation: undefined, + subscriptionRejection: undefined, + specificFields: undefined, + isCitizenDeleted: false, + enterpriseEmail: 'salarie.mcm.pro@yopmail.com', +}); diff --git a/api/src/__tests__/controllers/collectivity.controller.test.ts b/api/src/__tests__/controllers/collectivity.controller.test.ts new file mode 100644 index 0000000..fb16b51 --- /dev/null +++ b/api/src/__tests__/controllers/collectivity.controller.test.ts @@ -0,0 +1,127 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {CollectivityRepository} from '../../repositories'; +import {CollectivityController} from '../../controllers'; +import {KeycloakService} from '../../services'; +import {ValidationError} from '../../validationError'; + +describe('CollectivityController (unit)', () => { + let repository: StubbedInstanceWithSinonAccessor, + kcService: StubbedInstanceWithSinonAccessor, + controller: CollectivityController; + const input = { + id: 'randomInputId', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + name: 'name', + citizensCount: 10, + mobilityBudget: 12, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedService(); + controller = new CollectivityController(repository, kcService); + }); + + describe('CollectivityController', () => { + it('CollectivityController create : fails because of creategroupkc error', async () => { + const errorKc = new ValidationError(`funders.error.topgroup`, '/funder'); + try { + kcService.stubs.createGroupKc.rejects(errorKc); + + await controller.create(input); + } catch (err) { + expect(err.message).to.equal(errorKc.message); + } + + kcService.stubs.createGroupKc.restore(); + }); + + it('CollectivityController create : fails because of createUserkc error', async () => { + const errorKc = new ValidationError(`email.error.unique`, '/email'); + try { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.rejects(errorKc); + kcService.stubs.deleteGroupKc.resolves(); + + await controller.create(input); + } catch (err) { + expect(err.message).to.equal(errorKc.message); + } + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + kcService.stubs.deleteGroupKc.restore(); + }); + + it('CollectivityController create : fails because of creategCollectivity error', async () => { + const errorRepository = 'can not add user to database'; + try { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.resolves({id: 'randomId'}); + repository.stubs.create.rejects(errorRepository); + kcService.stubs.deleteGroupKc.resolves(); + kcService.stubs.deleteUserKc.resolves(); + + await controller.create(input); + } catch (err) { + expect(err.name).to.equal(errorRepository); + } + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + repository.stubs.create.restore(); + kcService.stubs.deleteGroupKc.restore(); + kcService.stubs.deleteUserKc.restore(); + }); + + it('CollectivityController create : successful', async () => { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.resolves({id: 'randomId'}); + repository.stubs.create.resolves(input); + + const result = await controller.create(input); + expect(result).to.deepEqual(input); + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + repository.stubs.create.restore(); + }); + + it('CollectivityController count : successful', async () => { + const countRes = { + count: 10, + }; + + repository.stubs.count.resolves(countRes); + const result = await controller.count(); + + expect(result).to.deepEqual(countRes); + }); + + it('CollectivityController find : successful', async () => { + repository.stubs.find.resolves([input]); + const result = await controller.find(); + + expect(result).to.deepEqual([input]); + }); + }); + + function givenStubbedRepository() { + repository = createStubInstance(CollectivityRepository); + } + + function givenStubbedService() { + kcService = createStubInstance(KeycloakService); + } +}); diff --git a/api/src/__tests__/controllers/contact.controller.test.ts b/api/src/__tests__/controllers/contact.controller.test.ts new file mode 100644 index 0000000..a711214 --- /dev/null +++ b/api/src/__tests__/controllers/contact.controller.test.ts @@ -0,0 +1,41 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {ContactController} from '../../controllers/contact.controller'; +import {ContactService, MailService} from '../../services'; + +import {Contact} from '../../models'; +import {USERTYPE} from '../../utils'; + +describe('Contact Controller ', () => { + let contactService: StubbedInstanceWithSinonAccessor, + controller: ContactController, + mailService: StubbedInstanceWithSinonAccessor; + beforeEach(() => { + givenStubbedService(); + controller = new ContactController(contactService, mailService); + }); + + it('post(v1/contact)', async () => { + contactService.stubs.sendMailClient.resolves(mockContact); + const result = await controller.create(mockContact); + + expect(result).to.deepEqual(mockContact); + }); + + function givenStubbedService() { + contactService = createStubInstance(ContactService); + } +}); + +const mockContact = new Contact({ + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + userType: USERTYPE.CITIZEN, + postcode: '55555', + message: 'Message test', + tos: true, +}); diff --git a/api/src/__tests__/controllers/dashboard.controller.test.ts b/api/src/__tests__/controllers/dashboard.controller.test.ts new file mode 100644 index 0000000..107bfc0 --- /dev/null +++ b/api/src/__tests__/controllers/dashboard.controller.test.ts @@ -0,0 +1,265 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {AnyObject} from '@loopback/repository'; +import {securityId} from '@loopback/security'; + +import {DashboardController} from '../../controllers'; +import {SubscriptionRepository, UserRepository} from '../../repositories'; +import {Subscription, User} from '../../models'; +import {IUser, SUBSCRIPTION_STATUS} from '../../utils'; + +describe('Dashboard Controller', () => { + let subscriptionRepository: StubbedInstanceWithSinonAccessor, + userRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + subscriptionRepository = createStubInstance(SubscriptionRepository); + userRepository = createStubInstance(UserRepository); + }); + + it('get(/v1/dashboards/citizens) success', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + userRepository.stubs.findOne.resolves(mockUser); + subscriptionRepository.stubs.find.resolves([ + mockSubscriptions1, + mockSubscriptions2, + mockSubscriptions3, + ]); + + const citizenDashboardService = controller + .findCitizen('2020', 'all') + .then(res => res) + .catch(err => err); + + expect(citizenDashboardService).to.deepEqual(mockSubscriptionDashboardResult); + done(); + }); + + it('get(/v1/dashboards/citizens) error', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + subscriptionRepository.stubs.find.rejects(mockSubscriptionDashboardError); + + const citizenDashboardService = controller + .findCitizen('2020', 'all') + .then(res => res) + .catch(err => err); + + expect(citizenDashboardService).to.deepEqual(mockSubscriptionDashboardError); + done(); + }); + + it('get(/v1/dashboards/subscriptions) no semester success', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + userRepository.stubs.findOne.resolves(mockUser); + subscriptionRepository.stubs.find.resolves([ + mockSubscriptions1, + mockSubscriptions2, + mockSubscriptions3, + ]); + + const subscriptionDashboardResult = controller + .find('2019', 'all') + .then(res => res) + .catch(err => err); + + expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult); + done(); + }); + + it('get(/v1/dashboards/subscriptions) semester 1 success', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + userRepository.stubs.findOne.resolves(mockUser); + subscriptionRepository.stubs.find.resolves([ + mockSubscriptions1, + mockSubscriptions2, + mockSubscriptions3, + ]); + + const subscriptionDashboardResult = controller + .find('2019', '1') + .then(res => res) + .catch(err => err); + + expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult); + done(); + }); + + it('get(/v1/dashboards/subscriptions) semester 2 success', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + userRepository.stubs.findOne.resolves(mockUser); + subscriptionRepository.stubs.find.resolves([ + mockSubscriptions1, + mockSubscriptions2, + mockSubscriptions3, + ]); + + const subscriptionDashboardResult = controller + .find('2019', '2') + .then(res => res) + .catch(err => err); + + expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult); + done(); + }); + + it('get(/v1/dashboards/subscriptions) Brouillon success', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + userRepository.stubs.findOne.resolves(mockUser); + subscriptionRepository.stubs.find.resolves([ + mockSubscriptions1, + mockSubscriptions2, + mockSubscriptions3, + ]); + + const subscriptionDashboardResult = controller + .find('2019', '2') + .then(res => res) + .catch(err => err); + + expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult); + done(); + }); + + it('get(/v1/dashboards/subscriptions) error', done => { + const controller = new DashboardController( + subscriptionRepository, + userRepository, + currentUser, + ); + + userRepository.stubs.findOne.resolves(mockUser); + subscriptionRepository.stubs.find.resolves([ + mockSubscriptions1, + mockSubscriptions2, + mockSubscriptions3, + ]); + + const subscriptionDashboardResult = controller.find('2019', '2'); + + expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardError); + done(); + }); +}); + +const mockSubscriptionDashboardResult: Promise = new Promise(() => { + return [ + { + status: '0', + count: '3', + }, + { + status: '1', + count: '2', + }, + ]; +}); + +const mockSubscriptionDashboardError: Promise = new Promise(() => { + throw new Error('error'); +}); + +const mockSubscriptions1 = new Subscription({ + id: 'randomInputId1', + incentiveId: 'incentiveId1', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: '7654321', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + communityId: 'id1', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const mockSubscriptions2 = new Subscription({ + id: 'randomInputId2', + incentiveId: 'incentiveId1', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: '1234567', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + communityId: 'id1', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const mockSubscriptions3 = new Subscription({ + id: 'randomInputId3', + incentiveId: 'incentiveId2', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: '1234567', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + communityId: 'id1', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const currentUser: IUser = { + id: 'idEnterprise', + emailVerified: true, + maas: undefined, + membership: ['/entreprises/Capgemini'], + roles: ['gestionnaires'], + [securityId]: 'idEnterprise', +}; + +const mockUser = new User({ + id: 'idUser', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires'], + communityIds: ['id1', 'id2'], +}); diff --git a/api/src/__tests__/controllers/enterprise.controller.test.ts b/api/src/__tests__/controllers/enterprise.controller.test.ts new file mode 100644 index 0000000..7623812 --- /dev/null +++ b/api/src/__tests__/controllers/enterprise.controller.test.ts @@ -0,0 +1,210 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {EnterpriseRepository, UserEntityRepository} from '../../repositories'; +import {EnterpriseController} from '../../controllers'; +import {KeycloakService} from '../../services'; +import {ValidationError} from '../../validationError'; +import {Enterprise, UserEntity, UserEntityRelations} from '../../models'; + +describe('EnterpriseController (unit)', () => { + let repository: StubbedInstanceWithSinonAccessor, + userEntityRepository: StubbedInstanceWithSinonAccessor, + kcService: StubbedInstanceWithSinonAccessor, + controller: EnterpriseController; + const input = { + id: 'randomInputId', + firstName: 'testName', + lastName: 'testLastName', + email: 'test@outlook.com', + emailFormat: ['@outlook.com', '@outlook.fr', '@outlook.xxx'], + name: 'test', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + isHris: false, + hasManualAffiliation: false, + clientId: '', + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + const inputClient = { + id: 'randomInputId', + firstName: 'testName', + lastName: 'testLastName', + email: 'test@outlook.com', + emailFormat: ['@outlook.com', '@outlook.fr', '@outlook.xxx'], + name: 'test', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + isHris: false, + hasManualAffiliation: false, + clientId: 'clientId', + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }; + + const mockEnterpriseWithTwoOptions = new Enterprise({ + id: 'randomInputId', + name: 'test', + emailFormat: ['@outlook.com', '@outlook.fr', '@outlook.xxx'], + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + isHris: true, + hasManualAffiliation: true, + clientId: 'clientId', + }); + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedService(); + controller = new EnterpriseController(repository, userEntityRepository, kcService); + }); + + describe('EnterpriseController', () => { + it('EnterpriseController failing case (createGroupKc) error', async () => { + const errorKc = new ValidationError(`funders.error.topgroup`, '/funder'); + try { + kcService.stubs.createGroupKc.rejects(errorKc); + + await controller.create(input); + } catch (error) { + expect(error.message).to.equal(errorKc.message); + } + kcService.stubs.createGroupKc.restore(); + }); + + it('EnterpriseController create : fails because of createUserkc error', async () => { + const errorKc = new ValidationError(`email.error.unique`, '/email'); + try { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.rejects(errorKc); + kcService.stubs.deleteGroupKc.resolves(); + userEntityRepository.stubs.getServiceUser.resolves({ + id: 'id', + } as UserEntity & UserEntityRelations); + await controller.create(input); + } catch (error) { + expect(error.message).to.equal(errorKc.message); + } + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + kcService.stubs.deleteGroupKc.restore(); + }); + it('EnterpriseController create : fails because of createEnterprise error', async () => { + const errorRepository = 'can not add user to database'; + try { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.resolves({id: 'randomId'}); + repository.stubs.create.rejects(errorRepository); + kcService.stubs.deleteGroupKc.resolves(); + kcService.stubs.deleteUserKc.resolves(); + + await controller.create(input); + } catch (err) { + expect(err.name).to.equal(errorRepository); + } + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + repository.stubs.create.restore(); + kcService.stubs.deleteGroupKc.restore(); + kcService.stubs.deleteUserKc.restore(); + }); + + it('EnterpriseController create : Client id not error', async () => { + const errorRepository = 'can not add user to database'; + try { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.resolves({id: 'randomId'}); + repository.stubs.create.rejects(errorRepository); + kcService.stubs.deleteGroupKc.resolves(); + kcService.stubs.deleteUserKc.resolves(); + userEntityRepository.stubs.getServiceUser.rejects(); + await controller.create(inputClient); + } catch (err) { + expect(err.message).to.equal('Error'); + } + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + repository.stubs.create.restore(); + kcService.stubs.deleteGroupKc.restore(); + kcService.stubs.deleteUserKc.restore(); + userEntityRepository.stubs.getServiceUser.restore(); + }); + + it('EnterpriseController create : fails because of two options checked error', async () => { + const twoOptionsError = new ValidationError( + `enterprise.options.invalid`, + '/enterpriseInvalidOptions', + ); + try { + await controller.create(mockEnterpriseWithTwoOptions); + } catch (error) { + expect(error.message).to.equal(twoOptionsError.message); + } + }); + + it('EnterpriseController create : successful', async () => { + kcService.stubs.createGroupKc.resolves({id: 'randomId'}); + kcService.stubs.createUserKc.resolves({id: 'randomId'}); + repository.stubs.create.resolves(input); + + const result = await controller.create(input); + expect(result).to.deepEqual(input); + + kcService.stubs.createGroupKc.restore(); + kcService.stubs.createUserKc.restore(); + repository.stubs.create.restore(); + }); + + it('EnterpriseController count: Successful', async () => { + const countRes = { + count: 10, + }; + + repository.stubs.count.resolves(countRes); + const result = await controller.count(); + + expect(result).to.deepEqual(countRes); + }); + + it('EnterpriseController find: successful', async () => { + repository.stubs.find.resolves([input]); + const result = await controller.find(); + + expect(result).to.deepEqual([input]); + }); + + it('EnterpriseController find emailFormatList: successful', async () => { + repository.stubs.find.resolves([ + new Enterprise({id: 'id', name: 'name', emailFormat: ['@format.com']}), + ]); + const result = await controller.findEmailFormat(); + expect(result).to.deepEqual([ + new Enterprise({id: 'id', name: 'name', emailFormat: ['@format.com']}), + ]); + }); + }); + + function givenStubbedRepository() { + repository = createStubInstance(EnterpriseRepository); + userEntityRepository = createStubInstance(UserEntityRepository); + } + + function givenStubbedService() { + kcService = createStubInstance(KeycloakService); + } +}); diff --git a/api/src/__tests__/controllers/external/subscriptionV1.controller.test.ts b/api/src/__tests__/controllers/external/subscriptionV1.controller.test.ts new file mode 100644 index 0000000..a9b1f2b --- /dev/null +++ b/api/src/__tests__/controllers/external/subscriptionV1.controller.test.ts @@ -0,0 +1,634 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {Readable} from 'stream'; +const amqp = require('amqplib'); + +import { + SubscriptionRepository, + IncentiveRepository, + CitizenRepository, + MetadataRepository, + CommunityRepository, + EnterpriseRepository, + CollectivityRepository, +} from '../../../repositories'; +import {SubscriptionV1Controller} from '../../../controllers/external'; +import { + Subscription, + Citizen, + Incentive, + Metadata, + CreateSubscription, + EncryptionKey, + Collectivity, + PrivateKeyAccess, +} from '../../../models'; +import { + MailService, + RabbitmqService, + S3Service, + SubscriptionService, +} from '../../../services'; +import * as invoiceUtils from '../../../utils/invoice'; +import {AFFILIATION_STATUS, IUser, SUBSCRIPTION_STATUS} from '../../../utils'; +import {Enterprise} from '../../../models/enterprise'; +import {Community} from '../../../models/community'; + +describe('SubscriptionController (unit)', () => { + let subscriptionRepository: StubbedInstanceWithSinonAccessor, + collectivityRepository: StubbedInstanceWithSinonAccessor, + incentiveRepository: StubbedInstanceWithSinonAccessor, + citizenRepository: StubbedInstanceWithSinonAccessor, + metadataRepository: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor, + rabbitmqService: StubbedInstanceWithSinonAccessor, + s3Service: StubbedInstanceWithSinonAccessor, + mailService: StubbedInstanceWithSinonAccessor, + subscptionService: StubbedInstanceWithSinonAccessor, + controller: SubscriptionV1Controller; + const currentUser: IUser = { + id: 'citizenId', + emailVerified: true, + maas: undefined, + membership: ['/citizens'], + roles: ['offline_access', 'uma_authorization'], + [securityId]: 'citizenId', + }; + const input = new CreateSubscription({ + incentiveId: 'incentiveId', + consent: true, + }); + + const inputRepo = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + incentiveTitle: 'incentiveTitle', + citizenId: 'citizenId', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + const inputRepoDraft = new Subscription({ + id: 'randomInputId', + citizenId: 'citizenId', + email: 'email@gmail.com', + status: SUBSCRIPTION_STATUS.DRAFT, + incentiveType: 'AideEmployeur', + }); + const inputRepoDraft1 = new Subscription({ + id: 'randomInputId', + citizenId: 'citizenId', + email: 'email@gmail.com', + status: SUBSCRIPTION_STATUS.DRAFT, + communityId: 'randomInputId', + incentiveType: 'AideEmployeur', + specificFields: ['test', 'test2'], + }); + const inputRepoDraft2 = new Subscription({ + id: 'randomInputId', + citizenId: 'citizenId', + email: 'email@gmail.com', + status: SUBSCRIPTION_STATUS.DRAFT, + communityId: 'randomInputId', + incentiveType: 'AideEmployeur1', + specificFields: ['test', 'test2'], + }); + const hrisFalse = new Enterprise({ + id: 'randomInputId', + isHris: false, + }); + + const hrisTrue = new Enterprise({ + id: 'randomInputId', + isHris: true, + }); + + const name: Community = new Community({ + id: 'randomInputId', + name: 'RabbitCo', + }); + + const citoyen: Citizen = new Citizen({ + id: 'test', + affiliation: Object.assign({ + enterpriseId: 'test', + enterpriseEmail: 'test@gmail.com', + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), + }); + + const subscriptionPayload = { + lastName: 'test', + firstName: 'test', + birthdate: 'test', + citizenId: 'test', + incentiveId: 'test', + subscriptionId: 'test', + email: 'test', + status: SUBSCRIPTION_STATUS.TO_PROCESS, + communityName: 'test', + specificFields: JSON.stringify(['test', 'test']), + attachments: ['urlTest', 'urlTest'], + encryptedAESKey: 'encryptedAESKey', + encryptedIV: 'encryptedIV', + encryptionKeyId: 'encryptionKeyId', + encryptionKeyVersion: 1, + }; + + const fileList: any[] = [ + { + originalname: 'test1.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + { + originalname: 'test2.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + ]; + const mockAttachment = { + body: { + data: JSON.stringify({ + metadataId: 'randomMetadataId', + }), + }, + files: fileList, + }; + const mockAttachmentWithoutMetadata = { + body: '', + files: fileList, + }; + const mockAttachmentWithoutMetadataWithoutFiles = { + body: '', + files: [], + }; + const mockAttachmentWithoutFiles = { + body: { + data: JSON.stringify({ + metadataId: 'randomMetadataId', + }), + }, + files: [], + }; + const invoiceMock = { + originalname: 'invoice.pdf', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }; + const attachmentDataMock: Metadata = Object.assign(new Metadata(), { + attachmentMetadata: { + invoices: [ + { + enterprise: { + enterpriseName: 'IDF Mobilités', + sirenNumber: '362521879', + siretNumber: '36252187900034', + apeCode: '4711D', + enterpriseAddress: { + zipCode: 75018, + city: 'Paris', + street: '6 rue Lepic', + }, + }, + customer: { + customerId: '123789', + customerName: 'DELOIN', + customerSurname: 'Alain', + }, + transaction: { + orderId: '30723', + purchaseDate: new Date('2021-03-03T14:54:18+01:00'), + amountInclTaxes: 7520, + amountExclTaxes: 7520, + }, + products: [ + { + productName: 'Forfait Navigo Mois', + quantity: 1, + amountInclTaxes: 7520, + amountExclTaxes: 7520, + percentTaxes: 10, + productDetails: { + periodicity: 'Mensuel', + zoneMin: 1, + zoneMax: 5, + }, + }, + ], + }, + ], + }, + }); + + const response: any = { + status: function () { + return this; + }, + contentType: function () { + return this; + }, + send: (body: any) => body, + }; + + beforeEach(() => { + givenStubbedRepository(); + controller = new SubscriptionV1Controller( + subscriptionRepository, + incentiveRepository, + citizenRepository, + metadataRepository, + enterpriseRepository, + communityRepository, + rabbitmqService, + s3Service, + mailService, + currentUser, + subscptionService, + collectivityRepository, + ); + }); + + describe('SubscriptionController', () => { + it('SubscriptionV1Controller create : successful', async () => { + subscriptionRepository.stubs.create.resolves(inputRepo); + incentiveRepository.stubs.findById.resolves(mockIncentive); + citizenRepository.stubs.findById.resolves(mockCitizen); + const result = await controller.createSubscription(input); + + expect(result.id).to.equal(inputRepo.id); + }); + + it('SubscriptionV1Controller create : error', async () => { + try { + subscriptionRepository.stubs.create.rejects('Error'); + incentiveRepository.stubs.findById.resolves(mockIncentive); + citizenRepository.stubs.findById.resolves(mockCitizen); + await controller.createSubscription(input); + } catch (err) { + expect(err.name).to.equal('Error'); + } + }); + + it('SubscriptionV1Controller addAttachments : successful with metadata, without files', async () => { + subscriptionRepository.stubs.findById.resolves(newSubscription); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + enterpriseRepository.stubs.findOne.resolves(undefined); + const citizenFindByIdStub = citizenRepository.stubs.findById.resolves( + new Citizen({id: 'Citizen'}), + ); + const metadataFindByIdStub = + metadataRepository.stubs.findById.resolves(attachmentDataMock); + const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves(); + const subscriptionUpdateByIdStub = + subscriptionRepository.stubs.updateById.resolves(); + const invoiceStub = sinon + .stub(invoiceUtils, 'generatePdfInvoices') + .resolves([invoiceMock]); + s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']); + const result = await controller.addAttachments( + 'randomInputId', + mockAttachmentWithoutFiles, + ); + expect(result.id).to.equal(inputRepo.id); + sinon.assert.calledOnce(citizenFindByIdStub); + sinon.assert.calledOnce(metadataFindByIdStub); + sinon.assert.calledOnce(metadataDeleteByIdStub); + sinon.assert.calledOnce(subscriptionUpdateByIdStub); + invoiceStub.restore(); + }); + + it('SubscriptionV1Controller addAttachments : successful with files, without metadata', async () => { + subscriptionRepository.stubs.findById.resolves(newSubscription); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + enterpriseRepository.stubs.findOne.resolves(undefined); + const citizenFindByIdStub = citizenRepository.stubs.findById.resolves( + new Citizen({id: 'Citizen'}), + ); + const metadataFindByIdStub = metadataRepository.stubs.findById.resolves(undefined); + const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves(); + const subscriptionUpdateByIdStub = + subscriptionRepository.stubs.updateById.resolves(); + const invoiceStub = sinon.stub(invoiceUtils, 'generatePdfInvoices').resolves([]); + s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']); + const result = await controller.addAttachments( + 'randomInputId', + mockAttachmentWithoutMetadata, + ); + expect(result.id).to.equal(inputRepo.id); + sinon.assert.calledOnce(citizenFindByIdStub); + sinon.assert.notCalled(metadataFindByIdStub); + sinon.assert.notCalled(metadataDeleteByIdStub); + sinon.assert.calledOnce(subscriptionUpdateByIdStub); + sinon.assert.notCalled(invoiceStub); + invoiceStub.restore(); + }); + + it('SubscriptionV1Controller addAttachments : successful with metadata and files', async () => { + subscriptionRepository.stubs.findById.resolves(newSubscription); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + enterpriseRepository.stubs.findOne.resolves(undefined); + const citizenFindByIdStub = citizenRepository.stubs.findById.resolves( + new Citizen({id: 'Citizen'}), + ); + const metadataFindByIdStub = + metadataRepository.stubs.findById.resolves(attachmentDataMock); + const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves(); + const subscriptionUpdateByIdStub = + subscriptionRepository.stubs.updateById.resolves(); + const invoiceStub = sinon + .stub(invoiceUtils, 'generatePdfInvoices') + .resolves([invoiceMock]); + s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']); + const result = await controller.addAttachments('randomInputId', mockAttachment); + expect(result.id).to.equal(inputRepo.id); + sinon.assert.calledOnce(citizenFindByIdStub); + sinon.assert.calledOnce(metadataFindByIdStub); + sinon.assert.calledOnce(metadataDeleteByIdStub); + sinon.assert.calledOnce(subscriptionUpdateByIdStub); + invoiceStub.restore(); + }); + + it('SubscriptionV1Controller addAttachments : successful without metadata, without files', async () => { + subscriptionRepository.stubs.findById.resolves(newSubscription); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + enterpriseRepository.stubs.findOne.resolves(undefined); + const citizenFindByIdStub = citizenRepository.stubs.findById.resolves( + new Citizen({id: 'Citizen'}), + ); + const metadataFindByIdStub = + metadataRepository.stubs.findById.resolves(attachmentDataMock); + const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves(); + const subscriptionUpdateByIdStub = + subscriptionRepository.stubs.updateById.resolves(); + const invoiceStub = sinon.stub(invoiceUtils, 'generatePdfInvoices').resolves([]); + s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']); + const result = await controller.addAttachments( + 'randomInputId', + mockAttachmentWithoutMetadataWithoutFiles, + ); + expect(result.id).to.equal(inputRepo.id); + sinon.assert.calledOnce(citizenFindByIdStub); + sinon.assert.notCalled(metadataFindByIdStub); + sinon.assert.notCalled(metadataDeleteByIdStub); + sinon.assert.notCalled(subscriptionUpdateByIdStub); + sinon.assert.notCalled(invoiceStub); + invoiceStub.restore(); + }); + + it('SubscriptionV1Controller addFiles : error', async () => { + try { + subscriptionRepository.stubs.findById.resolves(newSubscription); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + enterpriseRepository.stubs.findOne.resolves(undefined); + citizenRepository.stubs.findById.rejects('Error'); + await controller.addAttachments('randomInputId', mockAttachment); + } catch (err) { + expect(err.name).to.equal('Error'); + sinon.restore(); + } + }); + + it('SubscriptionV1Controller finalizeSubscription : successful', async () => { + subscriptionRepository.stubs.findById.resolves(inputRepoDraft); + subscriptionRepository.stubs.updateById.resolves(); + enterpriseRepository.stubs.findById.resolves(hrisFalse); + mailService.stubs.sendMailAsHtml.resolves('ok'); + const result = await controller.finalizeSubscription('randomInputId'); + sinon.assert.calledOnceWithExactly( + subscriptionRepository.stubs.updateById, + 'randomInputId', + {status: SUBSCRIPTION_STATUS.TO_PROCESS}, + ); + + sinon.assert.calledOnceWithExactly( + mailService.stubs.sendMailAsHtml, + inputRepoDraft.email, + 'Confirmation d’envoi de la demande', + 'requests-to-process', + sinon.match.any, + ); + expect(result.id).to.equal(inputRepo.id); + }); + + it('SubscriptionV1Controller finalizeSubscription hris true : successful', async () => { + subscriptionRepository.stubs.findById.resolves(inputRepoDraft); + subscriptionRepository.stubs.updateById.resolves(); + enterpriseRepository.stubs.findById.resolves(hrisTrue); + communityRepository.stubs.findById.resolves(name); + citizenRepository.stubs.findById.resolves(citoyen); + const connection: any = { + createChannel: () => { + return channel; + }, + close: () => {}, + }; + const channel: any = { + publish: () => { + return true; + }, + close: () => {}, + }; + const amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + await rabbitmqService.publishMessage(subscriptionPayload, 'header'); + const result = await controller.finalizeSubscription('randomInputId'); + expect(result.id).to.equal('randomInputId'); + amqpTest.restore(); + }); + + it('SubscriptionV1Controller finalizeSubscription hris true and commaunityId : successful', async () => { + subscriptionRepository.stubs.findById.resolves(inputRepoDraft1); + subscriptionRepository.stubs.updateById.resolves(); + enterpriseRepository.stubs.findById.resolves(hrisTrue); + communityRepository.stubs.findById.resolves(name); + citizenRepository.stubs.findById.resolves(citoyen); + const connection: any = { + createChannel: () => { + return channel; + }, + close: () => {}, + }; + const channel: any = { + publish: () => { + return true; + }, + close: () => {}, + }; + const amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + await rabbitmqService.publishMessage(subscriptionPayload, 'header'); + const result = await controller.finalizeSubscription('randomInputId'); + expect(result.id).to.equal('randomInputId'); + amqpTest.restore(); + }); + + it('SubscriptionV1Controller finalizeSubscription diffrent funderType: successful', async () => { + subscriptionRepository.stubs.findById.resolves(inputRepoDraft2); + subscriptionRepository.stubs.updateById.resolves(); + enterpriseRepository.stubs.findById.resolves(hrisTrue); + communityRepository.stubs.findById.resolves(name); + citizenRepository.stubs.findById.resolves(citoyen); + await rabbitmqService.publishMessage(subscriptionPayload, 'header'); + const result = await controller.finalizeSubscription('randomInputId'); + expect(result.id).to.equal('randomInputId'); + }); + it('SubscriptionV1Controller finalizeSubscription without entreprise : error', async () => { + try { + subscriptionRepository.stubs.findById.resolves(inputRepoDraft1); + subscriptionRepository.stubs.updateById.resolves(); + communityRepository.stubs.findById.resolves(name); + citizenRepository.stubs.findById.resolves(citoyen); + await rabbitmqService.publishMessage(subscriptionPayload, 'header'); + await controller.finalizeSubscription('randomInputId'); + } catch (err) { + expect(err.id).to.equal('randomInputId'); + } + }); + + it('SubscriptionV1Controller finalizeSubscription : error', async () => { + try { + subscriptionRepository.stubs.findById.resolves(inputRepoDraft); + subscriptionRepository.stubs.updateById.rejects('Error'); + await controller.finalizeSubscription('randomInputId'); + } catch (err) { + expect(err.name).to.equal('Error'); + } + }); + + // get subscription TU + it('get(v1/maas/subscriptions)', async () => { + subscriptionRepository.stubs.find.resolves([newSubscription]); + incentiveRepository.stubs.find.resolves([mockIncentive]); + const result = await controller.findMaasSubscription(); + expect(result).to.deepEqual([mockSubscription]); + }); + }); + + function givenStubbedRepository() { + subscriptionRepository = createStubInstance(SubscriptionRepository); + collectivityRepository = createStubInstance(CollectivityRepository); + incentiveRepository = createStubInstance(IncentiveRepository); + citizenRepository = createStubInstance(CitizenRepository); + metadataRepository = createStubInstance(MetadataRepository); + enterpriseRepository = createStubInstance(EnterpriseRepository); + communityRepository = createStubInstance(CommunityRepository); + rabbitmqService = createStubInstance(RabbitmqService); + s3Service = createStubInstance(S3Service); + mailService = createStubInstance(MailService); + subscptionService = createStubInstance(SubscriptionService); + } +}); + +const mockIncentive = new Incentive({ + id: 'incentiveId', + title: 'incentiveTitle', + transportList: ['velo'], + contact: 'Contactez le numéro vert au 05 206 308', +}); +const mockCitizen = new Citizen({ + id: 'citizenId', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + email: 'email@gmail.com', +}); + +const newSubscription = new Subscription({ + id: '619e3ff38dd34e1774b60789', + incentiveId: 'incentiveId', + funderName: 'Rabat', + funderId: 'funderId', + incentiveTitle: "Bonus Ecologique pour l'achat d'un vélo électrique", + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-11-24T13:36:51.423Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const mockSubscription = { + id: '619e3ff38dd34e1774b60789', + incentiveId: 'incentiveId', + funderName: 'Rabat', + incentiveTitle: "Bonus Ecologique pour l'achat d'un vélo électrique", + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-11-24T13:36:51.423Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + contact: 'Contactez le numéro vert au 05 206 308', + funderId: 'funderId', +}; + +const today = new Date(); +const expirationDate = new Date(today.setMonth(today.getMonth() + 3)); + +const mockencryptionKeyValid = new EncryptionKey({ + id: '62977dc80929474f84c403de', + version: 1, + publicKey: `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq +n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf +n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq +sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85 +OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG +X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n +owIDAQAB +-----END PUBLIC KEY----- +`, + expirationDate, + lastUpdateDate: new Date(), + privateKeyAccess: new PrivateKeyAccess({loginURL: 'loginURL', getKeyURL: 'getKeyURL'}), +}); + +const mockCollectivity = new Collectivity({ + id: '2b6ee373-4c5b-403b-afe5-3bf3cbd2473', + encryptionKey: mockencryptionKeyValid, +}); diff --git a/api/src/__tests__/controllers/funder.controller.test.ts b/api/src/__tests__/controllers/funder.controller.test.ts new file mode 100644 index 0000000..34688e3 --- /dev/null +++ b/api/src/__tests__/controllers/funder.controller.test.ts @@ -0,0 +1,330 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {FunderController} from '../../controllers'; +import { + Collectivity, + Community, + Enterprise, + EncryptionKey, + PrivateKeyAccess, + Client, +} from '../../models'; +import {FunderService} from '../../services'; + +import { + CommunityRepository, + EnterpriseRepository, + CollectivityRepository, + ClientScopeRepository, +} from '../../repositories'; +import {ValidationError} from '../../validationError'; +import {FUNDER_TYPE, ResourceName, StatusCode, IUser} from '../../utils'; +import {Clients} from 'keycloak-admin/lib/resources/clients'; + +describe('Funder Controller ', () => { + let collectivityRepository: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + clientScopeRepository: StubbedInstanceWithSinonAccessor, + funderService: StubbedInstanceWithSinonAccessor, + controller: FunderController; + + beforeEach(() => { + givenStubbedCollectivityRepository(); + givenStubbedCommunityRepository(); + givenStubbedEnterpriseRepository(); + givenStubbedFunderService(); + controller = new FunderController( + communityRepository, + enterpriseRepository, + collectivityRepository, + funderService, + clientScopeRepository, + ); + }); + + it('get(/v1/funders)', async () => { + funderService.stubs.getFunders.resolves(mockReturnFunder); + const result = await controller.find(); + + expect(result).to.deepEqual(mockReturnFunder); + funderService.stubs.getFunders.restore(); + }); + + it('FunderController create : succeeded-enterprise', async () => { + const community = new Community({ + name: 'random', + funderId: 'randomId', + }); + + communityRepository.stubs.find.resolves([]); + enterpriseRepository.stubs.find.resolves([ + new Enterprise({name: 'randomEnterprise'}), + ]); + communityRepository.stubs.create.resolves(community); + + const res = await controller.create(community); + expect(res).to.deepEqual(community); + }); + + it('FunderController create : succeeded-collectivity', async () => { + const community = new Community({ + name: 'random', + funderId: 'randomId', + }); + + communityRepository.stubs.find.resolves([]); + enterpriseRepository.stubs.find.resolves([]); + collectivityRepository.stubs.find.resolves([ + new Collectivity({name: 'randomEnterprise'}), + ]); + communityRepository.stubs.create.resolves(community); + + const res = await controller.create(community); + expect(res).to.deepEqual(community); + }); + + it('FunderController create : fails because name already exists', async () => { + const community = new Community({ + name: 'random', + funderId: 'randomId', + }); + const communityExisted = new Community({ + name: 'random', + funderId: 'randomId2', + }); + + const error = new ValidationError( + `communities.error.name.unique`, + `/communities`, + StatusCode.UnprocessableEntity, + ResourceName.Community, + ); + try { + communityRepository.stubs.find.resolves([communityExisted]); + + await controller.create(community); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('FunderController create : fails because funder is not present', async () => { + const community = new Community({ + name: 'random', + funderId: 'randomId', + }); + const error = new ValidationError( + `communities.error.funders.missed`, + `/communities`, + StatusCode.UnprocessableEntity, + ResourceName.Community, + ); + + communityRepository.stubs.find.resolves([]); + enterpriseRepository.stubs.find.resolves([]); + collectivityRepository.stubs.find.resolves([]); + try { + const res = await controller.create(community); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('FunderController get by funderId : successful', async () => { + const funderId = 'randomFunderId'; + const communitiesResult: [] = []; + communityRepository.stubs.findByFunderId.resolves(communitiesResult); + const res = await controller.findByFunderId(funderId); + + expect(res).to.deepEqual(communitiesResult); + }); + + it('FunderController count : successful', async () => { + const countRes = { + count: 10, + }; + + communityRepository.stubs.count.resolves(countRes); + const result = await controller.count(); + + expect(result).to.deepEqual(countRes); + }); + + it('FunderController v1/funders/communities', async () => { + communityRepository.stubs.find.resolves([newCommunity]); + funderService.stubs.getFunders.resolves(mockReturnFunder); + const result = await controller.findCommunities(); + + expect(result).to.deepEqual([mockAllCommunities]); + funderService.stubs.getFunders.restore(); + communityRepository.stubs.find.restore(); + }); + + it('encryption_key : asserts encryption key has been stored for collectivity', async () => { + collectivityRepository.stubs.findOne.resolves(mockCollectivity2); + collectivityRepository.stubs.updateById.resolves(); + enterpriseRepository.stubs.create.resolves(undefined); + await controller.storeEncryptionKey(mockCollectivity2.id, mockencryptionKeyValid); + sinon.assert.called(collectivityRepository.stubs.updateById); + }); + + it('encryption_key : asserts encryption key has been stored for enterprise', async () => { + collectivityRepository.stubs.findOne.resolves(undefined); + enterpriseRepository.stubs.updateById.resolves(); + enterpriseRepository.stubs.findOne.resolves(mockEnterprise); + await controller.storeEncryptionKey(mockEnterprise.id, mockencryptionKeyValid); + sinon.assert.called(enterpriseRepository.stubs.updateById); + }); + + it('findFunderById : returns enterprise when funder is of type enterprise', async () => { + collectivityRepository.stubs.findOne.resolves(undefined); + enterpriseRepository.stubs.findOne.resolves(mockEnterprise); + const enterprise = await controller.findFunderById(mockEnterprise.id); + expect(enterprise).to.deepEqual(mockEnterprise); + }); + + it('findFunderById : returns collectivity when funder is of type collectivity', async () => { + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + enterpriseRepository.stubs.findOne.resolves(undefined); + const collectivity = await controller.findFunderById(mockCollectivity.id); + expect(collectivity).to.deepEqual(mockCollectivity); + }); + + it('findClients : returns list of clients', async () => { + clientScopeRepository.stubs.getClients.resolves(mockClientsList); + const clients = await controller.findClients(); + expect(clients).to.deepEqual(mockClientsList); + }); + + it('findFunderById : throws error when funder not found', async () => { + const funderNotFoundError = new ValidationError( + `Funder not found`, + `/Funder`, + StatusCode.NotFound, + ResourceName.Funder, + ); + try { + collectivityRepository.stubs.findOne.resolves(undefined); + enterpriseRepository.stubs.findOne.resolves(undefined); + await controller.findFunderById('wrongFunderId'); + sinon.assert.fail(); + } catch (error) { + expect(error).to.deepEqual(funderNotFoundError); + } + }); + + function givenStubbedCollectivityRepository() { + collectivityRepository = createStubInstance(CollectivityRepository); + } + + function givenStubbedCommunityRepository() { + communityRepository = createStubInstance(CommunityRepository); + clientScopeRepository = createStubInstance(ClientScopeRepository); + } + + function givenStubbedFunderService() { + funderService = createStubInstance(FunderService); + } + + function givenStubbedEnterpriseRepository() { + enterpriseRepository = createStubInstance(EnterpriseRepository); + } +}); + +const today = new Date(); +const expirationDate = new Date(today.setMonth(today.getMonth() + 7)); +const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq +n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf +n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq +sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85 +OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG +X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n +owIDAQAB +-----END PUBLIC KEY----- +`; + +const mockencryptionKeyValid = new EncryptionKey({ + id: '62977dc80929474f84c403de', + version: 1, + publicKey, + expirationDate, + lastUpdateDate: new Date(), + privateKeyAccess: new PrivateKeyAccess( + new PrivateKeyAccess({loginURL: 'loginURL', getKeyURL: 'getKeyURL'}), + ), +}); + +const mockClientsList = [ + { + clientId: '62977dc80929474f84c403de', + } as Client, +]; +const mockencryptionKeyNogetKeyURL = new EncryptionKey({ + id: '62977dc80929474f84c403de', + version: 1, + publicKey, + expirationDate, + lastUpdateDate: new Date(), +}); + +const mockCollectivity2 = new Collectivity({ + id: '2b6ee373-4c5b-403b-afe5-3bf3cbd2473c', + name: 'Mulhouse', + citizensCount: 1, + mobilityBudget: 1, + encryptionKey: mockencryptionKeyValid, +}); + +const mockCollectivity = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + citizensCount: 10, + mobilityBudget: 12, + encryptionKey: mockencryptionKeyValid, +}); + +const mockEnterprise = new Enterprise({ + id: 'randomInputIdEnterprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'nameEnterprise', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + encryptionKey: mockencryptionKeyValid, +}); + +const mockReturnFunder = [ + {...mockCollectivity, funderType: FUNDER_TYPE.collectivity}, + {...mockEnterprise, funderType: FUNDER_TYPE.enterprise}, +]; + +const newCommunity = new Community({ + id: '6175d61442ebca0660ddf3fb', + name: 'fio', + funderId: 'randomInputIdEnterprise', +}); + +const mockAllCommunities = { + funderId: 'randomInputIdEnterprise', + funderName: 'nameEnterprise', + funderType: 'Entreprise', + id: '6175d61442ebca0660ddf3fb', + name: 'fio', +}; + +const currentUser: IUser = { + id: 'idUser', + emailVerified: true, + maas: undefined, + membership: ['/entreprises/Capgemini'], + roles: ['gestionnaires'], + [securityId]: 'idEnterprise', +}; diff --git a/api/src/__tests__/controllers/incentive.controller.test.ts b/api/src/__tests__/controllers/incentive.controller.test.ts new file mode 100644 index 0000000..9dcd289 --- /dev/null +++ b/api/src/__tests__/controllers/incentive.controller.test.ts @@ -0,0 +1,1325 @@ +import {AnyObject} from '@loopback/repository'; +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {IncentiveController} from '../../controllers'; +import { + Incentive, + Collectivity, + Enterprise, + Citizen, + Link, + Territory, +} from '../../models'; +import { + CitizenRepository, + CollectivityRepository, + EnterpriseRepository, + IncentiveRepository, + TerritoryRepository, +} from '../../repositories'; +import {ValidationError} from '../../validationError'; +import {IncentiveService, FunderService} from '../../services'; +import { + AFFILIATION_STATUS, + CITIZEN_STATUS, + Roles, + StatusCode, + HTTP_METHOD, + GET_INCENTIVES_INFORMATION_MESSAGES, + FUNDER_TYPE, + IUser, + ResourceName, +} from '../../utils'; +import {WEBSITE_FQDN} from '../../constants'; +import {TerritoryService} from '../../services/territory.service'; + +describe('Incentives Controller', () => { + let repository: StubbedInstanceWithSinonAccessor, + repositoryCollectivity: StubbedInstanceWithSinonAccessor, + repositoryEnterprise: StubbedInstanceWithSinonAccessor, + repositoryCitizen: StubbedInstanceWithSinonAccessor, + repositoryTerritory: StubbedInstanceWithSinonAccessor, + incentiveService: StubbedInstanceWithSinonAccessor, + funderService: StubbedInstanceWithSinonAccessor, + territoryService: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedCollectivityRepository(); + givenStubbedEntrpriseRepository(); + givenStubbedCitizenRepository(); + givenStubbedTerritoryRepository(); + givenStubbedIncentiveService(); + givenStubbedFunderService(); + givenStubbedTerritoryService(); + }); + + it('post(/v1/incentives) with funderName=AideEmployeur', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryMock); + repository.stubs.create.resolves(mockCreateEnterpriseIncentive); + repositoryEnterprise.stubs.find.resolves([mockEnterprise]); + const result = await controller.create(mockEnterpriseIncentive); + + expect(result.funderId).to.deepEqual('randomInputIdEnterprise'); + }); + + it('post(/v1/incentives) with funderName=AideEmployeur but enterprise not exist', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + try { + repositoryTerritory.stubs.findById.resolves(territoryMock); + repository.stubs.create.resolves(mockCreateEnterpriseIncentive); + repositoryEnterprise.stubs.find.resolves([]); + await controller.create(mockEnterpriseIncentive); + } catch ({message}) { + expect(error.message).to.equal(message); + } + }); + + it('post(/v1/incentives) with funderName=AideTerritoire', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryMock); + repository.stubs.create.resolves(mockCreateCollectivityIncentive); + repositoryCollectivity.stubs.find.resolves([mockCollectivity]); + const result = await controller.create(mockCollectivityIncentive); + + expect(result.funderId).to.deepEqual('randomInputIdCollectivity'); + }); + + it('post(/v1/incentives) with specific fields', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryMock); + repository.stubs.create.resolves(mockCreateIncentiveWithSpecificFields); + repositoryCollectivity.stubs.find.resolves([mockCollectivity]); + const result = await controller.create(mockIncentiveWithSpecificFields); + + expect(result.funderId).to.deepEqual('randomInputIdCollectivity'); + }); + + it('post(/v1/incentives) with territory mismatched name', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryWrongName); + + try { + await controller.create(mockEnterpriseIncentive); + } catch ({message}) { + expect(territoryNameMismatch.message).to.equal(message); + } + }); + + it('post(/v1/incentives) create territory', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + territoryService.stubs.createTerritory.resolves(territoryMock); + repository.stubs.create.resolves(mockCreateEnterpriseIncentive); + repositoryEnterprise.stubs.find.resolves([mockEnterprise]); + const result = await controller.create(mockIncentiveTerritoryPayload); + expect(result.funderId).to.deepEqual('randomInputIdEnterprise'); + }); + + it('post(/v1/incentives) transaction', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + try { + territoryService.stubs.createTerritory.resolves(territoryMock); + repository.stubs.create.resolves(mockCreateEnterpriseIncentive); + repositoryEnterprise.stubs.find.resolves([]); + repositoryTerritory.stubs.deleteById.resolves(); + await controller.create(mockEnterpriseIncentivePayload2); + } catch ({message}) { + expect(error.message).to.equal(message); + } + }); + + it('GET v1/incentives returns an error when a user or maas token is not provided', async () => { + const noToken = new ValidationError( + 'Authorization header not found', + '/authorization', + StatusCode.Unauthorized, + ); + try { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.find.resolves([]); + const response: any = {}; + await controller.find(response); + } catch (err) { + expect(err).to.equal(noToken); + } + }); + it('GET /v1/incentives should return public and private incentives for user content_editor', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.find.resolves([mockIncentiveWithSpecificFields]); + const response: any = {}; + const result = await controller.find(response); + + expect(result).to.deepEqual([mockIncentiveWithSpecificFields]); + }); + + it('GET /v1/incentives should return incentive belonging to user manager connected', async () => { + const userManager = currentUser; + userManager.roles = [Roles.MANAGERS]; + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + userManager, + ); + repository.stubs.find.resolves([mockEnterpriseIncentive]); + const response: any = {}; + const result = controller.find(response); + + expect(result).to.deepEqual(mockReturnIncentivesFunderEnterprise); + }); + + it('GET v1/incentives should return public incentives with user service_maas', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUserMaas, + ); + const incentiveFindStub = repository.stubs.find; + incentiveFindStub.onCall(0).resolves(mockReturnPublicAid); + incentiveFindStub.onCall(1).resolves(mockReturnPrivateAid); + funderService.stubs.getFunders.resolves([newFunder]); + repositoryCitizen.stubs.findOne.resolves(mockCitizen); + const response: any = {}; + const result: any = await controller.find(response); + expect(result).to.have.length(2); + expect(result).to.deepEqual(mockReturnPublicAid); + funderService.stubs.getFunders.restore(); + repositoryCitizen.stubs.findOne.restore(); + repository.stubs.find.restore(); + }); + + it('GET v1/incentives should return message when citizen affiliated but no private incentive', async () => { + const message = + GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_AFFILIATED_WITHOUT_INCENTIVES; + const userMaaS = currentUser; + userMaaS.roles = [Roles.MAAS]; + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + userMaaS, + ); + funderService.stubs.getFunders.resolves([newFunder]); + repositoryCitizen.stubs.findOne.resolves(mockCitizen); + repository.stubs.find.resolves([]); + const response: any = { + status: function () { + return this; + }, + contentType: function () { + return this; + }, + send: (buffer: Buffer) => buffer, + }; + const result: any = await controller.find(response); + expect(result.message).to.equal(message); + }); + + it('GET v1/incentives returns public incentives if has no affiliation', async () => { + const userMaaS = currentUser; + userMaaS.roles = [Roles.MAAS]; + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + userMaaS, + ); + repository.stubs.find.resolves(mockReturnPublicAid); + funderService.stubs.getFunders.resolves([]); + repositoryCitizen.stubs.findOne.resolves(mockCitizenNonSalarie); + repository.stubs.find.resolves(mockReturnPublicAid); + const response: any = {}; + const result: any = await controller.find(response); + + expect(result).to.have.length(2); + expect(result).to.deepEqual(mockReturnPublicAid); + + funderService.stubs.getFunders.restore(); + repositoryCitizen.stubs.findOne.restore(); + repository.stubs.find.restore(); + }); + + it('GET v1/incentives should return message if citizen not affiliated', async () => { + const message = GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_NOT_AFFILIATED; + const userMaaS = currentUser; + userMaaS.roles = [Roles.MAAS]; + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + userMaaS, + ); + funderService.stubs.getFunders.resolves([newFunder]); + repositoryCitizen.stubs.findOne.resolves(mockReturnCitizenNotAffiliated); + repository.stubs.find.resolves(mockReturnPublicAid); + const response: any = { + status: function () { + return this; + }, + contentType: function () { + return this; + }, + send: (buffer: Buffer) => buffer, + }; + const result: any = await controller.find(response); + expect(result.message).to.equal(message); + }); + + it('GET v1/incentives returns the public and private incentives when citizen affiliated', async () => { + const userMaaS = currentUser; + userMaaS.roles = [Roles.MAAS]; + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + userMaaS, + ); + const incentiveFindStub = repository.stubs.find; + incentiveFindStub.onCall(0).resolves(mockReturnPublicAid); + incentiveFindStub.onCall(1).resolves(mockReturnPrivateAid); + funderService.stubs.getFunders.resolves([newFunder]); + repositoryCitizen.stubs.findOne.resolves(mockCitizen); + const response: any = { + status: function () { + return this; + }, + contentType: function () { + return this; + }, + send: (buffer: Buffer) => buffer, + }; + const result: any = await controller.find(response); + + expect(result).to.have.length(3); + expect(result).to.deepEqual([...mockReturnPublicAid, ...mockReturnPrivateAid]); + + funderService.stubs.getFunders.restore(); + repositoryCitizen.stubs.findOne.restore(); + repository.stubs.findOne.restore(); + }); + + it('get(/v1/incentives/search)', done => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.execute.resolves([mockIncentiveWithSpecificFields]); + const incentiveList = controller + .search('AideNationale, AideTerritoire', 'vélo') + .then(res => res) + .catch(err => err); + + expect(incentiveList).to.deepEqual(mockReturnIncentives); + done(); + }); + + it('get(/v1/incentives/count)', async () => { + const countRes = { + count: 10, + }; + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.count.resolves(countRes); + const result = await controller.count(); + + expect(result).to.deepEqual(countRes); + }); + + it('get(/v1/incentives/{incentiveId})', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + const mockIncentive = Object.assign({}, mockCollectivityIncentive, { + isMCMStaff: false, + }); + repository.stubs.findById.resolves(mockIncentive); + const incentive = await controller.findIncentiveById('606c236a624cec2becdef276'); + + expect(incentive).to.deepEqual(mockIncentive); + }); + + it('get(/v1/incentives/{incentiveId}) with links', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.findById.resolves(mockIncentive); + const links = [ + new Link({ + href: `${WEBSITE_FQDN}/subscriptions/new?incentiveId=randomNationalId`, + rel: 'subscribe', + method: HTTP_METHOD.GET, + }), + ]; + const result = await controller.findIncentiveById('randomNationalId'); + mockIncentive.links = links; + expect(result).to.deepEqual(mockIncentive); + }); + + it('patch(/v1/incentives/{incentiveId}) territory id not given', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.updateById.resolves(); + try { + await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithoutTerritoryId, + ); + } catch (err) { + expect(err).to.deepEqual(territoryIdNotGiven); + } + }); + + it('patch(/v1/incentives/{incentiveId}) mismatch territory name', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryWrongName); + repository.stubs.updateById.resolves(); + try { + await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithSubscriptionLink, + ); + } catch (err) { + expect(err).to.deepEqual(territoryNameMismatch); + } + }); + + it('patch(/v1/incentives/{incentiveId}) subscription link', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryMock); + + repository.stubs.updateById.resolves(); + const incentiveList = await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithSubscriptionLink, + ); + + expect(incentiveList).to.deepEqual(mockIncentiveWithSubscriptionLink); + }); + it('patch(/v1/incentives/{incentiveId}) additionalInfos', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.updateById.resolves(); + const incentiveList = await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithoutAdditionalInfos, + ); + + expect(incentiveList).to.deepEqual(mockIncentiveWithoutAdditionalInfos); + }); + it('patch(/v1/incentives/{incentiveId}) validityDate', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.updateById.resolves(); + const incentiveList = await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithoutValidityDate, + ); + + expect(incentiveList).to.deepEqual(mockIncentiveWithoutValidityDate); + }); + it('patch(/v1/incentives/{incentiveId}) validityDuration', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.updateById.resolves(); + const incentiveList = await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithoutValidityDuration, + ); + + expect(incentiveList).to.deepEqual(mockIncentiveWithoutValidityDuration); + }); + it('patch(/v1/incentives/{incentiveId}) contact', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.updateById.resolves(); + const incentiveList = await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithoutContact, + ); + + expect(incentiveList).to.deepEqual(mockIncentiveWithoutContact); + }); + + it('patch(/v1/incentives/{incentiveId}) specific fields', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repositoryTerritory.stubs.findById.resolves(territoryMock); + repository.stubs.updateById.resolves(); + const incentiveList = await controller.updateById( + '606c236a624cec2becdef276', + mockIncentiveWithSpecificFields, + ); + + expect(incentiveList).to.deepEqual(mockIncentiveWithSpecificFields); + }); + + it('del(/v1/incentives/{incentiveId})', async () => { + const controller = new IncentiveController( + repository, + repositoryCollectivity, + repositoryEnterprise, + repositoryCitizen, + repositoryTerritory, + incentiveService, + funderService, + territoryService, + currentUser, + ); + repository.stubs.deleteById.resolves(); + + const incentiveList = await controller.deleteById('606c236a624cec2becdef276'); + + expect(incentiveList).to.deepEqual(undefined); + }); + + function givenStubbedRepository() { + repository = createStubInstance(IncentiveRepository); + } + function givenStubbedCollectivityRepository() { + repositoryCollectivity = createStubInstance(CollectivityRepository); + } + + function givenStubbedEntrpriseRepository() { + repositoryEnterprise = createStubInstance(EnterpriseRepository); + } + + function givenStubbedCitizenRepository() { + repositoryCitizen = createStubInstance(CitizenRepository); + } + + function givenStubbedTerritoryRepository() { + repositoryTerritory = createStubInstance(TerritoryRepository); + } + + function givenStubbedIncentiveService() { + incentiveService = createStubInstance(IncentiveService); + } + + function givenStubbedFunderService() { + funderService = createStubInstance(FunderService); + } + + function givenStubbedTerritoryService() { + territoryService = createStubInstance(TerritoryService); + } +}); + +const mockCollectivityIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockCreateCollectivityIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + funderId: 'randomInputIdCollectivity', +}); + +const mockEnterpriseIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'Capgemini', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideEmployeur', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockCreateEnterpriseIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'nameEnterprise', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideEmployeur', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + funderId: 'randomInputIdEnterprise', +}); + +const mockCreateIncentiveWithSpecificFields = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + funderId: 'randomInputIdCollectivity', + specificFields: [ + { + title: 'Liste de choix', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'choix1', + }, + { + inputChoice: 'choix2', + }, + ], + }, + }, + { + title: 'Un texte', + inputFormat: 'Texte', + }, + ], + jsonSchema: { + properties: { + 'Liste de choix': { + type: 'array', + maxItems: 2, + items: [ + { + enum: ['choix1', 'choix2'], + }, + ], + }, + 'Un texte': { + type: 'string', + minLength: 1, + }, + }, + title: 'Aide pour acheter vélo électrique', + type: 'object', + required: ['Liste de choix', 'Un texte'], + }, +}); + +const mockIncentiveWithSpecificFields = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + specificFields: [ + { + title: 'Liste de choix', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'choix1', + }, + { + inputChoice: 'choix2', + }, + ], + }, + }, + { + title: 'Un texte', + inputFormat: 'Texte', + }, + ], +}); + +const mockIncentiveWithSubscriptionLink = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: false, + subscriptionLink: 'http://link.com', +}); +const mockIncentiveWithoutAdditionalInfos = new Incentive({ + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: false, + subscriptionLink: 'http://link.com', +}); +const mockIncentiveWithoutValidityDate = new Incentive({ + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: false, + subscriptionLink: 'http://link.com', +}); +const mockIncentiveWithoutValidityDuration = new Incentive({ + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: false, + subscriptionLink: 'http://link.com', +}); +const mockIncentiveWithoutContact = new Incentive({ + additionalInfos: 'test', + funderName: 'nameTerritoire', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: false, + subscriptionLink: 'http://link.com', +}); + +const mockReturnIncentives: Promise = new Promise(() => { + return [ + { + id: '606c236a624cec2becdef276', + title: 'Aide pour acheter vélo électrique', + minAmount: 'A partir de 100 €', + incentiveType: 'AideTerritoire', + transportList: ['vélo'], + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }, + { + id: '606c236a624cec2becdef276', + title: 'Bonus écologique pour une voiture ou une camionnette électrique ou hybride', + minAmount: 'A partir de 1 000 €', + incentiveType: 'AideNationale', + transportList: ['autopartage', 'voiture'], + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }, + ]; +}); + +const mockCollectivity = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + citizensCount: 10, + mobilityBudget: 12, +}); + +const mockEnterprise = new Enterprise({ + id: 'randomInputIdEnterprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'nameEnterprise', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, +}); + +const mockReturnIncentivesFunderEnterprise: Promise = new Promise(() => { + return [mockEnterpriseIncentive]; +}); + +const error = new ValidationError( + `incentives.error.fundername.enterprise.notExist`, + '/enterpriseNotExist', +); + +const currentUser: IUser = { + id: 'idEnterprise', + emailVerified: true, + maas: undefined, + membership: ['/entreprises/Capgemini'], + roles: ['content_editor', 'gestionnaires'], + [securityId]: 'idEnterprise', +}; + +const currentUserMaas: IUser = { + id: 'citizenId', + emailVerified: true, + clientName: undefined, + membership: ['/citoyens'], + roles: ['offline_access', 'uma_authorization', 'maas', 'service_maas'], + [securityId]: 'citizenId', +}; +const mockReturnCitizenNotAffiliated = new Citizen({ + id: 'citizenId', + email: 'kennyg@gmail.com', + identity: Object.assign({ + firstName: Object.assign({ + value: 'Gerard', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Kenny', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '1994-02-18T00:00:00.000Z', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + city: 'Mulhouse', + postcode: '75000', + status: CITIZEN_STATUS.EMPLOYEE, + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'someFunderId', + enterpriseEmail: 'walid.housni@adevinta.com', + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), +}); + +const mockReturnPrivateAid = [ + new Incentive({ + id: 'randomEmployeurId', + title: 'Mulhouse', + funderName: 'nameEnterprise', + incentiveType: 'AideEmployeur', + minAmount: '200', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + transportList: ['transportsCommun', 'voiture'], + validityDate: '2023-06-07T00:00:00.000Z', + funderId: 'someFunderId', + }), +]; + +const mockReturnPublicAid = [ + new Incentive({ + id: 'randomTerritoireId', + title: 'Aide pour acheter vélo électrique', + minAmount: 'A partir de 100 €', + incentiveType: 'AideTerritoire', + transportList: ['vélo'], + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }), + new Incentive({ + id: '606c236a624cec2becdef276', + title: 'Bonus écologique pour une voiture ou une camionnette électrique ou hybride', + minAmount: 'A partir de 1 000 €', + incentiveType: 'AideNationale', + transportList: ['autopartage', 'voiture'], + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }), +]; + +const newFunder = { + id: 'someFunderId', + name: 'nameEnterprise', + funderType: FUNDER_TYPE.enterprise, +}; + +const mockCitizen = new Citizen({ + id: 'citizenId', + email: 'kennyg@gmail.com', + identity: Object.assign({ + firstName: Object.assign({ + value: 'Gerard', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Kenny', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '1994-02-18T00:00:00.000Z', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + city: 'Mulhouse', + postcode: '75000', + status: CITIZEN_STATUS.EMPLOYEE, + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'someFunderId', + enterpriseEmail: 'walid.housni@adevinta.com', + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), +}); + +const mockCitizenNonSalarie = new Citizen({ + email: 'samy-youssef@gmail.com', + identity: Object.assign({ + firstName: Object.assign({ + value: 'youssef', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Samy', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '1995-02-18T00:00:00.000Z', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + city: 'Paris', + postcode: '75000', + status: CITIZEN_STATUS.STUDENT, + tos1: true, + tos2: true, +}); + +const mockIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'randomNationalId', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockIncentiveWithoutTerritoryId = new Incentive({ + territory: {name: 'Toulouse', id: ''} as Territory, +}); + +const territoryIdNotGiven = new ValidationError( + `territory.id.undefined`, + `/territory`, + StatusCode.PreconditionFailed, + ResourceName.Territory, +); + +const territoryNameMismatch = new ValidationError( + `territory.name.mismatch`, + `/territory`, + StatusCode.UnprocessableEntity, + ResourceName.Territory, +); + +const territoryMock = new Territory({ + name: 'Toulouse', + id: 'test', +}); + +const territoryWrongName = new Territory({ + name: 'Toulouse Wrong Name', + id: '245', +}); + +const mockIncentiveTerritoryPayload = new Incentive({ + territory: {name: 'Toulouse'} as Territory, + additionalInfos: 'test', + funderName: 'nameEnterprise', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideEmployeur', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + funderId: 'randomInputIdEnterprise', +}); + +const mockEnterpriseIncentivePayload2 = new Incentive({ + territory: {name: 'Toulouse'} as Territory, + additionalInfos: 'test', + funderName: 'Capgemini', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideEmployeur', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef276', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); diff --git a/api/src/__tests__/controllers/subscription.controller.test.ts b/api/src/__tests__/controllers/subscription.controller.test.ts new file mode 100644 index 0000000..457e895 --- /dev/null +++ b/api/src/__tests__/controllers/subscription.controller.test.ts @@ -0,0 +1,556 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import { + CitizenRepository, + CommunityRepository, + EnterpriseRepository, + MetadataRepository, + SubscriptionRepository, + UserRepository, +} from '../../repositories'; +import {SubscriptionController} from '../../controllers'; +import { + Subscription, + Community, + User, + Metadata, + AttachmentMetadata, + Citizen, + ValidationSinglePayment, + CommonRejection, + OtherReason, +} from '../../models'; +import {S3Service, SubscriptionService, FunderService, MailService} from '../../services'; +import {ValidationError} from '../../validationError'; +import { + CITIZEN_STATUS, + INCENTIVE_TYPE, + IUser, + REJECTION_REASON, + ResourceName, + StatusCode, + SUBSCRIPTION_STATUS, +} from '../../utils'; + +describe('SubscriptionController', () => { + let repository: StubbedInstanceWithSinonAccessor, + s3Service: StubbedInstanceWithSinonAccessor, + subscriptionService: SubscriptionService, + controller: SubscriptionController, + funderService: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor, + userRepository: StubbedInstanceWithSinonAccessor, + subscriptionRepository: StubbedInstanceWithSinonAccessor, + metadataRepository: StubbedInstanceWithSinonAccessor, + citizenRepository: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + mailService: MailService, + spy: any, + input: any, + input1: any, + input2: any; + + const response: any = { + status: function () { + return this; + }, + contentType: function () { + return this; + }, + send: (body: any) => body, + }; + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedService(); + subscriptionService = new SubscriptionService( + s3Service, + subscriptionRepository, + citizenRepository, + mailService, + communityRepository, + enterpriseRepository, + ); + spy = sinon.spy(subscriptionService); + controller = new SubscriptionController( + repository, + s3Service, + spy, + funderService, + communityRepository, + metadataRepository, + userRepository, + citizenRepository, + currentUser, + response, + mailService, + ); + input = {...initInput}; + input1 = {...initInput1}; + input2 = {...initInput2}; + }); + + it('SubscriptionController find : successful', async () => { + repository.stubs.find.resolves([input]); + userRepository.stubs.findOne.resolves(user); + funderService.stubs.getFunderByName.resolves({ + name: 'funderName', + funderType: 'entreprise', + id: 'random', + }); + communityRepository.stubs.findByFunderId.resolves([ + new Community({id: 'idCommunity', funderId: 'random'}), + new Community({id: 'idCommunity1', funderId: 'random'}), + ]); + + const result = await controller.find( + 'A_TRAITER', + 'incentiveId', + 'incentiveType', + 'idCommunity', + 'lastName', + '', + '2021', + ); + + expect(result).to.deepEqual([input]); + }); + + it('SubscriptionController find with citizenId: successful', async () => { + repository.stubs.find.resolves([input]); + userRepository.stubs.findOne.resolves(user); + funderService.stubs.getFunderByName.resolves({ + name: 'funderName', + funderType: 'entreprise', + id: 'random', + }); + communityRepository.stubs.findByFunderId.resolves([ + new Community({id: 'idCommunity', funderId: 'random'}), + new Community({id: 'idCommunity1', funderId: 'random'}), + ]); + + const count = subscriptionRepository.stubs.count.resolves(); + + const result = await controller.find( + 'A_TRAITER', + 'incentiveId', + 'incentiveType', + 'idCommunity', + 'lastName', + 'email@gmail.com', + ); + + const res = { + subscriptions: [input], + ...count, + }; + + expect(result).to.deepEqual(res); + }); + + it('SubscriptionController find : community mismatch', async () => { + repository.stubs.find.resolves([input]); + userRepository.stubs.findOne.resolves(user); + funderService.stubs.getFunderByName.resolves({ + name: 'funderName', + funderType: 'entreprise', + id: 'random', + }); + try { + await controller.find('A_TRAITER', 'incentiveId', 'idCommunity', 'lastName'); + } catch (err) { + expect(err).to.deepEqual(mismatchCommunityError); + } + }); + + it('SubscriptionController findById : successful', async () => { + repository.stubs.findById.resolves(input); + const result = await controller.findById('someRandomId'); + + expect(result).to.deepEqual(input); + }); + + it('SubscriptionController validate : error', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId1').resolves(input1); + // Invokes business + try { + await controller.validate('randomInputId1', { + mode: 'unique', + amount: 1, + } as ValidationSinglePayment); + sinon.assert.fail(); + } catch (error) { + // Checks + expect(repository.stubs.updateById.notCalled).true(); + expect(spy.checkPayment.calledOnce).false(); + expect(error.message).to.equal(expectedError.message); + expect(error.path).to.equal(expectedError.path); + } + }); + + it('SubscriptionController validate with funderType=Enterprise : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input); + citizenRepository.stubs.findById.resolves(citizen); + + // Invokes business + const payment = { + mode: 'unique', + amount: 1, + } as ValidationSinglePayment; + const result = await controller.validate('randomInputId', payment); + // Checks + expect(input.status).equal(SUBSCRIPTION_STATUS.VALIDATED); + expect(input.subscriptionValidation).deepEqual(payment); + expect(result).to.Null; + }); + + it('SubscriptionController validate with funderType=Collectivity : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input2); + + // Invokes business + const payment = { + mode: 'unique', + amount: 1, + } as ValidationSinglePayment; + const result = await controller.validate('randomInputId', payment); + // Checks + expect(input2.status).equal(SUBSCRIPTION_STATUS.VALIDATED); + expect(input2.subscriptionValidation).deepEqual(payment); + expect(result).to.Null; + }); + + it('SubscriptionController reject : error', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId1').resolves(input1); + // Invokes business + + try { + await controller.reject('randomInputId1', { + type: 'ConditionsNonRespectees', + } as CommonRejection); + sinon.assert.fail(); + } catch (error) { + // Checks + expect(repository.stubs.updateById.notCalled).true(); + expect(spy.checkRefusMotif.calledOnce).false(); + expect(error.message).to.equal(expectedError.message); + } + }); + + it('SubscriptionController reject with funder=Enterprise && reason=Condition : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input); + citizenRepository.stubs.findById.resolves(citizen); + + // Invokes business + const reason = { + type: REJECTION_REASON.CONDITION, + } as CommonRejection; + const result = await controller.reject('randomInputId', reason); + // Checks + expect(input.status).equal(SUBSCRIPTION_STATUS.REJECTED); + expect(input.subscriptionRejection).deepEqual(reason); + expect(input.specificFields).to.Null; + expect(input.attachments).to.Null; + expect(result).to.Null; + }); + + it('SubscriptionController reject with funder=Collectivity && invalid proof : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input2); + + // Invokes business + const reason = { + type: REJECTION_REASON.INVALID_PROOF, + } as CommonRejection; + const result = await controller.reject('randomInputId', reason); + // Checks + expect(input2.status).equal(SUBSCRIPTION_STATUS.REJECTED); + expect(input2.subscriptionRejection).deepEqual(reason); + expect(input2.specificFields).to.Null; + expect(input2.attachments).to.Null; + expect(result).to.Null; + }); + + it('SubscriptionController reject with rejectionReason=Missing proof : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input2); + + // Invokes business + const reason = { + type: REJECTION_REASON.MISSING_PROOF, + } as CommonRejection; + const result = await controller.reject('randomInputId', reason); + // Checks + expect(input2.status).equal(SUBSCRIPTION_STATUS.REJECTED); + expect(input2.subscriptionRejection).deepEqual(reason); + expect(input2.specificFields).to.Null; + expect(input2.attachments).to.Null; + expect(result).to.Null; + }); + + it('SubscriptionController reject with rejectionReason=Other : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input2); + + // Invokes business + const reason = { + type: REJECTION_REASON.OTHER, + other: 'Other', + } as OtherReason; + const result = await controller.reject('randomInputId', reason); + // Checks + expect(input2.status).equal(SUBSCRIPTION_STATUS.REJECTED); + expect(input2.subscriptionRejection).deepEqual(reason); + expect(input2.specificFields).to.Null; + expect(input2.attachments).to.Null; + expect(result).to.Null; + }); + + it('SubscriptionController getSubscriptionFileByName : successful', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input); + s3Service.stubs.downloadFileBuffer.resolves({}); + + const result = await controller.getSubscriptionFileByName( + 'randomInputId', + 'helloworld.jpg', + ); + + sinon.assert.calledOnceWithExactly( + s3Service.stubs.downloadFileBuffer, + 'email@gmail.com', + 'randomInputId', + 'helloworld.jpg', + ); + expect(result).to.deepEqual({}); + }); + + it('SubscriptionController getSubscriptionFileByName : error', async () => { + // Stub method + repository.stubs.findById.withArgs('randomInputId').resolves(input); + try { + s3Service.stubs.downloadFileBuffer.rejects({}); + await controller.getSubscriptionFileByName('randomInputId', 'helloworld.jpg'); + sinon.assert.fail(); + } catch (error) { + expect(error).to.deepEqual({}); + } + }); + + it('SubscriptionController subscriptions/export : error', async () => { + // Stub method + try { + userRepository.stubs.findOne.resolves(user); + subscriptionRepository.stubs.find.resolves([initInput]); + const response: any = {}; + await controller.generateExcel(response); + } catch (error) { + expect(error).to.deepEqual(downloadError); + } + }); + + it('SubscriptionController getMetadata : successful', async () => { + metadataRepository.stubs.findById.resolves(mockMetadataFindById); + const result = await controller.getMetadata('randomMetadataId'); + expect(result).to.deepEqual(mockMetadataReturnOK); + }); + + it('SubscriptionController getMetadata : error', async () => { + try { + metadataRepository.stubs.findById.rejects('Error'); + await controller.getMetadata('randomMetadataId'); + } catch (err) { + expect(err.name).to.equal('Error'); + } + }); + + it('SubscriptionV1Controller createMetadata : successful', async () => { + metadataRepository.stubs.create.resolves(new Metadata({id: 'randomMetadataId'})); + const result: any = await controller.createMetadata(mockMetadata); + expect(result.metadataId).to.equal('randomMetadataId'); + }); + + it('SubscriptionV1Controller createMetadata : error', async () => { + try { + metadataRepository.stubs.create.rejects('Error'); + await controller.createMetadata(mockMetadata); + } catch (err) { + expect(err.name).to.equal('Error'); + } + }); + + function givenStubbedRepository() { + repository = createStubInstance(SubscriptionRepository); + communityRepository = createStubInstance(CommunityRepository); + userRepository = createStubInstance(UserRepository); + subscriptionRepository = createStubInstance(SubscriptionRepository); + citizenRepository = createStubInstance(CitizenRepository); + metadataRepository = createStubInstance(MetadataRepository); + } + + function givenStubbedService() { + s3Service = createStubInstance(S3Service); + funderService = createStubInstance(FunderService); + } +}); + +const initInput = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + communityId: 'id1', + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const initInput1 = new Subscription({ + id: 'randomInputId1', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const initInput2 = new Subscription({ + id: 'randomInputId1', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const citizen = Object.assign(new Citizen(), { + id: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + birthdate: '1991-11-17', + postcode: '31000', + tos1: true, + tos2: true, + affiliation: { + enterpriseId: 'enterpriseId', + enterpriseEmail: 'email1@gmail.com', + }, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), +}); + +const expectedError = new ValidationError( + 'subscriptions.error.bad.status', + '/subscriptionBadStatus', + StatusCode.UnprocessableEntity, +); + +const mismatchCommunityError = new ValidationError( + `subscriptions.error.communities.mismatch`, + `/subscriptions`, + StatusCode.UnprocessableEntity, + ResourceName.Subscription, +); + +const downloadError = new ValidationError( + 'Le téléchargement a échoué, veuillez réessayer', + '/downloadXlsx', + StatusCode.UnprocessableEntity, + ResourceName.Subscription, +); + +const currentUser: IUser = { + id: 'idUser', + emailVerified: true, + maas: undefined, + membership: ['/entreprises/Capgemini'], + roles: ['gestionnaires'], + [securityId]: 'idEnterprise', +}; + +const user = new User({ + id: 'idUser', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires'], + communityIds: ['id1', 'id2'], +}); + +const mockMetadataFindById = new Metadata({ + id: 'randomMetadataId', + incentiveId: 'randomAidId', + citizenId: 'randomCitizenId', + attachmentMetadata: new AttachmentMetadata( + Object.assign({ + invoices: [ + { + customer: { + customerName: 'name', + customerSurname: 'surname', + }, + transaction: { + purchaseDate: new Date('2021-03-03'), + }, + products: [ + { + productName: 'fileName', + }, + ], + }, + ], + totalElements: 1, + }), + ), +}); + +const mockMetadataReturnOK = { + incentiveId: 'randomAidId', + citizenId: 'randomCitizenId', + attachmentMetadata: [{fileName: '03-03-2021_fileName_surname_name.pdf'}], +}; + +const mockMetadata = new Metadata({ + incentiveId: 'randomAidId', + citizenId: 'randomCitizenId', + attachmentMetadata: new AttachmentMetadata({}), +}); diff --git a/api/src/__tests__/controllers/territory.controller.test.ts b/api/src/__tests__/controllers/territory.controller.test.ts new file mode 100644 index 0000000..a6a16d4 --- /dev/null +++ b/api/src/__tests__/controllers/territory.controller.test.ts @@ -0,0 +1,163 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {IncentiveRepository, TerritoryRepository} from '../../repositories'; +import {TerritoryController} from '../../controllers'; +import {ValidationError} from '../../validationError'; +import {TerritoryService} from '../../services/territory.service'; +import {Incentive, Territory} from '../../models'; +import {ResourceName, StatusCode} from '../../utils'; + +describe('TerritoryController', () => { + let territoryRepository: StubbedInstanceWithSinonAccessor, + incentiveRepository: StubbedInstanceWithSinonAccessor, + territoryService: StubbedInstanceWithSinonAccessor, + territoryController: TerritoryController; + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedService(); + territoryController = new TerritoryController( + territoryRepository, + incentiveRepository, + territoryService, + ); + }); + + it('TerritoryController Post /territories : Succesfull', async () => { + territoryService.stubs.createTerritory.resolves(territoryMock); + const result = await territoryController.create(territoryPayload); + expect(result).to.deepEqual(territoryMock); + }); + + it('TerritoryController Get /territories : Successful', async () => { + territoryRepository.stubs.find.resolves([territoryMock]); + const result = await territoryController.find(); + expect(result).to.deepEqual([territoryMock]); + }); + + it('TerritoryController Get /territories/{id} : Successful', async () => { + territoryRepository.stubs.findById.resolves(territoryMock); + const result = await territoryController.findById('634c83b994f56f610415f9c6'); + expect(result).to.deepEqual(territoryMock); + }); + + it('TerritoryController Patch /territories/{id} unique name', async () => { + try { + territoryRepository.stubs.findOne.resolves(territoryMockMatchName); + await territoryController.updateById('634c83b994f56f610415f9c6', { + name: 'new Territory name', + } as Territory); + } catch (error) { + expect(error).to.deepEqual(territoryNameUnique); + } + }); + + it('TerritoryController Patch /territories/{id} Successful', async () => { + territoryRepository.stubs.findOne.resolves(); + incentiveRepository.stubs.find.resolves([mockIncentive]); + territoryRepository.stubs.updateById.resolves(); + incentiveRepository.stubs.updateById.resolves(); + await territoryController.updateById('634c83b994f56f610415f9c6', { + name: 'new Territory name', + } as Territory); + }); + + // TODO: REMOVING DEPRECATED territoryName. + it('TerritoryController Patch /territories/{id} Successful with territoryName', async () => { + territoryRepository.stubs.findOne.resolves(); + incentiveRepository.stubs.find.resolves([mockIncentive2]); + territoryRepository.stubs.updateById.resolves(); + incentiveRepository.stubs.updateById.resolves(); + await territoryController.updateById('634c83b994f56f610415f9c6', { + name: 'new Territory name', + } as Territory); + }); + + it('TerritoryController GET /territories/count Successful', async () => { + const territoryCount = { + count: 12, + }; + territoryRepository.stubs.count.resolves(territoryCount); + const result = await territoryController.count(); + expect(result).to.deepEqual(territoryCount); + }); + + function givenStubbedRepository() { + territoryRepository = createStubInstance(TerritoryRepository); + incentiveRepository = createStubInstance(IncentiveRepository); + } + + function givenStubbedService() { + territoryService = createStubInstance(TerritoryService); + } + + const territoryPayload = new Territory({ + name: 'Toulouse', + }); + + const territoryMock = new Territory({ + name: 'Toulouse', + id: '634c83b994f56f610415f9c6', + }); + + const territoryMockMatchName = new Territory({ + name: 'new Territory Name', + id: '634c83b994f56f610415f9c6', + }); + + const territoryNameUnique = new ValidationError( + 'territory.name.error.unique', + '/territoryName', + StatusCode.UnprocessableEntity, + ResourceName.Territory, + ); + + // TODO: REMOVING DEPRECATED territoryName. + const mockIncentive2 = new Incentive({ + territory: {name: 'Toulouse', id: '634c83b994f56f610415f9c6'} as Territory, + territoryName: 'Toulouse', // TODO: REMOVING DEPRECATED territoryName. + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'randomNationalId', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + }); + + const mockIncentive = new Incentive({ + territory: {name: 'Toulouse', id: '634c83b994f56f610415f9c6'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'randomNationalId', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + }); +}); diff --git a/api/src/__tests__/controllers/user.controller.test.ts b/api/src/__tests__/controllers/user.controller.test.ts new file mode 100644 index 0000000..3315893 --- /dev/null +++ b/api/src/__tests__/controllers/user.controller.test.ts @@ -0,0 +1,505 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import { + CommunityRepository, + UserEntityRepository, + UserRepository, + KeycloakGroupRepository, +} from '../../repositories'; +import {UserController} from '../../controllers'; +import {KeycloakService, FunderService} from '../../services'; +import {Community, User, KeycloakRole} from '../../models'; +import {FUNDER_TYPE, IUser, ResourceName, StatusCode} from '../../utils'; +import {ValidationError} from '../../validationError'; + +describe('UserController (unit)', () => { + let userRepository: StubbedInstanceWithSinonAccessor, + keycloakGroupRepository: StubbedInstanceWithSinonAccessor, + userEntityRepository: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor, + funderService: StubbedInstanceWithSinonAccessor, + kcService: StubbedInstanceWithSinonAccessor, + controller: UserController; + + const currentUser: IUser = { + id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + emailVerified: true, + maas: undefined, + membership: ['/citizens'], + roles: ['offline_access', 'uma_authorization'], + [securityId]: 'citizenId', + }; + + const user = new User({ + id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires', 'superviseurs'], + communityIds: ['id1', 'id2'], + }); + + const userSupervisor = new User({ + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['superviseurs'], + }); + + const errorManualAffiliation = new ValidationError( + `users.funder.manualAffiliation.refuse`, + `/users`, + StatusCode.PreconditionFailed, + ResourceName.User, + ); + + const enterpriseNoManualAffiliation = { + id: 'random', + emailFormat: ['@arandom.fr', '@random.com'], + hasManualAffiliation: false, + name: 'enterprise', + isHris: true, + funderType: FUNDER_TYPE.enterprise, + }; + + const userWithManualAffiliation = new User({ + id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires', 'superviseurs'], + communityIds: ['id1', 'id2'], + canReceiveAffiliationMail: true, + }); + + beforeEach(() => { + givenStubbedComponent(); + controller = new UserController( + userRepository, + keycloakGroupRepository, + userEntityRepository, + communityRepository, + kcService, + funderService, + currentUser, + ); + }); + + describe('UserController', () => { + it('UserController create : fails because of emailformat error', async () => { + const errorKc = new ValidationError( + `email.error.emailFormat`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + try { + funderService.stubs.getFunders.resolves([ + { + id: 'random', + emailFormat: ['@arandom.fr', '@random.com'], + funderType: FUNDER_TYPE.enterprise, + }, + ]); + + await controller.create(user); + } catch ({message}) { + expect(message).to.equal(errorKc.message); + } + funderService.stubs.getFunders.restore(); + }); + + it('UserController create : fails because of unmismatched roles', async () => { + const errorKc = new ValidationError( + `users.error.roles.mismatch`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + try { + funderService.stubs.getFunders.resolves([ + { + id: 'random', + emailFormat: ['@random.fr', '@random.com'], + funderType: FUNDER_TYPE.enterprise, + }, + ]); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves(['superviseurs']); + + await controller.create(user); + } catch ({message}) { + expect(message).to.equal(errorKc.message); + } + funderService.stubs.getFunders.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + }); + + it('UserController create : fails because of unmismatched communauties', async () => { + const errorKc = new ValidationError( + `users.error.communities.mismatch`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + try { + funderService.stubs.getFunders.resolves([ + { + id: 'random', + emailFormat: ['@random.fr', '@random.com'], + funderType: FUNDER_TYPE.enterprise, + }, + ]); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([ + 'superviseurs', + 'gestionnaires', + 'newRole', + ]); + communityRepository.stubs.findByFunderId.resolves([]); + + await controller.create(user); + } catch ({message}) { + expect(message).to.equal(errorKc.message); + } + funderService.stubs.getFunders.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + communityRepository.stubs.findByFunderId.restore(); + }); + + it('UserController create : fails because of create repository error', async () => { + const errorRepository = 'can not add data in database'; + try { + funderService.stubs.getFunders.resolves([ + {id: 'random', funderType: FUNDER_TYPE.collectivity}, + ]); + kcService.stubs.createUserKc.resolves({id: 'randomInputId'}); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([ + 'superviseurs', + 'gestionnaires', + 'newRole', + ]); + communityRepository.stubs.findByFunderId.resolves([ + new Community({id: 'id1'}), + new Community({id: 'id2'}), + new Community({id: 'id3'}), + ]); + userRepository.stubs.create.rejects(errorRepository); + kcService.stubs.deleteUserKc.resolves(); + + await controller.create(user); + } catch ({name}) { + expect(name).to.equal(errorRepository); + } + + kcService.stubs.createUserKc.restore(); + userRepository.stubs.create.restore(); + funderService.stubs.getFunders.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + communityRepository.stubs.findByFunderId.restore(); + kcService.stubs.deleteUserKc.restore(); + }); + + it('UserController create : fails because of can not find funder', async () => { + const errorFunder = new ValidationError( + `users.error.funders.missed`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + try { + kcService.stubs.createUserKc.resolves({ + id: 'randomInputId', + }); + funderService.stubs.getFunders.resolves([]); + kcService.stubs.deleteUserKc.resolves(); + + await controller.create(user); + } catch (err) { + expect(err).to.deepEqual(errorFunder); + } + + kcService.stubs.createUserKc.restore(); + kcService.stubs.deleteUserKc.restore(); + userRepository.stubs.create.restore(); + funderService.stubs.getFunders.restore(); + }); + + it('UserController create : fails because of manual affiliation condition error', async () => { + try { + funderService.stubs.getFunders.resolves([enterpriseNoManualAffiliation]); + + await controller.create(userWithManualAffiliation); + } catch ({message}) { + expect(message).to.equal(errorManualAffiliation.message); + } + funderService.stubs.getFunders.restore(); + }); + + it('UserController create user funder : successful', async () => { + funderService.stubs.getFunders.resolves([ + { + id: 'random', + emailFormat: ['@random.fr', '@random.com'], + funderType: FUNDER_TYPE.enterprise, + }, + ]); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([ + 'superviseurs', + 'gestionnaires', + 'newRole', + ]); + communityRepository.stubs.findByFunderId.resolves([ + new Community({id: 'id1'}), + new Community({id: 'id2'}), + new Community({id: 'id3'}), + ]); + kcService.stubs.createUserKc.resolves({id: 'randomInputId'}); + + userRepository.stubs.create.resolves(user); + + const result = await controller.create(user); + + expect(result).to.deepEqual({ + id: user.id, + email: user.email, + lastName: user.lastName, + firstName: user.firstName, + }); + funderService.stubs.getFunders.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + communityRepository.stubs.findByFunderId.restore(); + kcService.stubs.createUserKc.restore(); + userRepository.stubs.create.restore(); + }); + + it('UserController create funder keycloakResult undefined', async () => { + funderService.stubs.getFunders.resolves([ + { + id: 'random', + emailFormat: ['@random.fr', '@random.com'], + funderType: FUNDER_TYPE.enterprise, + }, + ]); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([ + 'superviseurs', + 'gestionnaires', + 'newRole', + ]); + communityRepository.stubs.findByFunderId.resolves([ + new Community({id: 'id1'}), + new Community({id: 'id2'}), + new Community({id: 'id3'}), + ]); + + kcService.stubs.createUserKc.resolves(undefined); + + const result = await controller.create(user); + + expect(result).to.deepEqual(undefined); + + funderService.stubs.getFunders.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + communityRepository.stubs.findByFunderId.restore(); + kcService.stubs.createUserKc.restore(); + }); + + it('UserController create supervisor funder successful', async () => { + funderService.stubs.getFunders.resolves([ + { + id: 'random', + emailFormat: ['@random.fr', '@random.com'], + funderType: FUNDER_TYPE.enterprise, + }, + ]); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([ + 'superviseurs', + 'gestionnaires', + ]); + + kcService.stubs.createUserKc.resolves({id: 'randomInputId'}); + + userRepository.stubs.create.resolves(userSupervisor); + + const result = await controller.create(userSupervisor); + + expect(result).to.deepEqual({ + id: userSupervisor.id, + email: userSupervisor.email, + lastName: userSupervisor.lastName, + firstName: userSupervisor.firstName, + }); + funderService.stubs.getFunders.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + communityRepository.stubs.findByFunderId.restore(); + kcService.stubs.createUserKc.restore(); + userRepository.stubs.create.restore(); + }); + + it('UserController /count : successful', async () => { + const countRes = { + count: 12, + }; + + userRepository.stubs.count.resolves(countRes); + const result = await controller.count(); + + expect(result).to.deepEqual(countRes); + }); + + it('UserController /get : successful', async () => { + funderService.stubs.getFunders.resolves([newFunder]); + userRepository.stubs.find.resolves([users]); + communityRepository.stubs.find.resolves([newCommunity]); + userEntityRepository.stubs.getUserRoles.resolves([roles]); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves(['test', 'test1']); + + const result = await controller.find(); + + expect(result).to.deepEqual([mockUsersWithInfos]); + funderService.stubs.getFunders.restore(); + userRepository.stubs.find.restore(); + communityRepository.stubs.find.restore(); + userEntityRepository.stubs.getUserRoles.restore(); + keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore(); + }); + + it('UserController /v1/users/roles : successful', async () => { + const res: any = ['gestionnaires', 'superviseurs']; + keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves(res); + + const result = await controller.getRolesForUsers(); + + expect(result).to.deepEqual(res); + }); + + it('UserController findUserById: Successful ', async () => { + userRepository.stubs.findById.resolves(user); + userEntityRepository.stubs.getUserRoles.resolves([roles]); + const result = await controller.findUserById( + 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + ); + const res: string[] = ['test']; + expect(result).to.deepEqual({...result, roles: res}); + userRepository.stubs.findById.restore(); + userEntityRepository.stubs.getUserRoles.restore(); + }); + + it('UserController findUserById: Error ', async () => { + const userError = new ValidationError( + `Access Denied`, + `/authorization`, + StatusCode.Forbidden, + ); + + try { + userRepository.stubs.findById.resolves(user); + userEntityRepository.stubs.getUserRoles.resolves([roles]); + const communities = new Community({id: 'id1', name: 'name'}); + communityRepository.stubs.findByFunderId.resolves([communities]); + await controller.findUserById('1234567890'); + } catch ({message}) { + expect(message).to.equal(userError.message); + } + userRepository.stubs.findById.restore(); + userEntityRepository.stubs.getUserRoles.restore(); + }); + + it('UserController updateById : fails because of manual affiliation condition error', async () => { + try { + funderService.stubs.getFunders.resolves([enterpriseNoManualAffiliation]); + + await controller.updateById( + 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + userWithManualAffiliation, + ); + } catch ({message}) { + expect(message).to.equal(errorManualAffiliation.message); + } + funderService.stubs.getFunders.restore(); + }); + + it('UserController updateById: successful ', async () => { + userRepository.stubs.findById.resolves(users); + kcService.stubs.updateUserGroupsKc.resolves(); + const result = await controller.updateById( + 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + usersUpdated, + ); + expect(result).to.deepEqual({id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d'}); + userRepository.stubs.findById.restore(); + kcService.stubs.updateUserGroupsKc.restore(); + }); + + it('UserController deleteById: successful ', async () => { + kcService.stubs.deleteUserKc.resolves('a0e48494-1bfb-4142-951b-16ec6d9c8e1d'); + const result = await controller.deleteById('a0e48494-1bfb-4142-951b-16ec6d9c8e1d'); + expect(result).to.deepEqual({id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d'}); + kcService.stubs.deleteUserKc.restore(); + }); + }); + + function givenStubbedComponent() { + userRepository = createStubInstance(UserRepository); + communityRepository = createStubInstance(CommunityRepository); + userEntityRepository = createStubInstance(UserEntityRepository); + keycloakGroupRepository = createStubInstance(KeycloakGroupRepository); + communityRepository = createStubInstance(CommunityRepository); + kcService = createStubInstance(KeycloakService); + funderService = createStubInstance(FunderService); + } +}); + +const users = new User({ + email: 'w.housni24@gmail.co', + firstName: 'Walid', + lastName: 'Walid HOUSNI', + id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d', + communityIds: ['618a4dad80ea32653c7a20d7'], +}); + +const usersUpdated = new User({ + email: 'w.housni24@gmail.co', + firstName: 'Baghrous', + lastName: 'Abdelmoumene', + id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d', + communityIds: ['618a4dad80ea32653c7a20d7'], + roles: ['gestionnaires'], +}); + +const roles = new KeycloakRole({name: 'test'}); + +const newCommunity = new Community({ + id: '618a4dad80ea32653c7a20d7', + name: 'Something wonderful', + funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d', +}); + +const newFunder = { + id: 'efec7e68-fc17-4078-82c5-65d53961f34d', + name: 'Collectivity United.', + citizensCount: undefined, + mobilityBudget: undefined, + funderType: FUNDER_TYPE.collectivity, +}; + +const mockUsersWithInfos = { + email: 'w.housni24@gmail.co', + firstName: 'Walid', + lastName: 'Walid HOUSNI', + id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d', + funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d', + communityIds: ['618a4dad80ea32653c7a20d7'], + funderType: 'Collectivité', + communityName: 'Something wonderful', + funderName: 'Collectivity United.', + roles: 'Test', +}; diff --git a/api/src/__tests__/cronjob/nonActivatedAccountDeletionCronJob.test.ts b/api/src/__tests__/cronjob/nonActivatedAccountDeletionCronJob.test.ts new file mode 100644 index 0000000..1d0b70e --- /dev/null +++ b/api/src/__tests__/cronjob/nonActivatedAccountDeletionCronJob.test.ts @@ -0,0 +1,63 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {CronJobService, CitizenService} from '../../services'; +import {NonActivatedAccountDeletionCronJob} from '../../cronjob'; + +import {CronJob} from '../../models'; + +describe('nonActivatedAccountDeletion cronjob', () => { + let inactifAccountDeletionCronJobs: any = null; + + let cronJobService: StubbedInstanceWithSinonAccessor, + citizenService: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + cronJobService = createStubInstance(CronJobService); + citizenService = createStubInstance(CitizenService); + inactifAccountDeletionCronJobs = new NonActivatedAccountDeletionCronJob( + cronJobService, + citizenService, + ); + }); + + afterEach(() => {}); + + it('nonActivatedAccountDeletion cronjob : performJob success', async () => { + cronJobService.stubs.getCronsLog.resolves([]); + cronJobService.stubs.delCronLog.resolves(); + cronJobService.stubs.createCronLog.resolves(); + citizenService.stubs.accountDeletionService.resolves(); + cronJobService.stubs.delCronLog.resolves(); + await inactifAccountDeletionCronJobs.performJob(); + }); + + it('nonActivatedAccountDeletion cronjob : performJob success with a log in DB', async () => { + cronJobService.stubs.getCronsLog.resolves([mockCronLog]); + cronJobService.stubs.delCronLog.resolves(); + cronJobService.stubs.createCronLog.resolves(); + citizenService.stubs.accountDeletionService.resolves(); + cronJobService.stubs.delCronLog.resolves(); + await inactifAccountDeletionCronJobs.performJob(); + }); + + it('nonActivatedAccountDeletion cronjob : performJob error', async () => { + try { + cronJobService.stubs.getCronsLog.resolves([]); + cronJobService.stubs.createCronLog.rejects(); + await inactifAccountDeletionCronJobs.performJob(); + } catch (err) { + expect(citizenService.stubs.accountDeletionService.calledOnce).false(); + cronJobService.stubs.delCronLog.resolves(); + } + }); + + const mockCronLog = new CronJob({ + id: '123456', + type: 'Delete_user_account', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + }); +}); diff --git a/api/src/__tests__/cronjob/rabbitmqCronJob.test.ts b/api/src/__tests__/cronjob/rabbitmqCronJob.test.ts new file mode 100644 index 0000000..5d253e1 --- /dev/null +++ b/api/src/__tests__/cronjob/rabbitmqCronJob.test.ts @@ -0,0 +1,83 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {EventEmitter} from 'events'; + +import {RabbitmqService} from '../../services'; +import {RabbitmqCronJob} from '../../cronjob'; +import {EVENT_MESSAGE, UPDATE_MODE} from '../../utils'; + +describe('Rabbitmq cronjob', () => { + let rabbitmqCronjob: any = null; + + let clock: any, + rabbitmqService: StubbedInstanceWithSinonAccessor, + parentProcessService: any; + + beforeEach(() => { + rabbitmqService = createStubInstance(RabbitmqService); + parentProcessService = new EventEmitter(); + rabbitmqCronjob = new RabbitmqCronJob(rabbitmqService, parentProcessService); + }); + + afterEach(() => {}); + + it('Rabbitmq cronjob : performJob success', async () => { + const enterpriseRepositoryResult: string[] = ['enterprise']; + rabbitmqService.stubs.getHRISEnterpriseNameList.resolves(enterpriseRepositoryResult); + sinon.spy(parentProcessService, 'emit'); + await rabbitmqCronjob.performJob(); + sinon.assert.calledWithExactly(parentProcessService.emit, EVENT_MESSAGE.UPDATE, { + type: EVENT_MESSAGE.UPDATE, + data: {[UPDATE_MODE.ADD]: enterpriseRepositoryResult, [UPDATE_MODE.DELETE]: []}, + }); + rabbitmqService.stubs.getHRISEnterpriseNameList.restore(); + }); + + it('Rabbitmq cronjob : performJob error', async () => { + sinon.spy(parentProcessService, 'emit'); + try { + rabbitmqService.stubs.getHRISEnterpriseNameList.rejects(); + await rabbitmqCronjob.performJob(); + } catch (err) { + expect(err.message).to.equal('An error occurred'); + expect(parentProcessService.emit.calledOnce).false(); + rabbitmqService.stubs.getHRISEnterpriseNameList.restore(); + } + }); + + it('Rabbitmq cronjob : get ready event', async () => { + const rabbitStub = sinon.stub(rabbitmqCronjob, 'performJob').resolves(); + const fireStub = sinon.stub(rabbitmqCronjob, 'fireOnTick').resolves(); + const startStub = sinon.stub(rabbitmqCronjob, 'start').resolves(); + parentProcessService.emit(EVENT_MESSAGE.READY); + sinon.assert.calledOnce(fireStub); + sinon.assert.calledOnce(startStub); + startStub.restore(); + fireStub.restore(); + rabbitStub.restore(); + }); + + it('Rabbitmq cronjob : performJob no enterprise to update', async () => { + const enterpriseRepositoryResult: string[] = []; + rabbitmqService.stubs.getHRISEnterpriseNameList.resolves(enterpriseRepositoryResult); + sinon.spy(parentProcessService, 'emit'); + await rabbitmqCronjob.performJob(); + expect(parentProcessService.emit.calledOnce).false(); + rabbitmqService.stubs.getHRISEnterpriseNameList.restore(); + }); + + it('Rabbitmq cronjob : onTick', async () => { + clock = sinon.useFakeTimers(); + const rabbitStub = sinon.stub(rabbitmqCronjob, 'performJob').resolves(); + parentProcessService.emit(EVENT_MESSAGE.READY); + clock.tick(1 * 24 * 60 * 60 * 1000); + sinon.assert.calledOnce(rabbitStub); + rabbitStub.restore(); + clock.restore(); + }); +}); diff --git a/api/src/__tests__/cronjob/subscriptionCronJob.test.ts b/api/src/__tests__/cronjob/subscriptionCronJob.test.ts new file mode 100644 index 0000000..837c66c --- /dev/null +++ b/api/src/__tests__/cronjob/subscriptionCronJob.test.ts @@ -0,0 +1,58 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {CronJobService, SubscriptionService} from '../../services'; +import {SubscriptionCronJob} from '../../cronjob'; + +import {CronJob} from '../../models'; + +describe('subscription cronjob', () => { + let subscriptionCronjob: any = null; + + let cronJobService: StubbedInstanceWithSinonAccessor, + subscriptionService: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + cronJobService = createStubInstance(CronJobService); + subscriptionService = createStubInstance(SubscriptionService); + subscriptionCronjob = new SubscriptionCronJob(cronJobService, subscriptionService); + }); + + afterEach(() => {}); + + it('subscription cronjob : performJob success', async () => { + cronJobService.stubs.getCronsLog.resolves([]); + cronJobService.stubs.createCronLog.resolves(); + subscriptionService.stubs.deleteSubscription.resolves(); + cronJobService.stubs.delCronLog.resolves(); + await subscriptionCronjob.performJob(); + }); + + it('subscription cronjob : performJob success with a log in DB', async () => { + cronJobService.stubs.getCronsLog.resolves([mockCronLog]); + cronJobService.stubs.delCronLog.resolves(); + cronJobService.stubs.createCronLog.resolves(); + subscriptionService.stubs.deleteSubscription.resolves(); + cronJobService.stubs.delCronLog.resolves(); + await subscriptionCronjob.performJob(); + }); + + it('subscription cronjob : performJob error', async () => { + try { + cronJobService.stubs.getCronsLog.resolves([]); + cronJobService.stubs.createCronLog.rejects(); + await subscriptionCronjob.performJob(); + } catch (err) { + expect(subscriptionService.stubs.deleteSubscription.calledOnce).false(); + } + }); + + const mockCronLog = new CronJob({ + id: '123456', + type: 'Delete_subscription', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + }); +}); diff --git a/api/src/__tests__/interceptors/affiliation.interceptors.test.ts b/api/src/__tests__/interceptors/affiliation.interceptors.test.ts new file mode 100644 index 0000000..f7a9ef7 --- /dev/null +++ b/api/src/__tests__/interceptors/affiliation.interceptors.test.ts @@ -0,0 +1,375 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, + sinon, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {AffiliationInterceptor} from '../../interceptors'; +import {ValidationError} from '../../validationError'; +import { + IncentiveRepository, + CitizenRepository, + SubscriptionRepository, +} from '../../repositories'; +import {FunderService} from '../../services'; +import {Affiliation, Incentive, Citizen, Subscription, Territory} from '../../models'; +import { + AFFILIATION_STATUS, + FUNDER_TYPE, + IUser, + ResourceName, + Roles, + StatusCode, +} from '../../utils'; + +describe('affiliation Interceptor', () => { + let interceptor: any = null; + let citizenRepository: StubbedInstanceWithSinonAccessor, + incentiveRepository: StubbedInstanceWithSinonAccessor, + funderService: StubbedInstanceWithSinonAccessor, + currentUserProfile: IUser, + subscriptionRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new AffiliationInterceptor( + citizenRepository, + incentiveRepository, + subscriptionRepository, + funderService, + currentUserProfile, + ); + }); + const error = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + + it('AffiliationInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + it('AffiliationInterceptor: error"', async () => { + try { + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtx, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor Create Subscription: error"', async () => { + try { + const incentive = new Incentive({id: '78952215', funderId: 'someFunderId'}); + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + incentiveRepository.stubs.findOne.resolves(incentive); + + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxCreateSubscription, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor Create Subscription: error 2"', async () => { + try { + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + incentiveRepository.stubs.findOne.resolves(null); + + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxCreateSubscription2, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor add files: error"', async () => { + try { + const subscription = new Subscription({id: '5654555', incentiveId: '5854235'}); + const incentive = new Incentive({id: '78952215', funderId: 'someFunderId'}); + + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + incentiveRepository.stubs.findOne.resolves(incentive); + subscriptionRepository.stubs.findOne.resolves(subscription); + + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxAddFiles, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + incentiveRepository.stubs.findOne.restore(); + subscriptionRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor add files: error 2"', async () => { + try { + const subscription = new Subscription({id: '5654555'}); + const incentive = new Incentive({id: '78952215'}); + + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + incentiveRepository.stubs.findOne.resolves(incentive); + subscriptionRepository.stubs.findOne.resolves(subscription); + + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxAddFiles, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + subscriptionRepository.stubs.findOne.restore(); + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor: OK', async () => { + funderService.stubs.getFunders.resolves([ + {id: 'testFunder', funderType: FUNDER_TYPE.collectivity, name: 'testName'}, + ]); + const affiliation = new Affiliation( + Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}), + ); + affiliation.affiliationStatus = AFFILIATION_STATUS.AFFILIATED; + + const citizen = new Citizen({ + affiliation, + }); + citizenRepository.stubs.findOne.resolves(citizen); + + const result = await interceptor.intercept(invocationCtx, () => {}); + expect(result).to.Null; + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor find id incentive territory"', async () => { + try { + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + const affiliation = new Affiliation( + Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}), + ); + const citizen = new Citizen({ + affiliation, + }); + + incentiveRepository.stubs.findOne.resolves(mockPublicIncentive); + + citizenRepository.stubs.findOne.resolves(citizen); + + await interceptor.intercept(invocationCtxFindId, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor find id incentive"', async () => { + try { + funderService.stubs.getFunders.resolves([ + {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + ]); + + const affiliation = new Affiliation( + Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}), + ); + const citizen = new Citizen({ + affiliation, + }); + + incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive); + + citizenRepository.stubs.findOne.resolves(citizen); + + await interceptor.intercept(invocationCtxFindId, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + + funderService.stubs.getFunders.restore(); + citizenRepository.stubs.findOne.restore(); + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor findIncentiveById as content_editor with employer incentive"', async () => { + const contentEditor = currentUserProfile; + contentEditor.roles = [Roles.CONTENT_EDITOR]; + + incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive); + + const result = await interceptor.intercept(invocationCtxFindId, () => {}); + expect(result).to.Null; + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor addAttachments: subscription not found', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxAddAttachments, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor finalizeSubscription: subscription not found', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxFinalizeSubscription, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + incentiveRepository.stubs.findOne.restore(); + }); + + function givenStubbedRepository() { + citizenRepository = createStubInstance(CitizenRepository); + incentiveRepository = createStubInstance(IncentiveRepository); + subscriptionRepository = createStubInstance(SubscriptionRepository); + funderService = createStubInstance(FunderService); + currentUserProfile = { + id: 'testId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'testId', + roles: ['maas'], + }; + } +}); + +const invocationCtx = { + methodName: 'findCommunitiesByFunderId', + args: ['testFunder'], +}; + +const invocationCtxCreateSubscription = { + methodName: 'createSubscription', + args: [{incentiveId: 'testAides'}], +}; + +const invocationCtxCreateSubscription2 = { + methodName: 'createSubscription', + args: [{incentiveId: 'testAides'}], +}; + +const invocationCtxAddFiles = { + methodName: 'addFiles', + args: ['tstSubscription'], +}; + +const invocationCtxFindId = { + methodName: 'findIncentiveById', + args: ['606c236a624cec2becdef277'], +}; + +const invocationCtxAddAttachments = { + methodName: 'addAttachments', + args: ['606c236a624cec2becdef277'], +}; + +const invocationCtxFinalizeSubscription = { + methodName: 'finalizeSubscription', + args: ['606c236a624cec2becdef277'], +}; + +const mockPublicIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef277', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockPrivateIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'test'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideEmployeur', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef277', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + funderId: 'funderId', +}); + +const errorNotFound = new ValidationError( + `Subscription not found`, + '/subscriptionNotFound', + StatusCode.NotFound, + ResourceName.Subscription, +); diff --git a/api/src/__tests__/interceptors/affiliationPublic.interceptors.test.ts b/api/src/__tests__/interceptors/affiliationPublic.interceptors.test.ts new file mode 100644 index 0000000..83030c5 --- /dev/null +++ b/api/src/__tests__/interceptors/affiliationPublic.interceptors.test.ts @@ -0,0 +1,172 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, + sinon, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {AffiliationPublicInterceptor} from '../../interceptors'; +import {ValidationError} from '../../validationError'; +import {IncentiveRepository} from '../../repositories'; +import {Incentive, Territory} from '../../models'; +import {IUser, ResourceName, StatusCode} from '../../utils'; + +describe('affiliation Interceptor', () => { + let interceptor: any = null; + let interceptor2: any = null; + let currentUserProfile: IUser, + otherUser: IUser, + incentiveRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + givenStubbedRepository(); + givenStubbedRepository2(); + interceptor = new AffiliationPublicInterceptor( + incentiveRepository, + currentUserProfile, + ); + interceptor2 = new AffiliationPublicInterceptor(incentiveRepository, otherUser); + }); + const error = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + + const errorNotFound = new ValidationError( + `Incentive not found`, + '/incentiveNotFound', + StatusCode.NotFound, + ResourceName.Incentive, + ); + + it('AffiliationInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + it('AffiliationInterceptor find id incentive territoire"', async () => { + try { + incentiveRepository.stubs.findOne.resolves(mockPublicIncentive); + + await interceptor.intercept(invocationCtxFindId, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor find id incentive error"', async () => { + try { + incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive); + + await interceptor.intercept(invocationCtxFindId, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor find id incentive error2"', async () => { + try { + incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive); + + await interceptor2.intercept(invocationCtxFindId2, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + incentiveRepository.stubs.findOne.restore(); + }); + + it('AffiliationInterceptor find id incentive not found"', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + + await interceptor2.intercept(invocationCtxFindId2, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + incentiveRepository.stubs.findOne.restore(); + }); + + function givenStubbedRepository() { + incentiveRepository = createStubInstance(IncentiveRepository); + currentUserProfile = { + id: 'testId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'testId', + roles: ['maas', 'service_maas'], + }; + } + function givenStubbedRepository2() { + incentiveRepository = createStubInstance(IncentiveRepository); + otherUser = { + id: 'testId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'testId', + roles: ['maas'], + }; + } +}); + +const invocationCtxFindId = { + methodName: 'findIncentiveById', + args: ['606c236a624cec2becdef277'], +}; + +const invocationCtxFindId2 = { + methodName: 'findIncentiveById', + args: ['1111111'], +}; + +const mockPublicIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'randomTerritoryId'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'incentive pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef277', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockPrivateIncentive = new Incentive({ + territory: {name: 'Toulouse', id: 'randomTerritoryId'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'incentive pour acheter vélo électrique', + incentiveType: 'AideEmployeur', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: '606c236a624cec2becdef277', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, + funderId: 'funderId', +}); diff --git a/api/src/__tests__/interceptors/citizen.interceptors.test.ts b/api/src/__tests__/interceptors/citizen.interceptors.test.ts new file mode 100644 index 0000000..1e27bef --- /dev/null +++ b/api/src/__tests__/interceptors/citizen.interceptors.test.ts @@ -0,0 +1,491 @@ +import { + expect, + sinon, + StubbedInstanceWithSinonAccessor, + createStubInstance, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {Citizen, User} from '../../models'; +import {CitizenService} from '../../services'; +import {CitizenInterceptor} from '../../interceptors'; +import {CitizenRepository, UserRepository} from '../../repositories'; +import {ValidationError} from '../../validationError'; +import {AFFILIATION_STATUS, IUser, ResourceName, StatusCode} from '../../utils'; +import {CitizenController} from '../../controllers'; + +describe('CitizenInterceptor', () => { + let interceptor: any = null; + let secondInterceptor: any = null; + let thirdInterceptor: any = null; + let citizenService: StubbedInstanceWithSinonAccessor, + citizenRepository: StubbedInstanceWithSinonAccessor, + userRepository: StubbedInstanceWithSinonAccessor, + currentUser: IUser, + otherUser: IUser, + citizenUser: IUser; + + const err: any = new ValidationError(`citizens.error.birthdate.age`, '/birthdate'); + + const errorAffiliationNotFound: any = new ValidationError( + 'citizens.affiliation.not.found', + '/citizensAffiliationNotFound', + StatusCode.NotFound, + ResourceName.Affiliation, + ); + + const errorAffiliationImpossible: any = new ValidationError( + 'citizen.affiliation.impossible', + '/citizenAffiliationImpossible', + StatusCode.PreconditionFailed, + ResourceName.Affiliation, + ); + + const errorAccess: any = new ValidationError( + `citizen.disaffiliation.impossible`, + '/citizenDisaffiliationImpossible', + ); + + const errorNotFound: any = new ValidationError( + `Citizen not found`, + '/citizenNotFound', + StatusCode.NotFound, + ResourceName.Citizen, + ); + + const errorPassword: any = new ValidationError( + `Password cannot be empty`, + '/password', + StatusCode.PreconditionFailed, + ResourceName.Account, + ); + const AccessDenied: any = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + + const invocationContextCreates = { + target: {}, + methodName: 'create', + args: [ + { + identity: Object.assign({ + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '3000-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + email: 'test@gmail.com', + city: 'city', + status: 'salarie', + birthdate: '3000-11-17', + postcode: '31000', + tos1: true, + tos2: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationContextCreatesuccessful = { + target: {}, + methodName: 'create', + args: [ + { + identity: Object.assign({ + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + email: 'test@gmail.com', + city: 'city', + status: 'salarie', + birthdate: '1991-11-17', + postcode: '31000', + tos1: true, + tos2: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationContextReplaceById = { + target: {}, + methodName: 'replaceById', + args: [ + 'id', + { + identity: Object.assign({ + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + birthDate: Object.assign({ + value: '3000-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + email: 'test@gmail.com', + city: 'city', + status: 'salarie', + birthdate: '3000-11-17', + postcode: '31000', + tos1: true, + tos2: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationContextDisaffiliation = { + target: {}, + methodName: 'disaffiliation', + args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff'], + }; + + const invocationContextvalidateAffiliation = { + target: {}, + methodName: 'validateAffiliation', + args: ['', {token: ''}], + }; + + const invocationContextvalidateAffiliationWithId = { + target: {}, + methodName: 'validateAffiliation', + args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff', {token: ''}], + }; + + const invocationContextDisaffiliat = { + target: {}, + methodName: 'disaffiliation', + args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff'], + }; + + const invocationContextFindCitizenId = { + target: {}, + methodName: 'findCitizenId', + args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff'], + }; + + beforeEach(() => { + givenStubbedService(); + givenStubbedServiceId(); + givenStubbedServiceCitizen(); + interceptor = new CitizenInterceptor( + citizenService, + citizenRepository, + userRepository, + currentUser, + ); + secondInterceptor = new CitizenInterceptor( + citizenService, + citizenRepository, + userRepository, + otherUser, + ); + + thirdInterceptor = new CitizenInterceptor( + citizenService, + citizenRepository, + userRepository, + citizenUser, + ); + }); + + it('CitizenInterceptor creates: error password', async () => { + try { + await interceptor.intercept(invocationContextCreates); + } catch (error) { + expect(error.message).to.equal(errorPassword.message); + } + }); + + it('CitizenInterceptor ReplaceById: error date', async () => { + try { + await interceptor.intercept(invocationContextReplaceById); + } catch (error) { + expect(error.message).to.equal(err.message); + } + }); + + it('CitizenInterceptor find: findCitizenId', async () => { + try { + await interceptor.intercept(invocationContextvalidateAffiliation, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(errorAffiliationNotFound.message); + } + }); + + it('CitizenInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + it('CitizenInterceptor validateAffiliation: ValidationError', async () => { + try { + await interceptor.intercept(invocationContextvalidateAffiliation, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(errorAffiliationNotFound.message); + } + }); + + it('CitizenInterceptor validateAffiliation: impossible affiliation ', async () => { + const citizen: any = { + id: 'randomId', + affiliation: { + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + entrepriseId: 'c3234ee6-a932-40bf-8a46-52d694cf61ff', + }, + }; + try { + userRepository.stubs.findById.resolves(mockUser); + citizenRepository.stubs.findOne.resolves(citizen); + await secondInterceptor.intercept( + invocationContextvalidateAffiliationWithId, + () => {}, + ); + } catch (err) { + expect(err.message).to.deepEqual(errorAffiliationImpossible.message); + } + }); + + it('CitizenInterceptor validateAffiliation: impossible affiliation if not same citizen ', async () => { + const citizen: any = { + id: 'randomId', + affiliation: { + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + entrepriseId: 'c3234ee6-a932-40bf-8a46-52d694cf61ll', + }, + }; + try { + userRepository.stubs.findById.resolves(mockUser); + citizenRepository.stubs.findOne.resolves(citizen); + await thirdInterceptor.intercept( + invocationContextvalidateAffiliationWithId, + () => {}, + ); + } catch (err) { + expect(err.message).to.deepEqual(errorAffiliationImpossible.message); + } + }); + + it('CitizenInterceptor disaffiliation: error disaffiliation impossible', async () => { + try { + citizenRepository.stubs.findOne.resolves(mockAffiliatedCitizen); + citizenService.stubs.findEmployees.resolves({ + employees: [mockAffiliatedCitizen], + employeesCount: 1, + }); + await interceptor.intercept(invocationContextDisaffiliation, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(errorAccess.message); + } + }); + + it('CitizenInterceptor disaffiliation: error access denied', async () => { + try { + citizenRepository.stubs.findOne.resolves(mockAffiliatedCitizen); + citizenService.stubs.findEmployees.resolves({ + employees: [mockCitizenDisaffiliation], + employeesCount: 1, + }); + await interceptor.intercept(invocationContextDisaffiliat, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(AccessDenied.message); + } + }); + + it('CitizenInterceptor disaffiliation affiliated citizen: error access denied', async () => { + try { + citizenRepository.stubs.findOne.resolves(mockAffiliatedCitizen); + citizenService.stubs.findEmployees.resolves({ + employees: [mockCitizenToAffiliate], + employeesCount: 1, + }); + await interceptor.intercept(invocationContextDisaffiliat, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(AccessDenied.message); + } + }); + + it('CitizenInterceptor disaffiliation: error citizen not found', async () => { + try { + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationContextDisaffiliation, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(errorNotFound.message); + } + }); + + it('CitizenInterceptor findCitizenById: error citizen not found', async () => { + try { + citizenRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationContextFindCitizenId, () => {}); + } catch (err) { + expect(err.message).to.deepEqual(errorNotFound.message); + } + }); + + /** + * givenStubbedService without id + */ + function givenStubbedService() { + citizenService = createStubInstance(CitizenService); + citizenRepository = createStubInstance(CitizenRepository); + userRepository = createStubInstance(UserRepository); + currentUser = { + id: '', + emailVerified: true, + maas: undefined, + membership: ['/entreprises/Capgemini'], + roles: ['gestionnaires'], + [securityId]: 'idEnterprise', + }; + } + + /** + * givenStubbedService with id + */ + function givenStubbedServiceId() { + citizenService = createStubInstance(CitizenService); + citizenRepository = createStubInstance(CitizenRepository); + userRepository = createStubInstance(UserRepository); + otherUser = { + id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff', + emailVerified: true, + maas: undefined, + membership: ['/entreprises/Capgemini'], + roles: ['gestionnaires'], + [securityId]: 'idEnterprise', + }; + } + + /** + + * givenStubbedService with citizen as user + + */ + function givenStubbedServiceCitizen() { + citizenService = createStubInstance(CitizenService); + citizenRepository = createStubInstance(CitizenRepository); + userRepository = createStubInstance(UserRepository); + citizenUser = { + id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff', + emailVerified: true, + clientName: undefined, + funderType: undefined, + funderName: undefined, + incentiveType: undefined, + roles: ['citoyens'], + [securityId]: 'c3234ee6-a932-40bf-8a46-52d694cf61ff', + }; + } +}); + +const mockAffiliatedCitizen = new Citizen({ + id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff', + identity: Object.assign({ + firstName: Object.assign({ + value: 'Xina', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Zhong', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + affiliation: Object.assign({ + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), +}); + +const mockCitizenDisaffiliation = new Citizen({ + id: '123', + identity: Object.assign({ + firstName: Object.assign({ + value: 'Xina', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Zhong', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + affiliation: Object.assign({ + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), +}); + +const mockCitizenToAffiliate = new Citizen({ + id: '123', + identity: Object.assign({ + firstName: Object.assign({ + value: 'Xina', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + lastName: Object.assign({ + value: 'Zhong', + source: 'moncomptemobilite.fr', + certificationDate: new Date(), + }), + }), + affiliation: Object.assign({ + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), +}); + +const mockUser = new User({ + id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff', + roles: ['gestionnaires'], +}); diff --git a/api/src/__tests__/interceptors/enterprise.interceptor.test.ts b/api/src/__tests__/interceptors/enterprise.interceptor.test.ts new file mode 100644 index 0000000..16ec0f6 --- /dev/null +++ b/api/src/__tests__/interceptors/enterprise.interceptor.test.ts @@ -0,0 +1,74 @@ +import {expect, sinon} from '@loopback/testlab'; + +import {ValidationError} from '../../validationError'; +import {StatusCode} from '../../utils'; +import {EnterpriseInterceptor} from '../../interceptors'; + +describe('Enterprise Interceptor', () => { + let interceptor: any = null; + + beforeEach(() => { + interceptor = new EnterpriseInterceptor(); + }); + + it('EnterpriseInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + it('EnterpriseInterceptor create : success', async () => { + const result = await interceptor.intercept( + invocationContextCreateEnterprise, + () => {}, + ); + expect(result).to.Null; + }); + + it('EnterpriseInterceptor create : error 422 when bad email format provided', async () => { + try { + await interceptor.intercept(invocationContextCreateEnterpriseBadFormat); + } catch (err) { + expect(err).to.deepEqual(errorBadEmailFormat); + } + }); +}); + +const errorBadEmailFormat: any = new ValidationError( + 'Enterprise email formats are not valid', + '/enterpriseEmailBadFormat', + StatusCode.UnprocessableEntity, +); + +const invocationContextCreateEnterprise = { + target: {}, + methodName: 'create', + args: [ + { + name: 'Capgemini', + siretNumber: 33070384400036, + emailFormat: ['@professional.com'], + employeesCount: 200000, + budgetAmount: 300000, + isHris: false, + }, + ], +}; + +const invocationContextCreateEnterpriseBadFormat = { + target: {}, + methodName: 'create', + args: [ + { + name: 'Capgemini', + siretNumber: 33070384400036, + emailFormat: ['bademailformat'], + employeesCount: 200000, + budgetAmount: 300000, + isHris: false, + }, + ], +}; diff --git a/api/src/__tests__/interceptors/funder.interceptor.test.ts b/api/src/__tests__/interceptors/funder.interceptor.test.ts new file mode 100644 index 0000000..2cda7f6 --- /dev/null +++ b/api/src/__tests__/interceptors/funder.interceptor.test.ts @@ -0,0 +1,251 @@ +import { + expect, + sinon, + StubbedInstanceWithSinonAccessor, + createStubInstance, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {CollectivityRepository, EnterpriseRepository} from '../../repositories'; +import {ValidationError} from '../../validationError'; +import {IUser, ResourceName, StatusCode} from '../../utils'; +import {FunderInterceptor} from '../../interceptors/funder.interceptor'; +import {Collectivity, EncryptionKey, Enterprise, PrivateKeyAccess} from '../../models'; + +describe('FunderInterceptor', () => { + let interceptor: any = null; + let collectivityRepository: StubbedInstanceWithSinonAccessor, + currentUser: IUser, + enterpriseRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + givenStubbedService(); + interceptor = new FunderInterceptor( + collectivityRepository, + enterpriseRepository, + currentUser, + ); + }); + + it('FunderInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + it('storeEncryptionKey : asserts error happens when no funder found', async () => { + const error = new ValidationError( + `Funder not found`, + `/Funder`, + StatusCode.NotFound, + ResourceName.Funder, + ); + try { + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(undefined); + await interceptor.intercept(invocationContextStoreEncryptionKey); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('storeEncryptionKey : access denied when saving encryption_key for an other funder', async () => { + const error = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + try { + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockOtherEnterpise); + await interceptor.intercept(invocationContextStoreEncryptionKey); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('encryption_key : error when collectivity and no privateKeyAccess provided', async () => { + const privateKeyAccessmissingError = new ValidationError( + `encryptionKey.error.privateKeyAccess.missing`, + '/EncryptionKey', + StatusCode.UnprocessableEntity, + ); + try { + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextStoreEncryptionKeyNoPrivateKeyAccess); + } catch (error) { + expect(error).to.deepEqual(privateKeyAccessmissingError); + } + }); + + it('encryption_key : error when enterprise not Hris and no privateKeyAccess provided', async () => { + const privateKeyAccessmissingError = new ValidationError( + `encryptionKey.error.privateKeyAccess.missing`, + '/EncryptionKey', + StatusCode.UnprocessableEntity, + ); + try { + enterpriseRepository.stubs.findOne.resolves(mockEnterprise); + collectivityRepository.stubs.findOne.resolves(undefined); + await interceptor.intercept(invocationContextStoreEncryptionKeyNoPrivateKeyAccess); + } catch (error) { + expect(error).to.deepEqual(privateKeyAccessmissingError); + } + }); + + it('encryption_key : asserts encryption key has been stored for collectivity', async () => { + collectivityRepository.stubs.findOne.resolves(mockCollectivity2); + enterpriseRepository.stubs.create.resolves(undefined); + const result = await interceptor.intercept( + invocationContextStoreEncryptionKey, + () => {}, + ); + expect(result).to.Null; + }); + + it('encryption_key : asserts encryption key has been stored for enterprise', async () => { + collectivityRepository.stubs.findOne.resolves(undefined); + enterpriseRepository.stubs.findOne.resolves(mockEnterprise); + const result = await interceptor.intercept( + invocationContextStoreEncryptionKey, + () => {}, + ); + expect(result).to.Null; + }); + + it('storeEncryptionKey : encryption key stored for enterprise hris without privateKeyAccess', async () => { + collectivityRepository.stubs.findOne.resolves(undefined); + enterpriseRepository.stubs.findOne.resolves(mockEnterpriseHris); + const result = await interceptor.intercept( + invocationContextStoreEncryptionKeyNoPrivateKeyAccess, + () => {}, + ); + expect(result).to.Null; + }); + + it('encryption_key : asserts encryption key has been stored for enterprise hris', async () => { + collectivityRepository.stubs.findOne.resolves(undefined); + enterpriseRepository.stubs.findOne.resolves(mockEnterpriseHris); + const result = await interceptor.intercept( + invocationContextStoreEncryptionKey, + () => {}, + ); + expect(result).to.Null; + }); + + function givenStubbedService() { + collectivityRepository = createStubInstance(CollectivityRepository); + enterpriseRepository = createStubInstance(EnterpriseRepository); + currentUser = { + id: 'citizenId', + groups: ['funder'], + clientName: 'funder-backend', + emailVerified: true, + [securityId]: 'citizenId', + }; + } + + const today = new Date(); + const expirationDate = new Date(today.setMonth(today.getMonth() + 7)); + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq +n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf +n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq +sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85 +OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG +X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n +owIDAQAB +-----END PUBLIC KEY----- +`; + + const mockencryptionKeyValid = new EncryptionKey({ + id: '62977dc80929474f84c403de', + version: 1, + publicKey, + expirationDate, + lastUpdateDate: new Date(), + privateKeyAccess: new PrivateKeyAccess({ + loginURL: 'loginURL', + getKeyURL: 'getKeyURL', + }), + }); + + const mockCollectivity2 = new Collectivity({ + id: '2b6ee373-4c5b-403b-afe5-3bf3cbd2473c', + name: 'funder', + citizensCount: 1, + mobilityBudget: 1, + encryptionKey: mockencryptionKeyValid, + }); + + const mockCollectivity = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'funder', + citizensCount: 10, + mobilityBudget: 12, + encryptionKey: mockencryptionKeyValid, + }); + const mockEnterprise = new Enterprise({ + id: 'randomInputIdEnterprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'funder', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + }); + + const mockEnterpriseHris = new Enterprise({ + id: 'randomInputIdEnterprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'funder', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + isHris: true, + }); + + const mockOtherEnterpise = new Enterprise({ + id: 'randomInputIdEnterprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'funder-other', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + isHris: false, + }); + + const invocationContextStoreEncryptionKey = { + target: {}, + methodName: 'storeEncryptionKey', + args: [ + 'id', + { + id: '62977dc80929474f84c403de', + publicKey, + expirationDate, + lastUpdateDate: new Date(), + privateKeyAccess: new PrivateKeyAccess({ + loginURL: 'loginURL', + getKeyURL: 'getKeyURL', + }), + }, + ], + }; + + const invocationContextStoreEncryptionKeyNoPrivateKeyAccess = { + target: {}, + methodName: 'storeEncryptionKey', + args: [ + 'id', + { + id: '62977dc80929474f84c403de', + publicKey, + expirationDate, + lastUpdateDate: new Date(), + }, + ], + }; +}); diff --git a/api/src/__tests__/interceptors/incentive.interceptors.test.ts b/api/src/__tests__/interceptors/incentive.interceptors.test.ts new file mode 100644 index 0000000..76fc4c9 --- /dev/null +++ b/api/src/__tests__/interceptors/incentive.interceptors.test.ts @@ -0,0 +1,335 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {IncentiveInterceptor} from '../../interceptors'; +import {Incentive, Territory} from '../../models'; +import {IncentiveRepository} from '../../repositories'; +import {ResourceName, StatusCode} from '../../utils'; +import {ValidationError} from '../../validationError'; + +describe('IncentiveInterceptor', () => { + let interceptor: any = null; + + const errorMinDate: any = new ValidationError( + `incentives.error.validityDate.minDate`, + '/validityDate', + ); + + const errorIsMCMStaffSubscription: any = new ValidationError( + `incentives.error.isMCMStaff.subscriptionLink`, + '/isMCMStaff', + ); + + const errorIsMCMStaffSpecificFields: any = new ValidationError( + `incentives.error.isMCMStaff.specificFieldOrSubscriptionLink`, + '/isMCMStaff', + ); + + const errorTitleAlreadyUsedForFunder: any = new ValidationError( + `incentives.error.title.alreadyUsedForFunder`, + '/incentiveTitleAlreadyUsed', + ); + + const errorNotFound = new ValidationError( + `Incentive not found`, + '/incentiveNotFound', + StatusCode.NotFound, + ResourceName.Incentive, + ); + + const invocationContextCreates = { + target: {}, + methodName: 'create', + args: [ + { + title: 'incentives to test', + description: 'incentive to test in unit test', + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + funderName: 'idf', + incentiveType: 'AideNationale', + conditions: '', + paymentMethod: 'cb', + allocatedAmount: '1231', + minAmount: '15', + transportList: ['velo'], + additionalInfos: '', + contact: '', + validityDate: '2010-06-08', + isMCMStaff: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationCtxDeleteById = { + target: {}, + methodName: 'deleteById', + args: ['azezaeaz'], + }; + + const invocationContextCreatesMCMStaffCases = { + target: {}, + methodName: 'create', + args: [ + { + id: '', + title: 'incentives to test', + description: 'incentive to test in unit test', + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + funderName: 'idf', + incentiveType: 'AideNationale', + conditions: '', + paymentMethod: 'cb', + allocatedAmount: '1231', + minAmount: '15', + transportList: ['velo'], + additionalInfos: '', + contact: '', + validityDate: '3000-06-08', + isMCMStaff: true, + subscriptionLink: 'https://subscriptionLink.com', + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationContextCreate = { + target: {}, + methodName: 'create', + args: [ + { + title: 'incentives to test', + description: 'incentive to test in unit test', + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + funderName: 'idf', + incentiveType: 'AideNationale', + conditions: '', + paymentMethod: 'cb', + allocatedAmount: '1231', + minAmount: '15', + transportList: ['velo'], + additionalInfos: '', + contact: '', + validityDate: '3000-06-08', + isMCMStaff: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationContextReplaceById = { + target: {}, + methodName: 'replaceById', + args: [ + 'id', + { + title: 'incentives to test', + description: 'incentive to test in unit test', + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + funderName: 'idf', + incentiveType: 'AideNationale', + conditions: '', + paymentMethod: 'cb', + allocatedAmount: '1231', + minAmount: '15', + transportList: ['velo'], + additionalInfos: '', + contact: '', + validityDate: '2010-06-08', + isMCMStaff: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const invocationContextUpdateById = { + target: {}, + methodName: 'updateById', + args: [ + 'id', + { + title: 'Un réseau de transport en commun régional plus performant', + description: 'incentive to test in unit test', + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + conditions: '', + paymentMethod: 'cb', + allocatedAmount: '1231', + minAmount: '15', + transportList: ['velo'], + additionalInfos: '', + contact: '', + validityDate: '2024-06-08', + isMCMStaff: true, + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + ], + }; + + const mockTerritoryIncentive = new Incentive({ + id: '61d372bcf3a8e84cc09ace7f', + title: 'Un réseau de transport en commun régional plus performant', + description: 'Description dolor sit amet, consectetur adipiscing elit.', + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + conditions: + "Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + paymentMethod: + 'Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + allocatedAmount: 'Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + minAmount: 'A partir de 5€ par mois sous conditions', + transportList: ['transportsCommun'], + attachments: ['justificatifDomicile'], + additionalInfos: 'Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis.', + contact: 'Contactez le numéro vert au 05 603 603', + validityDuration: '24 mois', + validityDate: '2024-07-31T00:00:00.000Z', + isMCMStaff: true, + createdAt: new Date('2022-01-03T22:03:40.565Z'), + updatedAt: new Date('2022-01-05T09:31:10.289Z'), + funderId: '0d606650-4689-4438-9911-72bbd069cd2b', + }); + + let incentiveRepository: StubbedInstanceWithSinonAccessor; + + function givenStubbedRepository() { + incentiveRepository = createStubInstance(IncentiveRepository); + } + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new IncentiveInterceptor(incentiveRepository); + }); + + it('IncentiveInterceptor creates: error date', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + await interceptor.intercept(invocationContextCreates); + } catch (err) { + expect(err.message).to.equal(errorMinDate.message); + } + }); + + it('IncentiveInterceptor creates: error MCM true subscription', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + await interceptor.intercept(invocationContextCreatesMCMStaffCases); + } catch (err) { + expect(err.message).to.equal(errorIsMCMStaffSubscription.message); + } + }); + + it('IncentiveInterceptor creates: error MCM false no subscription', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + invocationContextCreatesMCMStaffCases.args[0].isMCMStaff = false; + delete (invocationContextCreatesMCMStaffCases?.args[0] as Partial) + .subscriptionLink; + await interceptor.intercept(invocationContextCreatesMCMStaffCases); + } catch (err) { + expect(err.message).to.equal(errorIsMCMStaffSpecificFields.message); + } + }); + + it('IncentiveInterceptor creates: error MCM false specific Fields', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + ( + invocationContextCreatesMCMStaffCases.args[0] as Partial + ).specificFields = [{} as any]; + await interceptor.intercept(invocationContextCreatesMCMStaffCases); + } catch (err) { + expect(err.message).to.equal(errorIsMCMStaffSpecificFields.message); + } + }); + + it('IncentiveInterceptor creates: error title already used for same funder', async () => { + try { + incentiveRepository.stubs.findOne.resolves(mockTerritoryIncentive); + await interceptor.intercept(invocationContextCreate); + } catch (err) { + expect(err.message).to.equal(errorTitleAlreadyUsedForFunder.message); + } + }); + + it('IncentiveInterceptor creates: successful', async () => { + incentiveRepository.stubs.findOne.resolves(null); + const result = await interceptor.intercept(invocationContextCreate, () => {}); + + expect(result).to.Null; + }); + + it('IncentiveInterceptor ReplaceById: error date', async () => { + try { + await interceptor.intercept(invocationContextReplaceById); + } catch (err) { + expect(err.message).to.equal(errorMinDate.message); + } + }); + + it('IncentiveInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + it('IncentiveInterceptor updateById: error title already used for same funder', async () => { + try { + incentiveRepository.stubs.findOne.resolves(mockTerritoryIncentive); + await interceptor.intercept(invocationContextUpdateById); + } catch (err) { + expect(err.message).to.equal(errorTitleAlreadyUsedForFunder.message); + } + }); + + it('IncentiveInterceptor updateById: error incentive not found', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + await interceptor.intercept(invocationContextUpdateById); + } catch (err) { + expect(err.message).to.equal(errorNotFound.message); + } + }); + + it('IncentiveInterceptor updateById: successful', async () => { + incentiveRepository.stubs.findOne.onCall(0).resolves(mockTerritoryIncentive); + incentiveRepository.stubs.findOne.onCall(1).resolves(null); + expect( + await interceptor.intercept(invocationContextUpdateById, () => {}), + ).not.to.throwError(); + }); + + it('IncentiveInterceptor deleteById: incentive not found', async () => { + try { + incentiveRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxDeleteById, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + incentiveRepository.stubs.findOne.restore(); + }); +}); diff --git a/api/src/__tests__/interceptors/subscription.interceptor.test.ts b/api/src/__tests__/interceptors/subscription.interceptor.test.ts new file mode 100644 index 0000000..92e0e05 --- /dev/null +++ b/api/src/__tests__/interceptors/subscription.interceptor.test.ts @@ -0,0 +1,289 @@ +import { + expect, + StubbedInstanceWithSinonAccessor, + createStubInstance, + sinon, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {SubscriptionInterceptor} from '../../interceptors'; +import {SubscriptionRepository, UserRepository} from '../../repositories'; +import {User, Subscription} from '../../models'; +import {ValidationError} from '../../validationError'; +import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../../utils'; + +describe('SubscriptionInterceptor', () => { + let interceptor: any = null; + + let subscriptionRepository: StubbedInstanceWithSinonAccessor, + user: StubbedInstanceWithSinonAccessor, + currentUserProfile: IUser; + + const error = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + + const errorNotFound = new ValidationError( + `Subscription not found`, + '/subscriptionNotFound', + StatusCode.NotFound, + ResourceName.Subscription, + ); + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new SubscriptionInterceptor( + user, + subscriptionRepository, + currentUserProfile, + ); + }); + + it('DelandesnInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + it('SubscriptionInterceptor user without community', async () => { + try { + user.stubs.findOne.resolves(mockUserWithoutCom); + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextFindById, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('SubscriptionInterceptor findById : subscription not found', async () => { + try { + user.stubs.findOne.resolves(mockUserWithoutCom); + subscriptionRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationContextFindById, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + }); + it('SubscriptionInterceptor user with all perimeter', async () => { + try { + user.stubs.findOne.resolves(mockUserWithAllCom); + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextFindById, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + it('SubscriptionInterceptor user with community, findById', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextFindById, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + it('SubscriptionInterceptor user with community, validate', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextValidate, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('SubscriptionInterceptor validate : subsciption not found', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationContextValidate, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + }); + + it('SubscriptionInterceptor user with community, reject', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextReject, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('SubscriptionInterceptor reject : subscription not found', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationContextReject, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + }); + + it('SubscriptionInterceptor user with community, getSubscriptionFileByName', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextGetSubscriptionFileByName, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('SubscriptionInterceptor getSubscriptionFileByName : subscription not found', async () => { + try { + user.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.findById.resolves(undefined); + + await interceptor.intercept(invocationContextGetSubscriptionFileByName, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + }); + + it('SubscriptionInterceptor subscription with wrong status, getSubscriptionFileByName', async () => { + try { + subscriptionRepository.stubs.findById.resolves(mockSubscription2); + + await interceptor.intercept(invocationContextSubscriptionStatus, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + it('SubscriptionInterceptor user with wrong clientName, getSubscriptionFileByName', async () => { + try { + subscriptionRepository.stubs.findById.resolves(mockSubscription); + + await interceptor.intercept(invocationContextUserClientName, () => {}); + } catch (err) { + expect(err).to.deepEqual(error); + } + }); + + function givenStubbedRepository() { + user = createStubInstance(UserRepository); + subscriptionRepository = createStubInstance(SubscriptionRepository); + currentUserProfile = { + id: 'testId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'testId', + roles: ['maas'], + }; + } +}); + +const invocationContextFindById = { + target: {}, + methodName: 'findById', + args: ['randomInputId'], +}; + +const invocationContextValidate = { + target: {}, + methodName: 'findById', + args: ['validate'], +}; + +const invocationContextReject = { + target: {}, + methodName: 'reject', + args: ['validate'], +}; + +const invocationContextGetSubscriptionFileByName = { + target: {}, + methodName: 'getSubscriptionFileByName', + args: ['validate'], +}; + +const invocationContextSubscriptionStatus = { + target: {}, + methodName: 'getSubscriptionFileByName', + args: ['randomInputId2'], +}; + +const invocationContextUserClientName = { + target: {}, + methodName: 'getSubscriptionFileByName', + args: ['randomInputId'], +}; + +const mockUserWithoutCom = new User({ + id: 'testId', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires'], + communityIds: [], +}); +const mockUserWithAllCom = new User({ + id: 'testId', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires'], +}); +const mockUserWithCom = new User({ + id: 'testId', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'random', + roles: ['gestionnaires'], + communityIds: ['id1'], +}); + +const mockSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + communityId: 'id1', + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const mockSubscription2 = new Subscription({ + id: 'randomInputId2', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + communityId: 'id1', + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); diff --git a/api/src/__tests__/interceptors/subscription.metadata.interceptors.test.ts b/api/src/__tests__/interceptors/subscription.metadata.interceptors.test.ts new file mode 100644 index 0000000..06db29c --- /dev/null +++ b/api/src/__tests__/interceptors/subscription.metadata.interceptors.test.ts @@ -0,0 +1,194 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {ValidationError} from '../../validationError'; +import {IncentiveRepository, MetadataRepository} from '../../repositories'; +import {IUser, ResourceName, StatusCode} from '../../utils'; +import {SubscriptionMetadataInterceptor} from '../../interceptors'; +import {Incentive, Metadata} from '../../models'; + +describe('SubscriptionV1 metadata Interceptor', () => { + let interceptor: any = null; + let metadataRepository: StubbedInstanceWithSinonAccessor, + incentiveRepository: StubbedInstanceWithSinonAccessor, + currentUserProfile: IUser; + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new SubscriptionMetadataInterceptor( + incentiveRepository, + metadataRepository, + currentUserProfile, + ); + }); + + it('SubscriptionMetadataInterceptor args: error incentive isMCMStaff false', async () => { + try { + incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: false})); + await interceptor.intercept(invocationContextArgsOK); + } catch (err) { + expect(err).to.deepEqual(errorNotMCMStaff); + } + }); + + it('SubscriptionMetadataInterceptor args: error products length === 0', async () => { + try { + incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: true})); + await interceptor.intercept(invocationContextArgsKOProducts); + } catch (err) { + expect(err).to.deepEqual(errorMetadataInvoicesOrProductsLength); + } + }); + + it('SubscriptionMetadataInterceptor args: error invoices length !== totalElements', async () => { + try { + incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: true})); + await interceptor.intercept(invocationContextArgsKOInvoicesTotalElements); + } catch (err) { + expect(err).to.deepEqual(errorMetadataInvoicesTotalElements); + } + }); + + it('SubscriptionMetadataInterceptor args: getMetadata user id not matches error', async () => { + try { + metadataRepository.stubs.findOne.resolves( + new Metadata({citizenId: 'citizenIdError'}), + ); + await interceptor.intercept(invocationContextArgsGetOK); + } catch (err) { + expect(err.message).to.equal(errorMetadataNotHis.message); + } + }); + + it('SubscriptionMetadataInterceptor args: success', async () => { + incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: true})); + metadataRepository.stubs.findOne.resolves(null); + await interceptor.intercept(invocationContextArgsOK, () => {}); + }); + + it('SubscriptionMetadataInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + it('SubscriptionMetadataInterceptor getMetadata: metadata not found', async () => { + try { + metadataRepository.stubs.findOne.resolves(null); + + await interceptor.intercept(invocationCtxGetMetadata, () => {}); + } catch (err) { + expect(err).to.deepEqual(errorNotFound); + } + incentiveRepository.stubs.findOne.restore(); + }); + + function givenStubbedRepository() { + incentiveRepository = createStubInstance(IncentiveRepository); + metadataRepository = createStubInstance(MetadataRepository); + currentUserProfile = { + id: 'citizenId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'citizenId', + }; + } +}); + +const errorNotMCMStaff: any = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, +); + +const errorMetadataInvoicesOrProductsLength: any = new ValidationError( + `Metadata invoices or products length invalid`, + '/metadata', + StatusCode.UnprocessableEntity, + ResourceName.Metadata, +); + +const errorMetadataInvoicesTotalElements: any = new ValidationError( + `Metadata invoices length must be equal to totalElements`, + '/metadata', + StatusCode.UnprocessableEntity, + ResourceName.Metadata, +); + +const errorMetadataNotHis: any = new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, +); + +const errorNotFound = new ValidationError( + `Metadata not found`, + '/metadataNotFound', + StatusCode.NotFound, + ResourceName.Metadata, +); + +const invocationCtxGetMetadata = { + methodName: 'getMetadata', + args: ['606c236a624cec2becdef277'], +}; + +const invocationContextArgsOK = { + target: {}, + methodName: 'createMetadata', + args: [ + { + incentiveId: 'incentiveId', + attachmentMetadata: { + invoices: [{products: [{}]}], + totalElements: 1, + }, + }, + ], +}; + +const invocationContextArgsGetOK = { + target: {}, + methodName: 'getMetadata', + args: [ + { + metadataId: 'idMetadata', + }, + ], +}; + +const invocationContextArgsKOProducts = { + target: {}, + methodName: 'createMetadata', + args: [ + { + incentiveId: 'incentiveId', + attachmentMetadata: { + invoices: [{products: []}], + totalElements: 3, + }, + }, + ], +}; + +const invocationContextArgsKOInvoicesTotalElements = { + target: {}, + methodName: 'createMetadata', + args: [ + { + incentiveId: 'incentiveId', + attachmentMetadata: { + invoices: [{products: [{}]}], + totalElements: 3, + }, + }, + ], +}; diff --git a/api/src/__tests__/interceptors/subscriptionV1.attachments.interceptors.test.ts b/api/src/__tests__/interceptors/subscriptionV1.attachments.interceptors.test.ts new file mode 100644 index 0000000..7443d08 --- /dev/null +++ b/api/src/__tests__/interceptors/subscriptionV1.attachments.interceptors.test.ts @@ -0,0 +1,531 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {Readable} from 'stream'; + +import { + Subscription, + AttachmentType, + Metadata, + EncryptionKey, + Collectivity, + PrivateKeyAccess, +} from '../../models'; +import {ValidationError} from '../../validationError'; +import { + SubscriptionRepository, + MetadataRepository, + CollectivityRepository, + EnterpriseRepository, +} from '../../repositories'; +import {ClamavService, S3Service} from '../../services'; +import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../../utils'; +import {SubscriptionV1AttachmentsInterceptor} from '../../interceptors'; + +describe('SubscriptionV1 attachments Interceptor', () => { + let interceptor: any = null; + let subscriptionRepository: StubbedInstanceWithSinonAccessor, + currentUserProfile: IUser, + clamavService: StubbedInstanceWithSinonAccessor, + metadataRepository: StubbedInstanceWithSinonAccessor, + collectivityRepository: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor; + const s3 = new S3Service(); + + const inputSubscription = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId', + status: SUBSCRIPTION_STATUS.DRAFT, + }); + + const inputSubscriptionWithFiles = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId', + status: SUBSCRIPTION_STATUS.DRAFT, + attachments: [{} as AttachmentType], + }); + + const inputSubscriptionWrongIncentiveId = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId', + incentiveId: 'incentiveId', + status: SUBSCRIPTION_STATUS.DRAFT, + }); + + const inputSubscriptionNotDraft = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId', + status: SUBSCRIPTION_STATUS.VALIDATED, + }); + + const inputSubscriptionFail = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId2', + status: SUBSCRIPTION_STATUS.DRAFT, + }); + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new SubscriptionV1AttachmentsInterceptor( + s3, + subscriptionRepository, + metadataRepository, + enterpriseRepository, + collectivityRepository, + currentUserProfile, + clamavService, + ); + }); + + it('SubscriptionV1Interceptor args: error Subscription does not exists', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(undefined); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(err).to.deepEqual(errorSubscriptionDoesnotExist); + } + }); + + it('SubscriptionV1Interceptor args: error User id fail : error', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscriptionFail); + await interceptor.intercept(invocationContextUserIdError); + sinon.assert.fail(); + } catch (err) { + expect(err.message).to.equal(errorStatusUser.message); + } + }); + + it('SubscriptionV1Interceptor args: error subscription not DRAFT', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscriptionNotDraft); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(err).to.deepEqual(errorStatus); + } + }); + + it('SubscriptionV1Interceptor args: error one file to upload', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsNoFileError); + sinon.assert.fail(); + } catch (err) { + expect(err.message).to.equal(errorNoFile.message); + } + }); + + it('SubscriptionV1Interceptor args: error already files in db', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscriptionWithFiles); + metadataRepository.stubs.findById.resolves(); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(err.message).to.equal(errorAlreadyFiles.message); + } + }); + + it('SubscriptionV1Interceptor args: error metadata does not match', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscriptionWrongIncentiveId); + metadataRepository.stubs.findById.resolves( + new Metadata({incentiveId: 'errorincentiveId'}), + ); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(err.message).to.equal(errorMismatchincentiveId.message); + } + }); + + it('SubscriptionV1Interceptor args: error nb of file', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsNbFileError); + sinon.assert.fail(); + } catch (err) { + expect(err).to.deepEqual(errorNbFile); + } + }); + + it('SubscriptionV1Interceptor args: error file mime type', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsMimeTypeError); + sinon.assert.fail(); + } catch (err) { + expect(err).to.deepEqual(errorMimeType); + } + }); + + it('SubscriptionV1Interceptor args: error file size', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsFileSizeError); + sinon.assert.fail(); + } catch (err) { + expect(err.message).to.equal(errorFileSize.message); + } + }); + + it('SubscriptionV1Interceptor args: error corrupted file', async () => { + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + metadataRepository.stubs.findById.resolves(); + clamavService.stubs.checkCorruptedFiles.resolves(false); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(err.message).to.equal(errorCorrepted.message); + } + }); + + it('SubscriptionV1Interceptor : error when funder not found', async () => { + const encryptionKeyNotFoundError = new ValidationError( + `Funder not found`, + '/Funder', + StatusCode.NotFound, + ResourceName.EncryptionKey, + ); + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(undefined); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(encryptionKeyNotFoundError).to.deepEqual(encryptionKeyNotFoundError); + } + }); + + it('SubscriptionV1Interceptor : error when Encryption Key not found', async () => { + const encryptionKeyNotFoundError = new ValidationError( + `Encryption Key not found`, + '/EncryptionKey', + StatusCode.NotFound, + ResourceName.EncryptionKey, + ); + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivityWithoutEncryptionKey); + await interceptor.intercept(invocationContextArgsOK); + sinon.assert.fail(); + } catch (err) { + expect(encryptionKeyNotFoundError).to.deepEqual(encryptionKeyNotFoundError); + } + }); + + it('SubscriptionV1Interceptor : error when Encryption Key expired', async () => { + const encryptionKeyExpiredError = new ValidationError( + `Encryption Key Expired`, + '/EncryptionKey', + StatusCode.UnprocessableEntity, + ResourceName.EncryptionKey, + ); + try { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivityEncryptionKeyExpired); + await interceptor.intercept(invocationContextArgsOK); + } catch (err) { + expect(err).to.deepEqual(encryptionKeyExpiredError); + } + }); + + it('SubscriptionV1Interceptor args: success', async () => { + subscriptionRepository.stubs.findOne.resolves(inputSubscription); + metadataRepository.stubs.findById.resolves(); + clamavService.stubs.checkCorruptedFiles.resolves(true); + enterpriseRepository.stubs.findOne.resolves(undefined); + collectivityRepository.stubs.findOne.resolves(mockCollectivity); + await interceptor.intercept(invocationContextArgsOK, () => {}); + }); + + it('SubscriptionV1Interceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + function givenStubbedRepository() { + metadataRepository = createStubInstance(MetadataRepository); + subscriptionRepository = createStubInstance(SubscriptionRepository); + enterpriseRepository = createStubInstance(EnterpriseRepository); + collectivityRepository = createStubInstance(CollectivityRepository); + clamavService = createStubInstance(ClamavService); + currentUserProfile = { + id: 'citizenId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'citizenId', + }; + } +}); + +const errorSubscriptionDoesnotExist: any = new ValidationError( + 'Subscription does not exist', + '/subscription', + StatusCode.NotFound, + ResourceName.Subscription, +); + +const errorStatus: any = new ValidationError( + `Only subscriptions with Draft status are allowed`, + '/status', + StatusCode.PreconditionFailed, + ResourceName.Subscription, +); + +const errorMimeType: any = new ValidationError( + `Uploaded files do not have valid content type`, + '/attachments', + StatusCode.PreconditionFailed, + ResourceName.AttachmentsType, +); + +const errorFileSize: any = new ValidationError( + `Uploaded files do not have a valid file size`, + '/attachments', +); + +const errorNoFile: any = new ValidationError( + `You need the provide at least one file or valid metadata`, + '/attachments', +); + +const errorAlreadyFiles: any = new ValidationError( + `You already provided files to this subscription`, + '/attachments', +); + +const errorCorrepted: any = new ValidationError( + 'A corrupted file has been found', + '/antivirus', +); +const errorStatusUser: any = new ValidationError('Access denied', '/authorization'); + +const errorNbFile: any = new ValidationError( + `Too many files to upload`, + '/attachments', + StatusCode.UnprocessableEntity, + ResourceName.Attachments, +); + +const errorMismatchincentiveId: any = new ValidationError( + `Metadata does not match this subscription`, + '/attachments', + StatusCode.UnprocessableEntity, + ResourceName.Attachments, +); + +const file: any = { + originalname: 'test1.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', +}; + +const fileListNbFileError: any[] = [ + file, + file, + file, + file, + file, + file, + file, + file, + file, + file, + file, +]; + +const fileListMimeTypeError: any[] = [ + { + originalname: 'test1.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/exe', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, +]; + +const fileListFileSizeError: any[] = [ + { + originalname: 'test1.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 12000000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, +]; + +const invocationContextArgsMimeTypeError = { + target: {}, + methodName: 'addFiles', + args: [ + 'idSubscription1', + { + files: fileListMimeTypeError, + body: { + data: '', + }, + }, + ], +}; + +const invocationContextUserIdError = { + target: {}, + methodName: 'addFiles', + args: [ + 'idSubscription1', + { + files: [file], + }, + ], +}; +const invocationContextArgsNoFileError = { + target: {}, + methodName: 'addFiles', + args: [ + 'idSubscription', + { + body: { + data: '', + }, + }, + ], +}; + +const invocationContextArgsFileSizeError = { + target: {}, + methodName: 'addFiles', + args: [ + 'idSubscription', + { + files: fileListFileSizeError, + body: { + data: '', + }, + }, + ], +}; + +const invocationContextArgsNbFileError = { + target: {}, + methodName: 'addFiles', + args: [ + 'idSubscription', + { + files: fileListNbFileError, + body: { + data: '', + }, + }, + ], +}; + +const invocationContextArgsOK = { + target: {}, + methodName: 'addFiles', + args: [ + 'idSubscription', + { + files: [file], + body: { + data: '{\r\n "metadataId": "metadataId"\r\n}', + }, + }, + ], +}; + +const today = new Date(); +const expirationDate = new Date(today.setMonth(today.getMonth() + 3)); + +const mockencryptionKeyValid = new EncryptionKey({ + id: '62977dc80929474f84c403de', + version: 1, + publicKey: 'publicKey', + expirationDate, + lastUpdateDate: new Date(), + privateKeyAccess: new PrivateKeyAccess({ + loginURL: 'loginURL', + getKeyURL: 'getKeyURL', + }), +}); + +const mockencryptionKeyExpired = new EncryptionKey({ + id: '62977dc80929474f84c403de', + version: 1, + publicKey: 'publicKey', + expirationDate: new Date(new Date().setMonth(new Date().getMonth() - 1)), + lastUpdateDate: new Date(), + privateKeyAccess: new PrivateKeyAccess({ + loginURL: 'loginURL', + getKeyURL: 'getKeyURL', + }), +}); + +const mockCollectivity = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + citizensCount: 10, + mobilityBudget: 12, + encryptionKey: mockencryptionKeyValid, +}); + +const mockCollectivityEncryptionKeyExpired = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + citizensCount: 10, + mobilityBudget: 12, + encryptionKey: mockencryptionKeyExpired, +}); + +const mockCollectivityWithoutEncryptionKey = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + citizensCount: 10, + mobilityBudget: 12, +}); diff --git a/api/src/__tests__/interceptors/subscriptionV1.finalize.interceptors.test.ts b/api/src/__tests__/interceptors/subscriptionV1.finalize.interceptors.test.ts new file mode 100644 index 0000000..8d14cff --- /dev/null +++ b/api/src/__tests__/interceptors/subscriptionV1.finalize.interceptors.test.ts @@ -0,0 +1,118 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; + +import {Subscription} from '../../models'; +import {ValidationError} from '../../validationError'; +import {SubscriptionRepository} from '../../repositories'; +import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../../utils'; +import {SubscriptionV1FinalizeInterceptor} from '../../interceptors/external'; + +describe('SubscriptionV1 finalize Interceptor', () => { + let interceptor: any = null; + let subscriptionRepository: StubbedInstanceWithSinonAccessor, + currentUserProfile: IUser; + + const inputSubscription = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId', + status: SUBSCRIPTION_STATUS.DRAFT, + }); + + const inputSubscriptionNotBrouillon = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId', + status: SUBSCRIPTION_STATUS.VALIDATED, + }); + + const inputSubscriptionFail = new Subscription({ + id: 'idSubscription', + citizenId: 'citizenId2', + status: SUBSCRIPTION_STATUS.DRAFT, + }); + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new SubscriptionV1FinalizeInterceptor( + subscriptionRepository, + currentUserProfile, + ); + }); + + it('SubscriptionV1FinalizeInterceptor args: error Subscription does not exists', async () => { + try { + subscriptionRepository.stubs.findById.resolves(undefined); + await interceptor.intercept(invocationContextArgsOK); + } catch (err) { + expect(err).to.deepEqual(errorSubscriptionDoesnotExist); + } + }); + + it('SubscriptionV1FinalizeInterceptor args: error User id fail : error', async () => { + try { + subscriptionRepository.stubs.findById.resolves(inputSubscriptionFail); + await interceptor.intercept(invocationContextArgsOK); + } catch (err) { + expect(err.message).to.equal(errorStatusUser.message); + } + }); + + it('SubscriptionV1FinalizeInterceptor args: error Subscription not BROUILLON', async () => { + try { + subscriptionRepository.stubs.findById.resolves(inputSubscriptionNotBrouillon); + await interceptor.intercept(invocationContextArgsOK); + } catch (err) { + expect(err).to.deepEqual(errorStatus); + } + }); + + it('SubscriptionV1FinalizeInterceptor args: success', async () => { + subscriptionRepository.stubs.findById.resolves(inputSubscription); + await interceptor.intercept(invocationContextArgsOK, () => {}); + }); + + it('SubscriptionV1FinalizeInterceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + function givenStubbedRepository() { + subscriptionRepository = createStubInstance(SubscriptionRepository); + currentUserProfile = { + id: 'citizenId', + clientName: 'testName-client', + emailVerified: true, + [securityId]: 'citizenId', + }; + } +}); + +const errorSubscriptionDoesnotExist: any = new ValidationError( + 'Subscription does not exist', + '/subscription', + StatusCode.NotFound, + ResourceName.Subscription, +); + +const errorStatus: any = new ValidationError( + `Only subscriptions with Draft status are allowed`, + '/status', + StatusCode.PreconditionFailed, + ResourceName.Subscription, +); + +const errorStatusUser: any = new ValidationError('Access denied', '/authorization'); + +const invocationContextArgsOK = { + target: {}, + methodName: 'finalizeSubscription', + args: ['idSubscription'], +}; diff --git a/api/src/__tests__/interceptors/subscriptionV1.interceptors.test.ts b/api/src/__tests__/interceptors/subscriptionV1.interceptors.test.ts new file mode 100644 index 0000000..f0be6fe --- /dev/null +++ b/api/src/__tests__/interceptors/subscriptionV1.interceptors.test.ts @@ -0,0 +1,311 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {SubscriptionV1Interceptor} from '../../interceptors'; +import {Incentive, Community, Territory} from '../../models'; +import {ValidationError} from '../../validationError'; +import {IncentiveRepository, CommunityRepository} from '../../repositories'; + +describe('SubscriptionV1 Interceptor', () => { + let interceptor: any = null; + let repository: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + givenStubbedRepository(); + interceptor = new SubscriptionV1Interceptor(repository, communityRepository); + }); + + it('SubscriptionV1Interceptor creates: error format "newField1"', async () => { + try { + repository.stubs.findById.resolves(mockIncentive); + await interceptor.intercept(invocationContextCreates, () => {}); + } catch (err) { + expect(err.message).to.equal(errorFormat.message); + } + repository.stubs.findById.restore(); + }); + + it('SubscriptionV1Interceptor creates: error with funderId without communities', async () => { + try { + repository.stubs.findById.resolves(mockIncentiveFunderId); + communityRepository.stubs.findByFunderId.resolves([ + new Community({id: 'community1'}), + ]); + + await interceptor.intercept(invocationContextCreatesuccessful, () => {}); + } catch (err) { + expect(err.message).to.equal('subscriptions.error.communities.mismatch'); + } + repository.stubs.findById.restore(); + communityRepository.stubs.findByFunderId.restore(); + }); + + // eslint-disable-next-line + it('SubscriptionV1Interceptor creates: error with funderId without communities and not mcmstaff', async () => { + try { + repository.stubs.findById.resolves(new Incentive({isMCMStaff: false})); + + await interceptor.intercept(invocationContextCreatesuccessful, () => {}); + } catch (err) { + expect(err.message).to.equal('Access denied'); + } + repository.stubs.findById.restore(); + }); + + it('SubscriptionV1Interceptor creates: successful with specificFields', async () => { + repository.stubs.findById.resolves(mockIncentive); + const result = await interceptor.intercept( + invocationContextCreatesuccessful, + () => {}, + ); + expect(result).to.Null; + repository.stubs.findById.restore(); + }); + + it('SubscriptionV1Interceptor creates: successful without specificFields', async () => { + repository.stubs.findById.resolves(mockIncentiveWithoutSpecificFields); + const result = await interceptor.intercept( + invocationContextCreatesuccessfulwithoutSpecFields, + () => {}, + ); + expect(result).to.Null; + repository.stubs.findById.restore(); + }); + + it('SubscriptionV1Interceptor args', async () => { + try { + repository.stubs.findById.resolves(mockIncentive); + await interceptor.intercept(invocationContextArgsMimeTypeError); + } catch (err) { + expect(err).to.Null; + } + repository.stubs.findById.restore(); + }); + + it('SubscriptionV1Interceptor value', async () => { + const res = 'successful binding'; + sinon.stub(interceptor.intercept, 'bind').resolves(res); + const result = await interceptor.value(); + + expect(result).to.equal(res); + interceptor.intercept.bind.restore(); + }); + + function givenStubbedRepository() { + repository = createStubInstance(IncentiveRepository); + communityRepository = createStubInstance(CommunityRepository); + } +}); + +const errorFormat: any = new ValidationError( + `is not of a type(s) array`, + '/subscription', +); + +const invocationContextCreates = { + target: {}, + methodName: 'createSubscription', + args: [ + { + incentiveId: 'incentiveId', + newField1: 'field1', + newField2: 'field2', + consent: true, + }, + ], +}; + +const invocationContextCreatesuccessfulwithoutSpecFields = { + target: {}, + methodName: 'createSubscription', + args: [ + { + incentiveId: 'incentiveId', + consent: true, + }, + ], +}; + +const mockIncentive = new Incentive({ + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'incentiveTitle', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'incentiveId', + conditions: 'Vivre à Toulouse', + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + jsonSchema: { + properties: { + newField1: { + type: 'array', + maxItems: 2, + items: [ + { + enum: ['newField1', 'newField11'], + }, + ], + }, + newField2: { + type: 'string', + minLength: 1, + }, + }, + title: 'test', + type: 'object', + required: ['newField1', 'newField2'], + additionalProperties: false, + }, + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockIncentiveFunderId = new Incentive({ + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'incentiveTitle', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'incentiveId', + conditions: 'Vivre à Toulouse', + funderId: 'testFunderId', + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + jsonSchema: { + properties: { + newField1: { + type: 'array', + maxItems: 2, + items: [ + { + enum: ['newField1', 'newField11'], + }, + ], + }, + newField2: { + type: 'string', + minLength: 1, + }, + }, + title: 'test', + type: 'object', + required: ['newField1', 'newField2'], + additionalProperties: false, + }, + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const mockIncentiveWithoutSpecificFields = new Incentive({ + territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'incentiveTitle', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'incentiveId', + conditions: 'Vivre à Toulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const invocationContextArgsMimeTypeError = { + target: {}, + methodName: 'createSubscription', + args: [ + { + incentiveId: 'incentiveId', + newField1: ['newField1'], + newField2: 'field2', + consent: true, + }, + { + files: [], + }, + ], +}; + +const invocationContextCreatesuccessful = { + target: {}, + methodName: 'createSubscription', + args: [ + { + incentiveId: 'incentiveId', + newField1: ['newField1'], + newField2: 'field2', + }, + ], +}; diff --git a/api/src/__tests__/providers/authorization.provider.test.ts b/api/src/__tests__/providers/authorization.provider.test.ts new file mode 100644 index 0000000..1c30632 --- /dev/null +++ b/api/src/__tests__/providers/authorization.provider.test.ts @@ -0,0 +1,92 @@ +import {expect} from '@loopback/testlab'; +import {securityId} from '@loopback/security'; +import { + AuthorizationContext, + AuthorizationDecision, + AuthorizationMetadata, +} from '@loopback/authorization'; +import {AuthorizationProvider} from '../../providers'; + +describe('Authorization provider', () => { + let authorizationProvider: AuthorizationProvider; + + beforeEach(() => { + authorizationProvider = new AuthorizationProvider(); + }); + + it('should value: OK', async () => { + const result = authorizationProvider.value(); + expect(result).to.deepEqual( + authorizationProvider.authorize.bind(authorizationProvider), + ); + }); + + it('should authorize: OK ALLOW', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + maas: 'maas', + membership: ['membership'], + roles: ['financeurs'], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: null as any, + }; + const metadata: AuthorizationMetadata = { + allowedRoles: ['financeurs'], + }; + const result = await authorizationProvider.authorize(authContext, metadata); + expect(result).to.deepEqual(AuthorizationDecision.ALLOW); + }); + + it('should authorize: OK ALLOW no allowed Roles', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + maas: 'maas', + membership: ['membership'], + roles: ['financeurs'], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: null as any, + }; + const metadata: AuthorizationMetadata = {}; + const result = await authorizationProvider.authorize(authContext, metadata); + expect(result).to.deepEqual(AuthorizationDecision.ALLOW); + }); + + it('should authorize: OK DENY allowed Roles && no user roles', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + maas: 'maas', + membership: ['membership'], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: null as any, + }; + const metadata: AuthorizationMetadata = { + allowedRoles: ['financeurs'], + }; + const result = await authorizationProvider.authorize(authContext, metadata); + expect(result).to.deepEqual(AuthorizationDecision.DENY); + }); +}); diff --git a/api/src/__tests__/repositories/communautes.repository.test.ts b/api/src/__tests__/repositories/communautes.repository.test.ts new file mode 100644 index 0000000..c999c3a --- /dev/null +++ b/api/src/__tests__/repositories/communautes.repository.test.ts @@ -0,0 +1,25 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {CommunityRepository} from '../../repositories'; +import {testdbMongo} from './testdb.datasource'; + +describe('Community repository (unit)', () => { + let repository: CommunityRepository; + + beforeEach(() => { + repository = new CommunityRepository(testdbMongo); + }); + + describe('Community', () => { + it('Community findByFunderId : successful', async () => { + const result = await repository.findByFunderId( + '757fa925-bcd9-4d88-a071-b6b189377029', + ); + expect(result).to.deepEqual([]); + }); + }); +}); diff --git a/api/src/__tests__/repositories/keycloak-group.repository.test.ts b/api/src/__tests__/repositories/keycloak-group.repository.test.ts new file mode 100644 index 0000000..d310b8c --- /dev/null +++ b/api/src/__tests__/repositories/keycloak-group.repository.test.ts @@ -0,0 +1,116 @@ +import {expect} from '@loopback/testlab'; + +import {realmName} from '../../constants'; +import {GroupRoleMapping, KeycloakGroup, KeycloakRole} from '../../models'; + +import { + GroupRoleMappingRepository, + KeycloakGroupRepository, + KeycloakRoleRepository, +} from '../../repositories'; +import {GROUPS} from '../../utils'; + +import {testdbPostgres} from './testdb.datasource'; + +describe('keycloakGroup (unit)', () => { + let repository: KeycloakGroupRepository, + groupRoleMappingRepository: GroupRoleMappingRepository, + keycloakRoleRepository: KeycloakRoleRepository; + + beforeEach(async () => { + repository = new KeycloakGroupRepository( + testdbPostgres, + async () => groupRoleMappingRepository, + async () => keycloakRoleRepository, + ); + + groupRoleMappingRepository = new GroupRoleMappingRepository(testdbPostgres); + + keycloakRoleRepository = new KeycloakRoleRepository(testdbPostgres); + + await repository.deleteAll(); + await groupRoleMappingRepository.deleteAll(); + await keycloakRoleRepository.deleteAll(); + }); + + describe('keycloakGroup', () => { + it('keycloakGroup get roles : successful', async () => { + await repository.create( + new KeycloakGroup({ + id: 'parent', + name: GROUPS.funders, + parentGroup: '', + realmId: realmName, + }), + ); + await repository.create( + new KeycloakGroup({ + id: 'grp1', + name: 'grp1', + parentGroup: 'parent', + realmId: realmName, + }), + ); + await repository.create( + new KeycloakGroup({ + id: 'grp2', + name: 'grp2', + parentGroup: 'parent', + realmId: realmName, + }), + ); + await repository.create( + new KeycloakGroup({ + id: 'grp3', + name: 'grp3', + parentGroup: 'parent2', + realmId: realmName, + }), + ); + + await keycloakRoleRepository.create(new KeycloakRole({id: 'role1', name: 'role1'})); + await keycloakRoleRepository.create(new KeycloakRole({id: 'role2', name: 'role2'})); + await keycloakRoleRepository.create(new KeycloakRole({id: 'role3', name: 'role3'})); + + await groupRoleMappingRepository.create( + new GroupRoleMapping({roleId: 'role1', groupId: 'grp1'}), + ); + await groupRoleMappingRepository.create( + new GroupRoleMapping({roleId: 'role2', groupId: 'grp1'}), + ); + await groupRoleMappingRepository.create( + new GroupRoleMapping({roleId: 'role5', groupId: 'grp3'}), + ); + await groupRoleMappingRepository.create( + new GroupRoleMapping({roleId: 'role6', groupId: 'grp2'}), + ); + await groupRoleMappingRepository.create( + new GroupRoleMapping({roleId: 'role3', groupId: 'grp1'}), + ); + + const result = await repository.getSubGroupFunderRoles(); + expect(result).to.deepEqual(['role1', 'role2', 'role3']); + }); + + it('keycloakGroup get roles : fails', async () => { + await repository.create( + new KeycloakGroup({ + id: 'parent', + name: 'grp1', + parentGroup: '', + realmId: realmName, + }), + ); + await repository.create( + new KeycloakGroup({ + id: 'grp2', + name: 'grp2', + parentGroup: 'grp1', + realmId: realmName, + }), + ); + const result = await repository.getSubGroupFunderRoles(); + expect(result).to.deepEqual([]); + }); + }); +}); diff --git a/api/src/__tests__/repositories/testdb.datasource.ts b/api/src/__tests__/repositories/testdb.datasource.ts new file mode 100644 index 0000000..894ad86 --- /dev/null +++ b/api/src/__tests__/repositories/testdb.datasource.ts @@ -0,0 +1,11 @@ +import {juggler} from '@loopback/repository'; + +export const testdbMongo: juggler.DataSource = new juggler.DataSource({ + name: 'mongoDS', + connector: 'memory', +}); + +export const testdbPostgres: juggler.DataSource = new juggler.DataSource({ + name: 'idpdbDS', + connector: 'memory', +}); diff --git a/api/src/__tests__/services/authentication.service.test.ts b/api/src/__tests__/services/authentication.service.test.ts new file mode 100644 index 0000000..412db21 --- /dev/null +++ b/api/src/__tests__/services/authentication.service.test.ts @@ -0,0 +1,295 @@ +import {expect, sinon} from '@loopback/testlab'; +import {Request} from 'express'; +import jwt from 'jsonwebtoken'; +import axios from 'axios'; + +import {AuthenticationService} from '../../services'; +import 'regenerator-runtime/runtime'; +import {securityId} from '@loopback/security'; +import {ValidationError} from '../../validationError'; +import {StatusCode} from '../../utils'; + +describe('authentication service', () => { + let authenticationService: AuthenticationService; + + beforeEach(() => { + authenticationService = new AuthenticationService(); + }); + + it('should extractCredentials: OK', () => { + const request = { + headers: { + authorization: 'Bearer xxx.yyy.zzz', + }, + }; + const result = authenticationService.extractCredentials(request as Request); + expect(result).to.equal(request.headers.authorization.split(' ')[1]); + }); + + it('should extractCredentials: KO no header', () => { + const expectedError = new ValidationError( + `Authorization header not found`, + '/authorization', + StatusCode.Unauthorized, + ); + const errorRequest = { + headers: {}, + }; + try { + authenticationService.extractCredentials(errorRequest as Request); + } catch (err) { + expect(err).to.deepEqual(expectedError); + } + }); + + it('should extractCredentials: KO not bearer', () => { + const expectedError = new ValidationError( + `Authorization header is not of type 'Bearer'.`, + '/authorization', + StatusCode.Unauthorized, + ); + const errorRequest = { + headers: { + authorization: 'Test', + }, + }; + try { + authenticationService.extractCredentials(errorRequest as Request); + } catch (err) { + expect(err).to.deepEqual(expectedError); + } + }); + + it('should extractCredentials: KO bearer too many parts', () => { + const expectedError = new ValidationError( + `Authorization header not valid`, + '/authorization', + StatusCode.Unauthorized, + ); + const errorRequest = { + headers: { + authorization: 'Bearer xxx.yyy.zzz test', + }, + }; + try { + authenticationService.extractCredentials(errorRequest as Request); + } catch (err) { + expect(err).to.deepEqual(expectedError); + } + }); + + it('should verifyToken: OK', async () => { + const user = { + sub: 'id', + email_verified: true, + realm_access: { + roles: ['roles'], + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + emailVerified: user.email_verified, + clientName: undefined, + funderType: undefined, + funderName: undefined, + groups: undefined, + incentiveType: undefined, + roles: user.realm_access.roles, + }; + const getResult = { + data: { + keys: [ + { + kid: 'kid', + kty: 'RSA', + alg: 'RS256', + use: 'sig', + n: `t1xAo85zcjxtsR-bSLe47pAJwj8yW7DX5G6LC0DgUc649J8E_f7QLnFwFkqbntD_jLWngnWceo_HoNR1o-8BS2d8n + dG__RKAu9nZof4qX8BV0WRbGQS85kSFfMlj9rW85kD_QZ-4FsP83Fzl4yCT868zRvarJyD3QSFRxVhueRwE5CtZWe + 7ycCPOzRPs_9XPUIkUIQd8Sk9JP7GB_3n27TEHl66ovSsC92f-7mJSwpX3EqD8jjlvyirTcxDOctGXA-ZoTMbGpZA + gsGo-U6BZHrFop-6HyDoaMjDUV-lhIBTUkLz2Cge_8I4jUWix_5twPATZ4sitYfmS8eUzh4hggw`, + e: 'AQAB', + x5c: [ + `MIIClTCCAX0CBgGC9AA0ojANBgkqhkiG9w0BAQsFADAOMQwwCgYDVQQDDANtY20wHhcNMjIwODMxMTMwMjEwWhcNMz + IwODMxMTMwMzUwWjAOMQwwCgYDVQQDDANtY20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3XECjznNyP + G2xH5tIt7jukAnCPzJbsNfkbosLQOBRzrj0nwT9/tAucXAWSpue0P+MtaeCdZx6j8eg1HWj7wFLZ3yd0b/9EoC72dmh + /ipfwFXRZFsZBLzmRIV8yWP2tbzmQP9Bn7gWw/zcXOXjIJPzrzNG9qsnIPdBIVHFWG55HATkK1lZ7vJwI87NE+z/1c9 + QiRQhB3xKT0k/sYH/efbtMQeXrqi9KwL3Z/7uYlLClfcSoPyOOW/KKtNzEM5y0ZcD5mhMxsalkCCwaj5ToFkesWin7o + fIOhoyMNRX6WEgFNSQvPYKB7/wjiNRaLH/m3A8BNniyK1h+ZLx5TOHiGCDAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAG + 7z4qy6KZYJwYvs8LveH5o9pah06T9Jd1fFTHVKCT0g6Ag8xaunccoFj6RLIFmOk5XsuXKp3lUMDOZR0utsLtoFU4mMdK + 19NdHl2a500DkW4ujk2pDYJlYqnAiVR4DJ4svyMCoLf1ubriZn6Ip6m1K9AZZw4d5jW/VitqVkwBY5DUYLJ0HNHZ242i + /VClj80u4zun7d7gIRvkcHF4k3lK7DB0LvJEXj8ZL7ExuNeyUGbblvjFLSKx0nJCL2AjFloqBO971NCm06bVLok/pOy4 + df9KfboIyL7C8EEWhdxUQnEoQIX1zyS1yJb1PWhF2Ay6XPCzYVZMLhSFaHlCULhMk=`, + ], + x5t: 'dAgpup7prlDbyAnTLYuAdZYTGUg', + 'x5t#S256': 'DVtIRKD8VQPG0GFIfL0mvDbmO9k8PC2z32HdhaZBaa0', + }, + ], + }, + }; + const axiosGet = sinon.stub(axios, 'get').returns(getResult as any); + const decodeStub = sinon.stub(jwt, 'decode').returns({header: {kid: 'kid'}} as any); + + const verifyStub = sinon.stub(jwt, 'verify').returns(userResult as any); + const result = await authenticationService.verifyToken('token'); + expect(result).to.deepEqual(userResult); + axiosGet.restore(); + verifyStub.restore(); + decodeStub.restore(); + }); + + it('should verifyToken: KO no token', async () => { + const expectedError = new ValidationError( + `Error verifying token'.`, + '/authorization', + StatusCode.Unauthorized, + ); + const token: any = undefined; + try { + await authenticationService.verifyToken(token); + } catch (err) { + expect(err).to.deepEqual(expectedError); + } + }); + + it('should verifyToken: KO jwt expired', async () => { + const expectedError = new ValidationError( + `Error verifying token`, + '/authorization', + StatusCode.Unauthorized, + ); + const verifyStub = sinon.stub(jwt, 'verify').returns(expectedError as any); + try { + await authenticationService.verifyToken('token'); + } catch (err) { + expect(err).to.deepEqual(expectedError); + } + verifyStub.restore(); + }); + + it('should convertToUser: OK collectivity', () => { + const user = { + sub: 'id', + email_verified: true, + membership: ['/collectivités/Mulhouse'], + realm_access: { + roles: ['roles'], + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + clientName: undefined, + emailVerified: user.email_verified, + funderType: 'collectivités', + groups: ['Mulhouse'], + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + roles: user.realm_access.roles, + }; + const result = authenticationService.convertToUser(user); + expect(result).to.deepEqual(userResult); + }); + + it('should convertToUser: OK enterprise', () => { + const user = { + sub: 'id', + email_verified: true, + membership: ['/entreprises/Capgemini'], + realm_access: { + roles: ['roles'], + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + clientName: undefined, + emailVerified: user.email_verified, + funderType: 'entreprises', + funderName: 'Capgemini', + groups: ['Capgemini'], + incentiveType: 'AideEmployeur', + roles: user.realm_access.roles, + }; + const result = authenticationService.convertToUser(user); + expect(result).to.deepEqual(userResult); + }); + + it('should convertToUser: OK undefined funderType && funderName && incentiveType', () => { + const user = { + sub: 'id', + email_verified: true, + clientName: undefined, + membership: ['undefined'], + realm_access: { + roles: ['roles'], + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + emailVerified: user.email_verified, + clientName: user.clientName, + funderType: undefined, + funderName: undefined, + groups: ['undefined'], + incentiveType: undefined, + roles: user.realm_access.roles, + }; + const result = authenticationService.convertToUser(user); + expect(result).to.deepEqual(userResult); + }); + + it('should convertToUser: OK no membership', () => { + const user = { + sub: 'id', + email_verified: true, + realm_access: { + roles: ['roles'], + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + emailVerified: user.email_verified, + clientName: undefined, + funderType: undefined, + funderName: undefined, + groups: undefined, + incentiveType: undefined, + roles: user.realm_access.roles, + }; + const result = authenticationService.convertToUser(user); + expect(result).to.deepEqual(userResult); + }); + + it('should convertToUser: OK more roles', () => { + const user = { + sub: 'id', + email_verified: true, + azp: 'test', + realm_access: { + roles: ['roles'], + }, + resource_access: { + test: { + roles: ['moreRoles'], + }, + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + emailVerified: user.email_verified, + clientName: undefined, + funderType: undefined, + funderName: undefined, + groups: undefined, + incentiveType: undefined, + roles: ['roles', 'moreRoles'], + }; + const result = authenticationService.convertToUser(user); + expect(result).to.deepEqual(userResult); + }); +}); diff --git a/api/src/__tests__/services/child_processes/connect.test.ts b/api/src/__tests__/services/child_processes/connect.test.ts new file mode 100644 index 0000000..b8dfbcb --- /dev/null +++ b/api/src/__tests__/services/child_processes/connect.test.ts @@ -0,0 +1,28 @@ +import {expect} from '@loopback/testlab'; + +import amqp, {Channel} from 'amqplib'; +import sinon from 'sinon'; +import {connect, createChannel} from '../../../services/child_processes/connect'; + +describe('Child Process connect', () => { + it('Child Process connect : success', async () => { + const connection: any = { + createChannel: () => {}, + }; + const amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + const connectionResult = await connect(); + expect(connectionResult).to.deepEqual(connection); + amqpTest.restore(); + }); + + it('Child Process create channel : success', async () => { + const channel: Channel = Object.assign({name: 'channel'}); + const connection: any = { + createChannel: () => { + return channel; + }, + }; + const channelResult = await createChannel(connection); + expect(channelResult).to.deepEqual(channel); + }); +}); diff --git a/api/src/__tests__/services/child_processes/consume.test.ts b/api/src/__tests__/services/child_processes/consume.test.ts new file mode 100644 index 0000000..522c549 --- /dev/null +++ b/api/src/__tests__/services/child_processes/consume.test.ts @@ -0,0 +1,327 @@ +import {expect, sinon} from '@loopback/testlab'; + +import amqp, {Channel, Connection} from 'amqplib'; +import process from 'process'; +import {EventEmitter} from 'events'; +import {ConsumeProcess} from '../../../services/child_processes/consume'; +import {EVENT_MESSAGE, UPDATE_MODE, IMessage, logger} from '../../../utils'; +import {connect, createChannel} from '../../../services/child_processes/connect'; +import {fromCallback} from 'bluebird'; + +describe('Child Process consume', () => { + let connectStub: any = null; + let processStub: any = null; + let consumeProcess: any = null; + let listenerConnectStub: any = null; + let listenerChannelStub: any = null; + let retryConnectStub: any = null; + let retryChannelStub: any = null; + let queue: any; + + const createQueue = () => { + let messages: any = []; + let subscriber: any = null; + + return { + add: (item: any) => { + if (subscriber) { + subscriber(item); + } else { + messages.push(item); + } + }, + addConsumer: (consumer: any) => { + messages.forEach((item: any) => consumer(item)); + messages = []; + subscriber = consumer; + }, + }; + }; + + const channel: any = Object.assign(new EventEmitter(), { + consume: async (queueName: string, consumer: any) => { + queue.addConsumer(consumer); + return Promise.resolve({consumerTag: 'consumerTag'}); + }, + cancel: () => {}, + close: () => {}, + checkQueue: () => { + return Promise.resolve(); + }, + sendToQueue: async (queueName: any, content: any) => { + queue.add({ + content, + }); + }, + }); + + const connection: any = Object.assign(new EventEmitter(), { + createChannel: () => { + return channel; + }, + }); + + beforeEach(() => { + queue = createQueue(); + process.send = () => { + return true; + }; + connectStub = sinon.stub(amqp, 'connect').resolves(connection); + processStub = sinon.stub(process, 'emit'); + consumeProcess = new ConsumeProcess(); + listenerConnectStub = sinon.stub(consumeProcess, 'addConnectionErrorListeners'); + listenerChannelStub = sinon.stub(consumeProcess, 'addChannelErrorListeners'); + retryConnectStub = sinon.stub(consumeProcess, 'retryConnection'); + retryChannelStub = sinon.stub(consumeProcess, 'retryChannel'); + }); + + afterEach(() => { + connectStub.restore(); + processStub.restore(); + listenerConnectStub.restore(); + listenerChannelStub.restore(); + retryConnectStub.restore(); + retryChannelStub.restore(); + }); + + it('Child Process consume : start error connection', async () => { + connectStub.restore(); + const errorConnection: any = { + createChannel: () => { + const err = new Error(); + return Promise.reject(err); + }, + }; + connectStub = sinon.stub(amqp, 'connect').resolves(errorConnection); + try { + await consumeProcess.start(); + } catch (err) { + expect(retryConnectStub.calledOnce).true(); + } + }); + + it('Child Process consume : addConnectionErrorListeners error', async () => { + const spy = sinon.spy(logger, 'error'); + await consumeProcess.start(); + listenerConnectStub.restore(); + await consumeProcess.addConnectionErrorListeners(); + await consumeProcess.connection.emit('error'); + expect(logger.error).called; + spy.restore(); + }); + + it('Child Process consume : addConnectionErrorListeners exit', async () => { + await consumeProcess.start(); + listenerConnectStub.restore(); + await consumeProcess.addConnectionErrorListeners(); + await consumeProcess.connection.emit('exit'); + expect(consumeProcess.retryConnection.calledOnce).true(); + }); + + it('Child Process consume : addConnectionErrorListeners close', async () => { + await consumeProcess.start(); + listenerConnectStub.restore(); + await consumeProcess.addConnectionErrorListeners(); + await consumeProcess.connection.emit('close'); + expect(consumeProcess.retryConnection.calledOnce).true(); + }); + + it('Child Process consume : addChannelErrorListeners error', async () => { + await consumeProcess.start(); + listenerChannelStub.restore(); + await consumeProcess.addChannelErrorListeners(); + await consumeProcess.channel.emit('error'); + expect(consumeProcess.retryChannel.calledOnce).true(); + }); + + it('Child Process consume : addChannelErrorListeners exit', async () => { + await consumeProcess.start(); + listenerChannelStub.restore(); + await consumeProcess.addChannelErrorListeners(); + await consumeProcess.channel.emit('exit'); + expect(consumeProcess.retryChannel.calledOnce).true(); + }); + + it('Child Process consume : addChannelErrorListeners close', async () => { + const spy = sinon.spy(logger, 'error'); + await consumeProcess.start(); + listenerChannelStub.restore(); + await consumeProcess.addChannelErrorListeners(); + await consumeProcess.channel.emit('close'); + expect(logger.error).called; + spy.restore(); + }); + + it('Child Process consume : bulkAddConsumer success', async () => { + const enterpriseNameList: string[] = ['enterprise']; + const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true); + await consumeProcess.start(); + await consumeProcess.bulkAddConsumer(enterpriseNameList); + expect(checkStub.calledOnce).true(); + expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({ + enterprise: 'consumerTag', + }); + checkStub.restore(); + }); + + it('Child Process consume : bulkAddConsumer success with message', async () => { + const enterpriseNameList: string[] = ['enterprise']; + const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true); + await consumeProcess.start(); + await consumeProcess.bulkAddConsumer(enterpriseNameList); + consumeProcess.channel.sendToQueue('mob.subscription.status.enterprise', 'test'); + expect(process.send).called; + expect(checkStub.calledOnce).true(); + expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({ + enterprise: 'consumerTag', + }); + checkStub.restore(); + }); + + it('Child Process consume : bulkAddConsumer error', async () => { + connectStub.restore(); + const errorChannel: any = { + consume: () => { + const err = new Error(); + return Promise.reject(err); + }, + }; + const errorConnection: any = { + createChannel: () => { + return errorChannel; + }, + }; + connectStub = sinon.stub(amqp, 'connect').resolves(errorConnection); + + const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true); + const enterpriseNameList: string[] = ['enterprise']; + await consumeProcess.start(); + try { + await consumeProcess.bulkAddConsumer(enterpriseNameList); + } catch (err) { + expect(checkStub.calledOnce).true(); + expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({}); + checkStub.restore(); + } + }); + + it('Child Process consume : bulkCancelConsumer success', async () => { + const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true); + const enterpriseNameList: string[] = ['enterprise']; + await consumeProcess.start(); + consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'}; + await consumeProcess.bulkCancelConsumer(enterpriseNameList); + expect(checkStub.calledOnce).true(); + expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({}); + checkStub.restore(); + }); + + it('Child Process consume : bulkCancelConsumer no existing queue', async () => { + const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(false); + const enterpriseNameList: string[] = ['enterprise']; + await consumeProcess.start(); + consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'}; + await consumeProcess.bulkCancelConsumer(enterpriseNameList); + expect(checkStub.calledOnce).true(); + expect(consumeProcess.channel.cancel).not.called; + expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({ + enterprise: 'consumerTag', + }); + checkStub.restore(); + }); + + it('Child Process consume : bulkCancelConsumer error', async () => { + connectStub.restore(); + const errorChannel: any = { + cancel: () => { + const err = new Error(); + return Promise.reject(err); + }, + }; + const errorConnection: any = { + createChannel: () => { + return errorChannel; + }, + }; + connectStub = sinon.stub(amqp, 'connect').resolves(errorConnection); + + const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true); + const enterpriseNameList: string[] = ['enterprise']; + await consumeProcess.start(); + consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'}; + + try { + await consumeProcess.bulkCancelConsumer(enterpriseNameList); + } catch (err) { + expect(checkStub.calledOnce).true(); + expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({ + enterprise: 'consumerTag', + }); + checkStub.restore(); + } + }); + + it('Child Process consume : checkExistingQueue returns true', async () => { + const enterpriseName: string = 'enterprise'; + await consumeProcess.start(); + const result: boolean = await consumeProcess.checkExistingQueue(enterpriseName); + expect(result).to.equal(true); + }); + + it('Child Process consume : restartConsumers success', async () => { + await consumeProcess.start(); + consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'}; + const bulkAddStub = sinon.stub(consumeProcess, 'bulkAddConsumer').resolves(); + await consumeProcess.restartConsumers(); + expect(consumeProcess.bulkAddConsumer.calledOnce).true(); + bulkAddStub.restore(); + }); + + it('Child Process consume : restartConsumers false', async () => { + await consumeProcess.restartConsumers(); + expect(consumeProcess.bulkAddConsumer).not.called; + }); + + it('Child Process consume : retryConnection landscape false', async () => { + retryConnectStub.restore(); + await consumeProcess.retryConnection(); + expect(consumeProcess.start).not.called; + expect(consumeProcess.restartConsumers).not.called; + }); + + it('Child Process consume : retryConnection landscape true', async () => { + retryConnectStub.restore(); + const env = Object.assign({}, process.env); + process.env.LANDSCAPE = 'test'; + const clock = sinon.useFakeTimers(); + const startStub = sinon.stub(consumeProcess, 'start').resolves(); + const restartConsumersStub = sinon + .stub(consumeProcess, 'restartConsumers') + .resolves(); + await consumeProcess.retryConnection(); + clock.tick(15000); + expect(consumeProcess.start).called; + expect(consumeProcess.restartConsumers).called; + process.env = env; + startStub.restore(); + restartConsumersStub.restore(); + clock.restore(); + }); + + it('Child Process consume : retryChannel success', async () => { + retryChannelStub.restore(); + const clock = sinon.useFakeTimers(); + const restartConsumersStub = sinon + .stub(consumeProcess, 'restartConsumers') + .resolves(); + await consumeProcess.start(); + await consumeProcess.retryChannel(); + expect(consumeProcess.addChannelErrorListeners).not.called; + expect(consumeProcess.restartConsumers).not.called; + clock.tick(15000); + expect(consumeProcess.addChannelErrorListeners).called; + expect(consumeProcess.restartConsumers).called; + restartConsumersStub.restore(); + clock.restore(); + }); +}); diff --git a/api/src/__tests__/services/citizen.service.test.ts b/api/src/__tests__/services/citizen.service.test.ts new file mode 100644 index 0000000..c5fb628 --- /dev/null +++ b/api/src/__tests__/services/citizen.service.test.ts @@ -0,0 +1,1589 @@ +import * as Excel from 'exceljs'; +import { + StubbedInstanceWithSinonAccessor, + createStubInstance, + expect, + sinon, + stubServerRequest, +} from '@loopback/testlab'; +import {securityId, UserProfile} from '@loopback/security'; +import { + CitizenService, + JwtService, + MailService, + Tab, + KeycloakService, +} from '../../services'; +import {ValidationError} from '../../validationError'; +import { + AFFILIATION_STATUS, + CITIZEN_STATUS, + ResourceName, + StatusCode, + SUBSCRIPTION_STATUS, +} from '../../utils'; +import { + Incentive, + Citizen, + Enterprise, + Subscription, + UserEntity, + OfflineUserSession, + OfflineClientSession, + Client, + User, + Territory, +} from '../../models'; +import { + UserRepository, + CitizenRepository, + EnterpriseRepository, + SubscriptionRepository, + UserEntityRepository, + ClientRepository, + OfflineClientSessionRepository, + OfflineUserSessionRepository, +} from '../../repositories'; + +describe('Citizen services', () => { + let citizenService: any = null; + let citizenRepository: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + userRepository: StubbedInstanceWithSinonAccessor, + subscriptionRepository: StubbedInstanceWithSinonAccessor, + userEntityRepository: StubbedInstanceWithSinonAccessor, + clientRepository: StubbedInstanceWithSinonAccessor, + offlineClientSessionRepository: StubbedInstanceWithSinonAccessor, + offlineUserSessionRepository: StubbedInstanceWithSinonAccessor; + + const currentUser: UserProfile = { + id: 'idUser', + emailVerified: true, + maas: undefined, + membership: ['/entreprise/capgemini'], + roles: ['offline_access', 'uma_authorization'], + incentiveType: 'AideEmployeur', + funderName: 'funderName', + [securityId]: 'idUser', + }; + let mailService: any = null; + let jwtService: any = null; + let keycloakService: any = null; + + const citizen = new Citizen({ + identity: { + gender: { + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + firstName: { + value: 'Xina', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + lastName: { + value: 'Zhong', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + }); + + beforeEach(() => { + mailService = createStubInstance(MailService); + keycloakService = createStubInstance(KeycloakService); + citizenRepository = createStubInstance(CitizenRepository); + enterpriseRepository = createStubInstance(EnterpriseRepository); + userRepository = createStubInstance(UserRepository); + subscriptionRepository = createStubInstance(SubscriptionRepository); + userRepository = createStubInstance(UserRepository); + userEntityRepository = createStubInstance(UserEntityRepository); + clientRepository = createStubInstance(ClientRepository); + offlineClientSessionRepository = createStubInstance(OfflineClientSessionRepository); + offlineUserSessionRepository = createStubInstance(OfflineUserSessionRepository); + + jwtService = new JwtService(); + citizenService = new CitizenService( + citizenRepository, + enterpriseRepository, + userRepository, + subscriptionRepository, + jwtService, + currentUser, + userEntityRepository, + clientRepository, + offlineClientSessionRepository, + offlineUserSessionRepository, + keycloakService, + mailService, + ); + }); + + it('sendAffiliationMail: successfull', () => { + citizenService.sendAffiliationMail(mailService, mockCitizen, 'funderName'); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + 'test@outlook.com', + `Bienvenue dans votre communauté moB ${'funderName'}`, + 'citizen-affiliation', + sinon.match.any, + ), + ).true(); + }); + + it('validateEmailPattern : successful', () => { + try { + citizenService.validateEmailPattern('rerer@toto.fr', ['@toto.fr', '@toto.com']); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('validateEmailPattern : fail', () => { + try { + citizenService.validateEmailPattern('rerer@titi.fr', ['@toto.fr', '@toto.com']); + sinon.assert.fail(); + } catch (error) { + expect(error).to.deepEqual(expectedErrorEmailFormat); + } + }); + + it('Check Affiliation: OK', async () => { + citizenRepository.stubs.findById.resolves(mockCitizen); + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true); + + sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken); + + const citizen = await citizenService.checkAffiliation(mockedToken); + expect(citizen).to.deepEqual(mockCitizen); + }); + + it('Check Affiliation: Token KO', async () => { + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(false); + + try { + await citizenService.checkAffiliation(mockedToken); + } catch (err) { + expect(err).to.deepEqual(expectedErrorNotValid); + } + }); + + it('Check Affiliation: Enterprise Repository KO', async () => { + citizenRepository.stubs.findById.resolves(mockCitizen); + enterpriseRepository.stubs.findById.rejects(); + + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true); + + sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken); + + try { + await citizenService.checkAffiliation(mockedToken); + } catch (err) { + expect(err).to.deepEqual(expectedErrorNotValid); + } + }); + + it('Check Affiliation: Citizen Repository KO', async () => { + citizenRepository.stubs.findById.rejects(); + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true); + + sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken); + + try { + await citizenService.checkAffiliation(mockedToken); + } catch (err) { + expect(err).to.deepEqual(expectedErrorNotValid); + } + }); + + it('Check Affiliation: Citizen Affiliation KO', async () => { + const mockCitizenAffliationKO = new Citizen({...mockCitizen}); + + citizenRepository.stubs.findById.resolves(mockCitizenAffliationKO); + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true); + + sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken); + + try { + await citizenService.checkAffiliation(mockedToken); + } catch (err) { + expect(err).to.deepEqual(expectedErrorNotValid); + } + }); + + it('Check Affiliation: Affiliation enterpriseId matches token KO', async () => { + const mockCitizenAffliationKO: any = { + ...mockCitizen, + affiliation: {...mockCitizen.affiliation}, + }; + mockCitizenAffliationKO.affiliation.enterpriseId = 'KO'; + + citizenRepository.stubs.findById.resolves(mockCitizenAffliationKO); + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true); + + sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken); + + try { + await citizenService.checkAffiliation(mockedToken); + } catch (err) { + expect(err).to.deepEqual(expectedErrorNotValid); + } + }); + + it('Check Affiliation: Status KO', async () => { + const mockCitizenAffliationKO: any = { + ...mockCitizen, + affiliation: {...mockCitizen.affiliation}, + }; + mockCitizenAffliationKO.affiliation!.affiliationStatus = + AFFILIATION_STATUS.AFFILIATED; + citizenRepository.stubs.findById.resolves(mockCitizenAffliationKO); + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + + sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true); + + sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken); + + try { + await citizenService.checkAffiliation(mockedToken); + } catch (err) { + expect(err).to.deepEqual(expectedErrorBadStatus); + } + }); + + it('sendDisaffiliationMail: successfull', () => { + citizenService.sendDisaffiliationMail(mailService, mockCitizen); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + 'email@gmail.com', + 'Votre affiliation employeur vient d’être supprimée', + 'disaffiliation-citizen', + ), + ).true(); + }); + it('sendRejectedAffiliation: successfull', () => { + citizenService.sendRejectedAffiliation(mockCitizen, 'enterpriseName'); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + 'email@gmail.com', + "Votre demande d'affiliation a été refusée", + 'affiliation-rejection', + ), + ).true(); + }); + it('sendValidatedAffiliation: successfull', () => { + citizenService.sendValidatedAffiliation(mockCitizen, 'enterpriseName'); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + 'email@gmail.com', + "Votre demande d'affiliation a été acceptée !", + 'affiliation-validation', + ), + ).true(); + }); + + it('Check Disaffiliation: KO', async () => { + citizenRepository.stubs.findById.resolves(new Citizen({id: 'randomInputId'})); + userRepository.stubs.findOne.resolves(mockUserWithCom); + subscriptionRepository.stubs.find.resolves(); + + try { + await citizenService.checkDisaffiliation('randomInputId'); + } catch (err) { + expect(err).to.deepEqual(expectedErrorDisaffiliation); + } + }); + + it('check generateRow : success', async () => { + try { + const header: string[] = [ + 'Date de la demande', + "Nom de l'aide", + 'Financeur', + 'Statut', + 'specificField', + ]; + const excepted: string[] = [ + '06/04/2021', + 'incentiveTitle', + 'funderName', + 'à traiter', + 'value', + ]; + const result: string[] = await citizenService.generateRow( + mockDemandeWithSpecefiqueFields, + header, + ); + expect(result).to.deepEqual(excepted); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check generateRow avec justificatifs: success', async () => { + try { + const header: string[] = [ + 'Date de la demande', + "Nom de l'aide", + 'Financeur', + 'Statut', + 'Nom des justificatifs transmis', + ]; + const excepted: string[] = [ + '06/04/2021', + 'incentiveTitle', + 'funderName', + 'à traiter', + 'originalName', + ]; + + const result: string[] = await citizenService.generateRow( + mockDemandeWithJustificatifs, + header, + ); + expect(result).to.deepEqual(excepted); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check generateHeader : success', async () => { + try { + const excepted: string[] = [ + 'Date de la demande', + "Nom de l'aide", + 'Financeur', + 'Statut', + 'Nom des justificatifs transmis', + 'specificField', + ]; + + const result: string[] = await citizenService.generateHeader(incentive); + expect(result).to.deepEqual(excepted); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check generateTabsDataStructure without SpecefiqueFields: success', async () => { + try { + const excepted: Tab[] = tabsWithoutSF; + const result: Tab[] = await citizenService.generateTabsDataStructure( + [mockSubscription, {incentiveId: null}], + [{id: 'incentiveId'}], + ); + expect(result).to.deepEqual(excepted); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check generateTabsDataStructure : success', async () => { + try { + const excepted: Tab[] = tabs; + const result: Tab[] = await citizenService.generateTabsDataStructure( + [mockDemandeWithSpecefiqueFields], + [incentive], + ); + expect(result).to.deepEqual(excepted); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check getListMaasNames : success', async () => { + userEntityRepository.stubs.findOne.resolves(userEntity); + offlineUserSessionRepository.stubs.find.resolves([offlineUserSession]); + offlineClientSessionRepository.stubs.find.resolves([offlineClientSession]); + clientRepository.stubs.find.resolves([client]); + try { + const result: string[] = await citizenService.getListMaasNames('email@gmail.com'); + expect(result).to.deepEqual(['name maas']); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check getListMaasNames empty: success', async () => { + userEntityRepository.stubs.findOne.resolves(userEntity); + offlineUserSessionRepository.stubs.find.resolves([]); + offlineClientSessionRepository.stubs.find.resolves([]); + clientRepository.stubs.find.resolves([]); + try { + const result: string[] = await citizenService.getListMaasNames('email@gmail.com'); + expect(result).to.deepEqual([]); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check addSheetSubscriptions: success', async () => { + const workbook = new Excel.Workbook(); + const userDemandes: Subscription[] = [mockSubscription]; + const userAides: Incentive[] = [mockAideCollectivite]; + try { + citizenService.addSheetSubscriptions(workbook, userDemandes, userAides); + const result = await workbook.xlsx.writeBuffer(); + expect(result).to.be.instanceof(Buffer); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check Buffer issue de la generation excel : success', async () => { + const userDemandes: Subscription[] = []; + const incentives: Incentive[] = []; + const listMaas: string[] = []; + const companyName = 'companyName'; + try { + const result = await citizenService.generateExcelRGPD( + citizen, + companyName, + userDemandes, + incentives, + listMaas, + ); + expect(result).to.be.instanceof(Buffer); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('sendDeletionMail: successfull', () => { + citizenService.sendDeletionMail(mailService, mockCitizen, '23/07/2022 à 12:12'); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + mockCitizen.email, + 'Votre compte a bien été supprimé', + 'deletion-account-citizen', + sinon.match.any, + ), + ).true(); + }); + + it('checkProEmailExistence : successful', () => { + citizenRepository.stubs.findOne.resolves(null); + try { + citizenService.checkProEmailExistence('test@test.com'); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('checkProEmailExistence : fail', () => { + citizenRepository.stubs.findOne.resolves(mockCitizen); + try { + citizenService.checkProEmailExistence('test@outlook.com'); + } catch (error) { + expect(error).to.deepEqual(expectedErrorEmailUnique); + } + }); + + it('findEmployees: successfull', async () => { + userEntityRepository.stubs.findOne.resolves(userEntity); + userRepository.stubs.findById.resolves(mockUserWithCom); + + const employees = [{employees: [mockCitizen, mockCitizen2], employeesCount: 2}]; + + citizenRepository.stubs.execute.resolves(employees); + + const result = citizenService + .findEmployees(AFFILIATION_STATUS.AFFILIATED, 'lastName', 0, 10) + .then((res: any) => res) + .catch((err: any) => err); + expect(result).deepEqual( + new Promise(() => { + return employees; + }), + ); + }); + + it('check getClientList : success', async () => { + clientRepository.stubs.find.resolves(clients); + try { + const result: string[] = await citizenService.getClientList( + 'simulation-maas-client', + ); + expect(result).to.deepEqual(clients); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check getClientList : fail', async () => { + clientRepository.stubs.find.resolves(clients); + try { + citizenService.getClientList('simulation'); + } catch (error) { + expect(error).to.deepEqual(expectedErrorClientId); + } + }); + + it('sendNonActivatedAccountDeletionMail: successfull', () => { + citizenService.sendNonActivatedAccountDeletionMail(mailService, mockCitizen); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + mockCitizen.email, + 'Votre compte moB vient d’être supprimé', + 'nonActivated-account-deletion', + sinon.match.any, + ), + ).true(); + }); + + it('SendManualAffiliationMail: successfull', () => { + userRepository.stubs.find.resolves([mockUserWithCom]); + keycloakService.stubs.getUser.resolves(userEntity); + citizenService.sendManualAffiliationMail(mockCitizen, mockEnterprise); + + [mockUserWithCom].map(async singleFunder => { + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + singleFunder.email, + `Vous avez une nouvelle demande d'affiliation !`, + 'manual-affiliation', + sinon.match.any, + ), + ).true(); + }); + }); + + it('CreateCitizen Service : fails because of create repository error', async () => { + const errorRepository = 'can not add data in database'; + try { + keycloakService.stubs.createUserKc.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.rejects(errorRepository); + enterpriseRepository.stubs.findById.resolves(enterprise); + keycloakService.stubs.deleteUserKc.resolves(); + + await citizenService.createCitizen(salarie); + } catch (err) { + expect(err.name).to.equal(errorRepository); + } + + keycloakService.stubs.createUserKc.restore(); + citizenRepository.stubs.create.restore(); + enterpriseRepository.stubs.findById.restore(); + keycloakService.stubs.deleteUserKc.restore(); + }); + + it('CreateCitizen Service create salarie : successful', async () => { + enterpriseRepository.stubs.findById.resolves(enterprise); + keycloakService.stubs.createUserKc.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdSalarie); + keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves(); + + const result = await citizenService.createCitizen(salarie); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + sinon.assert.calledWithExactly( + citizenRepository.stubs.create, + sinon.match(createdSalarie), + ); + + enterpriseRepository.stubs.findById.restore(); + keycloakService.stubs.createUserKc.restore(); + citizenRepository.stubs.create.restore(); + keycloakService.stubs.sendExecuteActionsEmailUserKc.restore(); + }); + + it('CreateCitizen Service create salarie with manual affiliation : successful', async () => { + enterpriseRepository.stubs.findById.resolves(mockEnterprise); + keycloakService.stubs.createUserKc.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdSalarieNoProEmail); + keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves(); + const sendManualAff = sinon + .stub(citizenService, 'sendManualAffiliationMail') + .resolves(null); + + const result = await citizenService.createCitizen(salarieNoProEmail); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + sinon.assert.calledWithExactly( + citizenRepository.stubs.create, + sinon.match(createdSalarieNoProEmail), + ); + + enterpriseRepository.stubs.findById.restore(); + keycloakService.stubs.createUserKc.restore(); + citizenRepository.stubs.create.restore(); + keycloakService.stubs.sendExecuteActionsEmailUserKc.restore(); + sendManualAff.restore(); + }); + + it('CreateCitizen Service create salarie no enterprise : successful', async () => { + keycloakService.stubs.createUserKc.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdSalarieNoEnterprise); + keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves(); + + const result = await citizenService.createCitizen(salarieNoEnterprise); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + sinon.assert.calledWithExactly( + citizenRepository.stubs.create, + sinon.match(createdSalarieNoEnterprise), + ); + + keycloakService.stubs.createUserKc.restore(); + citizenRepository.stubs.create.restore(); + keycloakService.stubs.sendExecuteActionsEmailUserKc.restore(); + }); + + it('CreateCitizen Service create student : successful', async () => { + keycloakService.stubs.createUserKc.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdStudent); + keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves(); + + const result = await citizenService.createCitizen(student); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + const arg: any = { + email: 'email@gmail.com', + identity: { + gender: { + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + firstName: { + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + lastName: { + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + }, + city: 'test', + postcode: '31000', + status: CITIZEN_STATUS.STUDENT, + tos1: true, + tos2: true, + id: 'randomInputId', + affiliation: { + enterpriseId: null, + enterpriseEmail: null, + affiliationStatus: AFFILIATION_STATUS.UNKNOWN, + }, + }; + + sinon.assert.notCalled(enterpriseRepository.stubs.findById); + keycloakService.stubs.createUserKc.restore(); + sinon.assert.calledWithExactly(citizenRepository.stubs.create, sinon.match(arg)); + citizenRepository.stubs.create.restore(); + keycloakService.stubs.sendExecuteActionsEmailUserKc.restore(); + }); + + it('CreateCitizen Service createCitizenFc salarie : successful', async () => { + enterpriseRepository.stubs.findById.resolves(enterprise); + keycloakService.stubs.updateCitizenRole.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdSalarie); + + const result = await citizenService.createCitizen(salarie, 'randomInputId'); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + sinon.assert.calledWithExactly( + citizenRepository.stubs.create, + sinon.match(createdSalarie), + ); + + enterpriseRepository.stubs.findById.restore(); + keycloakService.stubs.updateCitizenRole.restore(); + citizenRepository.stubs.create.restore(); + }); + + it('CreateCitizenFc salarie no enterprise : successful', async () => { + keycloakService.stubs.updateCitizenRole.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdSalarieNoEnterprise); + keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves(); + + const result = await citizenService.createCitizen( + salarieNoEnterprise, + 'randomInputId', + ); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + sinon.assert.calledWithExactly( + citizenRepository.stubs.create, + sinon.match(createdSalarieNoEnterprise), + ); + + keycloakService.stubs.updateCitizenRole.restore(); + citizenRepository.stubs.create.restore(); + }); + + it('CreateCitizen Service createCitizenFc student : successful', async () => { + keycloakService.stubs.updateCitizenRole.resolves({ + id: 'randomInputId', + }); + citizenRepository.stubs.create.resolves(createdStudent); + + const result = await citizenService.createCitizen(student, 'randomInputId'); + + expect(result).to.deepEqual({ + id: 'randomInputId', + }); + + const arg: any = { + email: 'email@gmail.com', + identity: { + firstName: { + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + lastName: { + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + }, + city: 'test', + postcode: '31000', + status: CITIZEN_STATUS.STUDENT, + tos1: true, + tos2: true, + id: 'randomInputId', + affiliation: { + enterpriseId: null, + enterpriseEmail: null, + affiliationStatus: AFFILIATION_STATUS.UNKNOWN, + }, + }; + + sinon.assert.notCalled(enterpriseRepository.stubs.findById); + keycloakService.stubs.updateCitizenRole.restore(); + sinon.assert.calledWithExactly(citizenRepository.stubs.create, sinon.match(arg)); + citizenRepository.stubs.create.restore(); + }); + + it('CreateCitizen Service createCitizenFc etudiant keycloakResult undefined', async () => { + keycloakService.stubs.updateCitizenRole.resolves({ + id: 'undefined', + }); + + const result = await citizenService.createCitizen(student); + + expect(result).to.deepEqual(undefined); + + keycloakService.stubs.updateCitizenRole.restore(); + }); +}); + +const expectedErrorNotValid = new ValidationError( + 'citizens.affiliation.not.valid', + '/citizensAffiliationNotValid', + StatusCode.UnprocessableEntity, + ResourceName.Affiliation, +); + +const expectedErrorBadStatus = new ValidationError( + 'citizens.affiliation.bad.status', + '/citizensAffiliationBadStatus', + StatusCode.PreconditionFailed, + ResourceName.AffiliationBadStatus, +); + +const expectedErrorEmailFormat = new ValidationError( + 'citizen.email.professional.error.format', + '/professionnalEmailBadFormat', + StatusCode.PreconditionFailed, + ResourceName.ProfessionalEmail, +); + +const expectedErrorDisaffiliation = new ValidationError( + 'citizens.disaffiliation.impossible', + '/citizensDisaffiliationImpossible', + StatusCode.PreconditionFailed, + ResourceName.Disaffiliation, +); +const expectedErrorClientId = new ValidationError( + 'client.id.notFound', + '/clientIdNotFound', + StatusCode.NotFound, + ResourceName.Client, +); + +const expectedErrorEmailUnique = new ValidationError( + 'citizen.email.error.unique', + '/affiliation.enterpriseEmail', + StatusCode.UnprocessableEntity, + ResourceName.UniqueProfessionalEmail, +); + +const mockedToken = 'montoken'; + +const mockedDecodedToken = { + id: 'randomInputId', + enterpriseId: 'randomInputEnterpriseId', +}; + +const mockCitizen2 = new Citizen({ + id: 'randomInputId', + identity: { + gender: { + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + firstName: { + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + lastName: { + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'funderId', + enterpriseEmail: 'test@outlook.com', + affiliationStatus: AFFILIATION_STATUS.AFFILIATED, + }), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), +}); + +const mockCitizen = new Citizen({ + id: 'randomInputId', + identity: { + gender: { + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + firstName: { + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + lastName: { + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), + }, + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'randomInputEnterpriseId', + enterpriseEmail: 'test@outlook.com', + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), +}); + +const mockEnterprise = new Enterprise({ + id: 'randomInputIdEntreprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'nameEntreprise', + siretNumber: 50, + budgetAmount: 102, + employeesCount: 100, + hasManualAffiliation: true, +}); + +const mockUserWithCom = new User({ + id: 'idUser', + email: 'random@random.fr', + firstName: 'firstName', + lastName: 'lastName', + funderId: 'funderId', + roles: ['gestionnaires'], + communityIds: ['id1'], +}); + +const mockSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + communityId: 'id1', + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), +}); + +const mockDemandeWithJustificatifs = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: 'AideEmployeur', + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + incentiveTransportList: ['velo'], + communityId: 'id1', + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + attachments: [ + { + originalName: 'originalName', + mimeType: '', + uploadDate: new Date(), + proofType: '', + }, + ], +}); + +const mockDemandeWithSpecefiqueFields = new Subscription({ + ...mockSubscription, + specificFields: { + specificField: 'value', + }, +}); + +const incentive = { + id: 'incentiveId', + specificFields: [{title: 'specificField', inputFormat: 'Numerique'}], +}; + +const tabs: Tab[] = [ + { + title: 'incentiveId', + header: [ + 'Date de la demande', + "Nom de l'aide", + 'Financeur', + 'Statut', + 'Nom des justificatifs transmis', + 'specificField', + ], + rows: [['06/04/2021', 'incentiveTitle', 'funderName', 'à traiter', '', 'value']], + }, +]; +const tabsWithoutSF: Tab[] = [ + { + title: 'incentiveId', + header: [ + 'Date de la demande', + "Nom de l'aide", + 'Financeur', + 'Statut', + 'Nom des justificatifs transmis', + ], + rows: [['06/04/2021', 'incentiveTitle', 'funderName', 'à traiter', '']], + }, +]; + +const userEntity: UserEntity = { + id: 'idUser', + email: 'email@gmail.com', + username: '', + emailVerified: true, + enabled: true, + notBefore: 0, + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), + keycloakGroups: [], +}; + +const offlineUserSession: OfflineUserSession = { + userId: 'userId', + userSessionId: 'sessionId', + realmId: 'null', + createdOn: 1, + offlineFlag: '', + lastSessionRefresh: 0, + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), +}; + +const offlineClientSession: OfflineClientSession = { + clientId: 'clientId', + userSessionId: 'sessionId', + offlineFlag: '', + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), + externalClientId: '', + clientStorageProvider: '', +}; + +const client: Client = { + id: 'clientId', + name: 'name maas', + clientId: 'clientIdMaas', + alwaysDisplayInConsole: false, + bearerOnly: false, + consentRequired: false, + enabled: false, + fullScopeAllowed: false, + publicClient: false, + surrogateAuthRequired: false, + frontchannelLogout: false, + serviceAccountsEnabled: false, + standardFlowEnabled: false, + implicitFlowEnabled: false, + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), + directAccessGrantsEnabled: false, +}; + +const clients: Client[] = [ + { + id: 'clientId', + name: 'simulation maas client', + clientId: 'simulation-maas-client', + alwaysDisplayInConsole: false, + bearerOnly: false, + consentRequired: false, + enabled: false, + fullScopeAllowed: false, + publicClient: false, + surrogateAuthRequired: false, + frontchannelLogout: false, + serviceAccountsEnabled: false, + standardFlowEnabled: false, + implicitFlowEnabled: false, + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), + directAccessGrantsEnabled: false, + }, + { + id: 'clientId2', + name: 'mulhouse maas client', + clientId: 'mulhouse-maas-client', + alwaysDisplayInConsole: false, + bearerOnly: false, + consentRequired: false, + enabled: false, + fullScopeAllowed: false, + publicClient: false, + surrogateAuthRequired: false, + frontchannelLogout: false, + serviceAccountsEnabled: false, + standardFlowEnabled: false, + implicitFlowEnabled: false, + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), + directAccessGrantsEnabled: false, + }, + { + id: 'clientId3', + name: 'paris maas client', + clientId: 'paris-maas-client', + alwaysDisplayInConsole: false, + bearerOnly: false, + consentRequired: false, + enabled: false, + fullScopeAllowed: false, + publicClient: false, + surrogateAuthRequired: false, + frontchannelLogout: false, + serviceAccountsEnabled: false, + standardFlowEnabled: false, + implicitFlowEnabled: false, + getId: () => {}, + getIdObject: () => new Object(), + toObject: () => new Object(), + toJSON: () => new Object(), + directAccessGrantsEnabled: false, + }, +]; + +const mockAideCollectivite = new Incentive({ + territory: {name: 'Toulouse', id: 'randomTerritoryId'} as Territory, + additionalInfos: 'test', + funderName: 'Mairie', + allocatedAmount: '200 €', + description: 'test', + title: 'Aide pour acheter vélo électrique', + incentiveType: 'AideTerritoire', + createdAt: new Date('2021-04-06T09:01:30.747Z'), + transportList: ['velo'], + validityDate: '2022-04-06T09:01:30.778Z', + minAmount: 'A partir de 100 €', + contact: 'Mr le Maire', + validityDuration: '1 an', + paymentMethod: 'En une seule fois', + attachments: ['RIB'], + id: 'incentiveId', + conditions: 'Vivre à TOulouse', + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + isMCMStaff: true, +}); + +const salarie = Object.assign(new Citizen(), { + identity: { + gender: { + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + firstName: { + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + lastName: { + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + }, + id: 'randomInputId', + email: 'email@gmail.com', + city: 'test', + + status: CITIZEN_STATUS.EMPLOYEE, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: 'enterpriseEmail@gmail.com', + }), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), +}); + +const enterprise: Enterprise = new Enterprise({ + name: 'enterprise', + emailFormat: ['@gmail.com', 'rr'], +}); + +const createdSalarie = Object.assign(new Citizen(), { + id: 'randomInputId', + email: 'email@gmail.com', + identity: { + gender: { + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + firstName: { + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + lastName: { + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + birthDate: { + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }, + }, + city: 'test', + postcode: '31000', + status: CITIZEN_STATUS.EMPLOYEE, + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: 'enterpriseEmail@gmail.com', + affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE, + }), +}); + +const createdSalarieNoEnterprise = Object.assign(new Citizen(), { + id: 'randomInputId', + email: 'email@gmail.com', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + city: 'test', + postcode: '31000', + status: CITIZEN_STATUS.EMPLOYEE, + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: null, + enterpriseEmail: null, + affiliationStatus: AFFILIATION_STATUS.UNKNOWN, + }), +}); + +const salarieNoEnterprise = Object.assign(new Citizen(), { + id: 'randomInputId', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: '', + enterpriseEmail: '', + }), +}); + +const student = Object.assign(new Citizen(), { + id: 'randomInputId', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.STUDENT, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: '', + enterpriseEmail: '', + }), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), +}); + +const createdStudent = Object.assign(new Citizen(), { + id: 'randomInputId', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.STUDENT, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: null, + enterpriseEmail: null, + affiliationStatus: AFFILIATION_STATUS.UNKNOWN, + }), + getId: () => {}, + getIdObject: () => ({id: 'random'}), + toJSON: () => ({id: 'random'}), + toObject: () => ({id: 'random'}), +}); + +const createdSalarieNoProEmail = new Citizen({ + id: 'randomInputId', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: null, + }), +}); + +const salarieNoProEmail = new Citizen({ + id: 'randomInputId', + identity: Object.assign({ + gender: Object.assign({ + value: 1, + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + firstName: Object.assign({ + value: 'firstName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + lastName: Object.assign({ + value: 'lastName', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + birthDate: Object.assign({ + value: '1991-11-17', + source: 'moncomptemobilite.fr', + certificationDate: new Date('2022-10-24'), + }), + }), + email: 'email@gmail.com', + city: 'test', + status: CITIZEN_STATUS.EMPLOYEE, + postcode: '31000', + tos1: true, + tos2: true, + affiliation: Object.assign({ + enterpriseId: 'enterpriseId', + enterpriseEmail: null, + affiliationStatus: AFFILIATION_STATUS.UNKNOWN, + }), +}); diff --git a/api/src/__tests__/services/clamav.service.test.ts b/api/src/__tests__/services/clamav.service.test.ts new file mode 100644 index 0000000..007086b --- /dev/null +++ b/api/src/__tests__/services/clamav.service.test.ts @@ -0,0 +1,85 @@ +import {expect, StubbedInstanceWithSinonAccessor} from '@loopback/testlab'; +import sinon from 'sinon'; +import {Readable} from 'stream'; +import NodeClam from 'clamscan'; +import {ClamavService} from '../../services'; +import {ValidationError} from '../../validationError'; + +describe('Clamav ', async () => { + let clamav: ClamavService, nodeClamStub: StubbedInstanceWithSinonAccessor; + beforeEach(() => { + clamav = new ClamavService(); + }); + afterEach(() => { + nodeClamStub.restore(); + }); + it('clamavService: no virus', async () => { + nodeClamStub = sinon.stub(NodeClam.prototype, 'init').resolves( + Promise.resolve({ + scanStream: () => { + return Promise.resolve(response); + }, + }), + ); + const res = await clamav.checkCorruptedFiles(fileList); + expect(res).to.equal(true); + }); + + it('clamavService: virus', async () => { + nodeClamStub = sinon.stub(NodeClam.prototype, 'init').resolves( + Promise.resolve({ + scanStream: () => { + return Promise.resolve(responseVirus); + }, + }), + ); + const res = await clamav.checkCorruptedFiles(fileList); + expect(res).to.equal(false); + }); + + it('clamavService: init fail', async () => { + try { + nodeClamStub = sinon.stub(NodeClam.prototype, 'init').rejects({}); + await clamav.checkCorruptedFiles(fileList); + } catch (err) { + expect(err.message).to.equal(errorUrl.message); + } + }); + + it('clamavService: scan stream fail', async () => { + try { + nodeClamStub = sinon.stub(NodeClam.prototype, 'init').resolves( + Promise.resolve({ + scanStream: () => { + return Promise.reject({}); + }, + }), + ); + await clamav.checkCorruptedFiles(fileList); + } catch (err) { + expect(err.message).to.equal(errorUrl.message); + } + }); + + const file: any = { + originalname: 'test', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }; + const fileList: any[] = [file, file, file]; +}); +const errorUrl: any = new ValidationError('Error during file list check', '/antivirus'); + +const response = { + isInfected: false, +}; +const responseVirus = { + isInfected: true, +}; diff --git a/api/src/__tests__/services/contact.service.test.ts b/api/src/__tests__/services/contact.service.test.ts new file mode 100644 index 0000000..a96d80b --- /dev/null +++ b/api/src/__tests__/services/contact.service.test.ts @@ -0,0 +1,38 @@ +import {createStubInstance, expect, sinon} from '@loopback/testlab'; +import {ContactService, MailService} from '../../services'; +import {USERTYPE} from '../../utils'; + +describe('contact service', () => { + let contactService: any = null; + let mailService: any = null; + + beforeEach(() => { + mailService = createStubInstance(MailService); + contactService = new ContactService(); + contactService._to = 'to'; + }); + + it('sendContactMail: successfull', async () => { + contactService.sendMailClient(mailService, mockContact); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + 'test@test.com', + 'Nous traitons votre demande !', + 'client-contact', + sinon.match.any, + ), + ).true(); + }); +}); + +const mockContact = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + userType: USERTYPE.CITIZEN, + postcode: '55555', + message: 'Message test', + tos: true, +}; diff --git a/api/src/__tests__/services/cronJobs.service.test.ts b/api/src/__tests__/services/cronJobs.service.test.ts new file mode 100644 index 0000000..9fa1f4b --- /dev/null +++ b/api/src/__tests__/services/cronJobs.service.test.ts @@ -0,0 +1,63 @@ +import { + expect, + sinon, + StubbedInstanceWithSinonAccessor, + createStubInstance, +} from '@loopback/testlab'; +import {AnyObject} from '@loopback/repository'; + +import {CronJobRepository} from '../../repositories'; +import {CronJobService} from '../../services'; +import {CronJob} from '../../models'; + +describe('CronJob services', () => { + let cronJobService: any = null; + let cronJobRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + cronJobRepository = createStubInstance(CronJobRepository); + cronJobService = new CronJobService(cronJobRepository); + }); + + it('getCronsLog: successfull', async () => { + cronJobRepository.stubs.find.resolves([mockCronJobs]); + + const result = cronJobService + .getCronsLog() + .then((res: any) => res) + .catch((err: any) => err); + expect(result).deepEqual(mockCronJobsReturn); + }); + + it('createCronLog: successfull', async () => { + cronJobRepository.stubs.create.resolves(mockCronJobs); + + const result = cronJobService + .createCronLog() + .then((res: any) => res) + .catch((err: any) => err); + expect(result).deepEqual(mockCronJobsReturn); + }); + + it('delete by id: successfull', async () => { + const deleteCronByID = cronJobRepository.stubs.deleteById.resolves(); + await cronJobService.delCronLogById('123'); + sinon.assert.calledOnce(deleteCronByID); + }); + + const mockCronJobs = new CronJob({ + id: '12345', + type: 'cronType', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + }); + + const mockCronJobsReturn: Promise = new Promise(() => { + return [ + { + id: '12345', + type: 'cronType', + createdAt: new Date('2021-04-06T09:01:30.778Z'), + }, + ]; + }); +}); diff --git a/api/src/__tests__/services/funder.service.test.ts b/api/src/__tests__/services/funder.service.test.ts new file mode 100644 index 0000000..4711ea1 --- /dev/null +++ b/api/src/__tests__/services/funder.service.test.ts @@ -0,0 +1,84 @@ +import { + StubbedInstanceWithSinonAccessor, + createStubInstance, + expect, +} from '@loopback/testlab'; + +import {FunderService} from '../../services'; +import {Collectivity, Enterprise} from '../../models'; +import {CollectivityRepository, EnterpriseRepository} from '../../repositories'; +import {FUNDER_TYPE} from '../../utils'; + +describe('Funder services', () => { + let collectivityRepository: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + funderService: FunderService; + + beforeEach(() => { + collectivityRepository = createStubInstance(CollectivityRepository); + (enterpriseRepository = createStubInstance(EnterpriseRepository)), + (funderService = new FunderService(collectivityRepository, enterpriseRepository)); + }); + + it('funderService: successfull', async () => { + collectivityRepository.stubs.find.resolves([mockCollectivity]); + enterpriseRepository.stubs.find.resolves([mockEnterprise]); + const result = await funderService.getFunders(); + + expect(result).to.deepEqual(mockReturnFunder); + }); + + it('funderService getFunderByName collectivity: successfull', async () => { + collectivityRepository.stubs.find.resolves([ + new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + }), + ]); + + const result = await funderService.getFunderByName('name', FUNDER_TYPE.collectivity); + + expect(result).to.deepEqual({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + funderType: FUNDER_TYPE.collectivity, + }); + }); + + it('funderService getFunderByName enterprises: successfull', async () => { + enterpriseRepository.stubs.find.resolves([ + new Enterprise({ + id: 'randomInputEnterpriseId', + name: 'nameenterprises', + }), + ]); + const result = await funderService.getFunderByName('name', FUNDER_TYPE.enterprise); + + expect(result).to.deepEqual({ + id: 'randomInputEnterpriseId', + name: 'nameenterprises', + funderType: FUNDER_TYPE.enterprise, + }); + }); + + const mockCollectivity = new Collectivity({ + id: 'randomInputIdCollectivity', + name: 'nameCollectivity', + citizensCount: 10, + mobilityBudget: 12, + }); + + const mockEnterprise = new Enterprise({ + id: 'randomInputIdEnterprise', + emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'], + name: 'nameEnterprise', + siretNumber: 50, + employeesCount: 2345, + budgetAmount: 102, + }); + + const mockReturnFunder = [ + {...mockCollectivity, funderType: FUNDER_TYPE.collectivity}, + {...mockEnterprise, funderType: FUNDER_TYPE.enterprise}, + ]; +}); diff --git a/api/src/__tests__/services/incentive.service.test.ts b/api/src/__tests__/services/incentive.service.test.ts new file mode 100644 index 0000000..bbea3a8 --- /dev/null +++ b/api/src/__tests__/services/incentive.service.test.ts @@ -0,0 +1,57 @@ +import {IncentiveService} from '../../services'; +import {expect} from '@loopback/testlab'; + +describe('Incentive services', () => { + let as: any = null; + + beforeEach(() => { + as = new IncentiveService(); + }); + + it('convertSpecificFields : successful', () => { + const specificFields = [ + {title: 'un nombre', inputFormat: 'Numerique'}, + {title: 'un text', inputFormat: 'Texte'}, + {title: 'une date', inputFormat: 'Date'}, + { + title: 'une liste de choix', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [{inputChoice: 'banane'}, {inputChoice: 'cerise'}], + }, + }, + ]; + const incentiveTitle = '1'; + const response = as.convertSpecificFields(incentiveTitle, specificFields); + // expect(response["$schema"]).to.equal("http://json-schema.org/draft-07/schema#"); + // expect(response["$id"]).to.equal("http://yourdomain.com/schemas/myschema.json"); + expect(response['title']).to.equal(incentiveTitle); + expect(response['type']).to.equal('object'); + expect(response['required']).to.deepEqual([ + 'un nombre', + 'un text', + 'une date', + 'une liste de choix', + ]); + expect(response['properties']).to.deepEqual({ + 'un nombre': {type: 'number'}, + 'un text': {type: 'string', minLength: 1}, + 'une date': {type: 'string', format: 'date'}, + 'une liste de choix': { + type: 'array', + maxItems: 2, + items: [{enum: ['banane', 'cerise']}], + }, + }); + }); + + it('convertSpecificFields : successful return empty object', () => { + const specificFields: any[] = []; + const incentiveTitle = '1'; + const response = as.convertSpecificFields(incentiveTitle, specificFields); + // expect(response["$schema"]).to.equal("http://json-schema.org/draft-07/schema#"); + // expect(response["$id"]).to.equal("http://yourdomain.com/schemas/myschema.json"); + expect(response).to.deepEqual({}); + }); +}); diff --git a/api/src/__tests__/services/jwt.service.test.ts b/api/src/__tests__/services/jwt.service.test.ts new file mode 100644 index 0000000..7cf36b2 --- /dev/null +++ b/api/src/__tests__/services/jwt.service.test.ts @@ -0,0 +1,101 @@ +import {expect, sinon} from '@loopback/testlab'; +import {JwtService} from '../../services'; +import jwt from 'jsonwebtoken'; + +import {ValidationError} from '../../validationError'; +import {StatusCode} from '../../utils'; + +const expectedError = new ValidationError( + 'jwt.error.no.affiliation', + '/jwtNoAffiliation', + StatusCode.PreconditionFailed, +); + +describe('jwt service', () => { + let jwtService: any = null; + + beforeEach(() => { + jwtService = new JwtService(); + }); + + it('should generateAffiliationAccessToken: OK', () => { + const signStub = sinon.stub(jwt, 'sign').returns(mockToken as any); + const token = jwtService.generateAffiliationAccessToken(mockCitizen); + expect(token).to.equal(mockToken); + signStub.restore(); + }); + + it('should generateAffiliationAccessToken: KO for no affiliation', () => { + try { + jwtService.generateAffiliationAccessToken(mockCitizenWithoutAffiliation); + } catch (err: any) { + expect(err.message).to.equal(expectedError.message); + } + }); + + it('should verifyAffiliationAccessToken: OK true', () => { + const verifyStub = sinon.stub(jwt, 'verify').returns(mockVerifyResult as any); + const verifyResult = jwtService.verifyAffiliationAccessToken(mockToken); + expect(verifyResult).to.equal(true); + verifyStub.restore(); + }); + + it('should verifyAffiliationAccessToken: OK false', () => { + const verifyStub = sinon.stub(jwt, 'verify').returns(mockVerifyResultKO as any); + const verifyResult = jwtService.verifyAffiliationAccessToken(mockToken); + expect(verifyResult).to.equal(false); + verifyStub.restore(); + }); + + it('should verifyAffiliationAccessToken: KO jwt', () => { + try { + jwtService.verifyAffiliationAccessToken(mockToken); + } catch (err) { + expect(err).to.deepEqual(new Error('JsonWebTokenError: jwt malformed')); + } + }); + + it('should decodeAffiliationAccessToken: OK', () => { + const verifyStub = sinon.stub(jwt, 'verify').returns(mockVerifyResult as any); + const decodeResult = jwtService.decodeAffiliationAccessToken(mockToken); + expect(decodeResult).to.deepEqual(mockVerifyResult); + verifyStub.restore(); + }); + + it('should decodeAffiliationAccessToken: KO jwt', () => { + try { + jwtService.decodeAffiliationAccessToken(mockToken); + } catch (err) { + expect(err).to.deepEqual(new Error('JsonWebTokenError: jwt malformed')); + } + }); +}); + +const mockCitizen = { + id: 'randomInputId', + affiliation: { + enterpriseId: 'randomInputEnterpriseId', + }, +}; + +const mockCitizenKO = { + id: 'randomInputId', + affiliation: { + enterpriseId: 'randomInputEnterpriseId', + }, +}; + +const mockCitizenWithoutAffiliation = { + id: 'randomInputId', +}; + +const mockToken = 'token'; + +const mockVerifyResult = { + id: 'randomInputId', + enterpriseId: 'randomInputEnterpriseId', +}; + +const mockVerifyResultKO = { + id: 'randomInputId', +}; diff --git a/api/src/__tests__/services/keycloak.service.test.ts b/api/src/__tests__/services/keycloak.service.test.ts new file mode 100644 index 0000000..5474936 --- /dev/null +++ b/api/src/__tests__/services/keycloak.service.test.ts @@ -0,0 +1,519 @@ +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import {KeycloakService} from '../../services'; +import {ValidationError} from '../../validationError'; +import {GROUPS, StatusCode} from '../../utils'; +import {KeycloakGroupRepository} from '../../repositories'; + +describe('keycloak services', () => { + let kc: any = null; + const errorMessageUser = new ValidationError('cannot connect to IDP or add user', ''); + + const errorMessageGroup = new ValidationError('cannot connect to IDP or add group', ''); + + let keycloakGroupRepository: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + keycloakGroupRepository = createStubInstance(KeycloakGroupRepository); + kc = new KeycloakService(keycloakGroupRepository); + }); + + it('deleteUserKc : successful', async () => { + const message = 'user supprimé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'del').resolves(message); + + const result = await kc.deleteUserKc('randomId'); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.del.restore(); + + expect(result).equal(message); + }); + + it('deleteUserKc fail : connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.deleteUserKc('randomId').catch((error: any) => { + expect(error.errors).to.equal(errorMessage); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('createUserKc Citizen: successful', async () => { + const message = 'user créé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'create').resolves(message); + + const result = await kc.createUserKc({ + email: 'test@gmail.com', + lastName: 'testLName', + firstName: 'testFName', + group: [GROUPS.citizens], + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.create.restore(); + + expect(result).equal(message); + }); + + it('createUserKc Funder: successful', async () => { + const message = 'user créé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'create').resolves(message); + + const result = await kc.createUserKc({ + email: 'test@gmail.com', + lastName: 'testLName', + firstName: 'testFName', + funderName: 'Funder', + group: ['collectivités'], + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.create.restore(); + + expect(result).equal(message); + }); + + it('createUserKc fail : email not unique', async () => { + const errorKc = new ValidationError('email.error.unique', '/email'); + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon + .stub(kc.keycloakAdmin.users, 'create') + .rejects({response: {status: StatusCode.Conflict}}); + + await kc + .createUserKc({ + email: 'test@gmail.com', + lastName: 'testLName', + firstName: 'testFName', + group: ['collectivités', 'financeurs'], + }) + .catch((error: any) => { + expect(error.message).to.equal(errorKc.message); + }); + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.create.restore(); + }); + + it('createUserKc fail : password does not met policies', async () => { + const errorKc = new ValidationError('password.error.format', '/password'); + + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'create').rejects({ + response: { + status: 400, + data: {errorMessage: 'Password policy not met'}, + }, + }); + + await kc + .createUserKc({ + email: 'test@gmail.com', + lastName: 'testLName', + firstName: 'testFName', + group: ['collectivités'], + }) + .catch((error: any) => { + expect(error.message).to.equal(errorKc.message); + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.create.restore(); + }); + + it('createUserKc fail : missing properties', async () => { + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'create').rejects('test'); + + await kc + .createUserKc({ + email: 'test@gmail.com', + lastName: 'testLName', + firstName: 'testFName', + }) + .catch((error: any) => { + expect(error.message).to.equal(errorMessageUser.message); + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.create.restore(); + }); + + it('createUserKc fail : connection fails', async () => { + sinon.stub(kc.keycloakAdmin, 'auth').rejects('connexion échoue'); + + await kc + .createUserKc({ + email: 'test@gmail.com', + lastName: 'testLName', + firstName: 'testFName', + group: ['collectivités'], + }) + .catch((error: any) => { + expect(error.message).to.equal(errorMessageUser.message); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('createGroupKc fail: no top group found', async () => { + const errorKc = new ValidationError('collectivités.error.topgroup', '/collectivités'); + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.groups, 'find').resolves([]); + + await kc.createGroupKc('group', 'collectivités').catch((error: any) => { + expect(error.message).to.equal(errorKc.message); + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.groups.find.restore(); + }); + + it('createGroupKc fail : funder not unique', async () => { + const errorKc = new ValidationError( + 'collectivités.error.name.unique', + '/collectivités', + ); + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.groups, 'find').resolves([{id: 'someWeirdId'}]); + sinon + .stub(kc.keycloakAdmin.groups, 'setOrCreateChild') + .rejects({response: {status: StatusCode.Conflict}}); + + await kc.createGroupKc('group', 'collectivités').catch((error: any) => { + expect(error.message).to.equal(errorKc.message); + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.groups.find.restore(); + kc.keycloakAdmin.groups.setOrCreateChild.restore(); + }); + + it('createGroupKc : successful', async () => { + const messageSucces = 'funder créé'; + + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.groups, 'find').resolves([{id: 'someWeirdId'}]); + sinon.stub(kc.keycloakAdmin.groups, 'setOrCreateChild').resolves(messageSucces); + + const result = await kc.createGroupKc('group', 'collectivités'); + expect(result).to.equal(messageSucces); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.groups.find.restore(); + kc.keycloakAdmin.groups.setOrCreateChild.restore(); + }); + + it('createGroupKc fail : connection fails', async () => { + sinon.stub(kc.keycloakAdmin, 'auth').rejects('connexion échoue'); + + await kc.createGroupKc('group', 'collectivités').catch((error: any) => { + expect(error.message).to.equal(errorMessageGroup.message); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('deleteGroupKc : successful', async () => { + const message = 'groupe supprimé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.groups, 'del').resolves(message); + + const result = await kc.deleteGroupKc('randomId'); + + expect(result).equal(message); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.groups.del.restore(); + }); + + it('deleteGroupKc fail : connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.deleteGroupKc('randomId').catch((error: any) => { + expect(error.errors).to.equal(errorMessage); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('sendExecuteActionsEmailUserKc fails : can not send email', async () => { + const message = 'email non envoyé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.users, 'executeActionsEmail').rejects(message); + + await kc.sendExecuteActionsEmailUserKc('randomId').catch((error: any) => { + expect(error.errors).to.equal(message); + }); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.executeActionsEmail.restore(); + }); + + it('sendExecuteActionsEmailUserKc : Successful', async () => { + const message = 'email envoyé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.users, 'executeActionsEmail').resolves(message); + + const result = await kc.sendExecuteActionsEmailUserKc('randomId'); + + expect(result).equal(message); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.executeActionsEmail.restore(); + }); + + it('updateUser fail : connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc + .updateUser('randomId', { + firstName: 'firstName', + lastName: 'lastName', + }) + .catch((error: any) => { + expect(error.message).to.equal('cannot connect to IDP or add user'); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('updateUser succes', async () => { + const messageSuccess = 'modification reussie'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.users, 'update').resolves(messageSuccess); + const result = await kc.updateUser('randomId', { + firstName: 'firstName', + lastName: 'lastName', + }); + sinon.assert.calledWithExactly( + kc.keycloakAdmin.users.update, + {id: 'randomId'}, + {firstName: 'firstName', lastName: 'lastName'}, + ); + expect(result).to.equal(messageSuccess); + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.update.restore(); + }); + + it('sendExecuteActionsEmailUserKc fail : connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.sendExecuteActionsEmailUserKc('randomId').catch((error: any) => { + expect(error.errors).to.equal(errorMessage); + }); + kc.keycloakAdmin.auth.restore(); + }); + + it('updateUserGroupsKc success :', async () => { + const groups = [ + {id: '123', name: 'superviseurs'}, + {id: '345', name: 'gestionnaires'}, + ]; + keycloakGroupRepository.stubs.getSubGroupFunder.resolves(groups); + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'delFromGroup').resolves('groupe supprimé'); + sinon.stub(kc.keycloakAdmin.users, 'addToGroup').resolves('groupe ajouté'); + + await kc.updateUserGroupsKc('id', ['superviseurs']); + sinon.assert.calledWithExactly(kc.keycloakAdmin.users.delFromGroup, { + id: 'id', + groupId: '123', + }); + sinon.assert.calledWithExactly(kc.keycloakAdmin.users.delFromGroup, { + id: 'id', + groupId: '345', + }); + sinon.assert.calledWithExactly(kc.keycloakAdmin.users.addToGroup, { + id: 'id', + groupId: '123', + }); + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.delFromGroup.restore(); + kc.keycloakAdmin.users.addToGroup.restore(); + }); + + it('updateUserGroupsKc fail : connection fails', async () => { + const errorMessage = 'connexion échoue'; + const groups = [ + {id: '123', name: 'superviseurs'}, + {id: '345', name: 'gestionnaires'}, + ]; + keycloakGroupRepository.stubs.getSubGroupFunder.resolves(groups); + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.updateUserGroupsKc('randomId', ['superviseurs']).catch((error: any) => { + expect(error.message).to.equal('cannot connect to IDP or add user'); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('disableUserKc : successful', async () => { + const message = 'compte désactivé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'update').resolves(message); + + const result = await kc.disableUserKc('randomId'); + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.update.restore(); + + expect(result).equal(message); + }); + + it('disableUserKc fail : connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.disableUserKc('randomId').catch((error: any) => { + expect(error.message).to.equal(errorMessageUser.message); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('listConsents : successful', async () => { + const consentList = [ + { + clientName: 'simulation maas client', + theClientId: 'simulation-maas-client', + }, + { + clientName: 'mulhouse maas client', + theClientId: 'mulhouse-maas-client', + }, + { + clientName: 'paris maas client', + theClientId: 'paris-maas-client', + }, + ]; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'listConsents').resolves(consentList); + + const result = await kc.listConsents('randomId'); + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.listConsents.restore(); + + expect(result).equal(consentList); + }); + + it('listConsents fail: connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.listConsents('randomId').catch((error: any) => { + expect(error.errors).to.equal(errorMessage); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('deleteConsent : successful', async () => { + const message = 'Grant revoked successfully'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'revokeConsent').resolves(message); + const result = await kc.deleteConsent('randomId'); + + expect(result).to.equal(message); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.revokeConsent.restore(); + }); + + it('deleteConsent fail: connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.deleteConsent('randomId', 'simulation-maas-client').catch((error: any) => { + expect(error.errors).to.equal(errorMessage); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('listUsers : successful', async () => { + const MockUsers = [ + { + username: 'bob@capgemini.com', + emailVerified: false, + firstName: 'bob', + lastName: 'l’éponge', + email: 'bob@capgemini.com', + }, + { + username: 'bob1@capgemini.com', + emailVerified: false, + firstName: 'bob1', + lastName: 'l’éponge1', + email: 'bob1@capgemini.com', + }, + ]; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + + sinon.stub(kc.keycloakAdmin.users, 'find').resolves(MockUsers); + const result = await kc.listUsers(); + + expect(result).to.equal(MockUsers); + + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.find.restore(); + }); + + it('listUsers fail: connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.listUsers().catch((error: any) => { + expect(error.errors).to.equal(errorMessage); + }); + + kc.keycloakAdmin.auth.restore(); + }); + + it('getUser : successful', async () => { + const message = 'user renvoyé'; + sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie'); + sinon.stub(kc.keycloakAdmin.users, 'findOne').resolves(message); + + const result = await kc.getUser('randomId'); + kc.keycloakAdmin.auth.restore(); + kc.keycloakAdmin.users.findOne.restore(); + + expect(result).equal(message); + }); + + it('getUser fails : connection fails', async () => { + const errorMessage = 'connexion échoue'; + sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage); + + await kc.getUser('randomId').catch((error: any) => { + expect(error.message).to.equal(errorMessageUser.message); + }); + + kc.keycloakAdmin.auth.restore(); + }); +}); diff --git a/api/src/__tests__/services/maas.authorizor.test.ts b/api/src/__tests__/services/maas.authorizor.test.ts new file mode 100644 index 0000000..90bd57f --- /dev/null +++ b/api/src/__tests__/services/maas.authorizor.test.ts @@ -0,0 +1,71 @@ +import {expect} from '@loopback/testlab'; +import {AuthorizationContext, AuthorizationDecision} from '@loopback/authorization'; +import {securityId} from '@loopback/security'; + +import {checkMaas} from '../../services'; +import {Roles} from '../../utils'; + +describe('maas authorizor', () => { + it('should checkMaas: OK ALLOW', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + clientName: 'maas', + membership: ['membership'], + roles: ['roles'], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: null as any, + }; + const result = await checkMaas(authContext, null as any); + expect(result).to.deepEqual(AuthorizationDecision.ALLOW); + }); + + it('should checkMaas: OK DENY maas backend', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + clientName: undefined, + membership: ['membership'], + roles: [Roles.MAAS_BACKEND], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: null as any, + }; + const result = await checkMaas(authContext, null as any); + expect(result).to.deepEqual(AuthorizationDecision.DENY); + }); + + it('should checkMaas: OK DENY maas', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + clientName: undefined, + membership: ['membership'], + roles: [Roles.MAAS], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: null as any, + }; + const result = await checkMaas(authContext, null as any); + expect(result).to.deepEqual(AuthorizationDecision.DENY); + }); +}); diff --git a/api/src/__tests__/services/mail.service.test.ts b/api/src/__tests__/services/mail.service.test.ts new file mode 100644 index 0000000..e70fcfc --- /dev/null +++ b/api/src/__tests__/services/mail.service.test.ts @@ -0,0 +1,40 @@ +import {expect, sinon} from '@loopback/testlab'; +import {MailService} from '../../services'; +import ejs from 'ejs'; +import nodemailer from 'nodemailer'; +import {MailConfig} from '../../config'; + +describe('mail service', () => { + let mailService: any = null; + let mailConfig: any = null; + + beforeEach(() => { + mailService = new MailService(); + mailConfig = new MailConfig(); + }); + + const transport: any = { + sendMail: () => { + const err = new Error('No recipients defined'); + return Promise.reject(err); + }, + }; + + it('sendMailAsHtml should return error', async () => { + const ejsStub = sinon.stub(ejs, 'renderFile').resolves('mymailtemplate'); + const nodemailerStub = sinon.stub(nodemailer, 'createTransport').returns(transport); + const mailer = { + mailer: nodemailerStub, + from: 'test@gmal.com', + }; + const mailerInfos = sinon.stub(mailConfig, 'configMailer').returns(mailer); + + try { + await mailService.sendMailAsHtml('', '', '', ''); + } catch (error) { + expect(error.message).to.equal('email.server.error'); + } + ejsStub.restore(); + mailerInfos.restore(); + }); +}); diff --git a/api/src/__tests__/services/parentProcess.service.test.ts b/api/src/__tests__/services/parentProcess.service.test.ts new file mode 100644 index 0000000..22c7fae --- /dev/null +++ b/api/src/__tests__/services/parentProcess.service.test.ts @@ -0,0 +1,78 @@ +import {expect} from '@loopback/testlab'; + +import sinon from 'sinon'; +import path from 'path'; +import child_process from 'child_process'; + +import {ParentProcessService} from '../../services'; +import {EventEmitter} from 'events'; +import {EVENT_MESSAGE, IMessage} from '../../utils'; + +describe('ParentProcessService', () => { + let pathStub: any = null; + let forkStub: any = null; + + beforeEach(() => { + const cp = new EventEmitter(); + pathStub = sinon.stub(path, 'join').returns('file.js'); + forkStub = sinon.stub(child_process, 'fork').returns(cp); + }); + + afterEach(() => { + pathStub.restore(); + forkStub.restore(); + }); + + it('ParentProcessService child process : error', async () => { + try { + const parentProcessService: any = new ParentProcessService(); + parentProcessService.child.emit('error', 'error'); + } catch (err) { + expect(err.message).to.equal('A problem occurred'); + } + }); + + it('ParentProcessService child process : close', async () => { + try { + const parentProcessService: any = new ParentProcessService(); + parentProcessService.child.emit('close', 0); + } catch (err) { + expect(err.message).to.equal('A problem occurred'); + } + }); + + it('ParentProcessService child process : message', async () => { + const message: IMessage = {type: EVENT_MESSAGE.READY}; + const parentProcessService: any = new ParentProcessService(); + sinon.spy(parentProcessService, 'emit'); + parentProcessService.child.emit('message', message); + sinon.assert.calledWithExactly( + parentProcessService.emit, + EVENT_MESSAGE.READY, + undefined, + ); + }); + + it('ParentProcessService fork child process : error', async () => { + forkStub.restore(); + forkStub = sinon.stub(child_process, 'fork').rejects(); + try { + new ParentProcessService(); + } catch (err) { + expect(err.message).to.equal('A problem occurred'); + } + }); + + it('ParentProcessService send message to child : success', async () => { + forkStub.restore(); + const message: IMessage = {type: EVENT_MESSAGE.UPDATE, data: ['test']}; + const cpStub = Object.assign({ + send: sinon.stub().returns(true), + on: sinon.stub().returnsThis(), + }); + forkStub = sinon.stub(child_process, 'fork').returns(cpStub); + const parentProcessService: any = new ParentProcessService(); + parentProcessService.sendMessageToChild(message); + sinon.assert.calledWithExactly(cpStub.send, message); + }); +}); diff --git a/api/src/__tests__/services/rabbitmq.service.test.ts b/api/src/__tests__/services/rabbitmq.service.test.ts new file mode 100644 index 0000000..64424c7 --- /dev/null +++ b/api/src/__tests__/services/rabbitmq.service.test.ts @@ -0,0 +1,324 @@ +import { + createStubInstance, + expect, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; + +import amqp, {Channel} from 'amqplib'; +import KeycloakAdminClient from 'keycloak-admin'; +import sinon from 'sinon'; +import {EventEmitter} from 'stream'; + +import {Enterprise} from '../../models'; +import {EnterpriseRepository} from '../../repositories'; +import {RabbitmqService, SubscriptionService} from '../../services'; +import {KeycloakService} from '../../services/keycloak.service'; +import {EVENT_MESSAGE, ISubscriptionPublishPayload} from '../../utils'; + +describe('Rabbitmq service', () => { + let rabbitmqService: any = null; + let amqpTest: any = null; + let kcAdminAuth: any = null; + let spy: any, + subscriptionService: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + keycloakService: StubbedInstanceWithSinonAccessor, + parentProcessService: any; + + beforeEach(() => { + subscriptionService = createStubInstance(SubscriptionService); + enterpriseRepository = createStubInstance(EnterpriseRepository); + keycloakService = createStubInstance(KeycloakService); + + keycloakService.keycloakAdmin = new KeycloakAdminClient(); + keycloakService.keycloakAdmin.accessToken = 'test'; + + parentProcessService = new EventEmitter(); + + rabbitmqService = new RabbitmqService( + subscriptionService, + enterpriseRepository, + parentProcessService, + keycloakService, + ); + spy = sinon.spy(rabbitmqService); + }); + + it('RabbitMQService event EVENT_MESSAGE.CONSUME : error', async () => { + sinon.spy(parentProcessService, 'on'); + const rabbitStub = sinon.stub(RabbitmqService.prototype, 'consumeMessage').resolves(); + parentProcessService.emit(EVENT_MESSAGE.CONSUME, {test: 'test'}); + sinon.assert.calledOnceWithExactly(rabbitmqService.consumeMessage, {test: 'test'}); + rabbitStub.restore(); + }); + + it('RabbitMQService getHRISEnterpriseNameList : error', async () => { + try { + enterpriseRepository.stubs.getHRISEnterpriseNameList.rejects(); + await rabbitmqService.getHRISEnterpriseNameList(); + } catch (error) { + expect(error.message).to.equal('rabbitmq error getting HRIS enterprises'); + enterpriseRepository.stubs.getHRISEnterpriseNameList.restore(); + } + }); + + it('RabbitMQService getHRISEnterpriseNameList : success', async () => { + const enterprise: Pick = new Enterprise({ + name: 'enterpriseName', + }); + const enterpriseRepositoryList: Pick[] = [enterprise]; + const enterpriseRepositoryResult: string[] = [enterprise.name.toLowerCase()]; + enterpriseRepository.stubs.getHRISEnterpriseNameList.resolves( + enterpriseRepositoryList, + ); + const enterpriseNameList: string[] = + await rabbitmqService.getHRISEnterpriseNameList(); + expect(enterpriseNameList).to.deepEqual(enterpriseRepositoryResult); + enterpriseRepository.stubs.getHRISEnterpriseNameList.restore(); + }); + + it('RabbitMQService connect : error', async () => { + try { + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + amqpTest = sinon.stub(amqp, 'connect').rejects('err'); + await rabbitmqService.connect(); + } catch (error) { + expect(error.message).to.equal('rabbitmq init connection error'); + amqpTest.restore(); + kcAdminAuth.restore(); + } + }); + + it('RabbitMQService connect : success', async () => { + const connection: any = { + createChannel: () => {}, + }; + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + await rabbitmqService.connect(); + expect(rabbitmqService.connection).to.deepEqual(connection); + amqpTest.restore(); + kcAdminAuth.restore(); + }); + + it('RabbitMQService disconnect : error', async () => { + try { + const connection: any = { + close: () => { + const err = new Error(); + return Promise.reject(err); + }, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + await rabbitmqService.disconnect(); + } catch (error) { + expect(error.message).to.equal('rabbitmq disconnect error'); + amqpTest.restore(); + kcAdminAuth.restore(); + } + }); + + it('RabbitMQService disconnect : success', async () => { + const connection: any = { + close: () => {}, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + await rabbitmqService.disconnect(); + expect(rabbitmqService.connection).to.deepEqual(connection); + amqpTest.restore(); + kcAdminAuth.restore(); + }); + + it('RabbitMQService open channel : error', async () => { + try { + const connection: any = { + createChannel: () => { + const err = new Error(); + return Promise.reject(err); + }, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + await rabbitmqService.openConnectionChannel(); + } catch (error) { + expect(error.message).to.equal(`rabbitmq connect to channel error`); + amqpTest.restore(); + kcAdminAuth.restore(); + } + }); + + it('RabbitMQService open channel : successful', async () => { + const channel: Channel = Object.assign({name: 'channel'}); + const connection: any = { + createChannel: () => { + return channel; + }, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + const connectionOpen = await rabbitmqService.openConnectionChannel(); + expect(connectionOpen).to.deepEqual(channel); + amqpTest.restore(); + kcAdminAuth.restore(); + }); + + it('RabbitMQService close channel : error', async () => { + try { + const channel: Channel = Object.assign({ + close: () => { + const err = new Error(); + return Promise.reject(err); + }, + }); + const connection: any = { + createChannel: () => { + return channel; + }, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + await rabbitmqService.openConnectionChannel(); + await rabbitmqService.closeConnectionChannel(channel); + } catch (error) { + expect(error.message).to.equal('rabbitmq close channel error'); + amqpTest.restore(); + kcAdminAuth.restore(); + } + }); + + it('RabbitMQService close channel : success', async () => { + const channel: Channel = Object.assign({ + close: () => {}, + }); + const connection: any = { + createChannel: () => { + return channel; + }, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + await rabbitmqService.openConnectionChannel(); + await rabbitmqService.closeConnectionChannel(channel); + expect(rabbitmqService.connection).to.deepEqual(connection); + amqpTest.restore(); + kcAdminAuth.restore(); + }); + + it('RabbitMQService publishMessage : error', async () => { + try { + const connection: any = { + createChannel: () => { + const err = new Error(); + return Promise.reject(err); + }, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.publishMessage('test'); + } catch (error) { + expect(error.message).to.equal('rabbitmq publish message error'); + expect(spy.openConnectionChannel.calledOnce).true(); + amqpTest.restore(); + kcAdminAuth.restore(); + } + }); + + it('RabbitMQService publishMessage : success', async () => { + const channel: any = { + publish: () => { + return true; + }, + close: () => {}, + }; + const connection: any = { + createChannel: () => { + return channel; + }, + close: () => {}, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + await rabbitmqService.publishMessage('test', 'enterpriseName'); + expect(spy.openConnectionChannel.calledOnce).true(); + expect(spy.closeConnectionChannel.calledOnce).true(); + expect(spy.disconnect.calledOnce).true(); + amqpTest.restore(); + kcAdminAuth.restore(); + }); + + it('RabbitMQService consume : error', async () => { + try { + const buf = JSON.stringify(Buffer.from('test').toString()); + const message = { + content: buf, + }; + subscriptionService.stubs.handleMessage.rejects(); + await rabbitmqService.consumeMessage(message); + } catch (error) { + expect(error.message).to.equal('rabbitmq consume message error'); + subscriptionService.stubs.handleMessage.restore(); + } + }); + + it('RabbitMQService consume : success', async () => { + sinon.spy(parentProcessService, 'emit'); + const buf = JSON.stringify(Buffer.from('test').toString()); + const message = { + content: buf, + }; + subscriptionService.stubs.handleMessage.resolves(); + await rabbitmqService.consumeMessage(message); + sinon.assert.calledWithExactly(parentProcessService.emit, EVENT_MESSAGE.ACK, { + type: EVENT_MESSAGE.ACK, + data: {content: '"test"'}, + }); + subscriptionService.stubs.handleMessage.restore(); + }); + + it('RabbitMQService consume inside the catch 412: fail', async () => { + try { + const message = { + content: '{"citizenId": "id","subscriptionId": "id"}', + }; + const err = { + statusCode: 412, + }; + const channel: any = { + publish: () => { + return true; + }, + close: () => {}, + }; + const connection: any = { + createChannel: () => { + return channel; + }, + close: () => {}, + }; + amqpTest = sinon.stub(amqp, 'connect').resolves(connection); + kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves(); + await rabbitmqService.connect(); + subscriptionService.stubs.handleMessage.rejects(err); + subscriptionService.stubs.getSubscriptionPayload.resolves({ + subscription: {lastName: 'tst'} as ISubscriptionPublishPayload, + enterprise: 'test', + }); + await rabbitmqService.consumeMessage(message); + } catch (err) { + expect(err.message).to.equal('rabbitmq consume message error'); + subscriptionService.stubs.handleMessage.restore(); + subscriptionService.stubs.getSubscriptionPayload.restore(); + amqpTest.restore(); + kcAdminAuth.restore(); + } + }); +}); diff --git a/api/src/__tests__/services/s3.service.test.ts b/api/src/__tests__/services/s3.service.test.ts new file mode 100644 index 0000000..3bce0a7 --- /dev/null +++ b/api/src/__tests__/services/s3.service.test.ts @@ -0,0 +1,293 @@ +/* eslint-disable max-len */ +import {S3Service} from '../../services'; +import {expect, sinon} from '@loopback/testlab'; +import {S3Client} from '@aws-sdk/client-s3'; +import {Readable} from 'stream'; +import {Express} from 'express'; +import {ValidationError} from '../../validationError'; +import {StatusCode} from '../../utils'; + +describe('S3Service ', async () => { + let s3: S3Service; + const deleteError: Error = new Error('Could not delete folder from S3'); + const downloadError: Error = new Error('Filename does not exist'); + const errorMessage = 'Error'; + const errorRejectMessage: Error = new Error('An error occurred: Error'); + + const fileList: any[] = [ + { + originalname: 'test1.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + { + originalname: 'test2.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + ]; + + const file = { + originalname: 'test1.txt', + buffer: Buffer.from('test de buffer'), + mimetype: 'image/png', + fieldname: 'test', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }; + + beforeEach(() => { + s3 = new S3Service(); + }); + + it('uploadFileListIntoBucket(bucketName, fileDirectory, fileList) success', async () => { + const checkBucketExistsStub = sinon.stub(s3, 'checkBucketExists').resolves(true); + const uploadFileListIntoBucketStub = sinon + .stub(s3, 'uploadFile') + .resolves(['test-directory/test1.txt', 'test-directory/test2.txt']); + + const result = await s3.uploadFileListIntoBucket( + 'test-bucket', + 'test-directory', + fileList, + ); + expect(result).to.deepEqual(['test-directory/test1.txt', 'test-directory/test2.txt']); + uploadFileListIntoBucketStub.restore(); + checkBucketExistsStub.restore(); + }); + + it('uploadFileListIntoBucket(bucketName, fileDirectory, fileList) bucket not created', async () => { + const checkBucketExistsStub = sinon.stub(s3, 'checkBucketExists').resolves(false); + const createBucketStub = sinon + .stub(s3, 'createBucket') + .resolves(mockCreateBucketResponse); + const uploadFileListIntoBucketStub = sinon + .stub(s3, 'uploadFile') + .resolves(['test-directory/test1.txt', 'test-directory/test2.txt']); + const result = await s3.uploadFileListIntoBucket( + 'test-bucket', + 'test-directory', + fileList, + ); + expect(result).to.deepEqual(['test-directory/test1.txt', 'test-directory/test2.txt']); + createBucketStub.restore(); + uploadFileListIntoBucketStub.restore(); + checkBucketExistsStub.restore(); + }); + + it('uploadFileListIntoBucket(bucketName, fileDirectory, fileList) error', async () => { + const checkBucketExistsStub = sinon.stub(S3Client.prototype, 'send').resolves(true); + const uploadFileListIntoBucketStubError = sinon + .stub(s3, 'uploadFile') + .rejects(errorMessage); + try { + await s3.uploadFileListIntoBucket('test-bucket', 'test-directory', fileList); + } catch (error) { + expect(error).to.deepEqual(errorRejectMessage); + uploadFileListIntoBucketStubError.restore(); + checkBucketExistsStub.restore(); + } + }); + + it('checkBucketExists(bucketName) success', async () => { + const checkBucketExistsStub = sinon.stub(S3Client.prototype, 'send').resolves(true); + const result = await s3.checkBucketExists('test-bucket'); + expect(result).to.equal(true); + checkBucketExistsStub.restore(); + }); + + it('checkBucketExists(bucketName) error', async () => { + const checkBucketExistsStub = sinon + .stub(S3Client.prototype, 'send') + .rejects(errorMessage); + const result = await s3.checkBucketExists('test-bucket'); + expect(result).to.equal(false); + checkBucketExistsStub.restore(); + }); + + it('createBucket(bucketName) success', async () => { + const createBucketStub = sinon + .stub(S3Client.prototype, 'send') + .resolves(mockCreateBucketResponse); + const result = await s3.createBucket('test-bucket'); + expect(result).to.deepEqual(mockCreateBucketResponse); + createBucketStub.restore(); + }); + + it('createBucket(bucketName) error', async () => { + const createBucketStub = sinon.stub(S3Client.prototype, 'send').rejects(errorMessage); + try { + await s3.createBucket('test-bucket'); + } catch (error) { + expect(error).to.deepEqual(errorRejectMessage); + createBucketStub.restore(); + } + }); + + it('uploadFile(bucketName, filePath, file) success', async () => { + const uploadFileStub = sinon + .stub(S3Client.prototype, 'send') + .resolves(mockUploadFileResponse); + const result = await s3.uploadFile( + 'test-bucket', + 'test2.txt', + Buffer.from( + 'UEsDBBQABgAIAAAAIQBSlIoMAQIAADUQAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIooAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMl99u0zAUxu', + ), + ); + expect(result).to.deepEqual(mockUploadFileResponse); + uploadFileStub.restore(); + }); + + it('uploadFile(bucketName, filePath, file) error', async () => { + const uploadFileStub = sinon.stub(S3Client.prototype, 'send').rejects(errorMessage); + try { + await s3.uploadFile('test-bucket', 'hello.txt', Buffer.from('test de buffer')); + } catch (error) { + expect(error).to.deepEqual(errorRejectMessage); + uploadFileStub.restore(); + } + }); + + it('downloadFiles(bucket, fileDirectory, file) success', async () => { + const downloadStub = sinon + .stub(s3, 'downloadFileBuffer') + .returns(mockDownloadFileResponse as any); + + const downloadResult = await s3.downloadFileBuffer( + 'testr-bucket', + 'testo-directory', + 'test4.txt', + ); + expect(downloadResult).to.equal(mockDownloadFileResponse); + downloadStub.restore(); + }); + + it('downloadFiles(bucket, fileDirectory, file) error', async () => { + const downloadFileStub = sinon.stub(S3Client.prototype, 'send').rejects(errorMessage); + const expectedError = new ValidationError( + 'Filename does not exist', + '/attachments', + StatusCode.NotFound, + ); + try { + await s3.downloadFileBuffer('test-bucket', 'test-directory', 'test1.txt'); + } catch (err: any) { + expect(err).to.deepEqual(expectedError); + downloadFileStub.restore(); + } + }); + + it('deleteFolder(bucketName, filePath) success', async () => { + const deleteFolderStub = sinon + .stub(S3Client.prototype, 'send') + .resolves(mockUploadFileResponse); + const result = await s3.deleteObjectFile('test-bucket', 'test-directory'); + expect(result).to.deepEqual(mockUploadFileResponse); + deleteFolderStub.restore(); + }); + + it('deleteFolder(bucketName, filePath) error', async () => { + const deleteFolderStub = sinon.stub(S3Client.prototype, 'send').rejects(deleteError); + try { + await s3.deleteObjectFile('test-bucke2t', 'test-directory2'); + } catch (error) { + expect(error).to.deepEqual(deleteError); + deleteFolderStub.restore(); + } + }); + + it('hasCorrectNumberOfFiles(fileList) success return true', () => { + const hasCorrectNumberOfFiles = s3.hasCorrectNumberOfFiles(fileList); + expect(hasCorrectNumberOfFiles).to.equal(true); + }); + + it('hasCorrectNumberOfFiles(fileList) success return false', () => { + const fileListNbExceeded = [ + file, + file, + file, + file, + file, + file, + file, + file, + file, + file, + file, + ]; + const hasCorrectNumberOfFiles = s3.hasCorrectNumberOfFiles(fileListNbExceeded); + expect(hasCorrectNumberOfFiles).to.equal(false); + }); + + it('hasValidMimeType(fileList) success return true', () => { + const hasValidMimeType = s3.hasValidMimeType(fileList); + expect(hasValidMimeType).to.equal(true); + }); + + it('hasValidMimeType(fileList) success return false', () => { + const originalMimeType = fileList[0].mimetype; + fileList[0].mimetype = 'image/gif'; + const hasValidMimeType = s3.hasValidMimeType(fileList); + expect(hasValidMimeType).to.equal(false); + fileList[0].mimetype = originalMimeType; + }); + + it('hasValidFileSize(fileList) success return true', () => { + const hasValidFileSize = s3.hasValidFileSize(fileList); + expect(hasValidFileSize).to.equal(true); + }); + + it('hasValidFileSize(fileList) success return false', () => { + const originalFileSize = fileList[0].size; + fileList[0].size = 80000000; + const hasValidFileSize = s3.hasValidFileSize(fileList); + expect(hasValidFileSize).to.equal(false); + fileList[0].size = originalFileSize; + }); + + const mockCreateBucketResponse = { + $metadata: { + httpStatusCode: 200, + attempts: 1, + totalRetryDelay: 0, + }, + Location: '/test-bucket', + }; + + const mockUploadFileResponse = { + $metadata: { + httpStatusCode: 200, + attempts: 1, + totalRetryDelay: 0, + }, + ETag: '"etag"', + }; + + const mockDownloadFileResponse = { + $metadata: { + httpStatusCode: 200, + attempts: 1, + totalRetryDelay: 0, + }, + ETag: '"etag"', + }; +}); diff --git a/api/src/__tests__/services/subscription.service.test.ts b/api/src/__tests__/services/subscription.service.test.ts new file mode 100644 index 0000000..301664b --- /dev/null +++ b/api/src/__tests__/services/subscription.service.test.ts @@ -0,0 +1,980 @@ +import { + expect, + sinon, + StubbedInstanceWithSinonAccessor, + createStubInstance, +} from '@loopback/testlab'; +import {AnyObject} from '@loopback/repository'; +import {Express} from 'express'; + +import {SubscriptionService, MailService, S3Service} from '../../services'; +import { + CitizenRepository, + CommunityRepository, + EnterpriseRepository, + SubscriptionRepository, +} from '../../repositories'; +import { + Subscription, + AttachmentType, + Enterprise, + Community, + Citizen, + Affiliation, +} from '../../models'; +import {ValidationError} from '../../validationError'; +import { + INCENTIVE_TYPE, + ISubscriptionBusError, + REJECTION_REASON, + SEND_MODE, + StatusCode, + SUBSCRIPTION_STATUS, +} from '../../utils'; +import {Readable} from 'stream'; +import { + ValidationMultiplePayment, + ValidationNoPayment, + ValidationSinglePayment, +} from '../../models/subscription/subscriptionValidation.model'; +import {NoReason} from '../../models/subscription/subscriptionRejection.model'; + +const expectedErrorPayment = new ValidationError( + 'is not allowed to have the additional property "frequency"', + '/subscriptionBadPayment', + StatusCode.PreconditionFailed, +); + +const expectedErrorPayment2 = new ValidationError( + 'is not allowed to have the additional property "lastPayment"', + '/subscriptionBadPayment', + StatusCode.PreconditionFailed, +); + +const expectedErrorBuffer = new ValidationError( + 'subscriptions.error.bad.buffer', + '/subscriptionBadBuffer', + StatusCode.PreconditionFailed, +); + +describe('Subscriptions service', () => { + let subscriptionService: any = null; + let subscriptionRepository: StubbedInstanceWithSinonAccessor, + citizenRepository: StubbedInstanceWithSinonAccessor, + s3Service: StubbedInstanceWithSinonAccessor, + enterpriseRepository: StubbedInstanceWithSinonAccessor, + communityRepository: StubbedInstanceWithSinonAccessor; + + let mailService: any = null; + + beforeEach(() => { + subscriptionRepository = createStubInstance(SubscriptionRepository); + enterpriseRepository = createStubInstance(EnterpriseRepository); + citizenRepository = createStubInstance(CitizenRepository); + communityRepository = createStubInstance(CommunityRepository); + s3Service = createStubInstance(S3Service); + subscriptionService = new SubscriptionService( + s3Service, + subscriptionRepository, + citizenRepository, + mailService, + communityRepository, + enterpriseRepository, + ); + mailService = createStubInstance(MailService); + }); + + it('check versement : aucun versement', () => { + const payment = { + mode: 'aucun', + } as ValidationNoPayment; + + try { + subscriptionService.checkPayment(payment); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check versement : versement unique ok', () => { + const payment = { + mode: 'unique', + } as ValidationSinglePayment; + + // Absence du montant + try { + const result = subscriptionService.checkPayment(payment); + expect(result).deepEqual(payment); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check versement : versement unique avec des informations du versement multiple', () => { + // Presence de la frequency + const payment = { + mode: 'unique', + frequency: undefined, + }; + + try { + subscriptionService.checkPayment(payment); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal(expectedErrorPayment.message); + } + + // Presence de la date + const paymentSecond = { + mode: 'unique', + lastPayment: '2021-01-01', + }; + + try { + subscriptionService.checkPayment(paymentSecond); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal(expectedErrorPayment2.message); + } + }); + + it('check versement : versement multiple ok', () => { + try { + const payment = { + mode: 'multiple', + lastPayment: '2025-01-01', + frequency: 'mensuelle', + amount: 1, + } as ValidationMultiplePayment; + const result = subscriptionService.checkPayment(payment); + expect(result).deepEqual(payment); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check versement : versement multiple - absence information', () => { + const payment = { + mode: 'multiple', + } as ValidationMultiplePayment; + + try { + subscriptionService.checkPayment(payment); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal('requires property "frequency"'); + } + + payment.lastPayment = '2025-01-01'; + try { + subscriptionService.checkPayment(payment); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal('requires property "frequency"'); + } + + payment.frequency = 'mensuelle'; + payment.amount = 1; + + try { + const result = subscriptionService.checkPayment(payment); + expect(result).deepEqual(payment); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check versement : versement multiple avec des informations du versement unique', () => { + try { + const payment = { + mode: 'multiple', + lastPayment: '2021-01-01', + frequency: 'mensuelle', + amount: 3, + } as ValidationMultiplePayment; + subscriptionService.checkPayment(payment); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal( + 'The date of the last payment must be greater than two months from the validation date', + ); + } + }); + + it('check versement : versement multiple avec une date de dernier versement au mauvais format', () => { + try { + const payment = { + mode: 'multiple', + lastPayment: '0101/2021', + frequency: 'mensuelle', + amount: 1, + } as ValidationMultiplePayment; + subscriptionService.checkPayment(payment); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal('does not conform to the "date" format'); + } + }); + + it('check versement : versement multiple avec une date de dernier versement incorrecte', () => { + try { + const payment = { + mode: 'multiple', + lastPayment: '2021-01-01', + frequency: 'mensuelle', + amount: 3, + } as ValidationMultiplePayment; + subscriptionService.checkPayment(payment); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal( + 'The date of the last payment must be greater than two months from the validation date', + ); + } + }); + + it('check motif : ConditionsNonRespectees motif: success', () => { + const reason = { + type: 'ConditionsNonRespectees', + } as NoReason; + + try { + const result = subscriptionService.checkRefusMotif(reason); + expect(result).deepEqual(reason); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check motif : Autre motif without autre text : error', () => { + const reason = { + type: 'Autre', + }; + + try { + subscriptionService.checkRefusMotif(reason); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal('requires property "other"'); + } + }); + + it('check Buffer genereation: generation excel : success', async () => { + const input: Subscription[] = []; + input.push(firstSubscription); + try { + const result = await subscriptionService.generateExcelValidatedIncentives(input); + expect(result).to.be.instanceof(Buffer); + } catch (error) { + sinon.assert.fail(); + } + }); + + it('check Buffer genereation: generation excel avec une liste vide : error', async () => { + const input: Subscription[] = []; + try { + await subscriptionService.generateExcelValidatedIncentives(input); + sinon.assert.fail(); + } catch (error) { + expect(error.message).to.equal(expectedErrorBuffer.message); + } + }); + + it('check Buffer generation: generation excel with specificFields : success', async () => { + const input = []; + input.push(secondDemande); + const result = await subscriptionService.generateExcelValidatedIncentives(input); + expect(result).to.be.instanceof(Buffer); + }); + + it('get citizens with subscriptions: success', () => { + subscriptionRepository.stubs.execute.resolves(mockSubscriptions); + const match = [ + {funderType: 'AideNationale'}, + {funderName: 'simulation-maas'}, + {status: {$ne: 'BROUILLON'}}, + ]; + const result = subscriptionService + .getCitizensWithSubscription(match, 0) + .then((res: any) => res) + .catch((err: any) => err); + expect(result).deepEqual(mockCitizens); + }); + + const sendMode = [SEND_MODE.VALIDATION, SEND_MODE.REJECTION]; + sendMode.forEach(mode => { + it('sendValidationOrRejectionMail: successfull', () => { + subscriptionService.sendValidationOrRejectionMail( + mode, + mailService, + 'Aide 1', + '23/05/2022', + 'Capgemini', + 'entreprise', + 'email@email.com', + 'subscriptionRejectionMessage', + 'comments', + ); + mailService.stubs.sendMailAsHtml.resolves('success'); + expect(mailService.sendMailAsHtml.calledOnce).true(); + expect( + mailService.sendMailAsHtml.calledWith( + 'email@email.com', + `Votre demande d’aide a été ${mode}`, + mode === SEND_MODE.VALIDATION + ? 'subscription-validation' + : 'subscription-rejection', + ), + ).true(); + }); + }); + + it('handleMessage citizenId not matching: Error', async () => { + try { + subscriptionRepository.stubs.findById.resolves(citizenSubscription); + await subscriptionService.handleMessage(objError); + } catch (error) { + expect(error.message).to.deepEqual('CitizenID does not match'); + } + }); + + it('check formatAttachments: success', async () => { + const result = await subscriptionService.formatAttachments(attachmentFiles); + const formattedAttachemnts = attachmentFiles; + formattedAttachemnts[1].originalname = 'file(1).pdf'; + formattedAttachemnts[2].originalname = 'file(2).pdf'; + expect(result).deepEqual(formattedAttachemnts); + }); + it('check formatAttachments no file: fail', async () => { + const result = await subscriptionService.formatAttachments([]); + expect(result).to.Null; + }); + it('check formatAttachments no extension: fail', async () => { + const result = await subscriptionService.formatAttachments( + attachmentWithoutExtension, + ); + expect(result).to.Null; + }); + it('handleMessage REJECTED payload : success', async () => { + subscriptionRepository.stubs.findById.resolves(RejectSubscription); + s3Service.stubs.deleteObjectFile.resolves('any'); + + const result = await subscriptionService.handleMessage(objReject1); + expect(result).to.Null; + }); + + it('handleMessage VALIDATED payload : success', async () => { + subscriptionRepository.stubs.findById.resolves(validateSubscription); + const result = await subscriptionService.handleMessage(objValidated); + expect(result).to.Null; + }); + + it('handleMessage VALIDATED status fail : Error', async () => { + try { + subscriptionRepository.stubs.findById.resolves(errorSubscription); + await subscriptionService.handleMessage(objValidated); + } catch (error) { + expect(error.message).to.deepEqual('subscriptions.error.bad.status'); + } + }); + it('sendPublishPayload test : Success', async () => { + subscriptionRepository.stubs.findById.resolves(RejectSubscription); + enterpriseRepository.stubs.findById.resolves(enterprise); + communityRepository.stubs.findById.resolves(community); + citizenRepository.stubs.findById.resolves(citizen); + + const result = await subscriptionService.getSubscriptionPayload('randomInputId', { + message: 'string', + property: 'string', + code: 'string', + } as ISubscriptionBusError); + expect(result).deepEqual(subscriptionCompare); + }); + it('rejectSubscription test : Success', async () => { + const objValidated = { + status: SUBSCRIPTION_STATUS.REJECTED, + type: REJECTION_REASON.CONDITION, + comment: 'test', + }; + s3Service.stubs.deleteObjectFile.resolves('any'); + const result = await subscriptionService.rejectSubscription( + objValidated, + RejectWithAttachments, + ); + expect(result).to.Null; + }); + it('rejectSubscription without aide type : Success', async () => { + const objValidated = { + status: SUBSCRIPTION_STATUS.REJECTED, + type: REJECTION_REASON.CONDITION, + comment: 'test', + }; + s3Service.stubs.deleteObjectFile.resolves('any'); + citizenRepository.stubs.findById.resolves(citizenNoAffilation); + const result = await subscriptionService.rejectSubscription( + objValidated, + RejectWithoutIncentive, + ); + expect(result).to.Null; + }); + it('rejectSubscription test : fail', async () => { + try { + const objValidated = { + status: SUBSCRIPTION_STATUS.TO_PROCESS, + type: 'Error', + comment: 'test', + }; + s3Service.stubs.deleteObjectFile.resolves('any'); + citizenRepository.stubs.findById.resolves(citizen); + await subscriptionService.rejectSubscription(objValidated, RejectSubscription); + } catch (error) { + expect(error.message).to.deepEqual('subscriptionRejection.type.not.found'); + } + }); + it('validateSubscription test : success', async () => { + const objValidated = { + status: SUBSCRIPTION_STATUS.VALIDATED, + mode: 'aucun', + }; + citizenRepository.stubs.findById.resolves(citizen); + subscriptionRepository.stubs.updateById.resolves(); + await subscriptionService.validateSubscription( + objValidated, + validateSubscriptionTest, + ); + }); + it('validateSubscription test diffrent type : success', async () => { + const objValidated = { + status: SUBSCRIPTION_STATUS.TO_PROCESS, + mode: 'aucun', + }; + citizenRepository.stubs.findById.resolves(citizen); + subscriptionRepository.stubs.updateById.resolves(); + await subscriptionService.validateSubscription( + objValidated, + validateSubscriptionOtherStatus, + ); + }); + it('handleMessage Error status payload : success', async () => { + subscriptionRepository.stubs.findById.resolves(validateSubscription); + const result = await subscriptionService.handleMessage(objValidatedError); + expect(result).to.Null; + }); + it('preparePayLoad payload : success', async () => { + communityRepository.stubs.findById.resolves(community); + citizenRepository.stubs.findById.resolves(citizen); + const result = await subscriptionService.preparePayLoad(RejectWithAttachment, { + message: 'string', + property: 'string', + code: 'string', + } as ISubscriptionBusError); + expect(result).to.Null; + }); + it('preparePayLoad no attachments payload : success', async () => { + communityRepository.stubs.findById.resolves(community2); + citizenRepository.stubs.findById.resolves(citizenNoEnterpriseMail); + await subscriptionService.preparePayLoad(RejectSubscriptionNoAttachment, { + message: 'string', + property: 'string', + code: 'string', + } as ISubscriptionBusError); + }); + it('checkRefusMotif error payload : fail', async () => { + try { + await subscriptionService.checkRefusMotif({ + comment: 'test', + }); + } catch (error) { + expect(error.message).to.deepEqual('requires property "type"'); + } + }); + + it('checkPayment error payload : fail', async () => { + try { + await subscriptionService.checkPayment({ + comment: 'test', + }); + } catch (error) { + expect(error.message).to.deepEqual('requires property "mode"'); + } + }); + + const subscriptionCompare = { + subscription: { + lastName: 'lastName', + firstName: 'firstName', + birthdate: undefined, + citizenId: 'email@gmail.com', + incentiveId: 'incentiveId', + subscriptionId: 'randomInputId', + email: 'test@gmail.com', + status: 'A_TRAITER', + communityName: '', + specificFields: '', + attachments: [], + error: {message: 'string', property: 'string', code: 'string'}, + encryptedAESKey: undefined, + encryptedIV: undefined, + encryptionKeyId: undefined, + encryptionKeyVersion: undefined, + }, + enterprise: 'enterprise', + }; + + const enterprise = new Enterprise({ + id: 'incentiveId', + name: 'enterprise', + emailFormat: ['@gmail.com'], + }); + const community2 = new Community({ + id: 'incentiveId', + funderId: 'incentiveId', + }); + const community = { + id: 'incentiveId', + name: 'enterprise', + funderId: 'incentiveId', + } as Community; + const citizen = new Citizen({ + id: 'email@gmail.com', + affiliation: { + enterpriseId: 'incentiveId', + enterpriseEmail: 'test@gmail.com', + affiliationStatus: 'AFFILIE', + } as Affiliation, + }); + const citizenNoAffilation = new Citizen({ + id: 'email@gmail.com', + }); + const citizenNoEnterpriseMail = new Citizen({ + id: 'email@gmail.com', + affiliation: { + enterpriseId: 'incentiveId', + affiliationStatus: 'AFFILIE', + } as Affiliation, + }); + const objReject1 = { + citizenId: 'email@gmail.com', + subscriptionId: 'randomInputId', + status: SUBSCRIPTION_STATUS.REJECTED, + type: 'ConditionsNonRespectees', + }; + + const objValidated = { + citizenId: 'email@gmail.com', + subscriptionId: 'randomInputId', + status: SUBSCRIPTION_STATUS.VALIDATED, + mode: 'unique', + amount: 1, + }; + const objValidatedError = { + citizenId: 'email@gmail.com', + subscriptionId: 'randomInputId', + status: 'ERREUR', + mode: 'unique', + amount: 1, + }; + const objError = { + citizenId: 'random', + subscriptionId: 'randomInputId', + status: SUBSCRIPTION_STATUS.VALIDATED, + mode: 'aucun', + }; + const firstSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + + const citizenSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + const RejectSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + const RejectWithAttachment = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + communityId: 'incentiveId', + attachments: [ + { + originalName: 'uploadedAttachment.pdf', + uploadDate: new Date('2022-01-01 00:00:00.000Z'), + proofType: 'Passport', + mimeType: 'application/pdf', + } as AttachmentType, + ], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + }); + + const RejectWithAttachments = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + communityId: 'incentiveId', + attachments: [ + { + originalName: 'uploadedAttachment.pdf', + uploadDate: new Date('2022-01-01 00:00:00.000Z'), + proofType: 'Passport', + mimeType: 'application/pdf', + } as AttachmentType, + ], + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + }); + const RejectWithoutIncentive = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + const RejectSubscriptionNoAttachment = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + const validateSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + + const validateSubscriptionTest = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + const validateSubscriptionOtherStatus = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: 'test', + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.REJECTED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + + const errorSubscription = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }); + + const secondDemande = new Subscription({ + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: 'email@gmail.com', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + specificFields: [ + { + title: 'newField1', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 2, + inputChoiceList: [ + { + inputChoice: 'newField1', + }, + { + inputChoice: 'newField11', + }, + ], + }, + }, + { + title: 'newField2', + inputFormat: 'Texte', + }, + ], + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + subscriptionValidation: { + mode: 'multipe', + amount: 11, + frequency: 'test', + lastPayment: '11-11-2022', + } as ValidationMultiplePayment, + }); + + const mockSubscriptions: Record[] = [ + { + id: 'randomInputId', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: '260a6356-3261-4335-bca8-4c1f8257613d', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }, + { + id: 'randomInputId1', + incentiveId: 'incentiveId', + funderName: 'funderName', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: 'incentiveTitle', + citizenId: '260a6356-3261-4335-bca8-4c1f8257613d', + lastName: 'lastName', + firstName: 'firstName', + email: 'email@gmail.com', + consent: true, + status: SUBSCRIPTION_STATUS.VALIDATED, + createdAt: new Date('2021-04-06T09:01:30.778Z'), + updatedAt: new Date('2021-04-06T09:01:30.778Z'), + }, + ]; + + const mockCitizens: Promise = new Promise(() => { + return [ + { + citizensData: [ + { + citizenId: '260a6356-3261-4335-bca8-4c1f8257613d', + lastName: 'leYellow', + firstName: 'Bob', + }, + ], + totalCitizens: 1, + }, + ]; + }); + + const attachmentFiles: Express.Multer.File[] = [ + { + originalname: 'file.pdf', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'fieldname', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + { + originalname: 'file.pdf', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'fieldname', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + { + originalname: 'file.pdf', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'fieldname', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + ]; + + const attachmentWithoutExtension: Express.Multer.File[] = [ + { + originalname: 'file', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'fieldname', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + { + originalname: 'file', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'fieldname', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + { + originalname: 'file', + buffer: Buffer.from('test de buffer'), + mimetype: 'application/pdf/png', + fieldname: 'fieldname', + size: 4000, + encoding: '7bit', + stream: new Readable(), + destination: 'string', + filename: 'fileName', + path: 'test', + }, + ]; +}); diff --git a/api/src/__tests__/services/territory.service.test.ts b/api/src/__tests__/services/territory.service.test.ts new file mode 100644 index 0000000..b7e1a4f --- /dev/null +++ b/api/src/__tests__/services/territory.service.test.ts @@ -0,0 +1,54 @@ +import { + StubbedInstanceWithSinonAccessor, + createStubInstance, + expect, +} from '@loopback/testlab'; +import {Territory} from '../../models'; + +import {TerritoryRepository} from '../../repositories'; +import {TerritoryService} from '../../services/territory.service'; +import {ResourceName, StatusCode} from '../../utils'; +import {ValidationError} from '../../validationError'; + +describe('Territory service', () => { + let territoryRepository: StubbedInstanceWithSinonAccessor, + territoryService: TerritoryService; + + beforeEach(() => { + territoryRepository = createStubInstance(TerritoryRepository); + territoryService = new TerritoryService(territoryRepository); + }); + + it('TerritoryService createTerritory: successfull', async () => { + territoryRepository.stubs.findOne.resolves(null); + territoryRepository.stubs.create.resolves(territoryMock); + + const result = await territoryService.createTerritory(territoryPayload); + expect(result).to.deepEqual(territoryMock); + }); + + it('TerritoryService createTerritory: unique name', async () => { + try { + territoryRepository.stubs.findOne.resolves(territoryMock); + await territoryService.createTerritory(territoryPayload); + } catch (error) { + expect(error).to.deepEqual(territoryNameUnique); + } + }); + + const territoryMock = new Territory({ + name: 'Toulouse', + id: '634c83b994f56f610415f9c6', + }); + + const territoryPayload = new Territory({ + name: 'Toulouse', + }); + + const territoryNameUnique = new ValidationError( + 'territory.name.error.unique', + '/territoryName', + StatusCode.UnprocessableEntity, + ResourceName.Territory, + ); +}); diff --git a/api/src/__tests__/services/user.authorizor.test.ts b/api/src/__tests__/services/user.authorizor.test.ts new file mode 100644 index 0000000..0f82090 --- /dev/null +++ b/api/src/__tests__/services/user.authorizor.test.ts @@ -0,0 +1,52 @@ +import {expect} from '@loopback/testlab'; +import {canAccessHisOwnData} from '../../services'; +import {securityId} from '@loopback/security'; +import {AuthorizationContext, AuthorizationDecision} from '@loopback/authorization'; +import {InvocationContext} from '@loopback/core'; + +describe('user authorizor', () => { + it('should canAccessHisOwnData: OK ALLOW', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + membership: ['membership'], + roles: ['roles'], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: { + args: ['id'], + } as InvocationContext, + }; + const result = await canAccessHisOwnData(authContext, null as any); + expect(result).to.deepEqual(AuthorizationDecision.ALLOW); + }); + + it('should checkMaas: OK DENY', async () => { + const authContext: AuthorizationContext = { + principals: [ + { + [securityId]: 'id', + id: 'id', + emailVerified: true, + maas: undefined, + membership: ['membership'], + roles: ['roles'], + }, + ], + roles: [], + scopes: [], + resource: '', + invocationContext: { + args: ['wrongId'], + } as InvocationContext, + }; + const result = await canAccessHisOwnData(authContext, null as any); + expect(result).to.deepEqual(AuthorizationDecision.DENY); + }); +}); diff --git a/api/src/__tests__/strategies/keycloak.strategy.test.ts b/api/src/__tests__/strategies/keycloak.strategy.test.ts new file mode 100644 index 0000000..eb4b815 --- /dev/null +++ b/api/src/__tests__/strategies/keycloak.strategy.test.ts @@ -0,0 +1,76 @@ +import {expect, sinon} from '@loopback/testlab'; +import {Request} from 'express'; +import 'regenerator-runtime/runtime'; +import {securityId} from '@loopback/security'; +import {KeycloakAuthenticationStrategy} from '../../strategies'; +import {AuthenticationService} from '../../services'; +import {ValidationError} from '../../validationError'; +import {StatusCode} from '../../utils'; + +describe('Keycloak strategy', () => { + let keycloakStrategy: KeycloakAuthenticationStrategy, + authenticationService: AuthenticationService; + + beforeEach(() => { + authenticationService = new AuthenticationService(); + keycloakStrategy = new KeycloakAuthenticationStrategy(authenticationService); + }); + + it('should authenticate: OK', async () => { + const user = { + sub: 'id', + email_verified: true, + clientName: undefined, + membership: ['/collectivités/Mulhouse'], + realm_access: { + roles: ['roles'], + }, + }; + const userResult = { + [securityId]: user.sub, + id: user.sub, + emailVerified: user.email_verified, + clientName: user.clientName, + funderType: 'collectivités', + funderName: 'Mulhouse', + groups: ['Mulhouse'], + incentiveType: 'AideTerritoire', + roles: user.realm_access.roles, + }; + sinon.stub(authenticationService, 'verifyToken').resolves(user); + const request = { + headers: { + authorization: 'Bearer xxx.yyy.zzz', + }, + }; + const result = await keycloakStrategy.authenticate(request as Request); + expect(result).to.deepEqual(userResult); + }); + + it('should authenticate: KO emailverified false', async () => { + const user = { + sub: 'id', + email_verified: false, + membership: ['membership'], + realm_access: { + roles: ['roles'], + }, + }; + sinon.stub(authenticationService, 'verifyToken').resolves(user); + const request = { + headers: { + authorization: 'Bearer xxx.yyy.zzz', + }, + }; + const expectedError = new ValidationError( + `Email not verified`, + '/authorization', + StatusCode.Unauthorized, + ); + try { + await keycloakStrategy.authenticate(request as Request); + } catch (err) { + expect(err).to.deepEqual(expectedError); + } + }); +}); diff --git a/api/src/__tests__/utils/affiliation.test.ts b/api/src/__tests__/utils/affiliation.test.ts new file mode 100644 index 0000000..6ddf20e --- /dev/null +++ b/api/src/__tests__/utils/affiliation.test.ts @@ -0,0 +1,33 @@ +import {expect} from '@loopback/testlab'; + +import {Affiliation, Citizen} from '../../models'; +import {AFFILIATION_STATUS, FUNDER_TYPE, isEnterpriseAffilitation} from '../../utils'; + +describe('affiliation functions', () => { + it('isEnterpriseAffilitation: KO : citizen not found', async () => { + const result = isEnterpriseAffilitation({ + inputFunderId: 'dunderId', + citizen: null, + funderMatch: {funderType: FUNDER_TYPE.collectivity, name: 'maasName'}, + }); + expect(result).to.equal(false); + }); + + it('isEnterpriseAffilitation: OK', async () => { + const affiliation = new Affiliation( + Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}), + ); + affiliation.affiliationStatus = AFFILIATION_STATUS.AFFILIATED; + + const citizen = new Citizen({ + affiliation, + }); + const result = isEnterpriseAffilitation({ + inputFunderId: 'funderId', + citizen, + funderMatch: {funderType: FUNDER_TYPE.enterprise, name: 'maasName'}, + }); + + expect(result).to.equal(true); + }); +}); diff --git a/api/src/__tests__/utils/date.test.ts b/api/src/__tests__/utils/date.test.ts new file mode 100644 index 0000000..bbb900b --- /dev/null +++ b/api/src/__tests__/utils/date.test.ts @@ -0,0 +1,18 @@ +import {expect} from '@loopback/testlab'; +import {isExpired} from '../../utils'; + +describe('date functions', () => { + it('isExpired : returns true when date is expired', async () => { + const today = new Date(); + const expiredDate = new Date(today.setMonth(today.getMonth() - 3)); + const check2 = isExpired(expiredDate, new Date()); + expect(check2).to.deepEqual(true); + }); + + it('isExpired : returns false when date is not expired', async () => { + const today = new Date(); + const expiredDate = new Date(today.setMonth(today.getMonth() + 3)); + const check2 = isExpired(expiredDate, new Date()); + expect(check2).to.deepEqual(false); + }); +}); diff --git a/api/src/__tests__/utils/encryption.test.ts b/api/src/__tests__/utils/encryption.test.ts new file mode 100644 index 0000000..49fd7c7 --- /dev/null +++ b/api/src/__tests__/utils/encryption.test.ts @@ -0,0 +1,38 @@ +import * as crypto from 'crypto'; +import {expect} from '@loopback/testlab'; +import {encryptAESKey, encryptFileHybrid, generateAESKey} from '../../utils/encryption'; + +describe('encryption service', () => { + it('should generate AES key', function () { + const checkKey = crypto.randomBytes(32); + const checkIV = crypto.randomBytes(16); + const generateKey = generateAESKey(); + expect(generateKey).to.not.equal({key: checkKey, iv: checkIV}); + }); + + it('should encrypt AES key', function () { + const checkKey = crypto.randomBytes(32); + const checkIV = crypto.randomBytes(16); + const encryptedKey = encryptAESKey(publicKey, checkKey, checkIV); + expect(encryptedKey).to.not.equal({key: checkKey, iv: checkIV}); + }); + + it('should encrypt the file', function () { + const file = Buffer.from('File test'); + const key = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const encryptedFile = encryptFileHybrid(file, key, iv); + expect(encryptedFile).to.not.equal(file); + }); +}); + +const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq +n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf +n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq +sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85 +OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG +X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n +owIDAQAB +-----END PUBLIC KEY----- +`; diff --git a/api/src/__tests__/utils/file-conversion.test.ts b/api/src/__tests__/utils/file-conversion.test.ts new file mode 100644 index 0000000..d5fc180 --- /dev/null +++ b/api/src/__tests__/utils/file-conversion.test.ts @@ -0,0 +1,110 @@ +import {expect, sinon} from '@loopback/testlab'; +import pdf from 'html-pdf'; +import ejs from 'ejs'; +import {generatePdfBufferFromHtml, generateTemplateAsHtml} from '../../utils'; +import {Invoice} from '../../models'; +import {formatDateInTimezone} from '../../utils/date'; + +const mockInvoice: Invoice = Object.assign(new Invoice(), { + enterprise: { + enterpriseName: 'IDF Mobilités', + sirenNumber: '362521879', + siretNumber: '36252187900034', + apeCode: '4711D', + enterpriseAddress: { + zipCode: 75018, + city: 'Paris', + street: '6 rue Lepic', + }, + }, + customer: { + customerId: '123789', + customerName: 'NEYMAR', + customerSurname: 'Jean', + customerAddress: { + zipCode: 75018, + city: 'Paris', + street: '15 rue Veron', + }, + }, + transaction: { + orderId: '30723', + purchaseDate: new Date('2021-03-03T14:54:18+01:00'), + amountInclTaxes: 7520, + amountExclTaxes: 7520, + }, + products: [ + { + productName: 'Forfait Navigo Mois', + quantity: 1, + amountInclTaxes: 7520, + amountExclTaxes: 7520, + percentTaxes: 10, + productDetails: { + periodicity: 'Mensuel', + zoneMin: 1, + zoneMax: 5, + validityStart: new Date('2021-03-01T00:00:00+01:00'), + validityEnd: new Date('2021-03-31T00:00:00+01:00'), + }, + }, + ], +}); +const mockHtml: string = `
Text Content
`; +const mockBuffer: Buffer = Buffer.from(mockHtml); + +describe('File conversion functions', () => { + it('generateTemplateAsHtml success', async () => { + const html = await generateTemplateAsHtml('invoice', { + invoice: mockInvoice, + formatDate: formatDateInTimezone, + }); + expect(html).to.containEql(mockInvoice.customer.customerName); + expect(html).to.containEql(mockInvoice.customer.customerSurname); + expect(html).to.containEql(mockInvoice.enterprise.enterpriseName); + expect(html).to.containEql(mockInvoice.enterprise.siretNumber); + expect(html).to.containEql( + (mockInvoice.transaction.amountInclTaxes / 100).toFixed(2), + ); + expect(html).to.containEql(mockInvoice.transaction.orderId); + expect(html).to.containEql( + formatDateInTimezone(mockInvoice.transaction.purchaseDate, 'dd/MM/yyyy'), + ); + expect(html).to.containEql(mockInvoice.products[0].productName); + }); + + it('generateTemplateAsHtml should throw error', async () => { + const ejsStub = sinon.stub(ejs, 'renderFile').rejects('HtmlTemplateError'); + try { + await generateTemplateAsHtml('invoice'); + } catch (error) { + expect(error.message).to.equal('HtmlTemplateError'); + } + ejsStub.restore(); + }); + + it('generatePdfBufferFromHtml success', async () => { + const pdfStub = sinon.stub(pdf, 'create').returns({ + toBuffer: sinon.stub().yields(null, Buffer.from(mockHtml)), + toFile: sinon.stub().returnsThis(), + toStream: sinon.stub().returnsThis(), + }); + const pdfBuffer = await generatePdfBufferFromHtml(mockHtml); + expect(pdfBuffer).to.eql(mockBuffer); + pdfStub.restore(); + }); + + it('generatePdfBufferFromHtml should throw error', async () => { + const pdfStub = sinon.stub(pdf, 'create').returns({ + toBuffer: sinon.stub().yields('generatePdfError', null), + toFile: sinon.stub().returnsThis(), + toStream: sinon.stub().returnsThis(), + }); + try { + await generatePdfBufferFromHtml(mockHtml); + } catch (error) { + expect(error).to.equal('generatePdfError'); + } + pdfStub.restore(); + }); +}); diff --git a/api/src/__tests__/utils/invoice.test.ts b/api/src/__tests__/utils/invoice.test.ts new file mode 100644 index 0000000..8a6588a --- /dev/null +++ b/api/src/__tests__/utils/invoice.test.ts @@ -0,0 +1,116 @@ +import {expect, sinon} from '@loopback/testlab'; +import ejs from 'ejs'; +import {generatePdfInvoices} from '../../utils/invoice'; +import * as fileConversion from '../../utils/file-conversion'; +import {Invoice} from '../../models'; + +const invoice1: Invoice = Object.assign(new Invoice(), { + enterprise: { + enterpriseName: 'IDF Mobilités', + sirenNumber: '362521879', + siretNumber: '36252187900034', + apeCode: '4711D', + enterpriseAddress: { + zipCode: 75018, + city: 'Paris', + street: '6 rue Lepic', + }, + }, + customer: { + customerId: '123789', + customerName: 'NEYMAR', + customerSurname: 'Jean', + customerAddress: { + zipCode: 75018, + city: 'Paris', + street: '15 rue Veron', + }, + }, + transaction: { + orderId: '30723', + purchaseDate: new Date('2021-03-03T14:54:18+01:00'), + amountInclTaxes: 7520, + amountExclTaxes: 7520, + }, + products: [ + { + productName: 'Forfait Navigo Mois', + quantity: 1, + amountInclTaxes: 7520, + amountExclTaxes: 7520, + percentTaxes: 10, + productDetails: { + periodicity: 'Mensuel', + zoneMin: 1, + zoneMax: 5, + validityStart: new Date('2021-03-01T00:00:00+01:00'), + validityEnd: new Date('2021-03-31T00:00:00+01:00'), + }, + }, + ], +}); + +const invoice2: Invoice = Object.assign(new Invoice(), { + enterprise: { + enterpriseName: 'IDF Mobilités', + sirenNumber: '362521879', + siretNumber: '36252187900034', + apeCode: '4711D', + enterpriseAddress: { + zipCode: 75018, + city: 'Paris', + street: '6 rue Lepic', + }, + }, + customer: { + customerId: '123789', + customerName: 'DELOIN', + customerSurname: 'Alain', + }, + transaction: { + orderId: '30723', + purchaseDate: new Date('2021-03-03T14:54:18+01:00'), + amountInclTaxes: 7520, + amountExclTaxes: 7520, + }, + products: [ + { + productName: 'Forfait Navigo Mois', + quantity: 1, + amountInclTaxes: 7520, + amountExclTaxes: 7520, + percentTaxes: 10, + productDetails: { + periodicity: 'Mensuel', + zoneMin: 1, + zoneMax: 5, + }, + }, + ], +}); + +const mockInvoices: Invoice[] = [invoice1, invoice2]; +const files: Record[] = [ + {key: 'file.txt'}, + {key: 'file.txt'}, + {key: 'file.txt'}, +]; +const mockHtml: string = `
Text Content
`; +const mockBuffer: Buffer = Buffer.from(mockHtml); +describe('Invoice', () => { + it('generatePdfInvoices success', async () => { + const ejsStub = sinon.stub(ejs, 'renderFile').resolves(mockHtml); + sinon + .stub(fileConversion, 'generatePdfBufferFromHtml') + .resolves(Buffer.from(await ejsStub(''))); + const invoicesPdf = await generatePdfInvoices(mockInvoices); + expect(invoicesPdf.length).to.equal(2); + expect(invoicesPdf[0].originalname).to.equal( + '03-03-2021_Forfait_Navigo_Mois_Jean_NEYMAR.pdf', + ); + expect(invoicesPdf[1].originalname).to.equal( + '03-03-2021_Forfait_Navigo_Mois_Alain_DELOIN.pdf', + ); + expect(invoicesPdf[0].buffer).to.eql(mockBuffer); + }); +}); diff --git a/api/src/application.ts b/api/src/application.ts new file mode 100644 index 0000000..57437e8 --- /dev/null +++ b/api/src/application.ts @@ -0,0 +1,115 @@ +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig, createBindingFromClass} from '@loopback/core'; +import {RestExplorerBindings, RestExplorerComponent} from '@loopback/rest-explorer'; +import {RepositoryMixin} from '@loopback/repository'; +import {RestApplication, RestBindings} from '@loopback/rest'; +import {ServiceMixin} from '@loopback/service-proxy'; +import path from 'path'; +import {MySequence} from './sequence'; +import { + AuthenticationComponent, + registerAuthenticationStrategy, +} from '@loopback/authentication'; +import {AuthorizationComponent, AuthorizationTags} from '@loopback/authorization'; +export {ApplicationConfig}; +import {InfoObject, mergeOpenAPISpec} from '@loopback/openapi-v3'; +import {CronComponent} from '@loopback/cron'; +import {MigrationBindings, MigrationComponent} from 'loopback4-migration'; + +import {SECURITY_SCHEME_SPEC} from './utils/security-spec'; +import {OPENAPI_CONFIG} from './constants'; +import {ApiKeyAuthenticationStrategy} from './strategies'; +import {KeycloakAuthenticationStrategy} from './strategies/keycloak.strategy'; +import {AuthorizationProvider} from './providers/authorization.provider'; +import {RabbitmqCronJob} from './cronjob/rabbitmqCronJob'; +import {SubscriptionCronJob} from './cronjob/subscriptionCronJob'; +import {NonActivatedAccountDeletionCronJob} from './cronjob/nonActivatedAccountDeletionCronJob'; +import {ParentProcessService} from './services'; +import {MongoDsDataSource} from './datasources'; +import {MigrationScript111} from './migrations/1.11.0.migration'; + +export class App extends BootMixin(ServiceMixin(RepositoryMixin(RestApplication))) { + constructor(options: ApplicationConfig = {}) { + super(options); + this.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS).to({ + $data: true, + validation: { + keywords: ['example'], + }, + }); + + const infoObject: InfoObject = { + title: OPENAPI_CONFIG.title, + version: OPENAPI_CONFIG.version, + contact: OPENAPI_CONFIG.contact, + }; + + this.api( + mergeOpenAPISpec(this.getSync(RestBindings.API_SPEC), { + info: infoObject, + components: { + securitySchemes: SECURITY_SCHEME_SPEC, + }, + }), + ); + + // Set up the custom sequence + this.sequence(MySequence); + + // Set up default home page + this.static('/', path.join(__dirname, '../public')); + // Configure migration component + this.bind(MigrationBindings.CONFIG).to({ + appVersion: '1.11.0', + dataSourceName: MongoDsDataSource.dataSourceName, + modelName: 'Migration', + migrationScripts: [MigrationScript111], + }); + // Bind migration component related elements + this.component(MigrationComponent); + + // Customize @loopback/rest-explorer configuration here + this.configure(RestExplorerBindings.COMPONENT).to({ + path: '/explorer', + indexTemplatePath: path.resolve(__dirname, '../explorer/index.html.ejs'), + indexTitle: 'API Explorer | Mon Compte Mobilité', + }); + + this.component(RestExplorerComponent); + + this.bind(RestBindings.ERROR_WRITER_OPTIONS).to({ + debug: false, + safeFields: ['path', 'resourceName'], + }); + this.component(AuthenticationComponent); + this.component(AuthorizationComponent); + this.bind('authorizationProviders.authorization-provider') + .toProvider(AuthorizationProvider) + .tag(AuthorizationTags.AUTHORIZER); + + registerAuthenticationStrategy(this, KeycloakAuthenticationStrategy); + registerAuthenticationStrategy(this, ApiKeyAuthenticationStrategy); + + // Create binding for ParentProcessService to be used as unique instance + // Please use @inject('services.ParentProcessService') if injection is needed in other classes + this.add(createBindingFromClass(ParentProcessService)); + + // Init cron component and RabbitmqCronJob + this.component(CronComponent); + this.add(createBindingFromClass(RabbitmqCronJob)); + // clean subscription collection + this.add(createBindingFromClass(SubscriptionCronJob)); + this.add(createBindingFromClass(NonActivatedAccountDeletionCronJob)); + + this.projectRoot = __dirname; + // Customize @loopback/boot Booter Conventions here + this.bootOptions = { + controllers: { + // Customize ControllerBooter Conventions here + dirs: ['controllers'], + extensions: ['.controller.js', '.controller.ts'], + nested: true, + }, + }; + } +} diff --git a/api/src/busError.ts b/api/src/busError.ts new file mode 100644 index 0000000..16fd343 --- /dev/null +++ b/api/src/busError.ts @@ -0,0 +1,18 @@ +import {logger} from './utils'; +import {ValidationError} from './validationError'; + +export class BusError extends ValidationError { + property?: string; + + constructor( + message: string, + property: string, + path: string, + statusCode = 500, + resourceName = '', + ) { + super(message, path, statusCode, resourceName); + this.property = property; + logger.error(message); + } +} diff --git a/api/src/config/clamAvConfig.ts b/api/src/config/clamAvConfig.ts new file mode 100644 index 0000000..132e113 --- /dev/null +++ b/api/src/config/clamAvConfig.ts @@ -0,0 +1,25 @@ +export class ClamAvConfig { + private host: string = process.env.CLAMAV_HOST + ? `${process.env.CLAMAV_HOST}` + : `localhost`; + + private port: number | undefined = process.env.CLAMAV_PORT + ? Number(process.env.CLAMAV_PORT) + : 3310; + + /** + * Get ClamAv configuration to init + * @returns clamav configuration + */ + getClamAvConfiguration() { + return { + debugMode: false, // This will put some debug info in your js console + clamdscan: { + host: this.host, // IP of host to connect to TCP interface + port: this.port, // Port of host to use when connecting via TCP interface + multiscan: true, // Scan using all available cores! Yay! + }, + preference: 'clamdscan', // If clamdscan is found and active, it will be used by default + }; + } +} diff --git a/api/src/config/index.ts b/api/src/config/index.ts new file mode 100644 index 0000000..bee3ceb --- /dev/null +++ b/api/src/config/index.ts @@ -0,0 +1,4 @@ +export * from './clamAvConfig'; +export * from './mailConfig'; +export * from './s3Config'; +export * from './rabbitmqConfig'; diff --git a/api/src/config/mailConfig.ts b/api/src/config/mailConfig.ts new file mode 100644 index 0000000..40e7bda --- /dev/null +++ b/api/src/config/mailConfig.ts @@ -0,0 +1,40 @@ +import nodemailer from 'nodemailer'; +export class MailConfig { + /** + * Check env before sending the email. + */ + configMailer() { + let mailer, from; + const mailHog = { + host: process.env.MAILHOG_HOST, + port: 1025, + }; + const sendGrid = { + host: process.env.SENDGRID_HOST, + port: 587, + auth: { + user: process.env.SENDGRID_USER, + pass: process.env.SENDGRID_API_KEY, + }, + }; + const fromLocal = 'Mon Compte Mobilité '; + + // check is FQDN is set + if (process.env.IDP_FQDN) { + // check landscape + if (process.env.LANDSCAPE === 'preview' || process.env.LANDSCAPE === 'testing') { + mailer = nodemailer.createTransport(mailHog); + from = process.env.MAILHOG_EMAIL_FROM; + } else { + mailer = nodemailer.createTransport(sendGrid); + from = process.env.SENDGRID_EMAIL_FROM; + } + } else { + mailer = nodemailer.createTransport({ + port: 1025, + }); + from = fromLocal; + } + return {mailer, from}; + } +} diff --git a/api/src/config/rabbitmqConfig.ts b/api/src/config/rabbitmqConfig.ts new file mode 100644 index 0000000..7f8ffe3 --- /dev/null +++ b/api/src/config/rabbitmqConfig.ts @@ -0,0 +1,74 @@ +import {BindingScope, injectable} from '@loopback/core'; +import {credentials} from 'amqplib'; +@injectable({scope: BindingScope.TRANSIENT}) +export class RabbitmqConfig { + private amqpUrl = 'amqp://' + (process.env.BUS_HOST ?? 'localhost'); + private apiKey = process.env.CLIENT_SECRET_KEY_KEYCLOAK_API ?? 'MOB_SECRET_KEY'; + + private user = process.env.BUS_MCM_CONSUME_USER ?? 'mob'; + private password = process.env.BUS_MCM_CONSUME_PASSWORD ?? 'mob'; + + private exchangeValue = process.env.BUS_MCM_HEADERS ?? 'mob.headers'; + private publishMessageType = process.env.BUS_MCM_MESSAGE_TYPE ?? 'subscriptions.put'; + + private consumerMessageType = + process.env.BUS_CONSUMER_QUEUE ?? 'mob.subscriptions.status'; + + /** + * Return the message type (put) and the secret key of the client + * @param enterpriseName + */ + getPublishQueue(enterpriseName: string): { + headers: {message_type: string; secret_key: string}; + } { + const publishQueue = { + headers: { + message_type: `${this.publishMessageType}.${enterpriseName}`, + secret_key: this.apiKey, + }, + }; + return publishQueue; + } + + /** + * Generate login credentials with KC token from api service account + * @returns { credentials: any } + */ + public getLogin(accessToken: string): {credentials: any} { + return { + credentials: credentials.plain('client_id', accessToken), + }; + } + + /** + * Get login with user/password + * @returns { credentials: any } + */ + public getUserLogin(): {credentials: any} { + return { + credentials: credentials.plain(this.user, this.password), + }; + } + + /** + * Return the message type (status) for the consumer + * @param enterpriseName + */ + getConsumerQueue(enterpriseName: string): string { + return `${this.consumerMessageType}.${enterpriseName}`; + } + + /** + * Return the url to connect to + */ + getAmqpUrl(): string { + return this.amqpUrl; + } + + /** + * Return the exchange headers to publish + */ + getExchange(): string { + return this.exchangeValue; + } +} diff --git a/api/src/config/s3Config.ts b/api/src/config/s3Config.ts new file mode 100644 index 0000000..179267e --- /dev/null +++ b/api/src/config/s3Config.ts @@ -0,0 +1,18 @@ +import {S3ClientConfig} from '@aws-sdk/client-s3'; + +export class S3Config { + protected getConfiguration(): S3ClientConfig { + return { + credentials: { + accessKeyId: process.env.S3_SERVICE_USER ?? 'minioadmin', + secretAccessKey: process.env.S3_SERVICE_PASSWORD ?? 'minioadmin', + }, + region: 'us-east-1', + endpoint: + process.env.S3_HOST && process.env.S3_PORT + ? `http://${process.env.S3_HOST}:${process.env.S3_PORT}` + : 'http://localhost:9001', + forcePathStyle: true, + }; + } +} diff --git a/api/src/constants.ts b/api/src/constants.ts new file mode 100644 index 0000000..415fc64 --- /dev/null +++ b/api/src/constants.ts @@ -0,0 +1,51 @@ +import {Credentials} from 'keycloak-admin/lib/utils/auth'; +import {InfoObject} from '@loopback/openapi-v3'; + +export const OPENAPI_CONFIG: InfoObject = { + title: 'moB - Mon Compte Mobilité', + version: `${process.env.PACKAGE_VERSION || '1.0.0'}`, + contact: { + email: 'mcm_admin@moncomptemobilite.org', + name: 'Support technique', + }, +}; + +export const emailRegexp = '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$'; + +export const IDP_FQDN = process.env.IDP_FQDN + ? `https://${process.env.IDP_FQDN}` + : process.env.IDP_URL + ? `${process.env.IDP_URL}` + : `http://localhost:9000`; + +export const WEBSITE_FQDN = process.env.WEBSITE_FQDN + ? `https://${process.env.WEBSITE_FQDN}` + : process.env.WEBSITE_URL + ? `${process.env.WEBSITE_URL}` + : 'http://localhost:8000'; + +export const API_FQDN = process.env.API_FQDN + ? `https://${process.env.API_FQDN}` + : process.env.API_URL + ? `${process.env.API_URL}` + : 'http://localhost:3000'; + +export const baseUrl = `${IDP_FQDN}/auth`; + +export const realmName = 'mcm'; + +export const IDP_SUFFIX_CLIENT = 'client'; + +export const IDP_SUFFIX_BACKEND = 'backend'; + +export const IDP_SUFFIX = [IDP_SUFFIX_CLIENT, IDP_SUFFIX_BACKEND]; + +export const TAG_MAAS = 'MaaS'; + +export const credentials: Credentials = { + grantType: 'client_credentials', + clientId: 'api', + clientSecret: process.env.CLIENT_SECRET_KEY_KEYCLOAK_API + ? `${process.env.CLIENT_SECRET_KEY_KEYCLOAK_API}` + : '${IDP_API_CLIENT_SECRET}', +}; diff --git a/api/src/controllers/README.md b/api/src/controllers/README.md new file mode 100644 index 0000000..ad4c4cc --- /dev/null +++ b/api/src/controllers/README.md @@ -0,0 +1,9 @@ +# Controllers + +This directory contains source files for the controllers exported by this app. + +To add a new empty controller, type in `lb4 controller []` from the +command-line of your application's root directory. + +For more information, please visit +[Controller generator](http://loopback.io/doc/en/lb4/Controller-generator.html). diff --git a/api/src/controllers/citizen.controller.ts b/api/src/controllers/citizen.controller.ts new file mode 100644 index 0000000..33e4bd7 --- /dev/null +++ b/api/src/controllers/citizen.controller.ts @@ -0,0 +1,1025 @@ +import * as Excel from 'exceljs'; +import {inject, intercept, service} from '@loopback/core'; +import {FilterExcludingWhere, repository, AnyObject} from '@loopback/repository'; +import { + post, + param, + get, + getModelSchemaRef, + put, + patch, + requestBody, + Response, + RestBindings, + del, +} from '@loopback/rest'; +import {SecurityBindings} from '@loopback/security'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; +import { + CitizenRepository, + EnterpriseRepository, + CommunityRepository, + UserRepository, + SubscriptionRepository, + IncentiveRepository, +} from '../repositories'; +import { + CitizenService, + KeycloakService, + MailService, + JwtService, + FunderService, + SubscriptionService, +} from '../services'; +import {CitizenInterceptor} from '../interceptors'; +import { + CitizenUpdate, + Affiliation, + Incentive, + Citizen, + Subscription, + Enterprise, + Error, +} from '../models'; +import {ValidationError} from '../validationError'; +import { + ResourceName, + StatusCode, + AFFILIATION_STATUS, + Roles, + SUBSCRIPTION_STATUS, + SECURITY_SPEC_API_KEY, + SECURITY_SPEC_KC_PASSWORD, + SECURITY_SPEC_JWT_KC_PASSWORD, + SECURITY_SPEC_JWT, + AUTH_STRATEGY, + Consent, + ClientOfConsent, + CITIZEN_STATUS, + IUser, + GENDER, +} from '../utils'; +import {emailRegexp} from '../constants'; + +import {canAccessHisOwnData} from '../services/user.authorizor'; +import {formatDateInTimezone} from '../utils/date'; +@intercept(CitizenInterceptor.BINDING_KEY) +export class CitizenController { + constructor( + @inject('services.MailService') + public mailService: MailService, + @repository(CitizenRepository) + public citizenRepository: CitizenRepository, + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + @inject('services.KeycloakService') + public kcService: KeycloakService, + @inject('services.FunderService') + public funderService: FunderService, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @inject('services.CitizenService') + public citizenService: CitizenService, + @inject('services.SubscriptionService') + public subscriptionService: SubscriptionService, + @inject('services.JwtService') + public jwtService: JwtService, + @service(UserRepository) + private userRepository: UserRepository, + @inject(SecurityBindings.USER, {optional: true}) + private currentUser: IUser, + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + ) {} + + /** + * Create a new user + * @param register the form or user object + * @returns an object with new user id + firstName + lastName + */ + @authenticate(AUTH_STRATEGY.API_KEY) + @authorize({allowedRoles: [Roles.API_KEY]}) + @post('v1/citizens', { + 'x-controller-name': 'Citizens', + summary: 'Crée un citoyen', + security: SECURITY_SPEC_API_KEY, + responses: { + [StatusCode.Created]: { + description: 'Le citoyen est enregistré', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + [StatusCode.NotFound]: { + description: "L'entreprise du salarié est inconnue", + }, + [StatusCode.PreconditionFailed]: { + description: + "L'email professionnel du salarié n'est pas du domaine de son entreprise", + }, + [StatusCode.Conflict]: { + description: "L'email personnel du salarié existe déjà", + }, + [StatusCode.UnprocessableEntity]: { + description: "L'email professionnel du salarié existe déjà", + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Citizen), + }, + }, + }) + register: Omit, + ): Promise<{id: string} | undefined> { + const result: {id: string} | undefined = await this.citizenService.createCitizen( + register, + ); + return result; + } + + /** + * @param [status] status in `status` + * @param [lastName] Search in `lastName` + * @param [skip] records. + * @returns {Citizen[], number} List of salaries sorted by `lastName` and the total + * number of employees based on their status or lastName + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({ + allowedRoles: [Roles.SUPERVISORS, Roles.MANAGERS], + }) + @get('/v1/citizens', { + 'x-controller-name': 'Citizens', + summary: "Retourne les salariés d'une entreprise", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Search filter employees ', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Citizen), + }, + }, + }, + }, + }, + }) + async findSalaries( + @param.query.string('status', { + description: `Filtre sur le statut de l'aide: AFFILIE | DESAFFILIE | A_AFFILIER`, + }) + status?: string, + @param.query.string('lastName', { + description: `Filtre sur le nom de famille du citoyen`, + }) + lastName?: string, + @param.query.number('skip', { + description: `Nombre d'éléments à sauter lors de la pagination`, + }) + skip?: number, + ): Promise<{employees: Citizen[] | undefined; employeesCount: number}> { + const result = await this.citizenService.findEmployees({ + status, + lastName, + skip, + limit: 10, + }); + + return result; + } + + /** + * get citizens with at least one subscription + * @param lastName search params + * @param skip number of element to skip + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({ + allowedRoles: [Roles.FUNDERS], + }) + @get('/v1/collectivitiesCitizens', { + 'x-controller-name': 'Citizens', + summary: 'Récupère la liste des citoyens ayant au moins une demande', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of subscriptions model instance', + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }, + }, + }) + async getCitizensWithSubscriptions( + @param.query.string('lastName', {description: 'Nom de famille du citoyen'}) + lastName?: string, + @param.query.number('skip', { + description: "Nombre d'éléments à sauter lors de la pagination", + }) + skip?: number | undefined, + ): Promise { + const {incentiveType, funderName} = this.currentUser; + + const match: object[] = [ + {incentiveType: incentiveType}, + {funderName: funderName}, + {status: {$ne: SUBSCRIPTION_STATUS.DRAFT}}, + ]; + + if (lastName) { + match.push({ + lastName: new RegExp('.*' + lastName + '.*', 'i'), + }); + } + + return this.subscriptionService.getCitizensWithSubscription(match, skip); + } + + /** + * get citizen profile by id + * @param citizenId id of citizen + * @param filter the citizen filter + * @returns the profile of citizen + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({voters: [canAccessHisOwnData]}) + @get('v1/citizens/profile/{citizenId}', { + 'x-controller-name': 'Citizens', + summary: "Retourne les informations d'un citoyen", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Citizen model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Citizen), + }, + }, + }, + }, + }) + async findById( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + @param.filter(Citizen, {exclude: 'where'}) + filter?: FilterExcludingWhere, + ): Promise { + return this.citizenRepository.findById(citizenId, filter); + } + + /** + * get citizen by id + * @param citizenId id of citizen + * @param filter the citizen filter + * @returns one citizen object + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @get('/v1/citizens/{citizenId}', { + 'x-controller-name': 'Citizens', + security: SECURITY_SPEC_KC_PASSWORD, + summary: "Retourne les informations d'un citoyen", + responses: { + [StatusCode.Success]: { + description: 'Citizen model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Citizen), + }, + }, + }, + [StatusCode.NotFound]: { + description: "Ce citoyen n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Citizen not found', + path: '/citizenNotFound', + resourceName: 'Citizen', + }, + }, + }, + }, + }, + }, + }) + async findCitizenId( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + ): Promise> { + const user = await this.userRepository.findById(this.currentUser?.id); + const citizenData = await this.citizenRepository.findById(citizenId); + if ( + user && + this.currentUser?.roles?.includes('gestionnaires') && + user.funderId === citizenData?.affiliation?.enterpriseId + ) { + return { + lastName: citizenData?.identity?.lastName?.value, + firstName: citizenData?.identity?.firstName?.value, + }; + } else { + throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden); + } + } + + /** + * affiliate connected citizen + * @param data has token needed to affiliate connected citizen + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY) + @authorize({ + allowedRoles: [Roles.SUPERVISORS, Roles.MANAGERS, Roles.API_KEY, Roles.CITIZENS], + }) + @put('/v1/citizens/{citizenId}/affiliate', { + 'x-controller-name': 'Citizens', + summary: 'Affilie un citoyen à une entreprise', + security: SECURITY_SPEC_JWT_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: "L'affiliation est validée", + }, + [StatusCode.NotFound]: { + description: "L'affiliation n'existe pas", + }, + [StatusCode.PreconditionFailed]: { + description: "L'affiliation n'est pas au bon status", + }, + [StatusCode.UnprocessableEntity]: { + description: "L'affiliation n'est pas valide", + }, + }, + }) + async validateAffiliation( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: { + type: 'string', + example: `Un token d'affiliation`, + }, + }, + }, + }, + }, + }) + data: { + token: string; + }, + ): Promise { + const user = this.currentUser; + + let citizen: Citizen | null; + + if (user.id) { + citizen = await this.citizenRepository.findOne({ + where: {id: citizenId}, + }); + citizen!.affiliation!.affiliationStatus = AFFILIATION_STATUS.AFFILIATED; + } else { + citizen = await this.citizenService.checkAffiliation(data.token); + citizen.affiliation!.affiliationStatus = AFFILIATION_STATUS.AFFILIATED; + } + + await this.citizenRepository.updateById(citizenId, { + affiliation: {...citizen!.affiliation}, + }); + if (user.id && user.funderName && citizen) { + await this.citizenService.sendValidatedAffiliation(citizen, user.funderName); + } + } + + /** + * disaffiliate citizen by id + * @param citizenId id citizen + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({ + allowedRoles: [Roles.MANAGERS, Roles.SUPERVISORS], + }) + @put('/v1/citizens/{citizenId}/disaffiliate', { + 'x-controller-name': 'Citizens', + summary: "Désaffilie un citoyen d'une entreprise", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'La désaffiliation est validée', + }, + [StatusCode.NotFound]: { + description: "Ce citoyen n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Citizen not found', + path: '/citizenNotFound', + resourceName: 'Citizen', + }, + }, + }, + }, + }, + [StatusCode.PreconditionFailed]: { + description: 'La désaffiliation est impossible', + }, + [StatusCode.UnprocessableEntity]: { + description: "La désaffiliation n'est pas valide", + }, + }, + }) + async disaffiliation( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + ): Promise { + const user = this.currentUser; + const citizen: Citizen = await this.citizenRepository.findById(citizenId); + const citizenInitialStatus: string = citizen.affiliation!.affiliationStatus; + citizen.affiliation!.affiliationStatus = AFFILIATION_STATUS.DISAFFILIATED; + + await this.citizenRepository.updateById(citizen.id, { + affiliation: {...citizen.affiliation}, + }); + + if ( + user && + user?.funderName && + citizenInitialStatus === AFFILIATION_STATUS.TO_AFFILIATE + ) { + await this.citizenService.sendRejectedAffiliation(citizen, user?.funderName); + } else { + await this.citizenService.sendDisaffiliationMail(this.mailService, citizen); + } + } + + /** + * Update citizen by id + * @param citizenId id of citizen + * @param citizen schema + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({voters: [canAccessHisOwnData]}) + @patch('/v1/citizens/{citizenId}', { + 'x-controller-name': 'Citizens', + summary: "Modifie des informations d'un citoyen", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Citizen PATCH success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + [StatusCode.UnprocessableEntity]: { + description: "L'email professionnel du salarié existe déjà", + }, + }, + }) + async updateById( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(CitizenUpdate), + }, + }, + }) + citizen: CitizenUpdate, + ): Promise<{id: string}> { + /** + * init a new citizen data for handling + */ + let affiliationEnterprise = new Enterprise(); + const newCitizen: { + id?: string; + city: string; + postcode: string; + status: CITIZEN_STATUS; + affiliation: Affiliation; + } = { + ...citizen, + }; + + // Enterprise Verification + if (citizen.affiliation?.enterpriseId) { + affiliationEnterprise = await this.enterpriseRepository.findById( + citizen.affiliation.enterpriseId, + ); + } + /** + * set a new affiliation object state depending on enterpriseId and enterpriseEmail existence and not empty strings + */ + if ( + newCitizen?.affiliation?.enterpriseId && + newCitizen?.affiliation?.enterpriseEmail + ) { + newCitizen.affiliation.affiliationStatus = AFFILIATION_STATUS.TO_AFFILIATE; + } + + /** + * set or not the new affiliation object state depending on what the form returns + */ + if ( + (newCitizen?.affiliation && + !newCitizen?.affiliation?.enterpriseId && + newCitizen?.affiliation?.enterpriseEmail) || + (newCitizen?.affiliation && + newCitizen?.affiliation?.enterpriseId && + !newCitizen?.affiliation?.enterpriseEmail && + !affiliationEnterprise?.hasManualAffiliation) || + (newCitizen?.affiliation && + !newCitizen?.affiliation?.enterpriseId && + !newCitizen?.affiliation?.enterpriseEmail) + ) { + newCitizen.affiliation.enterpriseId || null; + newCitizen.affiliation.enterpriseEmail || null; + newCitizen.affiliation.affiliationStatus = AFFILIATION_STATUS.UNKNOWN; + } + + if ( + (newCitizen?.affiliation && + newCitizen?.affiliation?.enterpriseId && + newCitizen?.affiliation?.enterpriseEmail) || + (newCitizen?.affiliation && + newCitizen?.affiliation?.enterpriseId && + !newCitizen?.affiliation?.enterpriseEmail && + affiliationEnterprise?.hasManualAffiliation) + ) { + newCitizen.affiliation.affiliationStatus = AFFILIATION_STATUS.TO_AFFILIATE; + } + let enterprise: Enterprise; + + /** + * get citizen data + */ + const oldCitizen: Citizen = await this.citizenRepository.findById(citizenId); + + if (newCitizen?.affiliation?.enterpriseId) { + /** + * get the related company by id + */ + enterprise = await this.enterpriseRepository.findById( + newCitizen.affiliation.enterpriseId, + ); + + /** + * pre-check and validation of the enterprise data + */ + if (newCitizen?.affiliation?.enterpriseEmail) { + /** + * Check if the professional email is unique + */ + if ( + newCitizen?.affiliation?.enterpriseEmail !== + oldCitizen?.affiliation?.enterpriseEmail + ) { + await this.citizenService.checkProEmailExistence( + newCitizen?.affiliation?.enterpriseEmail, + ); + } + + /** + * validate the professional email pattern + */ + const companyEmail = newCitizen.affiliation.enterpriseEmail; + this.citizenService.validateEmailPattern(companyEmail, enterprise?.emailFormat); + } + + /** + * Send a manual affiliaton mail to the company's funders accepting the manual affiliation + */ + if (!newCitizen?.affiliation.enterpriseEmail && enterprise.hasManualAffiliation) { + await this.citizenService.sendManualAffiliationMail(oldCitizen, enterprise); + } + } + + /** + * update the citizen data + */ + await this.citizenRepository.updateById(citizenId, newCitizen); + + /** + * proceed to send the affiliation email to the citizen's professional email + */ + if ( + newCitizen?.affiliation?.affiliationStatus === AFFILIATION_STATUS.TO_AFFILIATE && + newCitizen?.affiliation.enterpriseEmail + ) { + /** + * init the affiliation email payload + */ + const affiliationEmailData: any = { + ...newCitizen, + id: citizenId, + identity: {...oldCitizen.identity}, + }; + + /** + * send the affiliation email + */ + await this.citizenService.sendAffiliationMail( + this.mailService, + affiliationEmailData, + enterprise!.name, + ); + } + + return {id: citizenId}; + } + + /** + * Update citizen linked to FC by id + * @param citizenId id of citizen + * @param citizen schema + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CITIZENS_FC], voters: [canAccessHisOwnData]}) + @post('/v1/citizens/{citizenId}/complete', { + 'x-controller-name': 'Citizens', + summary: "Complète le profil d'un citoyen lié à France Connect", + security: SECURITY_SPEC_JWT, + responses: { + [StatusCode.Success]: { + description: 'Citizen POST success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + [StatusCode.NotFound]: { + description: "L'entreprise du salarié est inconnue", + }, + [StatusCode.PreconditionFailed]: { + description: + "L'email professionnel du salarié n'est pas du domaine de son entreprise", + }, + [StatusCode.Conflict]: { + description: "L'email personnel du salarié existe déjà", + }, + [StatusCode.UnprocessableEntity]: { + description: "L'email professionnel du salarié existe déjà", + }, + }, + }) + async createCitizenFc( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Citizen, { + title: 'CompleteProfile', + exclude: ['password', 'id'], + }), + }, + }, + }) + register: Omit, + ): Promise<{id: string} | undefined> { + const result: {id: string} | undefined = await this.citizenService.createCitizen( + register, + citizenId, + ); + return result; + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({voters: [canAccessHisOwnData]}) + @get('/v1/citizens/{citizenId}/export', { + 'x-controller-name': 'Citizens', + summary: 'Exporte les données personnelles du citoyen connecté', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Downloadable .xlsx file with validated aides list', + content: { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + schema: {type: 'string', format: 'base64'}, + }, + }, + }, + }, + }) + async generateUserRGPDExcelFile( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + @inject(RestBindings.Http.RESPONSE) resp: Response, + ): Promise> { + try { + const citizen: Citizen = await this.citizenRepository.findById(citizenId); + + const listMaas: string[] = await this.citizenService.getListMaasNames( + citizen?.email, + ); + + // get company name & company email from user affiliation + let companyName: string = ''; + if (citizen?.affiliation?.enterpriseId) { + const enterprise: Enterprise = await this.enterpriseRepository.findById( + citizen?.affiliation?.enterpriseId, + ); + companyName = enterprise.name; + } + + // get all user subscriptions + const subscriptions: Subscription[] = await this.subscriptionRepository.find({ + order: ['updatedAT ASC'], + where: {citizenId: this.currentUser.id, status: {neq: SUBSCRIPTION_STATUS.DRAFT}}, + }); + + // get all aides {id, title, specificFields} + const incentives: Incentive[] = await this.incentiveRepository.find({ + fields: {id: true, title: true, specificFields: true}, + }); + + // generate Excel RGPD file that contains all user private data + const excelBufferRGPD: Excel.Buffer = await this.citizenService.generateExcelRGPD( + citizen, + companyName, + subscriptions, + incentives, + listMaas, + ); + + // send the file to user + return resp + .status(200) + .contentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + .send(excelBufferRGPD); + } catch (error) { + throw new ValidationError( + 'Le téléchargement a échoué, veuillez réessayer', + '/downloadXlsx', + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } + } + + /** + * Delete citizen account + * @param citizenId id citizen + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({voters: [canAccessHisOwnData]}) + @put('/v1/citizens/{citizenId}/delete', { + 'x-controller-name': 'Citizens', + summary: "Supprimer le compte d'un citoyen", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'La suppression est effectuée', + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur n'est pas connecté", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: + "Vous n'avez pas les droits pour supprimer le compte de cet utilisateur", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + }, + }) + async deleteCitizenAccount( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + ): Promise { + const citizen: Citizen = await this.citizenRepository.findById(citizenId); + + // Delete citizen from MongoDB + await this.citizenRepository.deleteById(citizen.id); + + // Delete citizen from Keycloak + await this.kcService.deleteUserKc(citizen.id); + + // ADD Flag "Compte Supprimé" to citizen Subscription + const citizenSubscriptions = await this.subscriptionRepository.find({ + where: {citizenId: citizen.id}, + }); + + citizenSubscriptions?.forEach(async citizenSubscription => { + citizenSubscription.isCitizenDeleted = true; + await this.subscriptionRepository.updateById( + citizenSubscription.id, + citizenSubscription, + ); + }); + + const date = formatDateInTimezone(new Date(), "dd/MM/yyyy à H'h'mm"); + await this.citizenService.sendDeletionMail(this.mailService, citizen, date); + } + + /** + * get consents by citizen id + * @param citizenId id of citizen + * @returns liste des clients en consentement avec le citoyen + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({voters: [canAccessHisOwnData]}) + @get('/v1/citizens/{citizenId}/linkedAccounts', { + 'x-controller-name': 'Citizens', + security: SECURITY_SPEC_KC_PASSWORD, + summary: "Retourne le nom et l'ID des clients en consentement avec le citoyen", + responses: { + [StatusCode.Success]: { + description: 'Modèle des informations du consentement', + content: { + 'application/json': { + example: { + clientInfo: { + clientName: 'Mulhouse', + clientId: 'mulhouse-maas-client', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: "Vous n'êtes autorisé à consulter que vos propres consentements", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + }, + }, + }, + }, + }, + }, + }) + async findConsentsById( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + ): Promise<(ClientOfConsent | undefined)[]> { + const citizenConsentsList = await this.kcService.listConsents(citizenId); + const clientsList = await this.citizenService.getClientList(); + + const consentListData = citizenConsentsList.map((element: Consent) => + clientsList.find(clt => element.clientId === clt.clientId), + ); + + return consentListData; + } + + /** + * delete consent by id + * @param citizenId id of citizen + * @param clientId id du client en consentement avec le citoyen + */ + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({voters: [canAccessHisOwnData]}) + @del('/v1/citizens/{citizenId}/linkedAccounts/{clientId}', { + 'x-controller-name': 'Citizens', + summary: 'Supprime un consentement', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'Consentement supprimé', + }, + [StatusCode.Forbidden]: { + description: 'Vous ne pouvez supprimer que vos propres consentements', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + }, + }, + }, + }, + }, + [StatusCode.NotFound]: { + description: 'Consentement introuvable', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'consent not found', + path: '/consentNotFound', + }, + }, + }, + }, + }, + }, + }) + async deleteConsentById( + @param.path.string('citizenId', { + description: `L'identifiant du citoyen`, + }) + citizenId: string, + @param.path.string('clientId', { + description: `L'identifiant du client en consentement avec le citoyen`, + }) + clientId: string, + ): Promise { + await this.kcService.deleteConsent(citizenId, clientId); + } +} diff --git a/api/src/controllers/collectivity.controller.ts b/api/src/controllers/collectivity.controller.ts new file mode 100644 index 0000000..5a849ba --- /dev/null +++ b/api/src/controllers/collectivity.controller.ts @@ -0,0 +1,135 @@ +import {inject} from '@loopback/core'; +import {Count, CountSchema, repository, Where} from '@loopback/repository'; +import {post, param, get, getModelSchemaRef, requestBody} from '@loopback/rest'; +import {pick} from 'lodash'; +import {authorize} from '@loopback/authorization'; +import {authenticate} from '@loopback/authentication'; + +import {Collectivity} from '../models'; +import {CollectivityRepository} from '../repositories'; +import {KeycloakService} from '../services'; +import { + GROUPS, + Roles, + StatusCode, + SECURITY_SPEC_KC_PASSWORD, + AUTH_STRATEGY, +} from '../utils'; + +@authenticate(AUTH_STRATEGY.KEYCLOAK) +@authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) +export class CollectivityController { + constructor( + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + @inject('services.KeycloakService') + public kcService: KeycloakService, + ) {} + + /** + * Create one collectivity + * @param collectivity schema + */ + @post('/v1/collectivities', { + 'x-controller-name': 'Collectivities', + summary: 'Crée une collectivité', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Nouvelle collectivité', + content: {'application/json': {schema: getModelSchemaRef(Collectivity)}}, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Collectivity), + }, + }, + }) + collectivity: Collectivity, + ): Promise { + let keycloakGroupCreationResult; + + try { + const {name} = collectivity; + keycloakGroupCreationResult = await this.kcService.createGroupKc( + name, + GROUPS.collectivities, + ); + + if (keycloakGroupCreationResult && keycloakGroupCreationResult.id) { + const collectivityModel = pick(collectivity, [ + 'name', + 'citizensCount', + 'mobilityBudget', + 'encryptionKey', + ]); + + const RepositoryCollectivityCreationResult = + await this.collectivityRepository.create({ + ...collectivityModel, + ...keycloakGroupCreationResult, + }); + + return RepositoryCollectivityCreationResult; + } + } catch (error) { + if (keycloakGroupCreationResult && keycloakGroupCreationResult.id) + await this.kcService.deleteGroupKc(keycloakGroupCreationResult.id); + throw error; + } + } + + /** + * Count the collectivities + * @param where the collectivity where filter + * @returns the number the collectivities + */ + @get('/v1/collectivities/count', { + 'x-controller-name': 'Collectivities', + summary: 'Retourne le nombre de collectivités', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Collectivity model count', + content: { + 'application/json': { + schema: {...CountSchema, ...{title: 'Count'}}, + }, + }, + }, + }, + }) + async count(@param.where(Collectivity) where?: Where): Promise { + return this.collectivityRepository.count(where); + } + + /** + * Get all collectivities + * @returns all collectivities + */ + @get('/v1/collectivities', { + 'x-controller-name': 'Collectivities', + summary: 'Retourne les collectivités', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of Collectivity model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Collectivity), + }, + }, + }, + }, + }, + }) + async find(): Promise { + return this.collectivityRepository.find(); + } +} diff --git a/api/src/controllers/contact.controller.ts b/api/src/controllers/contact.controller.ts new file mode 100644 index 0000000..d7c50d6 --- /dev/null +++ b/api/src/controllers/contact.controller.ts @@ -0,0 +1,72 @@ +import {inject} from '@loopback/core'; +import {getModelSchemaRef, post, requestBody} from '@loopback/rest'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; + +import {Contact} from '../models'; +import {ContactService, MailService} from '../services'; +import {formatDateInTimezone} from '../utils/date'; +import { + AUTH_STRATEGY, + ResourceName, + Roles, + SECURITY_SPEC_API_KEY, + StatusCode, +} from '../utils'; +import {ValidationError} from '../validationError'; + +export class ContactController { + constructor( + @inject('services.ContactService') + public contactService: ContactService, + @inject('services.MailService') + public mailService: MailService, + ) {} + + /** + * Send contact form + * @param contact object from contact form + */ + @authenticate(AUTH_STRATEGY.API_KEY) + @authorize({allowedRoles: [Roles.API_KEY]}) + @post('v1/contact', { + 'x-controller-name': 'Contact', + summary: "Envoi d'un formulaire de contact", + security: SECURITY_SPEC_API_KEY, + responses: { + [StatusCode.Success]: { + description: 'Formulaire de contact', + content: {'application/json': {schema: getModelSchemaRef(Contact)}}, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Contact), + }, + }, + }) + contact: Contact, + ): Promise { + if (!contact.tos) { + throw new ValidationError( + `User must agree to terms of services`, + '/tos', + StatusCode.UnprocessableEntity, + ResourceName.Contact, + ); + } + try { + const contactDate = formatDateInTimezone(new Date(), 'dd/MM/yyyy'); + return await this.contactService.sendMailClient( + this.mailService, + contact, + contactDate, + ); + } catch (error) { + return error; + } + } +} diff --git a/api/src/controllers/dashboard.controller.ts b/api/src/controllers/dashboard.controller.ts new file mode 100644 index 0000000..0c53763 --- /dev/null +++ b/api/src/controllers/dashboard.controller.ts @@ -0,0 +1,438 @@ +import {AnyObject, repository} from '@loopback/repository'; +import {param, get} from '@loopback/rest'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; +import {SecurityBindings} from '@loopback/security'; +import {inject} from '@loopback/core'; + +import {SubscriptionRepository, UserRepository} from '../repositories'; +import {calculatePercentage, setValidDate, isFirstElementGreater} from './utils/helpers'; +import { + SECURITY_SPEC_KC_PASSWORD, + Roles, + SUBSCRIPTION_STATUS, + IDashboardCitizen, + IDashboardCitizenIncentiveList, + IDashboardSubscription, + IDashboardSubscriptionResult, + StatusCode, + AUTH_STRATEGY, + IUser, +} from '../utils'; +import {Subscription, SubscriptionRelations} from '../models'; + +/** + * CONTROLLERS + * + * + * dashboard controller function and api endpoints + */ +@authenticate(AUTH_STRATEGY.KEYCLOAK) +@authorize({allowedRoles: [Roles.FUNDERS]}) +export class DashboardController { + constructor( + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @repository(UserRepository) + public userRepository: UserRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + /** + * get the citizens for dashboard + * @param year the filter year value + * @param semester the filter semester value + * @returns an object with the incentive list + citizens statistics + */ + @get('/v1/dashboards/citizens', { + 'x-controller-name': 'Dashboards', + summary: + 'Retourne le nombre de citoyens ayant une souscription' + + ' validée concernant le financeur connecté', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Count of citizen with at least one validated subscription', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + incentiveList: { + type: 'array', + items: { + type: 'object', + properties: { + incentiveId: { + description: `Identifiant de l'aide`, + type: 'string', + example: '', + }, + incentiveTitle: { + description: `Titre de l'aide`, + type: 'string', + example: 'Le vélo électrique arrive à Mulhouse !', + }, + totalSubscriptionsCount: { + description: `Nombre de souscriptions validées selon l'aide`, + type: 'number', + example: 1, + }, + validatedSubscriptionPercentage: { + description: `Pourcentage de souscriptions validées par citoyen`, + type: 'number', + example: 25, + }, + }, + }, + }, + totalCitizensCount: { + description: `Nombre de citoyens ayant réalisé au moins une demande`, + type: 'number', + example: 4, + }, + }, + }, + }, + }, + }, + }, + }) + async findCitizen( + @param.query.string('year', { + description: `Filtre sur l'année de validation des demandes d'aide`, + }) + year: string, + @param.query.string('semester', { + description: `Filtre sur le semestre de validation des demandes d'aide`, + }) + semester: string, + ): Promise { + /** + * get he user community ids list + */ + const {id, roles, funderName, incentiveType} = this.currentUser; + const user = await this.userRepository.findOne({where: {id}}); + const communityIds = user?.communityIds; + + /** + * set the search query start and end dates value + */ + const searchQueryDate = setValidDate(year, semester); + const {startDate, endDate} = searchQueryDate; + + /** + * define the user role persona + */ + const isSupervisor = roles?.includes(Roles.SUPERVISORS); + const isManager = roles?.includes(Roles.MANAGERS); + + /** + * init a basic query search params + */ + let withParams: object = { + incentiveType: incentiveType, + funderName: funderName, + status: SUBSCRIPTION_STATUS.VALIDATED, + updatedAt: {between: [startDate, endDate]}, + }; + + /** + * inject the communityId param when checking if the user is belonging + * to any funder communities, for now only the user with Gestionnaire role + * can belong to a funder community. + * for this condition communityId with values means it's a Gestionnaire Role + * or if communityId returns undefined that means it's a Supervisor + */ + if (communityIds && communityIds.length > 0 && !isSupervisor && isManager) { + withParams = { + ...withParams, + communityId: {inq: communityIds}, + }; + } + + /** + * query the subscription repository + */ + const querySubscription = await this.subscriptionRepository.find({ + where: withParams, + fields: { + id: true, + incentiveId: true, + incentiveTitle: true, + citizenId: true, + }, + }); + + /** + * group the subscription list by incentive id + */ + const groupSubscriptionsByIncentive = querySubscription.reduce( + (groups: AnyObject, item: Subscription & SubscriptionRelations) => { + const group = groups[item.incentiveId] || []; + + const found = group.some( + (el: {citizenId: string}) => el.citizenId === item.citizenId, + ); + + if (!found) group.push(item); + groups[item.incentiveId] = group; + + return groups; + }, + {}, + ); + + /** + * merge all the subscriptions grouped by incentive result in one list + */ + const newQuerySubscription: AnyObject = Object.values( + groupSubscriptionsByIncentive, + ).flat(); + + /** + * init new subscriptions list + */ + const uniqueCitizensList: string[] = []; + let incentiveList: IDashboardCitizenIncentiveList[] = []; + + /** + * crete the subscriptions list and count unique subscriptions by citizen id + */ + newQuerySubscription.forEach( + ( + item: {citizenId: string; incentiveId: string; incentiveTitle: string}, + index: number, + ) => { + /** + * set the new unique citizen list + */ + const citizenIdFound = uniqueCitizensList.some( + el => el === newQuerySubscription[index].citizenId, + ); + + if (!citizenIdFound) uniqueCitizensList.push(item.citizenId); + + /** + * create a new demand element + */ + const elementIndex = incentiveList.findIndex( + (element: {incentiveId: string}) => + element.incentiveId.toString() === item.incentiveId.toString(), + ); + + if (elementIndex >= 0) { + incentiveList[elementIndex].totalSubscriptionsCount = + incentiveList[elementIndex].totalSubscriptionsCount + 1; + } else { + incentiveList.push({ + incentiveId: item.incentiveId, + incentiveTitle: item.incentiveTitle, + totalSubscriptionsCount: 1, + } as IDashboardCitizenIncentiveList); + } + }, + ); + + /** + * sort the subscription list by total subscription count + */ + incentiveList.sort(isFirstElementGreater); + + /** + * set the percentage of validated subscription by group + */ + incentiveList = calculatePercentage(incentiveList, uniqueCitizensList.length); + + /** + * return subscription statistic by incentive group + */ + return { + incentiveList: incentiveList, + totalCitizensCount: uniqueCitizensList.length, + } as IDashboardCitizen; + } + + /** + * get the subscriptions for the dashboard + * @param year the selected year value + * @param semester the selected semester value + * @returns an object with the query result + subscriptions statistics + */ + @get('/v1/dashboards/subscriptions', { + 'x-controller-name': 'Dashboards', + summary: + 'Retourne le nombre de souscriptions par statut concernant le financeur connecté', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Count of subscription per status', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { + type: 'array', + items: { + type: 'object', + properties: { + status: { + description: `Statut de la souscription`, + type: 'string', + example: SUBSCRIPTION_STATUS.TO_PROCESS, + }, + count: { + description: `Nombre de souscriptions pour le statut`, + type: 'number', + example: 5, + }, + }, + }, + }, + totalCount: { + description: `Nombre de total de souscriptions tous status confondus`, + type: 'number', + example: 25, + }, + totalPending: { + type: 'object', + properties: { + count: { + description: `Nombre de demande sur le périmètre de l'utilisateur connecté`, + type: 'number', + example: 3, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + async find( + @param.query.string('year') year: string, + @param.query.string('semester') semester: string, + ): Promise { + /** + * get he user community ids list + */ + const {id, roles, funderName, incentiveType} = this.currentUser; + const user = await this.userRepository.findOne({where: {id}}); + const communityIds = user?.communityIds; + + /** + * set the search query start and end dates value + */ + const searchQueryDate = setValidDate(year, semester); + const {startDate, endDate} = searchQueryDate; + + /** + * define the user role persona + */ + const isSupervisor = roles?.includes(Roles.SUPERVISORS); + const isManager = roles?.includes(Roles.MANAGERS); + + /** + * init a basic query search params + */ + let queryParams: object = { + incentiveType: incentiveType, + funderName: funderName, + status: {neq: SUBSCRIPTION_STATUS.DRAFT}, + updatedAt: {between: [startDate, endDate]}, + }; + + /** + * inject the communityId param when checking if the user is belonging + * to any funder communities, for now only the user with Gestionnaire role + * can belong to a funder community. + * for this condition communityId with values means it's a Gestionnaire Role + * or if communityId returns undefined that means it's a Supervisor + */ + if (communityIds && communityIds.length > 0 && !isSupervisor && isManager) { + queryParams = { + ...queryParams, + communityId: {inq: communityIds}, + }; + } + + /** + * query the subscription repository + */ + const querySubscription = await this.subscriptionRepository.find({ + where: queryParams, + fields: { + status: true, + }, + }); + + if (communityIds && communityIds.length > 0 && isManager) { + queryParams = { + ...queryParams, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + communityId: {inq: communityIds}, + }; + } + + /** + * If user has no communities, inject the status param so that the returned "totalPending" handles only the total of pending subscriptions + */ + if (!communityIds) { + queryParams = { + ...queryParams, + status: SUBSCRIPTION_STATUS.TO_PROCESS, + }; + } + + /** + * set the pending subscriptions count to be managed by the manager depending on his community + */ + let totalPending = {count: 0}; + if (isManager) { + totalPending = await this.subscriptionRepository.count(queryParams); + } + + /** + * group the demands query result by status and set the count of it + */ + const newDemandsGroupList: IDashboardSubscriptionResult[] = []; + querySubscription.map((groupEl: {status: SUBSCRIPTION_STATUS}) => { + const elementIndex = newDemandsGroupList.findIndex( + (element: IDashboardSubscriptionResult) => element.status === groupEl.status, + ); + + if (elementIndex >= 0) { + newDemandsGroupList[elementIndex].count = + newDemandsGroupList[elementIndex].count + 1; + } else { + newDemandsGroupList.push({ + status: groupEl.status, + count: 1, + }); + } + }); + + /** + * set the global count by auditioning all the demands status count + */ + const totalCount: number = newDemandsGroupList.reduce( + (sum: number, currentValue: {count: number}) => { + return sum + currentValue.count; + }, + 0, + ); + + /** + * final result return + */ + return { + result: newDemandsGroupList, + totalPending, + totalCount, + }; + } +} diff --git a/api/src/controllers/enterprise.controller.ts b/api/src/controllers/enterprise.controller.ts new file mode 100644 index 0000000..f8ab48c --- /dev/null +++ b/api/src/controllers/enterprise.controller.ts @@ -0,0 +1,262 @@ +import {inject, intercept} from '@loopback/core'; +import {Count, CountSchema, repository, Where} from '@loopback/repository'; +import {post, param, get, getModelSchemaRef, requestBody} from '@loopback/rest'; +import {pick} from 'lodash'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; + +import {KeycloakService} from '../services'; +import { + SECURITY_SPEC_KC_PASSWORD, + GROUPS, + Roles, + StatusCode, + SECURITY_SPEC_API_KEY, + AUTH_STRATEGY, + ResourceName, +} from '../utils'; +import {ValidationError} from '../validationError'; +import {Enterprise} from '../models/enterprise'; +import {EnterpriseRepository} from '../repositories/enterprise.repository'; +import {EnterpriseInterceptor} from '../interceptors'; +import {UserEntityRepository} from '../repositories'; + +export class EnterpriseController { + constructor( + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @repository(UserEntityRepository) + public userEntityRepository: UserEntityRepository, + @inject('services.KeycloakService') + public kcService: KeycloakService, + ) {} + + /** + * Create one enterprise + * @param enterprise the object to create + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @intercept(EnterpriseInterceptor.BINDING_KEY) + @post('/v1/enterprises', { + 'x-controller-name': 'Enterprises', + summary: 'Crée une entreprise', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Nouveau compte entreprise', + content: {'application/json': {schema: getModelSchemaRef(Enterprise)}}, + }, + [StatusCode.UnprocessableEntity]: { + description: + "Les formats des adresses email de l'entreprise ne sont pas valides.", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 422, + name: 'Error', + message: 'Enterprise email formats are not valid', + path: '/enterpriseEmailBadFormat', + }, + }, + }, + }, + }, + [StatusCode.PreconditionFailed]: { + description: + 'Vous nous pouvez pas activez les 2 options SI RH et Affiliation Manuelle', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 412, + name: 'Error', + message: 'enterprise.options.invalid', + path: '/enterpriseInvalidOptions', + }, + }, + }, + }, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Enterprise), + }, + }, + }) + enterprise: Enterprise, + ): Promise { + let keycloakGroupCreationResult: {id: string} | undefined; + + /** + * Throw an error if the two options are true + */ + if (enterprise.hasManualAffiliation && enterprise.isHris) { + throw new ValidationError( + `enterprise.options.invalid`, + `/enterpriseInvalidOptions`, + StatusCode.PreconditionFailed, + ResourceName.Enterprise, + ); + } + + try { + const {name} = enterprise; + keycloakGroupCreationResult = await this.kcService.createGroupKc( + name, + GROUPS.enterprises, + ); + + if (keycloakGroupCreationResult && keycloakGroupCreationResult.id) { + const enterpriseModel = pick(enterprise, [ + 'name', + 'siretNumber', + 'emailFormat', + 'employeesCount', + 'budgetAmount', + 'isHris', + 'hasManualAffiliation', + 'encryptionKey', + 'clientId', + ]); + if (enterprise.clientId) { + const serviceUser = await this.userEntityRepository.getServiceUser( + enterprise.clientId, + ); + await this.kcService.addUserGroupMembership( + serviceUser!.id, + keycloakGroupCreationResult.id, + ); + } + const RepositoryEnterpriseCreationResult = await this.enterpriseRepository.create( + { + ...enterpriseModel, + ...keycloakGroupCreationResult, + }, + ); + return RepositoryEnterpriseCreationResult; + } + } catch (error) { + if (keycloakGroupCreationResult && keycloakGroupCreationResult.id) + await this.kcService.deleteGroupKc(keycloakGroupCreationResult.id); + throw error; + } + } + + /** + * Count the enterprises + * @param where the enterprise where filter + * @returns the number of enterprises + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/enterprises/count', { + 'x-controller-name': 'Enterprises', + summary: "Retourne le nombre d'entreprises", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Enterprise model count', + content: { + 'application/json': { + schema: {...CountSchema, ...{title: 'Count'}}, + }, + }, + }, + }, + }) + async count(@param.where(Enterprise) where?: Where): Promise { + return this.enterpriseRepository.count(where); + } + + /** + * Get all enterprises + * @returns All enterprises + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY) + @authorize({ + allowedRoles: [Roles.SUPERVISORS, Roles.MANAGERS, Roles.CONTENT_EDITOR], + }) + @get('/v1/enterprises', { + 'x-controller-name': 'Enterprises', + summary: 'Retourne les entreprises', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of entreprises model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Enterprise), + }, + }, + }, + }, + }, + }) + async find(): Promise { + return this.enterpriseRepository.find(); + } + + /** + * Check the email format + * @returns if the email format is ok + */ + @authenticate(AUTH_STRATEGY.API_KEY) + @authorize({allowedRoles: [Roles.API_KEY]}) + @get('/v1/enterprises/email_format_list', { + 'x-controller-name': 'Enterprises', + summary: 'Retourne les informations détaillées des entreprises', + security: SECURITY_SPEC_API_KEY, + responses: { + [StatusCode.Success]: { + description: 'Array of entreprises model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + name: { + description: `Nom de l'entreprise`, + type: 'string', + example: 'Capgemini', + }, + id: { + description: `Identifiant du l'entreprise`, + type: 'string', + example: '', + }, + emailFormat: { + description: `Modèles d'email de l'entreprise`, + type: 'array', + items: { + type: 'string', + example: '@professional.com', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + async findEmailFormat(): Promise[]> { + const result: Pick[] = + await this.enterpriseRepository.find({ + fields: {name: true, id: true, emailFormat: true}, + }); + return result; + } +} diff --git a/api/src/controllers/external/funderV1.controller.ts b/api/src/controllers/external/funderV1.controller.ts new file mode 100644 index 0000000..8ec587a --- /dev/null +++ b/api/src/controllers/external/funderV1.controller.ts @@ -0,0 +1,62 @@ +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; +import {repository} from '@loopback/repository'; +import {param, get} from '@loopback/rest'; +import {intercept} from '@loopback/core'; + +import {Community} from '../../models'; +import {CommunityRepository} from '../../repositories'; +import {checkMaas} from '../../services'; +import {AffiliationInterceptor} from '../../interceptors'; +import {StatusCode, SECURITY_SPEC_JWT, AUTH_STRATEGY} from '../../utils'; + +export class FunderV1Controller { + constructor( + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + ) {} + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: ['maas'], voters: [checkMaas]}) + @intercept(AffiliationInterceptor.BINDING_KEY) + @get('v1/maas/funders/{funderId}/communities', { + 'x-controller-name': 'Funders', + summary: "Retourne les communautés d'un financeur", + security: SECURITY_SPEC_JWT, + responses: { + [StatusCode.Success]: { + description: "La liste des communautés d'un financeur.", + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Identifiant de la communauté', + }, + name: { + type: 'string', + description: 'Nom de la communauté', + }, + }, + }, + }, + }, + }, + }, + }, + }) + async findCommunitiesByFunderId( + @param.path.string('funderId', { + description: `L'identifiant du financeur`, + }) + funderId: string, + ): Promise<{id: string | undefined; name: string}[]> { + const communities: Community[] = await this.communityRepository.findByFunderId( + funderId, + ); + return communities.map(({id, name}) => ({id, name})); + } +} diff --git a/api/src/controllers/external/index.ts b/api/src/controllers/external/index.ts new file mode 100644 index 0000000..6f7d8ec --- /dev/null +++ b/api/src/controllers/external/index.ts @@ -0,0 +1,2 @@ +export * from './subscriptionV1.controller'; +export * from './funderV1.controller'; diff --git a/api/src/controllers/external/subscriptionV1.controller.ts b/api/src/controllers/external/subscriptionV1.controller.ts new file mode 100644 index 0000000..ba96f7e --- /dev/null +++ b/api/src/controllers/external/subscriptionV1.controller.ts @@ -0,0 +1,592 @@ +import {HttpErrors, param, toInterceptor} from '@loopback/rest'; +import {inject, intercept, service} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {get, post, getModelSchemaRef, requestBody} from '@loopback/rest'; +import {authorize} from '@loopback/authorization'; +import {authenticate} from '@loopback/authentication'; +import {SecurityBindings} from '@loopback/security'; +import multer from 'multer'; +import {Express} from 'express'; +import {capitalize} from 'lodash'; +import {WEBSITE_FQDN} from '../../constants'; + +import { + AffiliationInterceptor, + SubscriptionV1Interceptor, + SubscriptionV1AttachmentsInterceptor, + SubscriptionV1FinalizeInterceptor, +} from '../../interceptors'; +import { + Subscription, + AttachmentType, + Incentive, + CreateSubscription, + ValidationMultiplePayment, + ValidationSinglePayment, + ValidationNoPayment, + NoReason, + OtherReason, + Metadata, + Citizen, + EncryptionKey, + Collectivity, + Enterprise, +} from '../../models'; +import { + IncentiveRepository, + CitizenRepository, + SubscriptionRepository, + CommunityRepository, + EnterpriseRepository, + MetadataRepository, + CollectivityRepository, +} from '../../repositories'; +import { + checkMaas, + MailService, + S3Service, + RabbitmqService, + SubscriptionService, +} from '../../services'; +import {validationErrorExternalHandler} from '../../validationErrorExternal'; +import { + StatusCode, + SECURITY_SPEC_JWT, + SECURITY_SPEC_JWT_KC_PASSWORD, + SUBSCRIPTION_STATUS, + Roles, + AUTH_STRATEGY, + MaasSubscriptionList, + IUser, +} from '../../utils'; +import {generatePdfInvoices} from '../../utils/invoice'; +import {INCENTIVE_TYPE} from '../../utils/enum'; +import {encryptFileHybrid, generateAESKey, encryptAESKey} from '../../utils/encryption'; + +const multerInterceptor = toInterceptor(multer({storage: multer.memoryStorage()}).any()); +@authenticate(AUTH_STRATEGY.KEYCLOAK) +export class SubscriptionV1Controller { + constructor( + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @repository(CitizenRepository) + public citizenRepository: CitizenRepository, + @repository(MetadataRepository) + public metadataRepository: MetadataRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + @service(RabbitmqService) + public rabbitmqService: RabbitmqService, + @service(S3Service) + private s3Service: S3Service, + @service(MailService) + public mailService: MailService, + @inject(SecurityBindings.USER) + private currentUser: IUser, + @service(SubscriptionService) + public subscriptionService: SubscriptionService, + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + ) {} + + @authorize({allowedRoles: [Roles.MAAS, Roles.PLATFORM], voters: [checkMaas]}) + @intercept(AffiliationInterceptor.BINDING_KEY) + @intercept(SubscriptionV1Interceptor.BINDING_KEY) + @post('v1/maas/subscriptions', { + 'x-controller-name': 'Subscriptions', + summary: 'Crée une souscription', + security: SECURITY_SPEC_JWT_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: + "Création initiale d'une demande d'aide où additionalProp correspond aux champs spécifiques", + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + }, + [StatusCode.Forbidden]: { + description: "L'utilisateur n'a pas les droits pour souscrire à cette aide", + }, + [StatusCode.UnprocessableEntity]: { + description: 'La demande ne peut pas être traitée', + }, + }, + }) + async createSubscription( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(CreateSubscription), + }, + }, + }) + subscription: CreateSubscription, + ): Promise<{id: string} | HttpErrors.HttpError> { + try { + const incentive = await this.incentiveRepository.findById(subscription.incentiveId); + const citizen = await this.citizenRepository.findById(this.currentUser.id); + const newSubscription = new Subscription({ + ...subscription, + incentiveTitle: incentive.title, + incentiveTransportList: incentive.transportList, + citizenId: citizen.id, + lastName: citizen.identity.lastName.value, + firstName: citizen.identity.firstName.value, + email: citizen.email, + city: citizen.city, + postcode: citizen.postcode, + birthdate: citizen.identity.birthDate.value, + status: SUBSCRIPTION_STATUS.DRAFT, + funderName: incentive.funderName, + incentiveType: incentive.incentiveType, + funderId: incentive.funderId, + isCitizenDeleted: false, + }); + + // Add company email to subscription object if exists + if (citizen.affiliation?.enterpriseEmail) { + newSubscription.enterpriseEmail = citizen.affiliation.enterpriseEmail; + } + + const result = await this.subscriptionRepository.create(newSubscription); + return {id: result.id}; + } catch (error) { + return validationErrorExternalHandler(error); + } + } + + @authorize({allowedRoles: [Roles.MAAS, Roles.PLATFORM], voters: [checkMaas]}) + @intercept(multerInterceptor) + @intercept(AffiliationInterceptor.BINDING_KEY) + @intercept(SubscriptionV1AttachmentsInterceptor.BINDING_KEY) + @post('v1/maas/subscriptions/{subscriptionId}/attachments', { + 'x-controller-name': 'Subscriptions', + summary: 'Ajoute des justificatifs à une souscription', + security: SECURITY_SPEC_JWT_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: + "Ajouter les justificatifs pour une demande d'aide avec un status brouillon", + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + }, + [StatusCode.Forbidden]: { + description: + "L'utilisateur n'a pas les droits pour ajouter des justificatifs à la demande", + }, + [StatusCode.NotFound]: { + description: "Cette demande n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Subscription not found', + path: '/subscriptionNotFound', + resourceName: 'Subscription', + }, + }, + }, + }, + }, + [StatusCode.PreconditionFailed]: { + description: + 'La demande ou les justificatifs ne rencontrent pas les bonnes conditions', + }, + [StatusCode.UnprocessableEntity]: { + description: 'Les justificatifs ne peuvent pas être traités', + }, + }, + }) + async addAttachments( + @param.path.string('subscriptionId', {description: `L'identifiant de la demande`}) + subscriptionId: string, + @requestBody({ + description: `Multipart/form-data pour la creation d'une demande`, + content: { + 'multipart/form-data': { + // Skip body parsing + 'x-parser': 'stream', + schema: { + type: 'object', + properties: { + attachmentExample: { + description: `Exemple d'ajout d'un document justificatif`, + type: 'array', + items: { + format: 'binary', + type: 'string', + }, + }, + data: { + type: 'object', + description: `Ajout des metadataId`, + properties: { + metadataId: { + type: 'string', + }, + }, + example: { + metadataId: '', + }, + }, + }, + }, + }, + }, + }) + attachmentData?: any, + ): Promise<{id: string} | HttpErrors.HttpError> { + try { + const subscription: Subscription = await this.subscriptionRepository.findById( + subscriptionId, + ); + let encryptionKey: EncryptionKey | undefined = undefined; + const collectivity: Collectivity | null = await this.collectivityRepository.findOne( + { + where: {id: subscription.funderId}, + }, + ); + const enterprise: Enterprise | null = await this.enterpriseRepository.findOne({ + where: {id: subscription.funderId}, + }); + + encryptionKey = collectivity + ? collectivity.encryptionKey + : enterprise + ? enterprise.encryptionKey + : undefined; + + const metadataId: string | undefined = attachmentData.body.data + ? JSON.parse(attachmentData.body.data)?.metadataId + : undefined; + const citizen: Citizen = await this.citizenRepository.findById(this.currentUser.id); + let formattedSubscriptionAttachments: AttachmentType[] = []; + let invoicesPdf: Express.Multer.File[] = []; + let createSubscriptionAttachments: Express.Multer.File[] = []; + let attachments: Express.Multer.File[] = []; + + const {key, iv}: {key: Buffer; iv: Buffer} = generateAESKey(); + const {encryptKey, encryptIV}: {encryptKey: Buffer; encryptIV: Buffer} = + encryptAESKey(encryptionKey!.publicKey, key, iv); + + if (attachmentData) { + createSubscriptionAttachments = attachmentData.files as Express.Multer.File[]; + createSubscriptionAttachments.forEach((attachment: Express.Multer.File) => { + attachment.buffer = encryptFileHybrid(attachment.buffer, key, iv); + }); + } + + if (metadataId) { + const metadata: Metadata = await this.metadataRepository.findById(metadataId); + invoicesPdf = await generatePdfInvoices(metadata.attachmentMetadata.invoices); + invoicesPdf.forEach((invoicePdf: Express.Multer.File) => { + invoicePdf.buffer = encryptFileHybrid(invoicePdf.buffer, key, iv); + }); + } + + attachments = [...createSubscriptionAttachments, ...invoicesPdf]; + const formattedAttachments: Express.Multer.File[] = + this.subscriptionService.formatAttachments(attachments); + formattedSubscriptionAttachments = formattedAttachments?.map( + (file: Express.Multer.File) => { + return { + originalName: file.originalname, + uploadDate: new Date(), + proofType: file.fieldname, + mimeType: file.mimetype, + }; + }, + ); + + if (attachments.length > 0) { + await this.s3Service.uploadFileListIntoBucket( + citizen.id, + subscriptionId, + formattedAttachments, + ); + await this.subscriptionRepository.updateById(subscriptionId, { + attachments: formattedSubscriptionAttachments, + encryptedAESKey: encryptKey.toString('base64'), + encryptedIV: encryptIV.toString('base64'), + encryptionKeyId: encryptionKey!.id, + encryptionKeyVersion: encryptionKey!.version, + privateKeyAccess: encryptionKey!.privateKeyAccess + ? encryptionKey!.privateKeyAccess + : undefined, + }); + } + + // Delete metadata once subscription is updated with files + if (metadataId) { + await this.metadataRepository.deleteById(metadataId); + } + return {id: subscriptionId}; + } catch (error) { + return validationErrorExternalHandler(error); + } + } + + @authorize({allowedRoles: [Roles.MAAS, Roles.PLATFORM], voters: [checkMaas]}) + @intercept(AffiliationInterceptor.BINDING_KEY) + @intercept(SubscriptionV1FinalizeInterceptor.BINDING_KEY) + @post('v1/maas/subscriptions/{subscriptionId}/verify', { + 'x-controller-name': 'Subscriptions', + summary: 'Finalise une souscription', + security: SECURITY_SPEC_JWT_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Finalisation de la demande', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + }, + [StatusCode.Forbidden]: { + description: "L'utilisateur n'a pas les droits pour finaliser la demande", + }, + [StatusCode.NotFound]: { + description: "Cette demande n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Subscription not found', + path: '/subscriptionNotFound', + resourceName: 'Subscription', + }, + }, + }, + }, + }, + [StatusCode.PreconditionFailed]: { + description: "La demande n'est pas au bon statut", + }, + }, + }) + async finalizeSubscription( + @param.path.string('subscriptionId', {description: `L'identifiant de la demande`}) + subscriptionId: string, + ): Promise<{id: string} | HttpErrors.HttpError> { + try { + const subscription = await this.subscriptionRepository.findById(subscriptionId); + // Change subscription status + await this.subscriptionRepository.updateById(subscriptionId, { + status: SUBSCRIPTION_STATUS.TO_PROCESS, + }); + // check if the funder is entreprise and if its HRIS to publish msg to rabbitmq + if (subscription?.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) { + const enterprise = await this.enterpriseRepository.findById( + subscription?.funderId, + ); + if (enterprise?.isHris) { + const payload = await this.subscriptionService.preparePayLoad(subscription); + // Publish to rabbitmq + await this.rabbitmqService.publishMessage(payload, enterprise?.name); + } + } + // Send a notification as an email + const dashboardLink = `${WEBSITE_FQDN}/mon-dashboard`; + await this.mailService.sendMailAsHtml( + subscription.email, + 'Confirmation d’envoi de la demande', + 'requests-to-process', + { + username: capitalize(subscription.firstName), + funderName: subscription.funderName, + dashboardLink: dashboardLink, + }, + ); + return {id: subscriptionId}; + } catch (error) { + return validationErrorExternalHandler(error); + } + } + + @authorize({allowedRoles: [Roles.MAAS], voters: [checkMaas]}) + @get('v1/maas/subscriptions', { + 'x-controller-name': 'Subscriptions', + summary: 'Retourne les souscriptions', + security: SECURITY_SPEC_JWT, + responses: { + [StatusCode.Success]: { + description: + "Ce service permet au citoyen de consulter l'ensemble de ses demandes réalisées", + content: { + 'application/json': { + schema: { + title: 'MaasSubscriptionList', + type: 'array', + items: { + allOf: [ + getModelSchemaRef(Subscription, { + title: 'MaasSubscriptionItem', + exclude: [ + 'attachments', + 'incentiveType', + 'citizenId', + 'lastName', + 'firstName', + 'email', + 'communityId', + 'consent', + 'specificFields', + 'status', + 'subscriptionRejection', + 'subscriptionValidation', + ], + }), + { + anyOf: [ + { + properties: { + status: { + type: 'string', + example: SUBSCRIPTION_STATUS.TO_PROCESS, + }, + }, + }, + { + type: 'object', + properties: { + subscriptionValidation: { + oneOf: [ + {'x-ts-type': ValidationMultiplePayment}, + {'x-ts-type': ValidationSinglePayment}, + {'x-ts-type': ValidationNoPayment}, + ], + }, + status: { + type: 'string', + example: SUBSCRIPTION_STATUS.VALIDATED, + }, + }, + }, + { + type: 'object', + properties: { + subscriptionRejection: { + oneOf: [{'x-ts-type': NoReason}, {'x-ts-type': OtherReason}], + }, + status: { + type: 'string', + example: SUBSCRIPTION_STATUS.REJECTED, + }, + }, + }, + ], + }, + { + type: 'object', + properties: { + contact: { + type: 'string', + example: 'contact@mcm.com', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }) + async findMaasSubscription(): Promise { + // get current user id + const userId: string = this.currentUser.id; + + // get incentives + const incentiveList: Incentive[] = await this.incentiveRepository.find({ + fields: {id: true, contact: true}, + }); + + // get current users subscriptions + const userSubcriptionList: Subscription[] = await this.subscriptionRepository.find({ + where: {citizenId: userId, status: {neq: SUBSCRIPTION_STATUS.DRAFT}}, + fields: { + id: true, + incentiveId: true, + incentiveTitle: true, + funderName: true, + status: true, + createdAt: true, + updatedAt: true, + subscriptionValidation: true, + subscriptionRejection: true, + }, + }); + + // add help contact info to subscriptions + const response: MaasSubscriptionList[] = + userSubcriptionList && + userSubcriptionList.map((subscription: Subscription) => { + const newSubscription: Subscription = subscription; + const incentive = + incentiveList && + incentiveList.find( + (incentive: Incentive) => + newSubscription.incentiveId.toString() === incentive.id, + ); + newSubscription.status === SUBSCRIPTION_STATUS.VALIDATED && + delete newSubscription.subscriptionRejection; + newSubscription.status === SUBSCRIPTION_STATUS.REJECTED && + delete newSubscription.subscriptionValidation; + return { + ...newSubscription, + contact: incentive?.contact, + }; + }); + + return response; + } +} diff --git a/api/src/controllers/funder.controller.ts b/api/src/controllers/funder.controller.ts new file mode 100644 index 0000000..a603bf5 --- /dev/null +++ b/api/src/controllers/funder.controller.ts @@ -0,0 +1,480 @@ +import {post, param, get, getModelSchemaRef, requestBody, put} from '@loopback/rest'; +import {repository, Count, CountSchema, Where} from '@loopback/repository'; +import {inject, intercept, service} from '@loopback/core'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; + +import {orderBy, capitalize} from 'lodash'; + +import { + Collectivity, + Community, + Enterprise, + FunderCommunity, + EncryptionKey, + Error, + Client, +} from '../models'; +import { + CommunityRepository, + EnterpriseRepository, + CollectivityRepository, + ClientScopeRepository, +} from '../repositories'; +import {FunderService} from '../services'; +import { + ResourceName, + StatusCode, + SECURITY_SPEC_KC_PASSWORD, + Roles, + AUTH_STRATEGY, + IFunder, + IFindCommunities, + SECURITY_SPEC_KC_CREDENTIALS, + SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD, +} from '../utils'; +import {ValidationError} from '../validationError'; +import {FunderInterceptor} from '../interceptors'; +import {canAccessHisOwnData} from '../services'; +import _ from 'lodash'; + +@authenticate(AUTH_STRATEGY.KEYCLOAK) +export class FunderController { + constructor( + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + @inject('services.FunderService') + public funderService: FunderService, + @repository(ClientScopeRepository) + private clientScopeRepository: ClientScopeRepository, + ) {} + + /** + * Get all funders + * @returns all funders + */ + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/funders', { + 'x-controller-name': 'Funders', + summary: 'Retourne les financeurs', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of funders', + content: { + 'application/json': { + schema: { + title: 'Funders', + type: 'array', + items: { + allOf: [ + { + anyOf: [{'x-ts-type': Enterprise}, {'x-ts-type': Collectivity}], + }, + { + type: 'object', + properties: { + funderType: { + type: 'string', + }, + }, + required: ['funderType'], + }, + ], + }, + }, + }, + }, + }, + }, + }) + find(): Promise { + return this.funderService.getFunders(); + } + + /** + * Get the number of communities + */ + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/funders/communities/count', { + 'x-controller-name': 'Funders', + summary: 'Retourne le nombre de communautés', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Community model count', + content: { + 'application/json': { + schema: {...CountSchema, ...{title: 'Count'}}, + }, + }, + }, + }, + }) + async count(@param.where(Community) where?: Where): Promise { + return this.communityRepository.count(where); + } + + /** + * Get all communities with associated funders + * @returns all communities with associated funders + */ + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/funders/communities', { + 'x-controller-name': 'Funders', + summary: 'Retourne les communautés et leurs financeurs associés', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of Community model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(FunderCommunity), + }, + }, + }, + }, + }, + }) + async findCommunities(): Promise { + const funders = await this.funderService.getFunders(); + + const community: Community[] = await this.communityRepository.find({ + fields: {name: true, id: true, funderId: true}, + }); + const allCommunities = + community && + community.map((elt: Community) => { + const funder = + funders && funders.find((elt1: IFunder) => elt.funderId === elt1.id); + + return { + ...elt, + funderType: capitalize(funder?.funderType), + funderName: funder?.name, + }; + }); + + return orderBy(allCommunities, ['funderName', 'funderType', 'name'], ['asc']); + } + + /** + * Post funder's public encryption key + * @returns funder's public encryption key + */ + @authorize({allowedRoles: [Roles.SIRH_BACKEND, Roles.VAULT_BACKEND]}) + @intercept(FunderInterceptor.BINDING_KEY) + @put('/v1/funders/{funderId}/encryption_key', { + 'x-controller-name': 'Funders', + summary: 'Enregistre les paramètres de clé de chiffrement', + security: SECURITY_SPEC_KC_CREDENTIALS, + responses: { + [StatusCode.NoContent]: { + description: `Les paramètres de clé de chiffrement ont bien été enregistrés`, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: + "L'utilisateur n'a pas les droits pour enregistrer les paramètres de clé de chiffrement", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.NotFound]: { + description: "Ce financeur n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Funder not found', + path: '/Funder', + }, + }, + }, + }, + }, + [StatusCode.UnprocessableEntity]: { + description: 'Les informations sur la clé de chiffrement ne sont pas valides', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 422, + name: 'Error', + message: `encryptionKey.error.privateKeyAccess.missing`, + path: '/EncryptionKey', + }, + }, + }, + }, + }, + }, + }) + async storeEncryptionKey( + @param.path.string('funderId', {description: `L'identifiant du financeur`}) + funderId: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(EncryptionKey), + }, + }, + }) + encryptionKey: EncryptionKey, + ): Promise { + const enterprise: Enterprise | null = await this.enterpriseRepository.findOne({ + where: {id: funderId}, + }); + const collectivity: Collectivity | null = await this.collectivityRepository.findOne({ + where: {id: funderId}, + }); + + if (enterprise) { + await this.enterpriseRepository.updateById(enterprise.id, {encryptionKey}); + } + + if (collectivity) { + await this.collectivityRepository.updateById(collectivity.id, { + encryptionKey, + }); + } + } + + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @post('/v1/funders/communities', { + 'x-controller-name': 'Funders', + summary: 'Crée une communauté pour un financeur', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Community model instance', + content: {'application/json': {schema: getModelSchemaRef(Community)}}, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Community), + }, + }, + }) + community: Omit, + ): Promise { + const {name, funderId} = community; + + const communitiesByFunderId = await this.communityRepository.find({ + where: {funderId, name}, + fields: {id: true}, + }); + if (communitiesByFunderId.length === 0) { + const enterprises = await this.enterpriseRepository.find({ + where: {id: funderId}, + fields: {id: true}, + }); + let collectivities = []; + + if (enterprises.length === 0) { + collectivities = await this.collectivityRepository.find({ + where: {id: funderId}, + fields: {id: true}, + }); + } + + if (collectivities.length > 0 || enterprises.length > 0) + return this.communityRepository.create(community); + + throw new ValidationError( + `communities.error.funders.missed`, + `/communities`, + StatusCode.UnprocessableEntity, + ResourceName.Community, + ); + } + throw new ValidationError( + `communities.error.name.unique`, + `/communities`, + StatusCode.UnprocessableEntity, + ResourceName.Community, + ); + } + + @authorize({allowedRoles: [Roles.CONTENT_EDITOR, Roles.PLATFORM]}) + @get('/v1/funders/{funderId}/communities', { + 'x-controller-name': 'Funders', + summary: "Retourne les communautés d'un financeur", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: `Réponse si les communautés d'au moins\ + un financeur sont trouvées.`, + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Community), + }, + }, + }, + }, + }, + }) + async findByFunderId( + @param.path.string('funderId', {description: `L'identifiant du financeur`}) + funderId: string, + ): Promise { + return this.communityRepository.findByFunderId(funderId); + } + + @authorize({voters: [canAccessHisOwnData]}) + @get('/v1/funders/{funderId}', { + 'x-controller-name': 'Funders', + summary: 'Retourne la clé privée du financeur', + security: SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: `Funder`, + content: { + 'application/json': { + schema: { + oneOf: [getModelSchemaRef(Community), getModelSchemaRef(Enterprise)], + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: 'The user is not logged in', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: 'The user does not have access rights', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + }, + }) + async findFunderById( + @param.path.string('funderId', {description: `L'identifiant du financeur`}) + funderId: string, + ): Promise { + let funder: Collectivity | Enterprise | undefined = undefined; + const collectivity = await this.collectivityRepository.findOne({ + where: {id: funderId}, + }); + const enterprise = await this.enterpriseRepository.findOne({ + where: {id: funderId}, + }); + funder = collectivity ? collectivity : enterprise ? enterprise : undefined; + if (!funder) { + throw new ValidationError( + `Funder not found`, + `/Funder`, + StatusCode.NotFound, + ResourceName.Funder, + ); + } + return funder; + } + + /** + * Get all clients with a specific scope + * @returns all clients + */ + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/funders/clients', { + 'x-controller-name': 'Funders', + summary: 'Retourne les financeurs', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of clients', + content: { + 'application/json': { + schema: { + title: 'clients', + type: 'array', + items: { + type: 'object', + properties: { + clientId: { + description: `Identifiant du client`, + type: 'string', + example: '', + }, + id: { + description: `Identifiant`, + type: 'string', + example: '', + }, + }, + }, + }, + }, + }, + }, + }, + }) + async findClients(): Promise { + const clients = await this.clientScopeRepository.getClients(); + return clients!; + } +} diff --git a/api/src/controllers/idfm_invoice.ts b/api/src/controllers/idfm_invoice.ts new file mode 100644 index 0000000..7ecbb2c --- /dev/null +++ b/api/src/controllers/idfm_invoice.ts @@ -0,0 +1,53 @@ +export const idfm_invoice = { + invoices: [ + { + enterprise: { + enterpriseName: 'IDF Mobilités', // Obligatoire + sirenNumber: '362521879', // Facultatif + siretNumber: '36252187900034', // Obligatoire + apeCode: '4711D', // Facultatif + enterpriseAddress: { + // Facultatif + zipCode: 75018, // Facultatif + city: 'Paris', // Facultatif + street: '6 rue Lepic', // Facultatif + }, + }, + customer: { + customerId: '123789', // Obligatoire - Vérifier par IDFM si cette information peut être diffusable + customerName: 'NABLI', // Obligatoire + customerSurname: 'Samy', // Obligatoire + customerAddress: { + // Facultatif + zipCode: 75018, // Facultatif + city: 'Paris', // Facultatif + street: '15 rue Veron', // Facultatif + }, + }, + transaction: { + orderId: '30723', // Obligatoire + purchaseDate: '2021-03-03T14:54:18+01:00', // Obligatoire + amountInclTaxes: 7520, // Obligatoire + amountExclTaxes: 7520, // Facultatif + }, + products: [ + { + productName: 'Forfait Navigo Mois', // Obligatoire + quantity: 1, // Obligatoire + amountInclTaxes: 7520, // Obligatoire + amountExclTaxes: 7520, // Facultatif + percentTaxes: 10, // Facultatif + productDetails: { + // Facultatif + periodicity: 'Mensuel', // Facultatif + zoneMin: 1, // Facultatif + zoneMax: 5, // Facultatif + validityStart: '2021-03-01T00:00:00+01:00', // Facultatif + validityEnd: '2021-03-31T00:00:00+01:00', // Facultatif + }, + }, + ], + }, + ], + totalElements: 1, +}; diff --git a/api/src/controllers/incentive.controller.ts b/api/src/controllers/incentive.controller.ts new file mode 100644 index 0000000..f29e0a6 --- /dev/null +++ b/api/src/controllers/incentive.controller.ts @@ -0,0 +1,770 @@ +import {inject, intercept, service} from '@loopback/core'; +import { + AnyObject, + Count, + CountSchema, + Filter, + PredicateComparison, + repository, + Where, +} from '@loopback/repository'; +import { + post, + param, + get, + getModelSchemaRef, + patch, + del, + requestBody, + Response, + RestBindings, +} from '@loopback/rest'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; +import {SecurityBindings} from '@loopback/security'; + +import {Incentive, Link, SpecificField, Error, Citizen, Territory} from '../models'; +import { + IncentiveRepository, + CollectivityRepository, + EnterpriseRepository, + CitizenRepository, + TerritoryRepository, +} from '../repositories'; +import { + IncentiveInterceptor, + AffiliationInterceptor, + AffiliationPublicInterceptor, +} from '../interceptors'; +import {FunderService, IncentiveService} from '../services'; +import {ValidationError} from '../validationError'; +import { + INCENTIVE_TYPE, + ResourceName, + Roles, + StatusCode, + SECURITY_SPEC_KC_PASSWORD, + AFFILIATION_STATUS, + SECURITY_SPEC_ALL, + HTTP_METHOD, + SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS, + SECURITY_SPEC_API_KEY, + GET_INCENTIVES_INFORMATION_MESSAGES, + AUTH_STRATEGY, + IScore, + IUpdateAt, + IUser, +} from '../utils'; +import {TAG_MAAS, WEBSITE_FQDN} from '../constants'; +import { + incentiveExample, + incentiveContentDifferentExample, +} from './utils/incentiveExample'; +import {TerritoryService} from '../services/territory.service'; + +@intercept(IncentiveInterceptor.BINDING_KEY) +export class IncentiveController { + constructor( + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @repository(CitizenRepository) + public citizenRepository: CitizenRepository, + @repository(TerritoryRepository) + public territoryRepository: TerritoryRepository, + @inject('services.IncentiveService') + public incentiveService: IncentiveService, + @service(FunderService) + public funderService: FunderService, + @service(TerritoryService) + public territoryService: TerritoryService, + @inject(SecurityBindings.USER, {optional: true}) + private currentUser?: IUser, + ) {} + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @post('/v1/incentives', { + 'x-controller-name': 'Incentives', + summary: 'Crée une aide', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Incentives model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Incentive), + }, + }, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Incentive), + }, + }, + }) + incentive: Incentive, + ): Promise { + let createdTerritory: Territory; + try { + /** + * Check if the territory ID is provided. + */ + if (incentive.territory.id) { + /** + * Check if the provided ID exists in the territoy collection. + */ + const territoryResult: Territory = await this.territoryRepository.findById( + incentive.territory.id, + ); + + /** + * Check if the name provided matches the territory name. + */ + if ( + territoryResult.name !== incentive.territory.name || + (incentive.territoryName && incentive?.territoryName !== territoryResult.name) // TODO: REMOVING DEPRECATED territoryName. + ) { + throw new ValidationError( + 'territory.name.mismatch', + '/territory', + StatusCode.UnprocessableEntity, + ResourceName.Territory, + ); + } + } else { + /** + * Create Territory + */ + createdTerritory = await this.territoryService.createTerritory({ + name: incentive.territory.name, + } as Territory); + + incentive.territory = createdTerritory; + } + + if (incentive.incentiveType === INCENTIVE_TYPE.TERRITORY_INCENTIVE) { + const collectivity = await this.collectivityRepository.find({ + where: {name: incentive.funderName}, + }); + if (collectivity.length > 0) { + incentive.funderId = collectivity[0].id; + } + } else if (incentive.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) { + const enterprise = await this.enterpriseRepository.find({ + where: {name: incentive.funderName}, + }); + if (enterprise.length > 0) { + incentive.funderId = enterprise[0].id; + } else { + throw new ValidationError( + `incentives.error.fundername.enterprise.notExist`, + '/enterpriseNotExist', + StatusCode.NotFound, + ResourceName.Enterprise, + ); + } + } + if (incentive.specificFields) { + incentive.jsonSchema = this.incentiveService.convertSpecificFields( + incentive.title, + incentive.specificFields, + ); + } + return await this.incentiveRepository.create(incentive); + } catch (error) { + if (createdTerritory!) { + await this.territoryRepository.deleteById(createdTerritory.id); + } + throw error; + } + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({ + allowedRoles: [Roles.MANAGERS, Roles.CONTENT_EDITOR, Roles.MAAS, Roles.MAAS_BACKEND], + }) + @get('/v1/incentives', { + 'x-controller-name': 'Incentives', + summary: 'Retourne les aides', + security: SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS, + tags: ['Incentives', TAG_MAAS], + responses: { + [StatusCode.Success]: { + description: 'La liste des aides', + content: { + 'application/json': { + schema: { + title: 'Incentive', + type: 'array', + items: { + title: 'Incentive', + allOf: [ + getModelSchemaRef(Incentive, { + exclude: ['specificFields', 'links'], + }), + { + type: 'object', + properties: { + specificFields: { + type: 'array', + items: getModelSchemaRef(SpecificField), + }, + }, + }, + ], + }, + example: incentiveExample, + }, + }, + }, + }, + [StatusCode.ContentDifferent]: { + description: 'La liste des aides nationales et du territoire concerné', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + response: { + type: 'array', + items: { + title: 'Incentive', + allOf: [ + getModelSchemaRef(Incentive, { + exclude: [ + 'attachments', + 'additionalInfos', + 'contact', + 'jsonSchema', + 'subscriptionLink', + 'specificFields', + 'links', + ], + }), + { + type: 'object', + properties: { + specificFields: { + type: 'array', + items: getModelSchemaRef(SpecificField), + }, + }, + }, + ], + }, + }, + message: { + type: 'string', + enum: Object.values(GET_INCENTIVES_INFORMATION_MESSAGES), + example: + GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_AFFILIATED_WITHOUT_INCENTIVES, + }, + }, + example: incentiveContentDifferentExample, + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: + "L'utilisateur n'a pas les droits pour accéder au catalogue des aides", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + }, + }) + async find( + @inject(RestBindings.Http.RESPONSE) resp: Response, + ): Promise> { + const {id, roles} = this.currentUser!; + if (roles && roles.includes(Roles.CONTENT_EDITOR)) { + return this.incentiveRepository.find({ + order: ['updatedAt DESC'], + }); + } + + if (roles && roles.includes(Roles.MANAGERS)) { + const withParams: AnyObject[] = [ + {funderName: this.currentUser!.funderName}, + {incentiveType: this.currentUser!.incentiveType}, + ]; + return this.incentiveRepository.find({ + where: { + and: withParams, + }, + fields: { + id: true, + title: true, + }, + }); + } + + const commonFilter: Filter = { + order: ['updatedAt DESC'], + fields: { + id: true, + funderId: true, + title: true, + incentiveType: true, + conditions: true, + paymentMethod: true, + allocatedAmount: true, + funderName: true, + minAmount: true, + transportList: true, + validityDate: true, + createdAt: true, + updatedAt: true, + description: true, + isMCMStaff: true, + territory: true, + }, + }; + const incentiveList: Incentive[] = await this.incentiveRepository.find({ + where: { + or: [ + { + incentiveType: + INCENTIVE_TYPE.TERRITORY_INCENTIVE as PredicateComparison, + }, + { + incentiveType: + INCENTIVE_TYPE.NATIONAL_INCENTIVE as PredicateComparison, + }, + ], + }, + ...commonFilter, + }); + + if (roles && roles.includes(Roles.MAAS_BACKEND)) { + return incentiveList; + } + + if (roles && roles.includes(Roles.MAAS)) { + const citizen: Citizen | null = await this.citizenRepository.findOne({where: {id}}); + const citizenFunderId: string | null | undefined = + citizen?.affiliation?.enterpriseId; + const citizenStatus: string | null | undefined = + citizen?.affiliation?.affiliationStatus; + + if (citizenFunderId && citizenStatus === AFFILIATION_STATUS.AFFILIATED) { + const incentiveEnterpriseList: Incentive[] = await this.incentiveRepository.find({ + where: {funderId: citizenFunderId}, + ...commonFilter, + }); + + if (incentiveEnterpriseList.length === 0) + return resp.status(210).send({ + response: incentiveList, + message: + GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_AFFILIATED_WITHOUT_INCENTIVES, + }); + return [...incentiveList, ...incentiveEnterpriseList]; + } + + if ( + citizenStatus === + (AFFILIATION_STATUS.TO_AFFILIATE || AFFILIATION_STATUS.DISAFFILIATED) + ) { + return resp.status(210).send({ + response: incentiveList, + message: GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_NOT_AFFILIATED, + }); + } + return incentiveList; + } + return []; + } + @authenticate(AUTH_STRATEGY.API_KEY) + @authorize({allowedRoles: [Roles.API_KEY]}) + @get('/v1/incentives/search', { + 'x-controller-name': 'Incentives', + summary: 'Recherche les aides correspondantes', + security: SECURITY_SPEC_API_KEY, + responses: { + [StatusCode.Success]: { + description: 'Array of Incentive model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Incentive), + }, + }, + }, + }, + }, + }) + async search( + @param.query.string('_q') textSearch?: string, + @param.query.string('incentiveType') incentiveType?: string, + @param.query.string('enterpriseId') enterpriseId?: string, + ): Promise { + const sort: IScore | IUpdateAt = textSearch + ? {score: {$meta: 'textScore'}} + : {updatedAt: -1}; + const match: any = textSearch ? {$text: {$search: textSearch, $language: 'fr'}} : {}; + + if (incentiveType) { + match['$or'] = []; + const incentiveTypeList = incentiveType.split(','); + for (const row of incentiveTypeList) { + match['$or'].push({incentiveType: row}); + } + } + + if (enterpriseId) { + match['$or'].push({ + incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE, + funderId: enterpriseId, + }); + } + + return this.incentiveRepository + .execute('Incentive', 'aggregate', [ + {$match: match}, + {$sort: sort}, + {$addFields: {id: '$_id'}}, + {$project: {_id: 0}}, + ]) + .then(res => res.get()) + .catch(err => err); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/incentives/count', { + 'x-controller-name': 'Incentives', + summary: "Retourne le nombre d'aides", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Incentives model count', + content: { + 'application/json': { + schema: {...CountSchema, ...{title: 'Count'}}, + }, + }, + }, + }, + }) + async count(@param.where(Incentive) where?: Where): Promise { + return this.incentiveRepository.count(where); + } + + /** + * get incentive by id + * @param incentiveId the incentive id + * @returns incentive object details + */ + @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY) + @authorize({ + allowedRoles: [ + Roles.API_KEY, + Roles.CONTENT_EDITOR, + Roles.MAAS, + Roles.MAAS_BACKEND, + Roles.PLATFORM, + ], + }) + @intercept(AffiliationPublicInterceptor.BINDING_KEY) + @intercept(AffiliationInterceptor.BINDING_KEY) + @get('/v1/incentives/{incentiveId}', { + 'x-controller-name': 'Incentives', + summary: "Retourne le détail d'une aide", + security: SECURITY_SPEC_ALL, + tags: ['Incentives', TAG_MAAS], + responses: { + [StatusCode.Success]: { + description: "Le détail de l'aide", + content: { + 'application/json': { + schema: getModelSchemaRef(Incentive), + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: + "L'utilisateur n'a pas les droits pour accéder au détail de cette aide", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.NotFound]: { + description: "Cette aide n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Incentive not found', + path: '/incentiveNotFound', + resourceName: 'Incentive', + }, + }, + }, + }, + }, + }, + }) + async findIncentiveById( + @param.path.string('incentiveId', {description: `L'identifiant de l'aide`}) + incentiveId: string, + ): Promise { + const incentive: Incentive = await this.incentiveRepository.findById(incentiveId); + + if (incentive?.isMCMStaff) { + const links: Link[] = [ + new Link({ + href: `${WEBSITE_FQDN}/subscriptions/new?incentiveId=${incentive.id}`, + rel: 'subscribe', + method: HTTP_METHOD.GET, + }), + ]; + incentive.links = links; + } + + return incentive; + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @patch('/v1/incentives/{incentiveId}', { + 'x-controller-name': 'Incentives', + summary: 'Modifie une aide', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'Incentives put success', + }, + [StatusCode.NotFound]: { + description: "Cette aide n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Incentive not found', + path: '/incentiveNotFound', + resourceName: 'Incentive', + }, + }, + }, + }, + }, + }, + }) + async updateById( + @param.path.string('incentiveId', {description: `L'identifiant de l'aide`}) + incentiveId: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Incentive, { + title: 'IncentiveUpdate', + partial: true, + }), + }, + }, + }) + incentive: Incentive, + ): Promise { + // Remove contact from incentive object + if (!incentive.contact) { + delete incentive.contact; + await this.incentiveRepository.updateById(incentiveId, { + $unset: {contact: ''}, + } as any); + } + // Remove validityDuration from incentive object + if (!incentive.validityDuration) { + delete incentive.validityDuration; + await this.incentiveRepository.updateById(incentiveId, { + $unset: {validityDuration: ''}, + } as any); + } + // Remove additionalInfos from incentive object + if (!incentive.additionalInfos) { + delete incentive.additionalInfos; + await this.incentiveRepository.updateById(incentiveId, { + $unset: {additionalInfos: ''}, + } as any); + } + // Remove validityDate from incentive object + if (!incentive.validityDate) { + delete incentive.validityDate; + await this.incentiveRepository.updateById(incentiveId, { + $unset: {validityDate: ''}, + } as any); + } + + if (incentive.territory) { + if (!incentive.territory.id) { + throw new ValidationError( + 'territory.id.undefined', + '/territory', + StatusCode.PreconditionFailed, + ResourceName.Territory, + ); + } + /** + * Check if the provided ID exists in the territoy collection. + */ + const territoryResult: Territory = await this.territoryRepository.findById( + incentive.territory.id, + ); + + /** + * Check if the name provided matches the territory name. + */ + if ( + territoryResult.name !== incentive.territory.name || + (incentive.territoryName && incentive?.territoryName !== territoryResult.name) // TODO: REMOVING DEPRECATED territoryName. + ) { + throw new ValidationError( + 'territory.name.mismatch', + '/territory', + StatusCode.UnprocessableEntity, + ResourceName.Territory, + ); + } + } + + // Add new specificFields and unset subscription link + if ( + incentive.isMCMStaff && + incentive.specificFields && + incentive.specificFields.length + ) { + // Add json schema from specific fields + incentive.jsonSchema = this.incentiveService.convertSpecificFields( + incentive.title, + incentive.specificFields, + ); + // Remove subscription from incentive object + delete incentive.subscriptionLink; + // Unset subscription + await this.incentiveRepository.updateById(incentiveId, { + $unset: {subscriptionLink: ''}, + } as any); + } + + // Add subscription link and unset specificFields and jsonSchema + if ( + (!incentive.isMCMStaff && incentive.subscriptionLink) || + (incentive?.specificFields && !incentive.specificFields.length) + ) { + // Remove specific field && jsonSchema from incentive object + delete incentive.specificFields; + delete incentive.jsonSchema; + // Unset specificFields && jsonSchema + await this.incentiveRepository.updateById(incentiveId, { + $unset: {specificFields: '', jsonSchema: ''}, + } as any); + } + await this.incentiveRepository.updateById(incentiveId, incentive); + incentive.id = incentiveId; + return incentive; + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @del('/v1/incentives/{incentiveId}', { + 'x-controller-name': 'Incentives', + summary: 'Supprime une aide', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'Incentives DELETE success', + }, + [StatusCode.NotFound]: { + description: "Cette aide n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Incentive not found', + path: '/incentiveNotFound', + resourceName: 'Incentive', + }, + }, + }, + }, + }, + }, + }) + async deleteById( + @param.path.string('incentiveId', {description: `L'identifiant de l'aide`}) + incentiveId: string, + ): Promise { + await this.incentiveRepository.deleteById(incentiveId); + } +} diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts new file mode 100644 index 0000000..4a6cfe2 --- /dev/null +++ b/api/src/controllers/index.ts @@ -0,0 +1,10 @@ +export * from './incentive.controller'; +export * from './citizen.controller'; +export * from './contact.controller'; +export * from './collectivity.controller'; +export * from './enterprise.controller'; +export * from './subscription.controller'; +export * from './funder.controller'; +export * from './dashboard.controller'; +export * from './user.controller'; +export * from './territory.controller'; diff --git a/api/src/controllers/subscription.controller.ts b/api/src/controllers/subscription.controller.ts new file mode 100644 index 0000000..2ff29da --- /dev/null +++ b/api/src/controllers/subscription.controller.ts @@ -0,0 +1,917 @@ +import {AnyObject, repository, Count} from '@loopback/repository'; +import {inject, service, intercept} from '@loopback/core'; +import { + param, + get, + getModelSchemaRef, + put, + requestBody, + Response, + RestBindings, + post, + HttpErrors, +} from '@loopback/rest'; +import {authorize} from '@loopback/authorization'; +import {authenticate} from '@loopback/authentication'; +import {SecurityBindings} from '@loopback/security'; + +import {isEqual, intersection} from 'lodash'; + +import {AttachmentMetadata, Error, Invoice, Metadata, Subscription} from '../models'; +import { + CommunityRepository, + SubscriptionRepository, + UserRepository, + MetadataRepository, + CitizenRepository, +} from '../repositories'; +import { + SubscriptionService, + FunderService, + S3Service, + checkMaas, + MailService, +} from '../services'; +import {validationErrorExternalHandler} from '../validationErrorExternal'; +import {ValidationError} from '../validationError'; +import { + ResourceName, + StatusCode, + SECURITY_SPEC_KC_PASSWORD, + SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD, + FUNDER_TYPE, + INCENTIVE_TYPE, + Roles, + SUBSCRIPTION_STATUS, + AUTH_STRATEGY, + SECURITY_SPEC_JWT, + IUser, +} from '../utils'; +import { + AffiliationInterceptor, + SubscriptionInterceptor, + SubscriptionMetadataInterceptor, +} from '../interceptors'; +import {getInvoiceFilename} from '../utils/invoice'; +import {TAG_MAAS, WEBSITE_FQDN} from '../constants'; +import {endOfYear, startOfYear} from 'date-fns'; +import { + ValidationMultiplePayment, + SubscriptionValidation, + ValidationSinglePayment, + ValidationNoPayment, + NoReason, + OtherReason, + SubscriptionRejection, +} from '../models'; + +/** + * set the list pagination value + */ +const PAGINATION_LIMIT = 200; + +interface SubscriptionsWithCount { + subscriptions: Subscription[]; + count: number; +} + +@authenticate(AUTH_STRATEGY.KEYCLOAK) +export class SubscriptionController { + constructor( + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @service(S3Service) + private s3Service: S3Service, + @service(SubscriptionService) + private subscriptionService: SubscriptionService, + @service(FunderService) + private funderService: FunderService, + @service(CommunityRepository) + private communityRepository: CommunityRepository, + @service(MetadataRepository) + private metadataRepository: MetadataRepository, + @service(UserRepository) + private userRepository: UserRepository, + @service(CitizenRepository) + private citizenRepository: CitizenRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + @inject(RestBindings.Http.RESPONSE) private response: Response, + @inject('services.MailService') + public mailService: MailService, + ) {} + + /** + * get the subscriptions list + * @param status that subscription status (VALIDATED or REJECTED) + * @param incentiveId the incentive id + * @param lastName the last name related to the subscription + * @param citizenId the citizen id + * @returns subscription list + */ + @authorize({allowedRoles: [Roles.MANAGERS, Roles.CITIZENS]}) + @get('/v1/subscriptions', { + 'x-controller-name': 'Subscriptions', + summary: 'Retourne les souscriptions', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of Subscriptions model instances', + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'array', + items: getModelSchemaRef(Subscription), + }, + { + type: 'object', + properties: { + subscriptions: getModelSchemaRef(Subscription), + count: { + type: 'number', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }) + async find( + @param.query.string('status') status: string, + @param.query.string('incentiveId') incentiveId?: string, + @param.query.string('incentiveType') incentiveType?: string, + @param.query.string('idCommunities') idCommunities?: string, + @param.query.string('lastName') lastName?: string, + @param.query.string('citizenId') citizenId?: string, + @param.query.string('year') year?: string, + @param.query.string('skip') skip?: number | undefined, + ): Promise { + const withParams: AnyObject[] = [ + { + funderName: this.currentUser.funderName!, + incentiveType: this.currentUser.incentiveType!, + }, + ]; + + const userId = this.currentUser.id; + + let communityIds: '' | string[] | null | undefined = null; + + communityIds = + userId && (await this.userRepository.findOne({where: {id: userId}}))?.communityIds; + + if (communityIds && communityIds?.length > 0) { + withParams.push({communityId: {inq: communityIds}}); + } + + if (idCommunities) { + const match: AnyObject[] = []; + const idCommunitiesList = idCommunities.split(','); + const mapping: AnyObject = {}; + mapping[INCENTIVE_TYPE['TERRITORY_INCENTIVE']] = FUNDER_TYPE.collectivity; + mapping[INCENTIVE_TYPE['EMPLOYER_INCENTIVE']] = FUNDER_TYPE.enterprise; + + const funders = await this.funderService.getFunderByName( + this.currentUser.funderName!, + mapping[this.currentUser.incentiveType!], + ); + + if (funders?.id && idCommunitiesList && idCommunitiesList.length > 0) { + const communities = await this.communityRepository.findByFunderId(funders.id); + + if ( + communities && + communities.length > 0 && + isEqual( + intersection( + communities.map(({id}) => id), + idCommunitiesList, + ).sort(), + idCommunitiesList.sort(), + ) + ) { + for (const row of idCommunitiesList) { + match.push({communityId: row}); + } + withParams.push({or: match}); + } + } + if (match.length === 0) + throw new ValidationError( + `subscriptions.error.communities.mismatch`, + `/subscriptions`, + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } + + if (incentiveId) { + const match: AnyObject[] = []; + const incentiveIdList = incentiveId.split(','); + for (const row of incentiveIdList) { + match.push({incentiveId: row}); + } + withParams.push({or: match}); + } + + if (incentiveType) { + const match: AnyObject[] = []; + const incentiveTypeList = incentiveType.split(','); + for (const row of incentiveTypeList) { + match.push({incentiveType: row}); + } + withParams.push({or: match}); + } + + if (lastName) { + withParams.push({lastName: lastName}); + } + + if (citizenId) { + withParams.push({citizenId: citizenId}); + } + + if (year) { + const yearMatch: AnyObject[] = []; + const yearList = year.split(','); + for (const yearItem of yearList) { + const match: AnyObject[] = []; + match.push({ + updatedAt: { + gte: startOfYear(new Date(parseInt(yearItem), 0)), + }, + }); + match.push({ + updatedAt: { + lte: endOfYear(new Date(parseInt(yearItem), 0)), + }, + }); + yearMatch.push({and: match}); + } + withParams.push({or: yearMatch}); + } + + if (status) { + const match: AnyObject[] = []; + const statusList = status.split(','); + if (status.includes(SUBSCRIPTION_STATUS.DRAFT)) { + // ticket 1827 should not return subscription with statut BROUILLON + return { + subscriptions: [], + count: 0, + }; + } + for (const row of statusList) { + match.push({status: row}); + } + withParams.push({or: match}); + } else { + withParams.push({status: {neq: SUBSCRIPTION_STATUS.DRAFT}}); + } + + if (citizenId) { + // get citizen's subscription + const subscriptionsResponse = await this.subscriptionRepository.find({ + limit: 10, + skip, + order: ['createdAt DESC'], + where: { + and: withParams, + }, + }); + + // params to get count of citizen's subscription + const queryParams: object = { + funderName: this.currentUser.funderName!, + incentiveType: this.currentUser.incentiveType!, + citizenId: citizenId, + communityId: communityIds ? {inq: communityIds} : undefined, + status: {neq: SUBSCRIPTION_STATUS.DRAFT}, + updatedAt: year + ? { + gte: startOfYear(new Date(parseInt(year), 0)), + lte: endOfYear(new Date(parseInt(year), 0)), + } + : undefined, + }; + const subscriptionCount: Count = await this.subscriptionRepository.count( + queryParams, + ); + + return { + subscriptions: subscriptionsResponse, + ...subscriptionCount, + }; + } else { + return this.subscriptionRepository.find({ + limit: PAGINATION_LIMIT, + where: { + and: withParams, + }, + }); + } + } + + /** + * download the validated demands + * @param funderName is the funder name as collectivity or enterprise + * @param funderType is the funder type or nature + * @param Response the validated subscriptions from founder name and funder type + */ + @authorize({allowedRoles: [Roles.MANAGERS]}) + @get('/v1/subscriptions/export', { + 'x-controller-name': 'Subscriptions', + summary: 'Exporte les souscriptions validées du financeur connecté', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Downloadable .xlsx file with validated incentive list', + content: { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + schema: {type: 'string', format: 'base64'}, + }, + }, + }, + }, + }) + async generateExcel( + @inject(RestBindings.Http.RESPONSE) Response: Response, + ): Promise { + const withParams: AnyObject[] = [ + {funderName: this.currentUser.funderName}, + {incentiveType: this.currentUser.incentiveType}, + {status: SUBSCRIPTION_STATUS.VALIDATED}, + ]; + + const userId = this.currentUser.id; + + let communityIds: '' | string[] | null | undefined = null; + + communityIds = + userId && (await this.userRepository.findOne({where: {id: userId}}))?.communityIds; + if (communityIds && communityIds?.length > 0) { + withParams.push({communityId: {inq: communityIds}}); + } + + const subscriptionList = await this.subscriptionRepository.find({ + order: ['updatedAT ASC'], + where: { + and: withParams, + }, + }); + if (subscriptionList && subscriptionList.length > 0) { + const buffer = await this.subscriptionService.generateExcelValidatedIncentives( + subscriptionList, + ); + Response.status(200) + .contentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + .send(buffer); + } else if (subscriptionList && subscriptionList.length === 0) { + throw new ValidationError( + 'Aucune demande validée à télécharger', + '/downloadXlsx', + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } else { + throw new ValidationError( + 'Le téléchargement a échoué, veuillez réessayer', + '/downloadXlsx', + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } + } + + /** + * download file by id + * @param subscriptionId the requested subscription id + * @param filename the given name of the downloaded file + * @returns the response object for file download + */ + @authorize({allowedRoles: [Roles.MANAGERS, Roles.SIRH_BACKEND]}) + @intercept(SubscriptionInterceptor.BINDING_KEY) + @get('/v1/subscriptions/{subscriptionId}/attachments/{filename}', { + 'x-controller-name': 'Subscriptions', + summary: "Récupère le justificatif d'une souscription", + security: SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Le fichier du justificatif associé à la demande', + content: { + 'application/octet-stream': { + schema: { + type: 'string', + format: 'binary', + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: "L'utilisateur n'a pas les droits pour accéder au fichier", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.NotFound]: { + description: "La demande ou le fichier n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Not Found', + path: '/subscription', + }, + }, + }, + }, + }, + }, + }) + async getSubscriptionFileByName( + @param.path.string('subscriptionId', {description: `L'identifiant de la demande`}) + subscriptionId: string, + @param.path.string('filename', { + description: `Le nom de fichier du justificatif à récupérer`, + }) + filename: string, + ): Promise { + try { + const subscription = await this.subscriptionRepository.findById(subscriptionId); + const downloadBucket = await this.s3Service.downloadFileBuffer( + subscription.citizenId, + subscriptionId, + filename, + ); + this.response + .status(200) + .contentType('application/octet-stream') + .send(downloadBucket); + return downloadBucket; + } catch (error) { + return validationErrorExternalHandler(error); + } + } + + /** + * get the subscription by id + * @param subscriptionId the subscription id + * @param filter the subscriptions search filter + * @returns the subscriptions objects + */ + @authorize({allowedRoles: [Roles.MANAGERS]}) + @intercept(SubscriptionInterceptor.BINDING_KEY) + @get('/v1/subscriptions/{subscriptionId}', { + 'x-controller-name': 'Subscriptions', + summary: "Retourne le détail d'une souscription", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Subscriptions model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Subscription), + }, + }, + }, + [StatusCode.NotFound]: { + description: "Cette demande n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Subscription not found', + path: '/subscriptionNotFound', + resourceName: 'Subscription', + }, + }, + }, + }, + }, + }, + }) + async findById( + @param.path.string('subscriptionId', {description: `L'identifiant de la demande`}) + subscriptionId: string, + ): Promise { + return this.subscriptionRepository.findById(subscriptionId); + } + + /** + * subscriptions validation by subscription id + * @param subscriptionId the subscription id + */ + @authorize({allowedRoles: [Roles.MANAGERS]}) + @intercept(SubscriptionInterceptor.BINDING_KEY) + @put('/v1/subscriptions/{subscriptionId}/validate', { + 'x-controller-name': 'Subscriptions', + summary: 'Valide une souscription', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'La demande est validée', + }, + [StatusCode.NotFound]: { + description: "Cette demande n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Subscription not found', + path: '/subscriptionNotFound', + resourceName: 'Subscription', + }, + }, + }, + }, + }, + [StatusCode.PreconditionFailed]: { + description: "La demande n'est pas au bon status", + }, + [StatusCode.UnprocessableEntity]: { + description: "La demande n'est pas au bon format", + }, + }, + }) + async validate( + @param.path.string('subscriptionId', {description: `L'identifiant de la demande`}) + subscriptionId: string, + @requestBody({ + content: { + 'application/json': { + schema: { + anyOf: [ + getModelSchemaRef(ValidationMultiplePayment), + getModelSchemaRef(ValidationSinglePayment), + getModelSchemaRef(ValidationNoPayment), + ], + }, + }, + }, + }) + payment: SubscriptionValidation, + ): Promise { + // Vérification de l'existence de la subscription + const subscription = await this.subscriptionRepository.findById(subscriptionId); + if (subscription.status === SUBSCRIPTION_STATUS.TO_PROCESS) { + const result = this.subscriptionService.checkPayment(payment); + await this.subscriptionService.validateSubscription(result, subscription); + } else { + throw new ValidationError( + 'subscriptions.error.bad.status', + '/subscriptionBadStatus', + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + } + + /** + * Subscriptions rejection by subscription id + * @param subscriptionId the subscription id + * @param reason the property to update + */ + @authorize({allowedRoles: [Roles.MANAGERS]}) + @intercept(SubscriptionInterceptor.BINDING_KEY) + @put('/v1/subscriptions/{subscriptionId}/reject', { + 'x-controller-name': 'Subscriptions', + summary: 'Rejette une souscription', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'La demande est rejetée', + }, + [StatusCode.NotFound]: { + description: "Cette demande n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Subscription not found', + path: '/subscriptionNotFound', + resourceName: 'Subscription', + }, + }, + }, + }, + }, + [StatusCode.PreconditionFailed]: { + description: "La demande n'est pas au bon status", + }, + [StatusCode.UnprocessableEntity]: { + description: "La demande n'est pas au bon format", + }, + }, + }) + async reject( + @param.path.string('subscriptionId', {description: `L'identifiant de la demande`}) + subscriptionId: string, + @requestBody({ + content: { + 'application/json': { + schema: { + oneOf: [getModelSchemaRef(NoReason), getModelSchemaRef(OtherReason)], + }, + }, + }, + }) + reason: SubscriptionRejection, + ): Promise { + // Vérification de l'existence de la subscription + const subscription = await this.subscriptionRepository.findById(subscriptionId); + if (subscription.status === SUBSCRIPTION_STATUS.TO_PROCESS) { + const result = this.subscriptionService.checkRefusMotif(reason); + await this.subscriptionService.rejectSubscription(result, subscription); + } else { + throw new ValidationError( + 'subscriptions.error.bad.status', + '/subscriptionBadStatus', + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + } + + @authorize({allowedRoles: [Roles.PLATFORM]}) + @intercept(SubscriptionMetadataInterceptor.BINDING_KEY) + @get('v1/subscriptions/metadata/{metadataId}', { + 'x-controller-name': 'Subscriptions', + summary: "Récupère les métadonnées d'une souscription", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: "Métadonnées d'un utilisateur", + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + incentiveId: { + description: `Identifiant de l'aide`, + type: 'string', + example: '', + }, + citizenId: { + description: `Identifiant du citoyen`, + type: 'string', + example: '', + }, + attachmentMetadata: { + type: 'array', + items: { + type: 'object', + properties: { + fileName: { + description: `Nom du fichier de la preuve d'achat`, + type: 'string', + example: '03-03-2021_Forfait_Navigo_Mois_Bob_RASOVSKY.pdf', + }, + }, + }, + }, + }, + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + }, + [StatusCode.Forbidden]: { + description: "L'utilisateur n'a pas les droits pour récupérer les métadonnées", + }, + [StatusCode.NotFound]: { + description: "Ces métadonnées n'existent pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Metadata not found', + path: '/metadataNotFound', + resourceName: 'Metadata', + }, + }, + }, + }, + }, + }, + }) + async getMetadata( + @param.path.string('metadataId', { + description: `L'identifiant des métadonnées à envoyer \ + lors de la souscription d'une aide pour un citoyen`, + }) + metadataId: string, + ): Promise< + | { + incentiveId: string; + citizenId: string; + attachmentMetadata: {fileName: string}[]; + } + | HttpErrors.HttpError + > { + try { + const metadata: Metadata = await this.metadataRepository.findById(metadataId); + + // Get fileName for each invoices + const fileNameList = metadata.attachmentMetadata.invoices.map( + (invoice: Invoice) => { + return {fileName: getInvoiceFilename(invoice)}; + }, + ); + + return { + incentiveId: metadata.incentiveId, + citizenId: metadata.citizenId, + attachmentMetadata: fileNameList, + }; + } catch (error) { + return validationErrorExternalHandler(error); + } + } + + @authorize({allowedRoles: [Roles.MAAS], voters: [checkMaas]}) + @intercept(AffiliationInterceptor.BINDING_KEY) + @intercept(SubscriptionMetadataInterceptor.BINDING_KEY) + @post('v1/subscriptions/metadata', { + 'x-controller-name': 'Subscriptions', + summary: "Crée des métadonnées de justificatifs d'achat liées à une souscription", + security: SECURITY_SPEC_JWT, + tags: ['Subscriptions', TAG_MAAS], + responses: { + [StatusCode.Created]: { + description: 'Les métadonnées sont enregistrées', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + subscriptionURL: { + description: `URL de redirection pour initier la souscription à l'aide`, + type: 'string', + example: + 'https://website.${env}.moncomptemobilite.fr/subscriptions/new?incentiveId=6d0efef1-4dc9-422e-a17d-40c1bf1c37c4&metadataId=6d0efef1-4dc9-422e-a17d-40c1bf1c37c4', + }, + metadataId: { + description: `Identifiant des metadonnées`, + type: 'string', + example: '6d0efef1-4dc9-422e-a17d-40c1bf1c37c4', + }, + }, + }, + }, + }, + }, + [StatusCode.Unauthorized]: { + description: "L'utilisateur est non connecté", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 401, + name: 'Error', + message: 'Authorization header not found', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: + "L'utilisateur n'a pas les droits pour envoyer les métadonnées pour cette aide", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + path: '/authorization', + }, + }, + }, + }, + }, + [StatusCode.UnprocessableEntity]: { + description: "Les métadonnées ne sont pas conformes au contrat d'interface", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 422, + name: 'UnprocessableEntityError', + message: + 'The request body is invalid. See error object `details` property for more info.', + code: 'VALIDATION_FAILED', + details: [ + { + path: '/attachmentMetadata/invoices/0/enterprise', + code: 'required', + message: "should have required property 'enterpriseName'", + info: { + missingProperty: 'enterpriseName', + }, + }, + ], + }, + }, + }, + }, + }, + }, + }) + async createMetadata( + @requestBody({ + content: { + 'application/json': { + schema: { + title: 'MetadataPost', + allOf: [ + { + type: 'object', + properties: { + incentiveId: { + type: 'string', + example: '', + }, + }, + required: ['incentiveId'], + }, + { + type: 'object', + properties: { + attachmentMetadata: {'x-ts-type': AttachmentMetadata}, + }, + required: ['attachmentMetadata'], + }, + ], + }, + }, + }, + }) + metadata: Omit, + ): Promise< + Response<{subscriptionURL: string; metadataId: string}> | HttpErrors.HttpError + > { + try { + const result = await this.metadataRepository.create(metadata); + return this.response.status(201).send({ + // eslint-disable-next-line + subscriptionURL: `${WEBSITE_FQDN}/subscriptions/new?incentiveId=${result.incentiveId}&metadataId=${result.id}`, + metadataId: result.id, + }); + } catch (error) { + return validationErrorExternalHandler(error); + } + } +} diff --git a/api/src/controllers/territory.controller.ts b/api/src/controllers/territory.controller.ts new file mode 100644 index 0000000..1950da5 --- /dev/null +++ b/api/src/controllers/territory.controller.ts @@ -0,0 +1,301 @@ +import { + AnyObject, + Count, + CountSchema, + Filter, + repository, + Where, +} from '@loopback/repository'; +import {service} from '@loopback/core'; +import {post, param, get, getModelSchemaRef, patch, requestBody} from '@loopback/rest'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; +import {Incentive, Territory} from '../models'; +import {IncentiveRepository, TerritoryRepository} from '../repositories'; +import { + AUTH_STRATEGY, + SECURITY_SPEC_KC_PASSWORD, + StatusCode, + Roles, + ResourceName, + SECURITY_SPEC_API_KEY, +} from '../utils'; +import {ValidationError} from '../validationError'; +import {TerritoryService} from '../services/territory.service'; +import {removeWhiteSpace} from './utils/helpers'; + +const ObjectId = require('mongodb').ObjectId; + +export class TerritoryController { + constructor( + @repository(TerritoryRepository) + public territoryRepository: TerritoryRepository, + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @service(TerritoryService) + public territoryService: TerritoryService, + ) {} + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @post('/v1/territories', { + 'x-controller-name': 'Territories', + summary: 'Crée un territoire', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Le territoire est créé', + content: {}, + }, + [StatusCode.Forbidden]: { + description: "L'utilisateur n'a pas les droits pour gérer les territoires", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + }, + }, + }, + }, + }, + [StatusCode.UnprocessableEntity]: { + description: 'Le nom de territoire que vous avez fourni existe déjà', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 422, + name: 'Error', + message: 'territory.name.error.unique', + path: '/territoryName', + resourceName: 'Territoire', + }, + }, + }, + }, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Territory, { + title: 'TerritoryCreation', + exclude: ['id'], + }), + }, + }, + }) + territory: Omit, + ): Promise { + return this.territoryService.createTerritory(territory); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY) + @authorize({ + allowedRoles: [ + Roles.CONTENT_EDITOR, + Roles.API_KEY, + Roles.CITIZENS, + Roles.CITIZENS_FC, + ], + }) + @get('/v1/territories', { + 'x-controller-name': 'Territories', + summary: 'Retourne la liste des territoires', + security: SECURITY_SPEC_API_KEY, + responses: { + [StatusCode.Success]: { + description: 'La liste des territoires est retournée', + }, + }, + }) + async find(@param.filter(Territory) filter?: Filter): Promise { + return this.territoryRepository.find(filter); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/territories/count', { + 'x-controller-name': 'Territories', + summary: 'Récupère le nombre de territoires', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Le nombre de territoires est retourné', + content: { + 'application/json': { + schema: {...CountSchema, ...{title: 'Count'}}, + }, + }, + }, + }, + }) + async count(@param.where(Territory) where?: Where): Promise { + return this.territoryRepository.count(where); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @patch('/v1/territories/{id}', { + 'x-controller-name': 'Territories', + summary: 'Modifie un territoire', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.NoContent]: { + description: 'La modification est faite', + }, + [StatusCode.NotFound]: { + description: "Le territoire que vous voulez modifier n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Entity not found: Territory', + code: 'ENTITY_NOT_FOUND', + }, + }, + }, + }, + }, + [StatusCode.Forbidden]: { + description: "L'utilisateur n'a pas les droits pour gérer les territoires", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 403, + name: 'Error', + message: 'Access denied', + }, + }, + }, + }, + }, + [StatusCode.UnprocessableEntity]: { + description: 'Le nom de territoire que vous avez fourni existe déjà', + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 422, + name: 'Error', + message: 'territory.name.error.unique', + path: '/territoryName', + resourceName: 'Territoire', + }, + }, + }, + }, + }, + }, + }) + async updateById( + @param.path.string('id') id: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Territory, { + title: 'TerritoryUpdate', + exclude: ['id'], + partial: true, + }), + }, + }, + }) + newTerritory: Omit, + ): Promise { + /** + * Removing white spaces. + * Exemple : " Mulhouse aglo " returns "Mulhouse aglo". + */ + newTerritory.name = removeWhiteSpace(newTerritory.name); + + /** + * Perform a case-insensitive search excluding the territory that will be updated. + */ + const result: Territory | null = await this.territoryRepository.findOne({ + where: {id: {neq: new ObjectId(id)}, name: {regexp: `/^${newTerritory.name}$/i`}}, + }); + + /** + * Throw an error if the territory name is duplicated. + */ + if (result) { + throw new ValidationError( + 'territory.name.error.unique', + '/territoryName', + StatusCode.UnprocessableEntity, + ResourceName.Territory, + ); + } + + /** + * Get all incentives related to the territory. + */ + const incentiveWithTerritory: Incentive[] = await this.incentiveRepository.find({ + where: { + 'territory.id': id, + } as AnyObject, + }); + + /** + * Loop throught the incentives and change the territory name. + */ + await Promise.all( + incentiveWithTerritory.map((incentive: Incentive) => { + const queryParams = { + 'territory.name': newTerritory.name, + } as AnyObject; + if (incentive.territoryName) { + queryParams.territoryName = newTerritory.name; // TODO: REMOVING DEPRECATED territoryName. + } + return this.incentiveRepository.updateById(incentive.id, queryParams); + }), + ); + await this.territoryRepository.updateById(id, newTerritory); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/territories/{id}', { + 'x-controller-name': 'Territories', + summary: `Retoune les informations d'un territoire`, + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Le territoire est retourné', + }, + [StatusCode.NotFound]: { + description: "Le territoire n'existe pas", + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 404, + name: 'Error', + message: 'Entity not found: Territory', + code: 'ENTITY_NOT_FOUND', + }, + }, + }, + }, + }, + }, + }) + async findById(@param.path.string('id') id: string): Promise { + return this.territoryRepository.findById(id); + } +} diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts new file mode 100644 index 0000000..bc35c2b --- /dev/null +++ b/api/src/controllers/user.controller.ts @@ -0,0 +1,514 @@ +import {inject} from '@loopback/core'; +import { + Count, + CountSchema, + repository, + Where, + AnyObject, + Filter, +} from '@loopback/repository'; +import { + post, + patch, + param, + get, + del, + getModelSchemaRef, + requestBody, +} from '@loopback/rest'; +import {SecurityBindings} from '@loopback/security'; +import {authenticate} from '@loopback/authentication'; +import {authorize} from '@loopback/authorization'; + +import {head, omit, orderBy, capitalize, isEqual, intersection} from 'lodash'; + +import { + UserRepository, + CommunityRepository, + UserEntityRepository, + KeycloakGroupRepository, +} from '../repositories'; +import {FunderService, KeycloakService} from '../services'; +import {Community, CommunityRelations, KeycloakRole, User} from '../models'; +import { + ResourceName, + StatusCode, + SECURITY_SPEC_KC_PASSWORD, + Roles, + FUNDER_TYPE, + GROUPS, + AUTH_STRATEGY, + IUsersResult, + IFunder, + ICreate, + IUser, +} from '../utils'; +import {ValidationError} from '../validationError'; +import {RequiredActionAlias} from 'keycloak-admin/lib/defs/requiredActionProviderRepresentation'; +export class UserController { + constructor( + @repository(UserRepository) + public userRepository: UserRepository, + @repository(KeycloakGroupRepository) + public keycloakGroupRepository: KeycloakGroupRepository, + @repository(UserEntityRepository) + public userEntityRepository: UserEntityRepository, + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + @inject('services.KeycloakService') + public kcService: KeycloakService, + @inject('services.FunderService') + public funderService: FunderService, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/users/funders/count', { + 'x-controller-name': 'Users', + summary: "Récupère le nombre d'utilisateurs financeurs", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'User model count', + content: { + 'application/json': { + schema: {...CountSchema, ...{title: 'Count'}}, + }, + }, + }, + }, + }) + async count(@param.where(User) where?: Where): Promise { + return this.userRepository.count(where); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/users/funders', { + 'x-controller-name': 'Users', + summary: 'Retourne les utilisateurs financeurs', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of User model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(User), + }, + }, + }, + }, + }, + }) + async find(@param.filter(User) filter?: Filter): Promise { + const funders = await this.funderService.getFunders(); + const users: User[] = await this.userRepository.find(filter); + + const usersResult: Promise[] = + users && + users.map(async (user: User) => { + const funder: IFunder | undefined = + funders && funders.find((fnd: IFunder) => fnd.id === user.funderId); + + let communities = undefined; + if (user.communityIds && user.communityIds.length >= 0) { + communities = await Promise.all( + user.communityIds?.map((id: string) => + this.communityRepository.find({ + where: {id}, + fields: {name: true}, + }), + ), + ); + } + + const community: string[] | undefined = communities?.map( + (res: (Community & CommunityRelations)[]) => res[0].name, + ); + + const roles: KeycloakRole[] = await this.userEntityRepository.getUserRoles( + user.id, + ); + + const rolesFormatted: string[] = roles.map(({name}) => name); + + const funderRoles: string[] = + await this.keycloakGroupRepository.getSubGroupFunderRoles(); + + const funderRolesUser: string[] = intersection( + rolesFormatted, + funderRoles, + ).filter(x => x); + + const rolesMatchedAndMapped = + funderRolesUser && + funderRolesUser + .map((elt: string) => capitalize(elt.replace(/s$/, ''))) + .join(' ; '); + + return { + ...user, + funderType: capitalize(funder?.funderType), + communityName: community + ? community.join(' ; ') + : rolesMatchedAndMapped === 'Superviseur' + ? '' + : funderRolesUser + ? 'Ensemble du périmètre financeur' + : null, + funderName: funder?.name, + roles: rolesMatchedAndMapped, + }; + }); + + const resolved = await Promise.all( + orderBy(usersResult, ['funderName', 'lastName'], ['asc']), + ); + + return resolved; + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @post('/v1/users', { + 'x-controller-name': 'Users', + summary: 'Crée un utilisateur financeur', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'User financeur model instance', + content: {'application/json': {schema: getModelSchemaRef(User)}}, + }, + [StatusCode.PreconditionFailed]: { + description: `Votre entreprise n'accepte pas l'affiliation manuelle`, + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 412, + name: 'Error', + message: 'users.funder.manualAffiliation.refuse', + path: '/users', + }, + }, + }, + }, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(User), + }, + }, + }) + user: User, + ): Promise { + let keycloakResult: ICreate = {id: ''}; + try { + const funders = await this.funderService.getFunders(); + const fundersFiltered = head(funders.filter(({id}) => id === user.funderId)); + + /** + * Check if this user's company accepts manual affiliation + */ + if (!fundersFiltered?.hasManualAffiliation && user.canReceiveAffiliationMail) { + throw new ValidationError( + `users.funder.manualAffiliation.refuse`, + `/users`, + StatusCode.PreconditionFailed, + ResourceName.User, + ); + } + + if (fundersFiltered) { + const {name, funderType, emailFormat} = fundersFiltered; + if ( + funderType === FUNDER_TYPE.collectivity || + emailFormat?.some((format: string) => user.email.endsWith(format)) + ) { + const availableRoles = + await this.keycloakGroupRepository.getSubGroupFunderRoles(); + const {roles, funderId, communityIds} = user; + + if (isEqual(intersection(availableRoles, roles).sort(), roles.sort())) { + const availableCommunities: Community[] = + await this.communityRepository.findByFunderId(funderId); + const conditionNoCommunities = + (!communityIds || communityIds.length === 0) && + availableCommunities && + availableCommunities.length === 0; + + const conditionMismatch = + !conditionNoCommunities && + communityIds && + isEqual( + intersection( + availableCommunities.map(({id}) => id), + communityIds, + ).sort(), + communityIds.sort(), + ); + + if ( + !roles.includes(Roles.MANAGERS) || + (roles.includes(Roles.MANAGERS) && + (conditionNoCommunities || conditionMismatch)) + ) { + const actions: RequiredActionAlias[] = [ + RequiredActionAlias.VERIFY_EMAIL, + RequiredActionAlias.UPDATE_PASSWORD, + ]; + keycloakResult = await this.kcService.createUserKc( + { + ...user, + funderName: name, + group: [ + `/${ + funderType === FUNDER_TYPE.collectivity + ? GROUPS.collectivities + : GROUPS.enterprises + }/${name}`, + ...user.roles.map(role => `${GROUPS.funders}/${role}`), + ], + }, + actions, + ); + if (keycloakResult && keycloakResult.id) { + user.id = keycloakResult.id; + const propertiesToOmit = roles.includes(Roles.MANAGERS) + ? ['password', 'roles'] + : ['password', 'roles', 'communityIds']; + const userRepo = omit(user, propertiesToOmit); + + await this.userRepository.create(userRepo); + + // Send mail to set password and activate account. + await this.kcService.sendExecuteActionsEmailUserKc( + keycloakResult.id, + actions, + ); + + // returning id because of react-admin specifications + return { + id: userRepo.id, + email: userRepo.email, + lastName: userRepo.lastName, + firstName: userRepo.firstName, + }; + } + return keycloakResult; + } + throw new ValidationError( + `users.error.communities.mismatch`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + } + throw new ValidationError( + `users.error.roles.mismatch`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + } + + throw new ValidationError( + `email.error.emailFormat`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + } + throw new ValidationError( + `users.error.funders.missed`, + `/users`, + StatusCode.UnprocessableEntity, + ResourceName.User, + ); + } catch (error) { + if (keycloakResult && keycloakResult.id) { + await this.kcService.deleteUserKc(keycloakResult.id); + } + throw error; + } + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @get('/v1/users/roles', { + 'x-controller-name': 'Users', + summary: 'Retourne les rôles des utilisateurs financeurs', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Array of roles', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }) + async getRolesForUsers(): Promise { + return this.keycloakGroupRepository.getSubGroupFunderRoles(); + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({ + allowedRoles: [Roles.CONTENT_EDITOR, Roles.FUNDERS], + }) + @get('/v1/users/{userId}', { + 'x-controller-name': 'Users', + summary: "Retourne les informations de l'utilisateur financeur", + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'User model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(User), + }, + }, + }, + }, + }) + async findUserById( + @param.path.string('userId', { + description: `L'identifiant de l'utilisateur financeur`, + }) + userId: string, + ): Promise { + const isContentEditor = this.currentUser?.roles?.includes(Roles.CONTENT_EDITOR); + + if (this.currentUser?.id !== userId && !isContentEditor) { + throw new ValidationError(`Access Denied`, `/authorization`, StatusCode.Forbidden); + } + + const rolesQuery: KeycloakRole[] = await this.userEntityRepository.getUserRoles( + userId, + ); + const rolesFormatted: string[] = rolesQuery.map(({name}) => name); + const user: User = await this.userRepository.findById(userId); + + const res: any = { + ...user, + roles: rolesFormatted, + }; + + return res; + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @patch('/v1/users/{userId}', { + 'x-controller-name': 'Users', + summary: 'Modifie un utilisateur financeur', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'Utilisateur patch success', + }, + [StatusCode.PreconditionFailed]: { + description: `Votre entreprise n'accepte pas l'affiliation manuelle`, + content: { + 'application/json': { + schema: getModelSchemaRef(Error), + example: { + error: { + statusCode: 412, + name: 'Error', + message: 'users.funder.manualAffiliation.refuse', + path: '/users', + }, + }, + }, + }, + }, + }, + }) + async updateById( + @param.path.string('userId', { + description: `L'identifiant de l'utilisateur financeur`, + }) + userId: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(User, {title: 'UserUpdate', partial: true}), + }, + }, + }) + user: User, + ): Promise<{id: string}> { + /** + * Check if this user's company accepts manual affiliation + */ + if (user?.canReceiveAffiliationMail) { + const funders = await this.funderService.getFunders(); + const filteredFunder = head(funders.filter(({id}) => id === user.funderId)); + + if (!filteredFunder?.hasManualAffiliation) { + throw new ValidationError( + `users.funder.manualAffiliation.refuse`, + `/users`, + StatusCode.PreconditionFailed, + ResourceName.User, + ); + } + } + + const userRepo: User = await this.userRepository.findById(userId); + if (userRepo) { + const {roles} = user; + const propertiesToOmit = ['roles']; + + if (user.communityIds) { + user.communityIds = roles.includes(Roles.MANAGERS) ? user.communityIds : []; + } + + const userOmit = omit(user, propertiesToOmit); + await this.kcService.updateUserGroupsKc(userId, roles); + await this.kcService.updateUser(userId, userOmit); + await this.userRepository.updateById(userId, userOmit); + } + return {id: userId}; + } + + @authenticate(AUTH_STRATEGY.KEYCLOAK) + @authorize({allowedRoles: [Roles.CONTENT_EDITOR]}) + @del('/v1/users/{userId}', { + 'x-controller-name': 'Users', + summary: 'Supprime un utilisateur financeur', + security: SECURITY_SPEC_KC_PASSWORD, + responses: { + [StatusCode.Success]: { + description: 'utilisateur DELETE success', + }, + }, + }) + async deleteById( + @param.path.string('userId', { + description: `L'identifiant de l'utilisateur financeur`, + }) + userId: string, + ): Promise<{id: string}> { + await this.kcService.deleteUserKc(userId); + await this.userRepository.deleteById(userId); + return {id: userId}; + } +} diff --git a/api/src/controllers/utils/helpers.ts b/api/src/controllers/utils/helpers.ts new file mode 100644 index 0000000..4a1addc --- /dev/null +++ b/api/src/controllers/utils/helpers.ts @@ -0,0 +1,127 @@ +import {Subscription} from '../../models'; +import {FUNDER_TYPE, IDashboardCitizenIncentiveList, INCENTIVE_TYPE} from '../../utils'; + +/** + * INTERFACES + * + * + * + * + */ +interface SearchQueryDate { + startDate: Date; + endDate: Date; +} + +/** + * get the percentage of the validated subscription + */ +export const calculatePercentage = ( + incentiveList: Array, + totalCitizensCount: number, +): Array => { + incentiveList.forEach((incentive: IDashboardCitizenIncentiveList) => { + incentive.validatedSubscriptionPercentage = Math.round( + (100 * incentive.totalSubscriptionsCount) / totalCitizensCount, + ); + }); + + return incentiveList; +}; + +/** + * get valid date depending on the year and semester values + */ +export const setValidDate = (year: string, semester: string) => { + const newValidDate: SearchQueryDate = { + startDate: new Date(), + endDate: new Date(), + }; + + /** + * determine start and end Date for year + */ + newValidDate.startDate = new Date(year); + newValidDate.endDate = new Date( + newValidDate.startDate.getFullYear() + 1, + newValidDate.startDate.getMonth(), + newValidDate.startDate.getDate(), + ); + + /** + * determine start and end Date for semester additional filter + * set the first semester + */ + if (Number(semester) === 1) { + newValidDate.endDate = new Date( + newValidDate.startDate.getFullYear(), + newValidDate.startDate.getMonth() + 6, + newValidDate.startDate.getDate(), + ); + } + + /** + * set the second semester + */ + if (Number(semester) === 2) { + newValidDate.endDate = new Date( + newValidDate.startDate.getFullYear() + 1, + newValidDate.startDate.getMonth(), + newValidDate.startDate.getDate(), + ); + + newValidDate.startDate = new Date( + newValidDate.startDate.getFullYear(), + newValidDate.startDate.getMonth() + 6, + newValidDate.startDate.getDate(), + ); + } + + return newValidDate; +}; + +/** + * sort the demands list by the total subscriptions count + * @returns sorted list + */ +export const isFirstElementGreater = ( + a: {totalSubscriptionsCount: number}, + b: {totalSubscriptionsCount: number}, +) => { + if (a.totalSubscriptionsCount > b.totalSubscriptionsCount) { + return -1; + } + return 0; +}; + +/** + * Returns list of emails and funderType based on incentive type + */ +export const getFunderTypeAndListEmails = (subscription: Subscription) => { + const listEmails: string[] = [subscription.email]; + + let funderType: FUNDER_TYPE; + + if (subscription.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) { + funderType = FUNDER_TYPE.enterprise; + if (subscription.enterpriseEmail) { + listEmails.push(subscription.enterpriseEmail); + } + } else if (subscription.incentiveType === INCENTIVE_TYPE.TERRITORY_INCENTIVE) { + funderType = FUNDER_TYPE.collectivity; + } + return {listEmails: listEmails, funderType: funderType!}; +}; + +/** + * Returns the string with white spaces removed + */ +export const removeWhiteSpace = (word: string): string => { + /** + * Regex for removing white spaces. + * Exemple : " Removing white spaces " returns "Removing white spaces". + */ + const removeSpacesRegex: RegExp = new RegExp('^\\s+|\\s+$|\\s+(?=\\s)', 'g'); + const newWord: string = word.replace(removeSpacesRegex, ''); + return newWord; +}; diff --git a/api/src/controllers/utils/incentiveExample.ts b/api/src/controllers/utils/incentiveExample.ts new file mode 100644 index 0000000..431d760 --- /dev/null +++ b/api/src/controllers/utils/incentiveExample.ts @@ -0,0 +1,210 @@ +export const incentiveExample = [ + { + id: '', + title: 'Aide pour acheter vélo électrique', + description: `Sous conditions d'éligibilité,\ + Mulhouse met à disposition une aide au financement d'un vélo électrique`, + territory: {name: 'Mulhouse', id: 'randomTerritoryId'}, + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + conditions: "Fournir une preuve d'achat d'un vélo électrique", + paymentMethod: 'Remboursement par virement', + allocatedAmount: '500 €', + minAmount: '50 €', + transportList: ['electrique'], + attachments: ['Justificatif de domicile'], + additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Mulhouse', + contact: 'Contactez le numéro vert au 05 206 308', + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: false, + subscriptionLink: 'https://www.mulhouse.com', + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + funderId: '25eb2670-7984-4ff8-b43c-515a694edfe0', + }, + { + id: '', + title: 'Aide pour acheter scooter électrique', + description: `Sous conditions d'éligibilité,\ + Capgemini Toulouse met à disposition une aide au financement d'un scooter électrique`, + territory: {name: 'Toulouse', id: 'randomTerritoryId'}, + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + conditions: "Fournir une preuve d'achat d'un scooter électrique", + paymentMethod: 'Remboursement par virement', + allocatedAmount: '500 €', + minAmount: '50 €', + transportList: ['electrique'], + attachments: ['identite', 'factureAchat'], + additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Toulouse', + contact: 'Contactez le numéro vert au 05 206 308', + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: false, + subscriptionLink: 'https://www.capgemini.com', + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + funderId: '93a294b9-4ac3-483d-9831-ebe6e3609c29', + }, + { + id: '', + title: 'Bonus écologique', + description: `Profiter d'un bonus écologique pour les personnes,\ + qui souhaitent acquérir une voiture électrique ou hybride`, + territory: {name: 'France entière', id: 'randomTerritoryId'}, + funderName: 'État français', + incentiveType: 'AideNationale', + conditions: 'Acheter une voiture hybride ou électrique à compter du 1er janvier 2022', + paymentMethod: `Aide de l'état`, + allocatedAmount: '500 €', + minAmount: '1000 €', + transportList: ['voiture'], + attachments: ['identite', 'factureAchat'], + additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Toulouse', + contact: 'Contactez le numéro vert au 05 206 308', + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: false, + subscriptionLink: 'www.primealaconversion.gouv.fr', + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + }, + { + id: '', + title: 'Le vélo électrique arrive à Mulhouse !', + description: `Sous conditions d'éligibilité,\ + Mulhouse met à disposition une aide au financement d'un vélo électrique`, + territory: {name: 'Mulhouse', id: 'randomTerritoryId'}, + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + conditions: "Fournir une preuve d'achat d'un vélo électrique", + paymentMethod: 'Remboursement par virement', + allocatedAmount: '500 €', + minAmount: '50 €', + transportList: ['electrique'], + attachments: ['Justificatif de domicile'], + additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Mulhouse', + contact: 'Contactez le numéro vert au 05 206 308', + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: true, + specificFields: [ + { + title: 'Statut marital', + inputFormat: 'listeChoix', + choiceList: { + possibleChoicesNumber: 1, + inputChoiceList: [ + { + inputChoice: 'Marié', + }, + ], + }, + }, + ], + jsonSchema: { + properties: { + 'Statut marital': { + type: 'array', + maxItems: 1, + items: { + enum: ['Marié', 'Célibataire'], + }, + }, + }, + type: 'object', + required: ['Statut marital'], + additionalProperties: false, + }, + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + funderId: 'e13c5de9-d695-4c03-b7b7-9efdd13d937c', + }, +]; + +export const incentiveContentDifferentExample = { + response: [ + { + id: '', + title: 'Aide pour acheter vélo électrique', + description: `Sous conditions d'éligibilité,\ + Mulhouse met à disposition une aide au financement d'un vélo électrique`, + territory: {name: 'Mulhouse', id: 'randomTerritoryId'}, + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + conditions: "Fournir une preuve d'achat d'un vélo électrique", + paymentMethod: 'Remboursement par virement', + allocatedAmount: '500 €', + minAmount: '50 €', + transportList: ['electrique'], + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: false, + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + funderId: '25eb2670-7984-4ff8-b43c-515a694edfe0', + }, + { + id: '', + title: 'Aide pour acheter scooter électrique', + description: `Sous conditions d'éligibilité,\ + Capgemini Toulouse met à disposition une aide au financement d'un scooter électrique`, + territory: {name: 'Toulouse', id: 'randomTerritoryId'}, + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + conditions: "Fournir une preuve d'achat d'un scooter électrique", + paymentMethod: 'Remboursement par virement', + allocatedAmount: '500 €', + minAmount: '50 €', + transportList: ['electrique'], + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: false, + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + funderId: '93a294b9-4ac3-483d-9831-ebe6e3609c29', + }, + { + id: '', + title: 'Bonus écologique', + description: `Profiter d'un bonus écologique pour les personnes,\ + qui souhaitent acquérir une voiture électrique ou hybride`, + territory: {name: 'France entière', id: 'randomTerritoryId'}, + funderName: 'État français', + incentiveType: 'AideNationale', + conditions: + 'Acheter une voiture hybride ou électrique à compter du 1er janvier 2022', + paymentMethod: `Aide de l'état`, + allocatedAmount: '500 €', + minAmount: '1000 €', + transportList: ['voiture'], + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: false, + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + }, + { + id: '', + title: 'Le vélo électrique arrive à Mulhouse !', + description: `Sous conditions d'éligibilité,\ + Mulhouse met à disposition une aide au financement d'un vélo électrique`, + territory: {name: 'Mulhouse', id: 'randomTerritoryId'}, + funderName: 'Mulhouse', + incentiveType: 'AideTerritoire', + conditions: "Fournir une preuve d'achat d'un vélo électrique", + paymentMethod: 'Remboursement par virement', + allocatedAmount: '500 €', + minAmount: '50 €', + transportList: ['electrique'], + validityDuration: '12 mois', + validityDate: '2024-07-31', + isMCMStaff: true, + createdAt: '2022-01-01 00:00:00.000Z', + updatedAt: '2022-01-02 00:00:00.000Z', + funderId: 'e13c5de9-d695-4c03-b7b7-9efdd13d937c', + }, + ], + message: "Le Citoyen est bien affilié à son employeur, mais il ne dispose pas d'aides.", +}; diff --git a/api/src/cronjob/index.ts b/api/src/cronjob/index.ts new file mode 100644 index 0000000..4526765 --- /dev/null +++ b/api/src/cronjob/index.ts @@ -0,0 +1,3 @@ +export * from './rabbitmqCronJob'; +export * from './subscriptionCronJob'; +export * from './nonActivatedAccountDeletionCronJob'; diff --git a/api/src/cronjob/nonActivatedAccountDeletionCronJob.ts b/api/src/cronjob/nonActivatedAccountDeletionCronJob.ts new file mode 100644 index 0000000..ccdec26 --- /dev/null +++ b/api/src/cronjob/nonActivatedAccountDeletionCronJob.ts @@ -0,0 +1,74 @@ +import {service} from '@loopback/core'; +import {CronJob, cronJob} from '@loopback/cron'; + +import {CronJobService, CitizenService} from '../services'; +import {CronJob as CronJobModel} from '../models'; + +import {CRON_TYPES, logger} from '../utils'; +import {isAfterDate} from '../utils/date'; + +@cronJob() +export class NonActivatedAccountDeletionCronJob extends CronJob { + // Cron's type + private cronType: string = CRON_TYPES.DELETE_SUBSCRIPTION; + + constructor( + @service(CronJobService) + public cronJobService: CronJobService, + @service(CitizenService) + public citizenService: CitizenService, + ) { + super({ + name: 'nonActivatedAccountDeletion-job', + onTick: async () => { + logger.info(`${NonActivatedAccountDeletionCronJob.name} - ticked`); + await this.performJob(); + }, + cronTime: '0 3 * * *', + start: false, + }); + } + + // cron process + private async createCron(): Promise { + let createdCronId: CronJobModel | null = null; + try { + createdCronId = await this.cronJobService.createCronLog(this.cronType); + logger.info(`${NonActivatedAccountDeletionCronJob.name} created`); + await this.citizenService.accountDeletionService(); + await this.cronJobService.delCronLog(this.cronType); + logger.info(`${NonActivatedAccountDeletionCronJob.name} finished`); + } catch (error) { + if (createdCronId && createdCronId.id) { + await this.cronJobService.delCronLogById(createdCronId.id); + } + throw new Error(`${error}`); + } + } + + /** + * Perform cron job + */ + private async performJob(): Promise { + try { + // Get active crons jobs + const activeCronList: CronJobModel[] = await this.cronJobService.getCronsLog(); + + // Check if this cron is already in use + const cronAlreadyInUse: CronJobModel[] | [] = activeCronList.filter( + (cron: CronJobModel) => cron.type === this.cronType, + ); + if ( + cronAlreadyInUse?.[0]?.createdAt && + isAfterDate(cronAlreadyInUse?.[0]?.createdAt, 2) + ) { + // Delete old log + await this.cronJobService.delCronLog(this.cronType); + } + + await this.createCron(); + } catch (err) { + logger.error(`${NonActivatedAccountDeletionCronJob.name}: ${err}`); + } + } +} diff --git a/api/src/cronjob/rabbitmqCronJob.ts b/api/src/cronjob/rabbitmqCronJob.ts new file mode 100644 index 0000000..e5da0b2 --- /dev/null +++ b/api/src/cronjob/rabbitmqCronJob.ts @@ -0,0 +1,62 @@ +import {inject, service} from '@loopback/core'; +import {CronJob, cronJob} from '@loopback/cron'; + +import {isEqual, difference} from 'lodash'; + +import {ParentProcessService, RabbitmqService} from '../services'; +import {EVENT_MESSAGE, UPDATE_MODE, logger} from '../utils'; + +@cronJob() +export class RabbitmqCronJob extends CronJob { + private enterpriseHRISNameList: string[] = []; + + constructor( + @service(RabbitmqService) + public rabbitmqService: RabbitmqService, + @inject('services.ParentProcessService') + public parentProcessService: ParentProcessService, + ) { + super({ + name: 'rabbitmq-job', + onTick: async () => { + logger.info(`${RabbitmqCronJob.name} - ticked`); + await this.performJob(); + }, + cronTime: '0 2 * * *', + start: false, + }); + + // Start cronjob once process is ready + this.parentProcessService.on( + EVENT_MESSAGE.READY, + () => this.fireOnTick() && this.start(), + ); + } + + /** + * Perform cron job + */ + private async performJob(): Promise { + try { + const repositoryList: string[] = + await this.rabbitmqService.getHRISEnterpriseNameList(); + + // If arrays are the same, no need to send updated list + if (!isEqual(repositoryList, this.enterpriseHRISNameList)) { + // Find added HRIS enterprise + const added = difference(repositoryList, this.enterpriseHRISNameList); + + // Find deleted HRIS enterprise + const deleted = difference(this.enterpriseHRISNameList, repositoryList); + + this.enterpriseHRISNameList = repositoryList; + this.parentProcessService.emit(EVENT_MESSAGE.UPDATE, { + type: EVENT_MESSAGE.UPDATE, + data: {[UPDATE_MODE.ADD]: added, [UPDATE_MODE.DELETE]: deleted}, + }); + } + } catch (err) { + throw new Error('An error occurred'); + } + } +} diff --git a/api/src/cronjob/subscriptionCronJob.ts b/api/src/cronjob/subscriptionCronJob.ts new file mode 100644 index 0000000..3bda382 --- /dev/null +++ b/api/src/cronjob/subscriptionCronJob.ts @@ -0,0 +1,74 @@ +import {service} from '@loopback/core'; +import {CronJob, cronJob} from '@loopback/cron'; + +import {CronJobService, SubscriptionService} from '../services'; +import {CronJob as CronJobModel} from '../models'; + +import {logger, CRON_TYPES} from '../utils'; +import {isAfterDate} from '../utils/date'; + +@cronJob() +export class SubscriptionCronJob extends CronJob { + // Cron's type + private cronType: string = CRON_TYPES.DELETE_SUBSCRIPTION; + + constructor( + @service(CronJobService) + public cronJobService: CronJobService, + @service(SubscriptionService) + public subscriptionService: SubscriptionService, + ) { + super({ + name: 'subscription-job', + onTick: async () => { + logger.info(`${SubscriptionCronJob.name} - ticked`); + await this.performJob(); + }, + cronTime: '0 2 * * *', + start: false, + }); + } + + // cron process + private async createCron(): Promise { + let createdCronId: CronJobModel | null = null; + try { + createdCronId = await this.cronJobService.createCronLog(this.cronType); + logger.info(`${SubscriptionCronJob.name} created`); + await this.subscriptionService.deleteSubscription(); + await this.cronJobService.delCronLog(this.cronType); + logger.info(`${SubscriptionCronJob.name} finished`); + } catch (error) { + if (createdCronId && createdCronId.id) { + await this.cronJobService.delCronLogById(createdCronId.id); + } + throw new Error(`${error}`); + } + } + + /** + * Perform cron job + */ + private async performJob(): Promise { + try { + // Get active crons jobs + const activeCronList: CronJobModel[] = await this.cronJobService.getCronsLog(); + + // Check if this cron is already in use + const cronAlreadyInUse: CronJobModel[] | [] = activeCronList.filter( + (cron: CronJobModel) => cron.type === this.cronType, + ); + if ( + cronAlreadyInUse?.[0]?.createdAt && + isAfterDate(cronAlreadyInUse?.[0]?.createdAt, 2) + ) { + // Delete old log + await this.cronJobService.delCronLog(this.cronType); + } + + await this.createCron(); + } catch (err) { + logger.error(`${SubscriptionCronJob.name}: ${err}`); + } + } +} diff --git a/api/src/datasources/README.md b/api/src/datasources/README.md new file mode 100644 index 0000000..57ae382 --- /dev/null +++ b/api/src/datasources/README.md @@ -0,0 +1,3 @@ +# Datasources + +This directory contains config for datasources used by this app. diff --git a/api/src/datasources/idpdb-ds.datasource.ts b/api/src/datasources/idpdb-ds.datasource.ts new file mode 100644 index 0000000..e240fa5 --- /dev/null +++ b/api/src/datasources/idpdb-ds.datasource.ts @@ -0,0 +1,32 @@ +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import {juggler} from '@loopback/repository'; + +const config = { + name: 'idpdbDS', + connector: 'postgresql', + host: process.env.IDP_DB_HOST ?? 'localhost', + port: process.env.IDP_DB_PORT ?? 5432, + user: process.env.IDP_DB_SERVICE_USER ?? 'admin', + password: process.env.IDP_DB_SERVICE_PASSWORD ?? 'pass', + database: process.env.IDP_DB_DATABASE ?? 'idp_db', + ssl: + process.env.LANDSCAPE && process.env.LANDSCAPE !== 'preview' + ? { + rejectUnauthorized: true, + ca: Buffer.from(process.env.PGSQL_FLEX_SSL_CERT!, 'base64').toString('utf-8'), + } + : null, +}; + +@lifeCycleObserver('datasource') +export class IdpDbDataSource extends juggler.DataSource implements LifeCycleObserver { + static dataSourceName = 'idpdbDS'; + static readonly defaultConfig = config; + + constructor( + @inject('datasources.config.idpdbDS', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } +} diff --git a/api/src/datasources/index.ts b/api/src/datasources/index.ts new file mode 100644 index 0000000..4dd6aeb --- /dev/null +++ b/api/src/datasources/index.ts @@ -0,0 +1,2 @@ +export * from './mongo-ds.datasource'; +export * from './idpdb-ds.datasource'; diff --git a/api/src/datasources/mongo-ds.datasource.ts b/api/src/datasources/mongo-ds.datasource.ts new file mode 100644 index 0000000..95a3892 --- /dev/null +++ b/api/src/datasources/mongo-ds.datasource.ts @@ -0,0 +1,43 @@ +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import {juggler} from '@loopback/repository'; + +const configProd = { + name: 'mongoDS', + connector: 'mongodb', + url: `mongodb+srv://${process.env.MONGO_SERVICE_USER}:${process.env.MONGO_SERVICE_PASSWORD}@${process.env.MONGO_HOST}/${process.env.MONGO_DATABASE}?retryWrites=true&w=majority`, +}; + +const configPreview = { + name: 'mongoDS', + connector: 'mongodb', + host: process.env.MONGO_HOST ?? 'localhost', + port: process.env.MONGO_PORT ?? 27017, + user: process.env.MONGO_SERVICE_USER ?? 'admin', + password: process.env.MONGO_SERVICE_PASSWORD ?? 'pass', + database: process.env.MONGO_DATABASE ?? 'mcm', + authSource: process.env.MONGO_AUTH_SOURCE ?? 'admin', + useNewUrlParser: true, + allowExtendedOperators: true, +}; + +const config = + process.env.LANDSCAPE && process.env.LANDSCAPE !== 'preview' + ? configProd + : configPreview; + +// Observe application's life cycle to disconnect the datasource when +// application is stopped. This allows the application to be shut down +// gracefully. The `stop()` method is inherited from `juggler.DataSource`. +// Learn more at https://loopback.io/doc/en/lb4/Life-cycle.html +@lifeCycleObserver('datasource') +export class MongoDsDataSource extends juggler.DataSource implements LifeCycleObserver { + static dataSourceName = 'mongoDS'; + static readonly defaultConfig = config; + + constructor( + @inject('datasources.config.mongoDS', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..b737578 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,39 @@ +import {ApplicationConfig, App} from './application'; +import {logger} from './utils'; + +export async function main(options: ApplicationConfig = {}) { + const app = new App(options); + await app.boot(); + await app.start(); + + const url = app.restServer.url; + logger.info(`Server is running at ${url}`); + return app; +} + +if (require.main === module) { + // Run the application + const config = { + rest: { + expressSettings: { + 'x-powered-by': false, + }, + port: +(process.env.PORT ?? 3000), + host: process.env.HOST, + // The `gracePeriodForClose` provides a graceful close for http/https + // servers with keep-alive clients. The default value is `Infinity` + // (don't force-close). If you want to immediately destroy all sockets + // upon stop, set its value to `0`. + // See https://www.npmjs.com/package/stoppable + gracePeriodForClose: 5000, // 5 seconds + openApiSpec: { + // useful when used with OpenAPI-to-GraphQL to locate your application + setServersFromRequest: true, + }, + }, + }; + main(config).catch(err => { + logger.error(`Cannot start the application: ${err}`); + process.exit(1); + }); +} diff --git a/api/src/interceptors/citizen.interceptor.ts b/api/src/interceptors/citizen.interceptor.ts new file mode 100644 index 0000000..7975285 --- /dev/null +++ b/api/src/interceptors/citizen.interceptor.ts @@ -0,0 +1,229 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {CitizenService} from '../services'; +import {Citizen} from '../models/citizen/citizen.model'; +import {CitizenRepository, UserRepository} from '../repositories'; +import {isAgeValid} from './utils'; +import {ValidationError} from '../validationError'; +import { + ResourceName, + StatusCode, + logger, + AFFILIATION_STATUS, + IUser, + Roles, +} from '../utils'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ + +@injectable({tags: {key: CitizenInterceptor.BINDING_KEY}}) +export class CitizenInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${CitizenInterceptor.name}`; + + /* + * constructor + */ + constructor( + @inject('services.CitizenService') + public citizenService: CitizenService, + @repository(CitizenRepository) + public citizenRepository: CitizenRepository, + @repository(UserRepository) + private userRepository: UserRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + let citizen: Citizen | undefined; + if (invocationCtx.methodName === 'create') { + citizen = invocationCtx.args[0]; + if (!citizen?.tos1 || !citizen?.tos2) { + throw new ValidationError( + `Citizen must agree to terms of services`, + '/tos', + StatusCode.UnprocessableEntity, + ResourceName.Account, + ); + } + + if (!citizen?.password) { + throw new ValidationError( + `Password cannot be empty`, + '/password', + StatusCode.PreconditionFailed, + ResourceName.Account, + ); + } + } + + if (invocationCtx.methodName === 'replaceById') citizen = invocationCtx.args[1]; + + if (invocationCtx.methodName === 'findCitizenId') { + const citizenId = invocationCtx.args[0]; + const citizen = await this.citizenRepository.findOne({ + where: {id: citizenId}, + }); + if (!citizen) { + throw new ValidationError( + `Citizen not found`, + '/citizenNotFound', + StatusCode.NotFound, + ResourceName.Citizen, + ); + } + } + + if (citizen && !isAgeValid(citizen.identity.birthDate.value)) { + throw new ValidationError( + `citizens.error.birthdate.age`, + '/birthdate', + StatusCode.PreconditionFailed, + ResourceName.Account, + ); + } + if (invocationCtx.methodName === 'validateAffiliation') { + const user = this.currentUser; + if (user.id && !user.roles?.includes(Roles.CITIZENS)) { + const userData = await this.userRepository.findById(this.currentUser?.id); + const citizenId = invocationCtx.args[0]; + const citizen = await this.citizenRepository.findOne({ + where: {id: citizenId}, + }); + if ( + userData?.funderId !== citizen?.affiliation.enterpriseId || + citizen!.affiliation!.affiliationStatus !== AFFILIATION_STATUS.TO_AFFILIATE + ) { + throw new ValidationError( + 'citizen.affiliation.impossible', + '/citizenAffiliationImpossible', + StatusCode.PreconditionFailed, + ResourceName.Affiliation, + ); + } + } else if (user.id && user.roles?.includes(Roles.CITIZENS)) { + if (user?.id !== invocationCtx.args[0]) { + throw new ValidationError( + 'citizen.affiliation.impossible', + '/citizenAffiliationImpossible', + StatusCode.PreconditionFailed, + ResourceName.Affiliation, + ); + } + } else if (!invocationCtx.args[1].token) { + throw new ValidationError( + 'citizens.affiliation.not.found', + '/citizensAffiliationNotFound', + StatusCode.NotFound, + ResourceName.Affiliation, + ); + } + } + + if (invocationCtx.methodName === 'disaffiliation') { + const citizenId = invocationCtx.args[0]; + let newAffiliatedEmployees: Citizen | undefined, + newManuelAffiliatedEmployees: Citizen | undefined; + + const citizenToDisaffiliate = await this.citizenRepository.findOne({ + where: {id: citizenId}, + }); + if (!citizenToDisaffiliate) { + throw new ValidationError( + `Citizen not found`, + '/citizenNotFound', + StatusCode.NotFound, + ResourceName.Citizen, + ); + } + if ( + citizenToDisaffiliate.affiliation.affiliationStatus === + AFFILIATION_STATUS.AFFILIATED + ) { + const affiliatedEmployees = await this.citizenService.findEmployees({ + status: AFFILIATION_STATUS.AFFILIATED, + lastName: undefined, + skip: 0, + }); + newAffiliatedEmployees = affiliatedEmployees?.employees?.find( + (elt: Citizen) => elt.id === citizenId, + ); + } + + if ( + citizenToDisaffiliate.affiliation.affiliationStatus === + AFFILIATION_STATUS.TO_AFFILIATE + ) { + const manuelAffiliatedEmployees = await this.citizenService.findEmployees({ + status: AFFILIATION_STATUS.TO_AFFILIATE, + lastName: undefined, + skip: 0, + limit: 0, + }); + + newManuelAffiliatedEmployees = manuelAffiliatedEmployees?.employees?.find( + (elt: Citizen) => elt.id === citizenId, + ); + } + + if (!newAffiliatedEmployees && !newManuelAffiliatedEmployees) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } else { + const checkDisaffiliation = await this.citizenService.checkDisaffiliation( + citizenId, + ); + + if (!checkDisaffiliation) { + throw new ValidationError( + 'citizen.disaffiliation.impossible', + '/citizenDisaffiliationImpossible', + StatusCode.PreconditionFailed, + ResourceName.Disaffiliation, + ); + } + } + } + + const result = await next(); + return result; + } + catch(err: string) { + logger.error(err); + throw err; + } +} diff --git a/api/src/interceptors/enterprise.interceptor.ts b/api/src/interceptors/enterprise.interceptor.ts new file mode 100644 index 0000000..82357d6 --- /dev/null +++ b/api/src/interceptors/enterprise.interceptor.ts @@ -0,0 +1,60 @@ +import { + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {StatusCode} from '../utils'; +import {ValidationError} from '../validationError'; +import {isEmailFormatValid} from './utils'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: EnterpriseInterceptor.BINDING_KEY}}) +export class EnterpriseInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${EnterpriseInterceptor.name}`; + + constructor() {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const {methodName, args} = invocationCtx; + const {emailFormat} = args[0]; + if (methodName === 'create') { + const allEmailFormatsValid: boolean = emailFormat.every( + (emailFormatString: string) => isEmailFormatValid(emailFormatString), + ); + if (!allEmailFormatsValid) { + throw new ValidationError( + 'Enterprise email formats are not valid', + '/enterpriseEmailBadFormat', + StatusCode.UnprocessableEntity, + ); + } + } + + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/external/affiliation.interceptor.ts b/api/src/interceptors/external/affiliation.interceptor.ts new file mode 100644 index 0000000..72b82ee --- /dev/null +++ b/api/src/interceptors/external/affiliation.interceptor.ts @@ -0,0 +1,152 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + service, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import { + IncentiveRepository, + CitizenRepository, + SubscriptionRepository, +} from '../../repositories'; +import {FunderService} from '../../services'; +import { + FUNDER_TYPE, + INCENTIVE_TYPE, + isEnterpriseAffilitation, + IUser, + ResourceName, + Roles, + StatusCode, +} from '../../utils'; +import {ValidationError} from '../../validationError'; + +@injectable({tags: {key: AffiliationInterceptor.BINDING_KEY}}) +export class AffiliationInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${AffiliationInterceptor.name}`; + + constructor( + @repository(CitizenRepository) + public citizenRepository: CitizenRepository, + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @service(FunderService) + public funderService: FunderService, + @inject(SecurityBindings.USER) + private currentUserProfile: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const {methodName, args} = invocationCtx; + const {id, clientName, roles} = this.currentUserProfile; + if (!roles?.includes(Roles.MAAS_BACKEND)) { + let inputFunderId: string | undefined = undefined; + if (methodName === 'findCommunitiesByFunderId') { + inputFunderId = args[0]; + } + + if ( + methodName === 'createSubscription' || + methodName === 'createMetadata' || + methodName === 'getMetadata' + ) { + const {incentiveId} = args[0]; + inputFunderId = + incentiveId && + (await this.incentiveRepository.findOne({where: {id: incentiveId}}))?.funderId; + } + + if (methodName === 'addAttachments' || methodName === 'finalizeSubscription') { + const subscriptionId = args[0]; + const subscription = await this.subscriptionRepository.findOne({ + where: {id: subscriptionId}, + }); + if (!subscription) { + throw new ValidationError( + `Subscription not found`, + '/subscriptionNotFound', + StatusCode.NotFound, + ResourceName.Subscription, + ); + } + + const incentiveId = subscription.incentiveId; + inputFunderId = + incentiveId && + (await this.incentiveRepository.findOne({where: {id: incentiveId}}))?.funderId; + } + + // Find IncentiveMaasById + if (methodName === 'findIncentiveById') { + const incentiveId = args[0]; + + const incentive = await this.incentiveRepository.findOne({ + where: {id: incentiveId}, + }); + if (!incentive) { + throw new ValidationError( + `Incentive not found`, + '/incentiveNotFound', + StatusCode.NotFound, + ResourceName.Incentive, + ); + } + if ( + incentive?.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE && + !roles?.includes(Roles.CONTENT_EDITOR) + ) { + inputFunderId = incentiveId && incentive?.funderId; + } else { + return next(); + } + } + + const funders: any = inputFunderId && (await this.funderService.getFunders()); + const funderMatch = funders && funders.find(({id}: any) => inputFunderId === id); + const citizen = await this.citizenRepository.findOne({where: {id}}); + + // Users from platform can subscribe to all collectivity aid + // Users from MaaS can subscribe to all collectivity aid + // Users from platform needs to be affiliated to a company to subscribe + if ( + (roles?.includes(Roles.PLATFORM) || roles?.includes(Roles.MAAS)) && + (funderMatch?.funderType === FUNDER_TYPE.collectivity || + isEnterpriseAffilitation({citizen, funderMatch, inputFunderId})) + ) { + const result = await next(); + return result; + } + + throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden); + } + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/external/affiliationPublic.interceptor.ts b/api/src/interceptors/external/affiliationPublic.interceptor.ts new file mode 100644 index 0000000..f54a7e9 --- /dev/null +++ b/api/src/interceptors/external/affiliationPublic.interceptor.ts @@ -0,0 +1,76 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {IncentiveRepository} from '../../repositories'; +import {INCENTIVE_TYPE, StatusCode, ResourceName, IUser} from '../../utils'; +import {ValidationError} from '../../validationError'; + +@injectable({tags: {key: AffiliationPublicInterceptor.BINDING_KEY}}) +export class AffiliationPublicInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${AffiliationPublicInterceptor.name}`; + + constructor( + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @inject(SecurityBindings.USER) + private currentUserProfile: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const {methodName, args} = invocationCtx; + const {roles} = this.currentUserProfile; + + if (roles && roles.includes('service_maas')) { + // Find IncentiveById + if (methodName === 'findIncentiveById') { + const incentiveId = args[0]; + const incentive = await this.incentiveRepository.findOne({ + where: {id: incentiveId}, + }); + if (!incentive) { + throw new ValidationError( + `Incentive not found`, + '/incentiveNotFound', + StatusCode.NotFound, + ResourceName.Incentive, + ); + } + if (incentive?.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + } + } + return next(); + } +} diff --git a/api/src/interceptors/external/index.ts b/api/src/interceptors/external/index.ts new file mode 100644 index 0000000..a2aca30 --- /dev/null +++ b/api/src/interceptors/external/index.ts @@ -0,0 +1,5 @@ +export * from './affiliation.interceptor'; +export * from './subscriptionV1.interceptor'; +export * from './affiliationPublic.interceptor'; +export * from './subscriptionV1Attachments.interceptor'; +export * from './subscriptionV1Finalize.interceptor'; diff --git a/api/src/interceptors/external/subscriptionV1.interceptor.ts b/api/src/interceptors/external/subscriptionV1.interceptor.ts new file mode 100644 index 0000000..68402c2 --- /dev/null +++ b/api/src/interceptors/external/subscriptionV1.interceptor.ts @@ -0,0 +1,116 @@ +import { + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {getJsonSchema} from '@loopback/rest'; +import {repository} from '@loopback/repository'; +import {Validator} from 'jsonschema'; +import {format} from 'date-fns'; +import {SecurityBindings} from '@loopback/security'; + +import {IncentiveRepository, CommunityRepository} from '../../repositories'; +import {ResourceName, StatusCode} from '../../utils'; +import {ValidationError} from '../../validationError'; +import {Community, CreateSubscription} from '../../models'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: SubscriptionV1Interceptor.BINDING_KEY}}) +export class SubscriptionV1Interceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${SubscriptionV1Interceptor.name}`; + + constructor( + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + let subscriptionIncentiveData: CreateSubscription | undefined = invocationCtx.args[0]; + subscriptionIncentiveData = subscriptionIncentiveData as CreateSubscription; + const {jsonSchema, funderId, isMCMStaff} = await this.incentiveRepository.findById( + subscriptionIncentiveData?.incentiveId, + ); + if (!isMCMStaff) + throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden); + + if (funderId) { + const communities: Community[] = await this.communityRepository.findByFunderId( + funderId, + ); + const communityId = subscriptionIncentiveData?.communityId; + + const conditionWithCommunity = + communityId && !communities.map(({id}) => id).some(elt => elt === communityId); + const conditionWOCommunity = !communityId && communities.length > 0; + + if (conditionWithCommunity || conditionWOCommunity) { + throw new ValidationError( + `subscriptions.error.communities.mismatch`, + `/subscriptions`, + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } + } + const validator = new Validator(); + const {incentiveId, consent, communityId, ...specificFieldsToCompare} = + subscriptionIncentiveData; + + // If no incentive.json schema, compare with createSubscription model and no additional properties + const resultCompare = validator.validate( + jsonSchema ? specificFieldsToCompare : subscriptionIncentiveData, + jsonSchema ?? {...getJsonSchema(CreateSubscription), additionalProperties: false}, + ); + if (resultCompare.errors.length > 0) { + throw new ValidationError( + resultCompare.errors[0].message, + resultCompare.errors[0].path.toString(), + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } + // Extract specific fields if incentive.jsonSchema (meaning specificFields) + if (jsonSchema) { + const specificFields: Object = {}; + Object.keys(resultCompare.schema.properties as Object).forEach(element => { + let value = subscriptionIncentiveData?.[element]; + // Format date + if (resultCompare.schema.properties?.[element]?.format === 'date') { + value = format(new Date(value), 'dd/MM/yyyy'); + } + Object.assign(specificFields, {[element]: value}); + delete subscriptionIncentiveData?.[element]; + }); + subscriptionIncentiveData!.specificFields = specificFields; + } + invocationCtx.args[0] = {...subscriptionIncentiveData}; + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/external/subscriptionV1Attachments.interceptor.ts b/api/src/interceptors/external/subscriptionV1Attachments.interceptor.ts new file mode 100644 index 0000000..b91c824 --- /dev/null +++ b/api/src/interceptors/external/subscriptionV1Attachments.interceptor.ts @@ -0,0 +1,239 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + service, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {Express} from 'express'; +import {EncryptionKey} from '../../models'; + +import { + SubscriptionRepository, + MetadataRepository, + EnterpriseRepository, + CollectivityRepository, +} from '../../repositories'; +import {ClamavService, S3Service} from '../../services'; +import { + ResourceName, + StatusCode, + canAccessHisSubscriptionData, + SUBSCRIPTION_STATUS, + IUser, +} from '../../utils'; +import {isExpired} from '../../utils/date'; +import {ValidationError} from '../../validationError'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: SubscriptionV1AttachmentsInterceptor.BINDING_KEY}}) +export class SubscriptionV1AttachmentsInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${SubscriptionV1AttachmentsInterceptor.name}`; + + constructor( + @inject('services.S3Service') + private s3Service: S3Service, + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @repository(MetadataRepository) + public metadataRepository: MetadataRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + @service(ClamavService) + public clamavService: ClamavService, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const subscriptionDetails = await this.subscriptionRepository.findOne({ + where: {id: invocationCtx.args[0]}, + }); + + // Check if subscription exists + if (!subscriptionDetails) { + throw new ValidationError( + 'Subscription does not exist', + '/subscription', + StatusCode.NotFound, + ResourceName.Subscription, + ); + } + + // Check if user has access to his own data + if ( + !canAccessHisSubscriptionData(this.currentUser.id, subscriptionDetails?.citizenId) + ) { + throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden); + } + + // Check subscription status + if (subscriptionDetails?.status !== SUBSCRIPTION_STATUS.DRAFT) { + throw new ValidationError( + `Only subscriptions with Draft status are allowed`, + '/status', + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + + // Check encryptionKey Exists + let encryptionKey: EncryptionKey | undefined = undefined; + const collectivity = await this.collectivityRepository.findOne({ + where: {id: subscriptionDetails.funderId}, + }); + const enterprise = await this.enterpriseRepository.findOne({ + where: {id: subscriptionDetails.funderId}, + }); + if (!collectivity && !enterprise) { + throw new ValidationError( + `Funder not found`, + '/Funder', + StatusCode.NotFound, + ResourceName.EncryptionKey, + ); + } + + if (collectivity) encryptionKey = collectivity.encryptionKey; + if (enterprise) encryptionKey = enterprise.encryptionKey; + if (!encryptionKey) { + throw new ValidationError( + `Encryption Key not found`, + '/EncryptionKey', + StatusCode.NotFound, + ResourceName.EncryptionKey, + ); + } + + // check if public key is expired + if (isExpired(encryptionKey.expirationDate, new Date())) { + throw new ValidationError( + `Encryption Key Expired`, + '/EncryptionKey', + StatusCode.UnprocessableEntity, + ResourceName.EncryptionKey, + ); + } + + // Check if there is already files in db + if ( + subscriptionDetails!.attachments && + subscriptionDetails!.attachments?.length > 0 + ) { + throw new ValidationError( + `You already provided files to this subscription`, + '/attachments', + StatusCode.UnprocessableEntity, + ResourceName.Attachments, + ); + } + + const attachments = invocationCtx.args[1].files; + const idMetadata = invocationCtx.args[1].body.data + ? JSON.parse(invocationCtx.args[1].body.data)?.metadataId + : undefined; + + const subscriptionMetadata = idMetadata + ? await this.metadataRepository.findById(idMetadata) + : undefined; + + // Check at least one of attachments and idMetadata + if (!subscriptionMetadata && (!attachments || !(attachments.length > 0))) { + throw new ValidationError( + `You need the provide at least one file or valid metadata`, + '/attachments', + StatusCode.UnprocessableEntity, + ResourceName.Attachments, + ); + } + + // Check if incentiveId in metadata is the same than the one from the subscription + if ( + subscriptionMetadata && + subscriptionDetails!.incentiveId?.toString() !== + subscriptionMetadata?.incentiveId?.toString() + ) { + throw new ValidationError( + `Metadata does not match this subscription`, + '/attachments', + StatusCode.UnprocessableEntity, + ResourceName.Attachments, + ); + } + + // Check number of file to upload + if ( + !this.s3Service.hasCorrectNumberOfFiles([ + ...attachments, + ...(subscriptionMetadata?.attachmentMetadata?.invoices ?? []), + ]) + ) { + throw new ValidationError( + `Too many files to upload`, + '/attachments', + StatusCode.UnprocessableEntity, + ResourceName.Attachments, + ); + } + // Check attachments valid mime type + if (!this.s3Service.hasValidMimeType(attachments)) { + throw new ValidationError( + `Uploaded files do not have valid content type`, + '/attachments', + StatusCode.PreconditionFailed, + ResourceName.AttachmentsType, + ); + } + // Check attachments valid mime size + if (!this.s3Service.hasValidFileSize(attachments)) { + throw new ValidationError( + `Uploaded files do not have a valid file size`, + '/attachments', + StatusCode.PreconditionFailed, + ResourceName.Attachments, + ); + } + + // Check if a file is corrupted using ClamAV + if (!(await this.clamavService.checkCorruptedFiles(attachments))) { + throw new ValidationError( + 'A corrupted file has been found', + '/antivirus', + StatusCode.UnprocessableEntity, + ResourceName.Antivirus, + ); + } + + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/external/subscriptionV1Finalize.interceptor.ts b/api/src/interceptors/external/subscriptionV1Finalize.interceptor.ts new file mode 100644 index 0000000..82a0908 --- /dev/null +++ b/api/src/interceptors/external/subscriptionV1Finalize.interceptor.ts @@ -0,0 +1,88 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {SubscriptionRepository} from '../../repositories'; +import { + ResourceName, + StatusCode, + canAccessHisSubscriptionData, + SUBSCRIPTION_STATUS, + IUser, +} from '../../utils'; +import {ValidationError} from '../../validationError'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: SubscriptionV1FinalizeInterceptor.BINDING_KEY}}) +export class SubscriptionV1FinalizeInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${SubscriptionV1FinalizeInterceptor.name}`; + + constructor( + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const subscription = await this.subscriptionRepository.findById( + invocationCtx.args[0], + ); + + // Check if subscription exists + if (!subscription) { + throw new ValidationError( + 'Subscription does not exist', + '/subscription', + StatusCode.NotFound, + ResourceName.Subscription, + ); + } + + // Check if user has access to his own data + if (!canAccessHisSubscriptionData(this.currentUser.id, subscription?.citizenId)) { + throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden); + } + + // Check subscription status + if (subscription?.status !== SUBSCRIPTION_STATUS.DRAFT) { + throw new ValidationError( + `Only subscriptions with Draft status are allowed`, + '/status', + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/funder.interceptor.ts b/api/src/interceptors/funder.interceptor.ts new file mode 100644 index 0000000..fc4b161 --- /dev/null +++ b/api/src/interceptors/funder.interceptor.ts @@ -0,0 +1,99 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {CollectivityRepository, EnterpriseRepository} from '../repositories'; +import {ValidationError} from '../validationError'; +import {ResourceName, StatusCode, IUser} from '../utils'; +import {Collectivity, EncryptionKey, Enterprise} from '../models'; +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ + +@injectable({tags: {key: FunderInterceptor.BINDING_KEY}}) +export class FunderInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${FunderInterceptor.name}`; + + /* + * constructor + */ + constructor( + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const encryptionKey: EncryptionKey = invocationCtx.args[1]; + const funderId = invocationCtx.args[0]; + let funder: Collectivity | Enterprise | undefined = undefined; + + const collectivity: Collectivity | null = await this.collectivityRepository.findOne({ + where: {id: funderId}, + }); + const enterprise: Enterprise | null = await this.enterpriseRepository.findOne({ + where: {id: funderId}, + }); + + funder = collectivity ? collectivity : enterprise ? enterprise : undefined; + if (!funder) { + throw new ValidationError( + `Funder not found`, + `/Funder`, + StatusCode.NotFound, + ResourceName.Funder, + ); + } + + // Will not anymore if we stop using ${funderName}-backend convention when creating KC backend clients + if ( + funder && + this.currentUser?.clientName && + !this.currentUser?.groups?.includes(funder.name) + ) { + throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden); + } + if ( + (collectivity || (enterprise && !enterprise.isHris)) && + !encryptionKey.privateKeyAccess + ) { + throw new ValidationError( + `encryptionKey.error.privateKeyAccess.missing`, + '/EncryptionKey', + StatusCode.UnprocessableEntity, + ); + } + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/incentive.interceptor.ts b/api/src/interceptors/incentive.interceptor.ts new file mode 100644 index 0000000..0abfa14 --- /dev/null +++ b/api/src/interceptors/incentive.interceptor.ts @@ -0,0 +1,158 @@ +import { + /* inject, */ + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; + +import {Incentive} from '../models/incentive/incentive.model'; +import {IncentiveRepository} from '../repositories'; +import {ResourceName, StatusCode} from '../utils'; +import {ValidationError} from '../validationError'; +import {isValidityDateValid} from './utils'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: IncentiveInterceptor.BINDING_KEY}}) +export class IncentiveInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${IncentiveInterceptor.name}`; + + constructor( + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + let incentives: Incentive | undefined; + if (invocationCtx.methodName === 'create') { + incentives = invocationCtx.args[0]; + if (incentives && incentives.title && incentives.funderName) { + const incentive = await this.incentiveRepository.findOne({ + where: {title: incentives.title, funderName: incentives.funderName}, + }); + if (incentive) { + throw new ValidationError( + `incentives.error.title.alreadyUsedForFunder`, + '/incentiveTitleAlreadyUsed', + StatusCode.Conflict, + ResourceName.Incentive, + ); + } + } + } + + if (invocationCtx.methodName === 'replaceById') incentives = invocationCtx.args[1]; + + if (invocationCtx.methodName === 'updateById') { + const incentiveId = invocationCtx.args[0]; + incentives = invocationCtx.args[1]; + + const incentiveToUpdate = await this.incentiveRepository.findOne({ + where: {id: incentiveId}, + }); + if (!incentiveToUpdate) { + throw new ValidationError( + `Incentive not found`, + '/incentiveNotFound', + StatusCode.NotFound, + ResourceName.Incentive, + ); + } + if (incentives && incentives.title && incentives.funderName) { + const incentive = await this.incentiveRepository.findOne({ + where: { + id: {nin: [incentiveId]}, + title: incentives.title, + funderName: incentives.funderName, + }, + }); + if (incentive) { + throw new ValidationError( + `incentives.error.title.alreadyUsedForFunder`, + '/incentiveTitleAlreadyUsed', + StatusCode.Conflict, + ResourceName.Incentive, + ); + } + } + } + + if (invocationCtx.methodName === 'deleteById') { + const incentiveId = invocationCtx.args[0]; + const incentiveToDelete = await this.incentiveRepository.findOne({ + where: {id: incentiveId}, + }); + if (!incentiveToDelete) { + throw new ValidationError( + `Incentive not found`, + '/incentiveNotFound', + StatusCode.NotFound, + ResourceName.Incentive, + ); + } + } + + if ( + incentives && + incentives.validityDate && + !isValidityDateValid(incentives.validityDate) + ) { + throw new ValidationError( + `incentives.error.validityDate.minDate`, + '/validityDate', + StatusCode.PreconditionFailed, + ResourceName.Incentive, + ); + } + + // If isMCMStaff === true , should not have a subscription link + if (incentives && incentives.isMCMStaff && incentives.subscriptionLink) { + throw new ValidationError( + `incentives.error.isMCMStaff.subscriptionLink`, + '/isMCMStaff', + StatusCode.PreconditionFailed, + ResourceName.Incentive, + ); + } + // If isMCMStaff === false , should not have a specific fields but subscription is required + if ( + incentives && + !incentives.isMCMStaff && + (incentives.specificFields?.length || !incentives.subscriptionLink) + ) { + throw new ValidationError( + `incentives.error.isMCMStaff.specificFieldOrSubscriptionLink`, + '/isMCMStaff', + StatusCode.PreconditionFailed, + ResourceName.Incentive, + ); + } + + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/index.ts b/api/src/interceptors/index.ts new file mode 100644 index 0000000..5964951 --- /dev/null +++ b/api/src/interceptors/index.ts @@ -0,0 +1,7 @@ +export * from './incentive.interceptor'; +export * from './citizen.interceptor'; +export * from './subscription.interceptor'; +export * from './subscriptionMetadata.interceptor'; +export * from './enterprise.interceptor'; +export * from './funder.interceptor'; +export * from './external'; diff --git a/api/src/interceptors/subscription.interceptor.ts b/api/src/interceptors/subscription.interceptor.ts new file mode 100644 index 0000000..03d17c1 --- /dev/null +++ b/api/src/interceptors/subscription.interceptor.ts @@ -0,0 +1,130 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {UserRepository, SubscriptionRepository} from '../repositories'; +import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../utils'; +import {ValidationError} from '../validationError'; +import {Subscription} from '../models'; +import {IDP_SUFFIX_BACKEND} from '../constants'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: SubscriptionInterceptor.BINDING_KEY}}) +export class SubscriptionInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${SubscriptionInterceptor.name}`; + + constructor( + @repository(UserRepository) + private userRepository: UserRepository, + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const {methodName, args} = invocationCtx; + const {id} = this.currentUser; + + let communityIds: '' | string[] | undefined = []; + + const subscriptionId = args[0]; + const subscription: Subscription = await this.subscriptionRepository.findById( + subscriptionId, + ); + + if (!subscription) { + throw new ValidationError( + `Subscription not found`, + '/subscriptionNotFound', + StatusCode.NotFound, + ResourceName.Subscription, + ); + } + + if (methodName === 'findById') { + if (subscription?.status === SUBSCRIPTION_STATUS.DRAFT) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + } + + if (methodName === 'getSubscriptionFileByName') { + if (this.currentUser?.clientName) { + const clientName = this.currentUser?.clientName; + if (`${subscription.funderName}-${IDP_SUFFIX_BACKEND}` !== clientName) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + } + if (subscription.status !== SUBSCRIPTION_STATUS.TO_PROCESS) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + } + + if ( + ['findById', 'validate', 'reject', 'getSubscriptionFileByName'].includes(methodName) + ) { + // get current user communities + communityIds = + id && (await this.userRepository.findOne({where: {id}}))?.communityIds; + + if (communityIds) { + if ( + subscription && + subscription.communityId && + communityIds?.includes(subscription.communityId.toString()) + ) { + return next(); + } else { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + } else { + return next(); + } + } + } +} diff --git a/api/src/interceptors/subscriptionMetadata.interceptor.ts b/api/src/interceptors/subscriptionMetadata.interceptor.ts new file mode 100644 index 0000000..40502f7 --- /dev/null +++ b/api/src/interceptors/subscriptionMetadata.interceptor.ts @@ -0,0 +1,123 @@ +import { + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {SecurityBindings} from '@loopback/security'; + +import {IncentiveRepository, MetadataRepository} from '../repositories'; +import {canAccessHisSubscriptionData, IUser, ResourceName, StatusCode} from '../utils'; +import {ValidationError} from '../validationError'; +import {Invoice, Metadata} from '../models'; + +/** + * This class will be bound to the application as an `Interceptor` during + * `boot` + */ +@injectable({tags: {key: SubscriptionMetadataInterceptor.BINDING_KEY}}) +export class SubscriptionMetadataInterceptor implements Provider { + static readonly BINDING_KEY = `interceptors.${SubscriptionMetadataInterceptor.name}`; + + constructor( + @repository(IncentiveRepository) + public incentiveRepository: IncentiveRepository, + @repository(MetadataRepository) + public metadataRepository: MetadataRepository, + @inject(SecurityBindings.USER) + private currentUser: IUser, + ) {} + + /** + * This method is used by LoopBack context to produce an interceptor function + * for the binding. + * + * @returns An interceptor function + */ + value() { + return this.intercept.bind(this); + } + + /** + * The logic to intercept an invocation + * @param invocationCtx - Invocation context + * @param next - A function to invoke next interceptor or the target method + */ + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const {methodName, args} = invocationCtx; + const {incentiveId, attachmentMetadata} = new Metadata(args[0]); + const {id} = this.currentUser; + if (methodName === 'createMetadata') { + // Get incentive to see if it exists + // Check isMCMStaff + const {isMCMStaff} = await this.incentiveRepository.findById(incentiveId); + if (!isMCMStaff) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + + // Check empty invoices or empty products + if ( + attachmentMetadata.invoices.length === 0 || + attachmentMetadata.invoices.find( + (invoice: Invoice) => invoice.products.length === 0, + ) + ) { + throw new ValidationError( + 'Metadata invoices or products length invalid', + '/metadata', + StatusCode.UnprocessableEntity, + ResourceName.Metadata, + ); + } + + // Check totalElements && nb invoices + if (attachmentMetadata.invoices.length !== attachmentMetadata.totalElements) { + throw new ValidationError( + 'Metadata invoices length must be equal to totalElements', + '/metadata', + StatusCode.UnprocessableEntity, + ResourceName.Metadata, + ); + } + + // Attach citizenId and token to metadata + args[0].citizenId = id; + } + + if (methodName === 'getMetadata') { + const metadataId = args[0]; + const metadata = await this.metadataRepository.findOne({where: {id: metadataId}}); + if (!metadata) { + throw new ValidationError( + `Metadata not found`, + '/metadataNotFound', + StatusCode.NotFound, + ResourceName.Metadata, + ); + } + + // Check if user has access to his own data + if (!canAccessHisSubscriptionData(this.currentUser.id, metadata.citizenId)) { + throw new ValidationError( + 'Access denied', + '/authorization', + StatusCode.Forbidden, + ); + } + } + + const result = await next(); + return result; + } +} diff --git a/api/src/interceptors/utils.ts b/api/src/interceptors/utils.ts new file mode 100644 index 0000000..5fd10b6 --- /dev/null +++ b/api/src/interceptors/utils.ts @@ -0,0 +1,44 @@ +const isValidityDateValid = (validityDate: string) => { + return new Date(validityDate) >= new Date(); +}; + +const formatDate = (date: Date) => { + return [date.getFullYear(), date.getMonth() + 1, date.getDate()].join('-'); +}; + +const isAgeValid = (birthdate: string) => { + const currentDate = new Date(); + + const lowelLimitDate = [ + currentDate.getFullYear() - 16, + currentDate.getMonth() + 1, + currentDate.getDate(), + ].join('-'); + + return formatDate(new Date(birthdate)) <= lowelLimitDate; +}; + +/** + * Format Date as Day/Month/Year + */ +const formatDateInFrenchNotation = (date: Date) => { + const dd = String(date.getDate()).padStart(2, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const yyyy = date.getFullYear(); + return `${dd}/${mm}/${yyyy}`; +}; + +const isEmailFormatValid = (email: string) => { + const regex = new RegExp( + /^@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ); + return email.match(regex); +}; + +export { + isValidityDateValid, + formatDate, + isAgeValid, + formatDateInFrenchNotation, + isEmailFormatValid, +}; diff --git a/api/src/migrate.ts b/api/src/migrate.ts new file mode 100644 index 0000000..5f97929 --- /dev/null +++ b/api/src/migrate.ts @@ -0,0 +1,21 @@ +import {App} from './application'; +import {logger} from './utils'; + +export async function migrate(args: string[]) { + const existingSchema = args.includes('--rebuild') ? 'drop' : 'alter'; + logger.info(`Migrating schemas (%s existing schema): ${existingSchema}`); + + const app = new App(); + await app.boot(); + await app.migrateSchema({existingSchema}); + + // Connectors usually keep a pool of opened connections, + // this keeps the process running even after all work is done. + // We need to exit explicitly. + process.exit(0); +} + +migrate(process.argv).catch(err => { + logger.error(`Cannot migrate database schema: ${err}`); + process.exit(1); +}); diff --git a/api/src/migrations/1.10.0.migration.ts b/api/src/migrations/1.10.0.migration.ts new file mode 100644 index 0000000..9a40396 --- /dev/null +++ b/api/src/migrations/1.10.0.migration.ts @@ -0,0 +1,127 @@ +import {service} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {MigrationScript, migrationScript} from 'loopback4-migration'; +import {removeWhiteSpace} from '../controllers/utils/helpers'; +import {Incentive, Territory} from '../models'; +import {IncentiveRepository, TerritoryRepository} from '../repositories'; +import {TerritoryService} from '../services/territory.service'; +import {logger} from '../utils'; + +@migrationScript() +export class MigrationScript110 implements MigrationScript { + version = '1.10.0'; + scriptName = MigrationScript110.name; + description = 'Add Territory Model'; + + constructor( + @repository(IncentiveRepository) private incentiveRepository: IncentiveRepository, + @repository(TerritoryRepository) private territoryRepository: TerritoryRepository, + @service(TerritoryService) public territoryService: TerritoryService, + ) {} + + async up(): Promise { + logger.info(`${MigrationScript110.name} - Started`); + + const territoriesToCreate: string[] = []; + let count: number = 0; + + const incentivesList: Incentive[] = await this.incentiveRepository.find({ + where: {territory: {exists: false}}, + fields: {id: true, territoryName: true}, + }); + + const createdTerritories: Territory[] = await this.territoryRepository.find(); + logger.info( + `${MigrationScript110.name} - {${createdTerritories.length}} Territories from database`, + ); + logger.info( + `${MigrationScript110.name} - Initial Created Territories from database : \ + ${ + createdTerritories.length ? JSON.stringify(createdTerritories, null, 2) : 'None' + }`, + ); + + incentivesList.map((incentive: Incentive) => { + const name: string = removeWhiteSpace(incentive.territoryName); + + const foundInIncentive: string | undefined = territoriesToCreate.find( + (territoryName: string) => + territoryName.toLowerCase() === incentive.territoryName.toLowerCase(), + ); + + const foundInTerritory: Territory | undefined = createdTerritories.find( + (territory: Territory) => territory.name.toLowerCase() === name.toLowerCase(), + ); + + if (!foundInTerritory && !foundInIncentive) { + territoriesToCreate.push(incentive.territoryName); + } + }); + + logger.info( + `${MigrationScript110.name} - {${territoriesToCreate.length}} Territories will be created`, + ); + logger.info( + `${MigrationScript110.name} - Territories that will be Created : \ + ${ + territoriesToCreate.length + ? JSON.stringify(territoriesToCreate, null, 2) + : 'None' + }`, + ); + + await Promise.all( + territoriesToCreate.map(async (territoy: string) => { + const createdTerritory: Territory = await this.territoryService.createTerritory({ + name: territoy, + } as Territory); + createdTerritories.push(createdTerritory); + + logger.info( + `${MigrationScript110.name} - Territory ${createdTerritory.name} with ID \ + ${createdTerritory.id} is Created`, + ); + }), + ); + + logger.info( + `${MigrationScript110.name} - {${incentivesList.length}} Incentives to Update`, + ); + await Promise.all( + incentivesList.map(async (incentive: Incentive) => { + const name: string = removeWhiteSpace(incentive.territoryName); + + const foundTerritory: Territory | undefined = createdTerritories.find( + (territory: Territory) => territory.name.toLowerCase() === name.toLowerCase(), + ); + + if (foundTerritory) { + await this.incentiveRepository.updateById(incentive.id, { + territory: { + id: foundTerritory?.id, + name: foundTerritory?.name, + }, + territoryName: foundTerritory?.name, + }); + + logger.info( + `${MigrationScript110.name} - Incentive with ID \ ${ + incentive.id + } is updated with territory : ${JSON.stringify(foundTerritory, null, 2)}`, + ); + count++; + } else { + logger.error( + `${MigrationScript110.name} - Incentive with ID ${incentive.id} is not updating`, + ); + } + }), + ); + + logger.info(`${MigrationScript110.name} - {${count}} Incentives are updated`); + if (count !== incentivesList.length) { + logger.error(`${MigrationScript110.name} - Error in updating incentives`); + } + logger.info(`${MigrationScript110.name} - Completed`); + } +} diff --git a/api/src/migrations/1.11.0.migration.ts b/api/src/migrations/1.11.0.migration.ts new file mode 100644 index 0000000..6b7490c --- /dev/null +++ b/api/src/migrations/1.11.0.migration.ts @@ -0,0 +1,104 @@ +import {AnyObject, repository} from '@loopback/repository'; +import {inject} from '@loopback/core'; +import {MigrationScript, migrationScript} from 'loopback4-migration'; + +import {KeycloakService} from '../services'; +import {CitizenMigrationRepository, CitizenRepository} from '../repositories'; +import {logger, GENDER, IUser} from '../utils'; + +@migrationScript() +export class MigrationScript111 implements MigrationScript { + version = '1.11.0'; + scriptName = MigrationScript111.name; + description = 'add identity object to citizen'; + + constructor( + @repository(CitizenRepository) + private citizenRepository: CitizenRepository, + @repository(CitizenMigrationRepository) + private citizenMigrationRepository: CitizenMigrationRepository, + @inject('services.KeycloakService') + public kcService: KeycloakService, + ) {} + + async up(): Promise { + logger.info(`${MigrationScript111.name} - Started`); + + // Update all citizens to add identity object + const citizens: Array = await this.citizenMigrationRepository.find({ + where: {identity: {exists: false}}, + }); + + const updateCitizens: Promise[] = citizens.map(async citizen => { + logger.info( + `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \ + ${citizen.id} will be updated with identity object`, + ); + + logger.info( + `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \ + ${citizen.id} Get User Information for certification source`, + ); + + const user: IUser = await this.kcService.getUser(citizen.id); + + const certificationSource: string = + user?.attributes?.provider?.[0] === 'FC' + ? 'franceconnect.gouv.fr' + : 'moncomptemobilite.fr'; + + const newCitizen: AnyObject = { + ...citizen, + identity: { + gender: { + value: citizen.gender === GENDER.FEMALE ? 2 : 1, + source: certificationSource, + certificationDate: new Date(), + }, + lastName: { + value: citizen.lastName, + source: certificationSource, + certificationDate: new Date(), + }, + firstName: { + value: citizen.firstName, + source: certificationSource, + certificationDate: new Date(), + }, + birthDate: { + value: citizen.birthdate, + source: certificationSource, + certificationDate: new Date(), + }, + }, + }; + + delete newCitizen.gender; + delete newCitizen.lastName; + delete newCitizen.firstName; + delete newCitizen.birthdate; + delete newCitizen?.certifiedData; + + // Update the citizen attributes on KC + logger.info( + `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \ + ${citizen.id} will be updated on KC with identity attributes`, + ); + + await this.kcService.updateCitizenAttributes(citizen.id, { + ...newCitizen.identity, + }); + + // Update the citizen on mongo + logger.info( + `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \ + ${citizen.id} will be updated on Mongo with identity object`, + ); + + return this.citizenRepository.replaceById(citizen.id, newCitizen); + }); + await Promise.all(updateCitizens); + + logger.info(`${MigrationScript111.name} - Completed`); + } +} diff --git a/api/src/migrations/1.2.0.migration.ts b/api/src/migrations/1.2.0.migration.ts new file mode 100644 index 0000000..cef0260 --- /dev/null +++ b/api/src/migrations/1.2.0.migration.ts @@ -0,0 +1,46 @@ +import {Count, repository} from '@loopback/repository'; +import {MigrationScript, migrationScript} from 'loopback4-migration'; +import {Enterprise} from '../models'; +import {EnterpriseRepository, SubscriptionRepository} from '../repositories'; +import {logger} from '../utils'; + +@migrationScript() +export class MigrationScript120 implements MigrationScript { + version = '1.2.0'; + scriptName = MigrationScript120.name; + description = 'add manual affiliation to enterprises and delete all subscriptions'; + + constructor( + @repository(EnterpriseRepository) + private enterpriseRepository: EnterpriseRepository, + @repository(SubscriptionRepository) + private subscriptionRepository: SubscriptionRepository, + ) {} + + async up(): Promise { + logger.info(`${MigrationScript120.name} - Started`); + + // Update all enterprises to add hasManualAffiliation property + const enterprises: Enterprise[] = await this.enterpriseRepository.find({ + where: {hasManualAffiliation: {exists: false}}, + }); + const updateEnterprises: Promise[] = enterprises.map(enterprise => { + logger.info( + `${MigrationScript120.name} - Enterprise ${enterprise.name} with ID \ + ${enterprise.id} will be updated with hasManualAffiliation`, + ); + return this.enterpriseRepository.updateById(enterprise.id, { + hasManualAffiliation: false, + }); + }); + await Promise.all(updateEnterprises); + + // Delete all old subscriptions + const deletedSubscriptionCount: Count = await this.subscriptionRepository.deleteAll(); + logger.info( + `${MigrationScript120.name} - ${deletedSubscriptionCount.count} subscriptions deleted`, + ); + + logger.info(`${MigrationScript120.name} - Completed`); + } +} diff --git a/api/src/models/README.md b/api/src/models/README.md new file mode 100644 index 0000000..f5ea972 --- /dev/null +++ b/api/src/models/README.md @@ -0,0 +1,3 @@ +# Models + +This directory contains code for models provided by this app. diff --git a/api/src/models/citizen/affiliation.model.ts b/api/src/models/citizen/affiliation.model.ts new file mode 100644 index 0000000..675313a --- /dev/null +++ b/api/src/models/citizen/affiliation.model.ts @@ -0,0 +1,38 @@ +import {Model, model, property} from '@loopback/repository'; + +import {AFFILIATION_STATUS} from '../../utils'; + +@model({settings: {idInjection: false}}) +export class Affiliation extends Model { + @property({ + type: 'string', + description: `Identifiant de l'entreprise professionnelle du citoyen`, + jsonSchema: { + example: ``, + }, + }) + enterpriseId: string | null; + + @property({ + type: 'string', + description: `Email professionnel du citoyen`, + jsonSchema: { + example: `bob.rasovsky@professional.com`, + }, + }) + enterpriseEmail: string | null; + + @property({ + type: 'string', + description: `Statut de l'affiliation`, + jsonSchema: { + example: AFFILIATION_STATUS.TO_AFFILIATE, + }, + }) + affiliationStatus: AFFILIATION_STATUS; + + constructor(affiliation: Affiliation) { + super(affiliation); + this.affiliationStatus = AFFILIATION_STATUS.TO_AFFILIATE; + } +} diff --git a/api/src/models/citizen/citizen.model.ts b/api/src/models/citizen/citizen.model.ts new file mode 100644 index 0000000..8689898 --- /dev/null +++ b/api/src/models/citizen/citizen.model.ts @@ -0,0 +1,125 @@ +import {model, property, Entity} from '@loopback/repository'; + +import {Affiliation} from './affiliation.model'; +import {Identity} from './identity.model'; +import {CITIZEN_STATUS, GENDER} from '../../utils'; +import {DgfipInformation} from './dgfipInformation.model'; + +import {emailRegexp} from '../../constants'; + +@model() +export class Citizen extends Entity { + @property({ + type: 'string', + description: `Email`, + required: true, + jsonSchema: { + example: `bob.rasovsky@example.com`, + pattern: emailRegexp, + }, + }) + email: string; + + @property({ + type: 'string', + description: `Identifiant du citoyen`, + id: true, + generated: false, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + description: `Mot de passe`, + required: true, + hidden: true, + jsonSchema: { + example: ``, + }, + }) + password: string; + + @property({ + type: 'string', + description: `Ville du citoyen`, + required: true, + jsonSchema: { + example: `Toulouse`, + minLength: 2, + }, + }) + city: string; + + @property({ + type: 'string', + description: `Code postal du citoyen`, + required: true, + jsonSchema: { + example: `31000`, + pattern: '^[0-9]{5}$', + }, + }) + postcode: string; + + @property({ + type: 'string', + description: `Statut professionnel du citoyen`, + required: true, + jsonSchema: { + example: CITIZEN_STATUS.STUDENT, + enum: Object.values(CITIZEN_STATUS), + }, + }) + status: CITIZEN_STATUS; + + @property({ + type: 'boolean', + description: `Acceptation des CGU`, + required: true, + hidden: true, + jsonSchema: { + example: true, + }, + }) + tos1: boolean; + + @property({ + type: 'boolean', + description: `Acceptation de la politique de protections des données`, + required: true, + hidden: true, + jsonSchema: { + example: true, + }, + }) + tos2: boolean; + + @property({ + description: `Objet d'affiliation du citoyen à une entreprise`, + }) + affiliation: Affiliation; + + @property({ + description: `Objet identité`, + required: true, + }) + identity: Identity; + + @property({ + type: DgfipInformation, + description: `Les données French DGFIP d'un citoyen`, + required: false, + }) + dgfipInformation: DgfipInformation; + + constructor(data?: Partial) { + super(data); + } +} + +export interface CitizenRelations {} + +export type CitizenWithRelations = Citizen & CitizenRelations; diff --git a/api/src/models/citizen/citizenMigration.model.ts b/api/src/models/citizen/citizenMigration.model.ts new file mode 100644 index 0000000..6d0f31d --- /dev/null +++ b/api/src/models/citizen/citizenMigration.model.ts @@ -0,0 +1,15 @@ +import {model} from '@loopback/repository'; +import {Citizen} from './citizen.model'; + +@model({ + settings: {strict: false, mongodb: {collection: 'Citizen'}}, +}) +export class CitizenMigration extends Citizen { + constructor(data?: CitizenMigration) { + super(data); + } +} + +export interface CitizenMigrationRelations {} + +export type CitizenMigrationWithRelations = CitizenMigration & CitizenMigrationRelations; diff --git a/api/src/models/citizen/citizenUpdate.model.ts b/api/src/models/citizen/citizenUpdate.model.ts new file mode 100644 index 0000000..7cdac9d --- /dev/null +++ b/api/src/models/citizen/citizenUpdate.model.ts @@ -0,0 +1,52 @@ +import {Model, model, property} from '@loopback/repository'; +import {Affiliation} from './affiliation.model'; +import {CITIZEN_STATUS} from '../../utils'; + +@model({settings: {idInjection: false}}) +export class CitizenUpdate extends Model { + @property({ + type: 'string', + description: `Ville`, + required: true, + jsonSchema: { + example: `Paris`, + minLength: 2, + }, + }) + city: string; + + @property({ + type: 'string', + description: `Code postal`, + required: true, + jsonSchema: { + example: `75000`, + pattern: '[0-9]{5}', + }, + }) + postcode: string; + + @property({ + type: 'string', + description: `Statut professionnel du citoyen`, + required: true, + jsonSchema: { + example: CITIZEN_STATUS.STUDENT, + enum: Object.values(CITIZEN_STATUS), + }, + }) + status: CITIZEN_STATUS; + + @property({ + description: `Objet d'affiliation du citoyen à une entreprise`, + }) + affiliation: Affiliation; + + constructor(data?: Partial) { + super(data); + } +} + +export interface CitizenUpdateRelations {} + +export type CitizenUpdateWithRelations = CitizenUpdate & CitizenUpdateRelations; diff --git a/api/src/models/citizen/declarant.model.ts b/api/src/models/citizen/declarant.model.ts new file mode 100644 index 0000000..2fc7280 --- /dev/null +++ b/api/src/models/citizen/declarant.model.ts @@ -0,0 +1,83 @@ +import {Model, model, property} from '@loopback/repository'; +import { + City, + Country, + DateType, + EmailType, + PhoneNumber, + PostalAddress, + StringType, +} from '../cmsTypes.model'; + +@model({settings: {idInjection: false}}) +export class Declarant extends Model { + @property({ + type: StringType, + description: 'Nom du déclarant', + }) + lastName: StringType; + + @property({ + type: StringType, + description: 'Nom de naissance du déclarant', + }) + birthName: StringType; + + @property({ + type: StringType, + description: 'Prénom du déclarant', + }) + firstName: StringType; + + @property({ + type: StringType, + description: 'Deuxièmes prénoms du déclarant', + }) + middleNames: StringType; + + @property({ + type: DateType, + description: 'Date de naissance du déclarant', + }) + birthDate: DateType; + + @property({ + type: City, + description: 'Lieu de naissance du déclarant', + }) + birthPlace: City; + + @property({ + type: Country, + description: 'Pays de naissance du déclarant', + }) + birthCountry: Country; + + @property({ + type: EmailType, + description: 'Email du déclarant', + }) + email: EmailType; + + @property({ + type: PostalAddress, + description: 'Première Adresse postale du déclarant', + }) + primaryPostalAddress: PostalAddress; + + @property({ + type: PostalAddress, + description: 'Deuxième adresse postale du déclarant', + }) + secondaryPostalAddress: PostalAddress; + + @property({ + type: PhoneNumber, + description: 'Numéro de téléphone du déclarant', + }) + primaryPhoneNumber: PhoneNumber; + + constructor(data: Declarant) { + super(data); + } +} diff --git a/api/src/models/citizen/dgfipInformation.model.ts b/api/src/models/citizen/dgfipInformation.model.ts new file mode 100644 index 0000000..226638d --- /dev/null +++ b/api/src/models/citizen/dgfipInformation.model.ts @@ -0,0 +1,25 @@ +import {Model, model, property} from '@loopback/repository'; +import {Declarant} from './declarant.model'; +import {TaxNotice} from './taxNotice.model'; + +@model({settings: {idInjection: false}}) +export class DgfipInformation extends Model { + @property({ + type: Declarant, + description: `Les informations du premier déclarant`, + }) + declarant1: Declarant; + + @property({ + type: Declarant, + description: 'Les informations du deuxième déclarant', + }) + declarant2: Declarant; + + @property.array(TaxNotice) + taxNotices: TaxNotice[]; + + constructor(data: DgfipInformation) { + super(data); + } +} diff --git a/api/src/models/citizen/identity.model.ts b/api/src/models/citizen/identity.model.ts new file mode 100644 index 0000000..919e375 --- /dev/null +++ b/api/src/models/citizen/identity.model.ts @@ -0,0 +1,48 @@ +import {Model, model, property} from '@loopback/repository'; +import {StringType, Gender, DateType, City, Country} from '../cmsTypes.model'; + +@model({settings: {idInjection: false}}) +export class Identity extends Model { + @property({ + description: 'Nom de famille du citoyen', + required: true, + }) + lastName: StringType; + + @property({ + description: 'Prénom du citoyen', + required: true, + }) + firstName: StringType; + + @property({ + description: 'Deuxième prénom du citoyen', + }) + middleNames?: StringType; + + @property({ + description: 'Sexe du citoyen', + required: true, + }) + gender: Gender; + + @property({ + description: 'Date de naissance du citoyen', + required: true, + }) + birthDate: DateType; + + @property({ + description: 'Lieu de naissance du citoyen', + }) + birthPlace?: City; + + @property({ + description: 'Pays de naissance du citoyen', + }) + birthCountry?: Country; + + constructor(identity: Identity) { + super(identity); + } +} diff --git a/api/src/models/citizen/index.ts b/api/src/models/citizen/index.ts new file mode 100644 index 0000000..27930de --- /dev/null +++ b/api/src/models/citizen/index.ts @@ -0,0 +1,4 @@ +export * from './affiliation.model'; +export * from './citizen.model'; +export * from './citizenUpdate.model'; +export * from './citizenMigration.model'; diff --git a/api/src/models/citizen/taxNotice.model.ts b/api/src/models/citizen/taxNotice.model.ts new file mode 100644 index 0000000..bcab363 --- /dev/null +++ b/api/src/models/citizen/taxNotice.model.ts @@ -0,0 +1,45 @@ +import {Model, model, property} from '@loopback/repository'; +import {IntegerType, NumberType} from '../cmsTypes.model'; + +@model({settings: {idInjection: false}}) +export class TaxNotice extends Model { + @property({ + type: IntegerType, + description: 'Année de déclaration', + }) + declarationYear: IntegerType; + + @property({ + type: IntegerType, + description: `Nombre d'actions`, + }) + numberOfShares: IntegerType; + + @property({ + type: NumberType, + description: 'Revenu brut global', + }) + grossIncome: NumberType; + + @property({ + type: NumberType, + description: 'Revenu fiscal', + }) + taxableIncome: NumberType; + + @property({ + type: NumberType, + description: 'Revenu fiscal de référence', + }) + referenceTaxIncome: NumberType; + + @property({ + type: NumberType, + description: `Montant de l'impôt`, + }) + taxAmount: NumberType; + + constructor(data: TaxNotice) { + super(data); + } +} diff --git a/api/src/models/cmsTypes.model.ts b/api/src/models/cmsTypes.model.ts new file mode 100644 index 0000000..1cec648 --- /dev/null +++ b/api/src/models/cmsTypes.model.ts @@ -0,0 +1,314 @@ +import {Entity, Model, model, property} from '@loopback/repository'; +import {emailRegexp} from '../constants'; +import {GENDER_TYPE} from '../utils/enum'; + +@model({settings: {idInjection: false}}) +export class CommonFields extends Entity { + @property({ + type: 'string', + description: 'Name of the service requested to certify data', + jsonSchema: { + example: 'franceconnect.gouv.fr', + }, + }) + source: string; + + @property({ + type: 'date', + description: 'Last certification date', + jsonSchema: { + example: '2022-06-17T14:22:01Z', + }, + }) + certificationDate: Date; + + constructor(data?: Partial) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class BooleanType extends CommonFields { + @property({ + type: 'boolean', + required: true, + description: 'Boolean value', + jsonSchema: { + example: true, + }, + }) + value: boolean; + + constructor(data: BooleanType) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class StringType extends CommonFields { + @property({ + type: 'string', + required: true, + description: 'String value', + jsonSchema: { + example: 'test', + minLength: 2, + }, + }) + value: string; + + constructor(data: StringType) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class IntegerType extends CommonFields { + @property({ + type: 'number', + required: true, + description: 'Integer value', + jsonSchema: { + example: 10, + format: 'int32', + }, + }) + value: number; + + constructor(data: IntegerType) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class NumberType extends CommonFields { + @property({ + type: 'number', + required: true, + description: 'Number value', + jsonSchema: { + example: 12345, + }, + }) + value: number; + + constructor(data: NumberType) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class DateType extends CommonFields { + @property({ + type: 'date', + required: true, + description: 'Date value', + jsonSchema: { + example: '2022-06-17', + format: 'date', + }, + }) + value: string; + + constructor(data: DateType) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class EmailType extends CommonFields { + @property({ + type: 'string', + required: true, + description: 'Email address', + jsonSchema: { + example: 'bob@yopmail.com', + format: 'email', + pattern: emailRegexp, + }, + }) + value: string; + + constructor(data: EmailType) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class PostalAddressValue extends Model { + @property({ + type: 'string', + required: true, + description: 'Address - Line 1', + jsonSchema: { + example: 'Rue 30 Boulevard la paix', + maxLength: 38, + }, + }) + line1: string; + + @property({ + type: 'string', + description: 'Address - Line 2', + jsonSchema: { + example: '', + maxLength: 38, + }, + }) + line2: string; + + @property({ + type: 'string', + description: 'Address - Line 3', + jsonSchema: { + example: '', + maxLength: 38, + }, + }) + line3: string; + + @property({ + type: 'string', + description: 'Address - Line 4', + jsonSchema: { + example: '', + maxLength: 38, + }, + }) + line4: string; + + @property({ + type: 'string', + description: 'Address - Line 5', + jsonSchema: { + example: '', + maxLength: 38, + }, + }) + line5: string; + + @property({ + type: 'string', + description: 'Address - Line 6', + jsonSchema: { + example: '', + maxLength: 38, + }, + }) + line6: string; + + constructor(data: PostalAddressValue) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class PostalAddress extends CommonFields { + @property({ + type: PostalAddressValue, + required: true, + description: 'Postal Address', + }) + value: PostalAddressValue; + + constructor(data: PostalAddressValue) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class PhoneNumber extends CommonFields { + @property({ + type: 'string', + required: true, + description: 'International Phone number', + jsonSchema: { + example: '+33660909080', + pattern: '^\\+[1-9]\\d{1,14}$', + }, + }) + value: string; + + @property({ + type: 'boolean', + required: true, + description: 'Is a mobile phone number', + jsonSchema: { + example: true, + }, + }) + mobile: boolean; + + constructor(data: PhoneNumber) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class Gender extends CommonFields { + @property({ + type: 'number', + required: true, + description: 'Gender ISO/IEC 5218 value', + jsonSchema: { + example: GENDER_TYPE.MALE, + enum: Object.values(GENDER_TYPE).filter(x => typeof x === 'number'), + minLength: 2, + }, + }) + value: GENDER_TYPE; + + constructor(data: Gender) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class Country extends CommonFields { + @property({ + type: 'string', + description: 'Country name', + jsonSchema: { + example: 'France', + }, + }) + value: string; + + @property({ + type: 'string', + description: 'Country ISO 3166-1 alpha-3 value', + jsonSchema: { + example: 'FRA', + }, + }) + isoValue: string; + + constructor(data: Country) { + super(data); + } +} + +@model({settings: {idInjection: false}}) +export class City extends CommonFields { + @property({ + type: 'string', + description: 'City INSEE value', + jsonSchema: { + example: '', + }, + }) + inseeValue: string; + + @property({ + type: 'string', + description: 'Name of the city', + jsonSchema: { + example: 'Paris', + }, + }) + name: string; + + constructor(data: City) { + super(data); + } +} diff --git a/api/src/models/collectivity/collectivity.model.ts b/api/src/models/collectivity/collectivity.model.ts new file mode 100644 index 0000000..2b46438 --- /dev/null +++ b/api/src/models/collectivity/collectivity.model.ts @@ -0,0 +1,59 @@ +import {model, property, Entity} from '@loopback/repository'; +import {EncryptionKey} from '../funder'; + +@model() +export class Collectivity extends Entity { + @property({ + type: 'string', + id: true, + description: `Identifiant de la collectivité`, + generated: false, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + description: `Nom de la collectivité`, + required: true, + jsonSchema: { + example: `Mulhouse`, + }, + }) + name: string; + + @property({ + type: 'number', + description: `Nombre de citoyens de la collectivité`, + nullable: true, + jsonSchema: { + example: 110000, + }, + }) + citizensCount?: number; + + @property({ + type: 'number', + description: `Budget total alloué à la mobilité`, + nullable: true, + jsonSchema: { + example: 100000, + }, + }) + mobilityBudget?: number; + + @property() + encryptionKey?: EncryptionKey; + + constructor(data?: Partial) { + super(data); + } +} + +export interface CollectivityRelations { + // describe navigational properties here +} + +export type CollectivityWithRelations = Collectivity & CollectivityRelations; diff --git a/api/src/models/collectivity/index.ts b/api/src/models/collectivity/index.ts new file mode 100644 index 0000000..6586d05 --- /dev/null +++ b/api/src/models/collectivity/index.ts @@ -0,0 +1 @@ +export * from './collectivity.model'; diff --git a/api/src/models/community/community.model.ts b/api/src/models/community/community.model.ts new file mode 100644 index 0000000..72ef245 --- /dev/null +++ b/api/src/models/community/community.model.ts @@ -0,0 +1,45 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Community extends Entity { + @property({ + type: 'string', + description: `Identifiant de la communauté`, + id: true, + generated: true, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + description: `Nom de la communauté`, + required: true, + jsonSchema: { + example: `Communauté A de Mulhouse`, + }, + }) + name: string; + + @property({ + type: 'string', + description: `Identifiant du financeur à laquelle la communauté est attachée`, + required: true, + jsonSchema: { + example: ``, + }, + }) + funderId: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface CommunityRelations { + // describe navigational properties here +} + +export type CommunityWithRelations = Community & CommunityRelations; diff --git a/api/src/models/community/funderCommunity.model.ts b/api/src/models/community/funderCommunity.model.ts new file mode 100644 index 0000000..557f265 --- /dev/null +++ b/api/src/models/community/funderCommunity.model.ts @@ -0,0 +1,38 @@ +import {model, property} from '@loopback/repository'; + +import {Community} from '.'; +import {FUNDER_TYPE} from '../../utils'; + +@model() +export class FunderCommunity extends Community { + @property({ + type: 'string', + description: `Nom du financeur attaché à la communauté`, + required: true, + jsonSchema: { + example: `Mulhouse`, + }, + }) + funderName: string; + + @property({ + type: 'string', + description: `Type du financeur attaché à la communauté`, + required: true, + jsonSchema: { + example: FUNDER_TYPE.collectivity, + enum: Object.values(FUNDER_TYPE), + }, + }) + funderType: FUNDER_TYPE; + + constructor(data?: Partial) { + super(data); + } +} + +export interface FunderCommunityRelations { + // describe navigational properties here +} + +export type FunderCommunityWithRelations = FunderCommunity & FunderCommunityRelations; diff --git a/api/src/models/community/index.ts b/api/src/models/community/index.ts new file mode 100644 index 0000000..72aff83 --- /dev/null +++ b/api/src/models/community/index.ts @@ -0,0 +1,2 @@ +export * from './community.model'; +export * from './funderCommunity.model'; diff --git a/api/src/models/contact/contact.model.ts b/api/src/models/contact/contact.model.ts new file mode 100644 index 0000000..41331fc --- /dev/null +++ b/api/src/models/contact/contact.model.ts @@ -0,0 +1,91 @@ +import {Model, model, property} from '@loopback/repository'; + +import {USERTYPE} from '../../utils'; + +@model() +export class Contact extends Model { + @property({ + type: 'string', + description: `Nom de famille`, + required: true, + jsonSchema: { + example: 'Rasovsky', + minLength: 1, + }, + }) + lastName: string; + + @property({ + type: 'string', + description: `Prénom`, + required: true, + jsonSchema: { + example: 'Bob', + minLength: 1, + }, + }) + firstName: string; + + @property({ + type: 'string', + description: `Type d'utilisateur`, + required: true, + jsonSchema: { + example: USERTYPE.CITIZEN, + enum: Object.values(USERTYPE), + }, + }) + userType: string; + + @property({ + type: 'string', + description: `Email`, + required: true, + jsonSchema: { + example: `bob.rasovsky@example.com`, + pattern: '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$', + }, + }) + email: string; + + @property({ + type: 'string', + description: `Code postal`, + required: true, + jsonSchema: { + example: 31000, + pattern: '^[0-9]{5}$', + }, + }) + postcode: string; + + @property({ + type: 'string', + description: `Message que l'utilisateur souhaite envoyer au service MOB`, + jsonSchema: { + example: `Quand sera-t-il possible de souscrire à une aide de Toulouse ?`, + }, + }) + message?: string; + + @property({ + type: 'boolean', + description: `Acceptation des CGU et de la politique de protections des données`, + required: true, + hidden: true, + jsonSchema: { + example: true, + }, + }) + tos: boolean; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ContactRelations { + // describe navigational properties here +} + +export type ContactWithRelations = Contact & ContactRelations; diff --git a/api/src/models/contact/index.ts b/api/src/models/contact/index.ts new file mode 100644 index 0000000..3dd4eaf --- /dev/null +++ b/api/src/models/contact/index.ts @@ -0,0 +1 @@ +export * from './contact.model'; diff --git a/api/src/models/cronJob/cronJob.model.ts b/api/src/models/cronJob/cronJob.model.ts new file mode 100644 index 0000000..d9e162e --- /dev/null +++ b/api/src/models/cronJob/cronJob.model.ts @@ -0,0 +1,46 @@ +import {model, property, Entity} from '@loopback/repository'; + +@model() +export class CronJob extends Entity { + @property({ + type: 'string', + description: `Identifiant du cron job`, + id: true, + generated: true, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + description: `Type du cron job`, + required: true, + index: { + unique: true, + }, + jsonSchema: { + example: `cron_type`, + }, + }) + type: string; + + @property({ + description: `Date de création du cron`, + type: 'date', + defaultFn: 'now', + jsonSchema: { + example: `2022-01-01 00:00:00.000Z`, + }, + }) + createdAt?: Date; + + constructor(data?: Partial) { + super(data); + } +} + +export interface CronJobRelations {} + +export type CronJobWithRelations = CronJob & CronJobRelations; diff --git a/api/src/models/cronJob/index.ts b/api/src/models/cronJob/index.ts new file mode 100644 index 0000000..5d28f45 --- /dev/null +++ b/api/src/models/cronJob/index.ts @@ -0,0 +1 @@ +export * from './cronJob.model'; diff --git a/api/src/models/enterprise/enterprise.model.ts b/api/src/models/enterprise/enterprise.model.ts new file mode 100644 index 0000000..dff5523 --- /dev/null +++ b/api/src/models/enterprise/enterprise.model.ts @@ -0,0 +1,106 @@ +import {model, property, Entity} from '@loopback/repository'; +import {EncryptionKey} from '../funder'; + +@model() +export class Enterprise extends Entity { + @property({ + type: 'string', + description: `Identifiant du l'entreprise`, + id: true, + generated: false, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + required: true, + description: `Nom de l'entreprise`, + jsonSchema: { + example: `Capgemini`, + }, + }) + name: string; + + @property({ + type: 'number', + description: `Numéro SIRET de l'entreprise`, + jsonSchema: { + example: 33070384400036, + }, + }) + siretNumber?: number; + + @property({ + type: 'array', + description: `Modèles d'email de l'entreprise`, + itemType: 'string', + required: true, + jsonSchema: { + example: `@professional.com`, + }, + }) + emailFormat: string[]; + + @property({ + type: 'number', + description: `Nombre d'employés de l'entreprise`, + nullable: true, + jsonSchema: { + example: 200000, + }, + }) + employeesCount?: number; + + @property({ + type: 'number', + description: `Bugdet total alloué à la mobilité`, + nullable: true, + jsonSchema: { + example: 300000, + }, + }) + budgetAmount?: number; + + @property({ + type: 'boolean', + description: `Séléction du processus SIRH`, + required: true, + jsonSchema: { + example: true, + }, + }) + isHris: boolean; + + @property({ + type: 'boolean', + description: `L'entreprise accepte l'affiliation manuelle`, + required: true, + jsonSchema: { + example: true, + }, + }) + hasManualAffiliation: boolean; + + @property({ + type: 'string', + description: `Nom du client`, + jsonSchema: { + example: `Capgemini`, + }, + }) + clientId: string; + + @property() + encryptionKey?: EncryptionKey; + + constructor(data?: Partial) { + super(data); + } +} + +export interface EnterpriseRelations {} + +export type EnterpriseWithRelations = Enterprise & EnterpriseRelations; diff --git a/api/src/models/enterprise/index.ts b/api/src/models/enterprise/index.ts new file mode 100644 index 0000000..e12497f --- /dev/null +++ b/api/src/models/enterprise/index.ts @@ -0,0 +1 @@ +export * from './enterprise.model'; diff --git a/api/src/models/error/error.model.ts b/api/src/models/error/error.model.ts new file mode 100644 index 0000000..805a086 --- /dev/null +++ b/api/src/models/error/error.model.ts @@ -0,0 +1,21 @@ +import {Model, model, property} from '@loopback/repository'; +import {ErrorBody} from './errorBody.model'; + +@model() +export class Error extends Model { + @property({ + required: true, + description: `Enveloppe contenant les détails de l'erreur`, + }) + error: ErrorBody; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ErrorRelations { + // describe navigational properties here +} + +export type ErrorWithRelations = Error & ErrorRelations; diff --git a/api/src/models/error/errorBody.model.ts b/api/src/models/error/errorBody.model.ts new file mode 100644 index 0000000..a4b45a5 --- /dev/null +++ b/api/src/models/error/errorBody.model.ts @@ -0,0 +1,40 @@ +import {Model, model, property} from '@loopback/repository'; +import {ErrorDetail} from './errorDetail.model'; + +@model() +export class ErrorBody extends Model { + @property({ + required: true, + description: `Code HTTP`, + }) + statusCode: number; + + @property({ + required: true, + description: `Message décrivant l'erreur`, + }) + message: string; + + @property({ + required: true, + description: `Nom de l'erreur`, + }) + name: string; + + @property({ + description: `Code identifiant le type d'erreur`, + }) + code?: string; + + @property({ + description: `Localisation de l'erreur`, + }) + path?: string; + + @property.array(ErrorDetail) + details?: ErrorDetail[]; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/api/src/models/error/errorDetail.model.ts b/api/src/models/error/errorDetail.model.ts new file mode 100644 index 0000000..6734e1c --- /dev/null +++ b/api/src/models/error/errorDetail.model.ts @@ -0,0 +1,33 @@ +import {Model, model, property} from '@loopback/repository'; + +@model() +export class ErrorDetail extends Model { + @property({ + required: true, + description: `Localisation de l'erreur`, + }) + path: string; + + @property({ + required: true, + description: `Code identifiant le type d'erreur`, + }) + code: string; + + @property({ + required: true, + description: `Message décrivant le détail de l'erreur`, + }) + message: string; + + @property({ + type: 'object', + required: true, + description: `Objet contenant des informations supplémentaires concernant l'erreur`, + }) + info: Object; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/api/src/models/error/index.ts b/api/src/models/error/index.ts new file mode 100644 index 0000000..214901a --- /dev/null +++ b/api/src/models/error/index.ts @@ -0,0 +1,2 @@ +export * from './error.model'; +export * from './errorBody.model'; diff --git a/api/src/models/funder/encryptionKey.model.ts b/api/src/models/funder/encryptionKey.model.ts new file mode 100644 index 0000000..fe6d961 --- /dev/null +++ b/api/src/models/funder/encryptionKey.model.ts @@ -0,0 +1,79 @@ +import {Model, model, property} from '@loopback/repository'; +import {PrivateKeyAccess} from './privateKeyAccess.model'; + +@model({settings: {idInjection: false}}) +export class EncryptionKey extends Model { + @property({ + type: 'string', + description: `Identifiant de la clé de chiffrement du financeur`, + required: true, + jsonSchema: { + example: `1`, + minLength: 1, + }, + }) + id: string; + + @property({ + type: 'number', + description: `Version de la clé de chiffrement du financeur`, + required: true, + jsonSchema: { + example: 1, + }, + }) + version: number; + + @property({ + type: 'string', + description: `Clé publique du financeur`, + required: true, + jsonSchema: { + example: `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq +n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf +n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq +sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85 +OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG +X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n +owIDAQAB +-----END PUBLIC KEY----- +`, + minLength: 1, + }, + }) + publicKey: string; + + @property({ + type: 'date', + description: `Date d'expiration de la clé`, + required: true, + jsonSchema: { + example: `2022-12-17T14:22:01Z`, + }, + }) + expirationDate: Date; + + @property({ + type: 'date', + description: `Date de la dernière mise à jour de la clé`, + required: true, + jsonSchema: { + example: `2022-06-17T14:22:01Z`, + }, + }) + lastUpdateDate: Date; + + @property() + privateKeyAccess?: PrivateKeyAccess; + + constructor(data?: Partial) { + super(data); + } +} + +export interface EncryptionKeyRelations { + // describe navigational properties here +} + +export type EncryptionKeyWithRelations = EncryptionKey & EncryptionKeyRelations; diff --git a/api/src/models/funder/index.ts b/api/src/models/funder/index.ts new file mode 100644 index 0000000..d28dbcb --- /dev/null +++ b/api/src/models/funder/index.ts @@ -0,0 +1,2 @@ +export * from './encryptionKey.model'; +export * from './privateKeyAccess.model'; diff --git a/api/src/models/funder/privateKeyAccess.model.ts b/api/src/models/funder/privateKeyAccess.model.ts new file mode 100644 index 0000000..acb69eb --- /dev/null +++ b/api/src/models/funder/privateKeyAccess.model.ts @@ -0,0 +1,34 @@ +import {Model, model, property} from '@loopback/repository'; + +@model({settings: {idInjection: false}}) +export class PrivateKeyAccess extends Model { + @property({ + type: 'string', + description: `URL interne de connexion au Key Manager`, + jsonSchema: { + example: `https://keyvault/auth/cert/login`, + minLength: 1, + }, + }) + loginURL: string; + + @property({ + type: 'string', + description: `URL interne d'accès à la clé privée`, + jsonSchema: { + example: `https://keyvault/keyname`, + minLength: 1, + }, + }) + getKeyURL: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface PrivateKeyAccessRelations { + // describe navigational properties here +} + +export type PrivateKeyAccessWithRelations = PrivateKeyAccess & PrivateKeyAccessRelations; diff --git a/api/src/models/idp/client-scope-client.model.ts b/api/src/models/idp/client-scope-client.model.ts new file mode 100644 index 0000000..b5a9818 --- /dev/null +++ b/api/src/models/idp/client-scope-client.model.ts @@ -0,0 +1,66 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'client_scope_client'}, + }, +}) +export class ClientScopeClient extends Entity { + @property({ + type: 'string', + required: true, + length: 255, + id: 1, + postgresql: { + columnName: 'client_id', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + clientId: string; + + @property({ + type: 'string', + required: true, + length: 255, + id: 2, + postgresql: { + columnName: 'scope_id', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + clientScopeId: string; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'default_scope', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + defaultScope: boolean; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ClientScopeClientRelations { + // describe navigational properties here +} + +export type ClientScopeClientWithRelations = ClientScopeClient & + ClientScopeClientRelations; diff --git a/api/src/models/idp/client-scope.model.ts b/api/src/models/idp/client-scope.model.ts new file mode 100644 index 0000000..0366796 --- /dev/null +++ b/api/src/models/idp/client-scope.model.ts @@ -0,0 +1,93 @@ +import {Entity, model, property, hasMany} from '@loopback/repository'; +import {Client} from './client.model'; +import {ClientScopeClient} from './client-scope-client.model'; + +@model({ + settings: {idInjection: false, postgresql: {schema: 'idp_db', table: 'client_scope'}}, +}) +export class ClientScope extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'id', + dataType: 'character varying', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + id: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'name', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + name?: string; + + @property({ + type: 'string', + length: 36, + postgresql: { + columnName: 'realm_id', + dataType: 'character varying', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + realmId?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'description', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + description?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'protocol', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + protocol?: string; + + @hasMany(() => Client, {through: {model: () => ClientScopeClient}}) + clients: Client[]; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ClientScopeRelations { + // describe navigational properties here +} + +export type ClientScopeWithRelations = ClientScope & ClientScopeRelations; diff --git a/api/src/models/idp/client.model.ts b/api/src/models/idp/client.model.ts new file mode 100644 index 0000000..6a2a70e --- /dev/null +++ b/api/src/models/idp/client.model.ts @@ -0,0 +1,382 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: {idInjection: false, postgresql: {schema: 'idp_db', table: 'client'}}, +}) +export class Client extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'id', + dataType: 'character varying', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + id: string; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'enabled', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + enabled: boolean; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'full_scope_allowed', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + fullScopeAllowed: boolean; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'client_id', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + clientId?: string; + + @property({ + type: 'number', + scale: 0, + postgresql: { + columnName: 'not_before', + dataType: 'integer', + dataLength: null, + dataPrecision: null, + dataScale: 0, + nullable: 'YES', + }, + }) + notBefore?: number; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'public_client', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + publicClient: boolean; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'secret', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + secret?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'base_url', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + baseUrl?: string; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'bearer_only', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + bearerOnly: boolean; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'management_url', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + managementUrl?: string; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'surrogate_auth_required', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + surrogateAuthRequired: boolean; + + @property({ + type: 'string', + length: 36, + postgresql: { + columnName: 'realm_id', + dataType: 'character varying', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + realmId?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'protocol', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + protocol?: string; + + @property({ + type: 'number', + scale: 0, + postgresql: { + columnName: 'node_rereg_timeout', + dataType: 'integer', + dataLength: null, + dataPrecision: null, + dataScale: 0, + nullable: 'YES', + }, + }) + nodeReregTimeout?: number; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'frontchannel_logout', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + frontchannelLogout: boolean; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'consent_required', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + consentRequired: boolean; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'name', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + name?: string; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'service_accounts_enabled', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + serviceAccountsEnabled: boolean; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'client_authenticator_type', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + clientAuthenticatorType?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'root_url', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + rootUrl?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'description', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + description?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'registration_token', + dataType: 'character varying', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'YES', + }, + }) + registrationToken?: string; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'standard_flow_enabled', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + standardFlowEnabled: boolean; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'implicit_flow_enabled', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + implicitFlowEnabled: boolean; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'direct_access_grants_enabled', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + directAccessGrantsEnabled: boolean; + + @property({ + type: 'boolean', + required: true, + postgresql: { + columnName: 'always_display_in_console', + dataType: 'boolean', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + alwaysDisplayInConsole: boolean; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ClientRelations { + // describe navigational properties here +} + +export type ClientWithRelations = Client & ClientRelations; diff --git a/api/src/models/idp/group-role-mapping.model.ts b/api/src/models/idp/group-role-mapping.model.ts new file mode 100644 index 0000000..e959e03 --- /dev/null +++ b/api/src/models/idp/group-role-mapping.model.ts @@ -0,0 +1,51 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'group_role_mapping'}, + }, +}) +export class GroupRoleMapping extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'role_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + roleId: string; + + @property({ + type: 'string', + required: true, + length: 36, + id: 2, + postgresql: { + columnName: 'group_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + groupId: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface GroupRoleMappingRelations { + // describe navigational properties here +} + +export type GroupRoleMappingWithRelations = GroupRoleMapping & GroupRoleMappingRelations; diff --git a/api/src/models/idp/index.ts b/api/src/models/idp/index.ts new file mode 100644 index 0000000..dc41ad8 --- /dev/null +++ b/api/src/models/idp/index.ts @@ -0,0 +1,12 @@ +export * from './group-role-mapping.model'; +export * from './keycloak-group.model'; +export * from './keycloak-role.model'; +export * from './user-entity.model'; +export * from './user-group-membership.model'; +export * from './user-role-mapping.model'; +export * from './user-entity.model'; +export * from './offline-user-session.model'; +export * from './offline-client-session.model'; +export * from './client.model'; +export * from './client-scope.model'; +export * from './client-scope-client.model'; diff --git a/api/src/models/idp/keycloak-group.model.ts b/api/src/models/idp/keycloak-group.model.ts new file mode 100644 index 0000000..5e03632 --- /dev/null +++ b/api/src/models/idp/keycloak-group.model.ts @@ -0,0 +1,86 @@ +import {Entity, model, property, hasMany} from '@loopback/repository'; + +import {KeycloakRole} from './keycloak-role.model'; +import {GroupRoleMapping} from './group-role-mapping.model'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'keycloak_group'}, + }, +}) +export class KeycloakGroup extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + id: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'name', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + name?: string; + + @property({ + type: 'string', + required: false, + length: 36, + postgresql: { + columnName: 'parent_group', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + parentGroup?: string; + + @property({ + type: 'string', + length: 36, + postgresql: { + columnName: 'realm_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + realmId?: string; + + @hasMany(() => KeycloakRole, { + through: {model: () => GroupRoleMapping, keyFrom: 'groupId', keyTo: 'roleId'}, + }) + keycloakRoles: KeycloakRole[]; + + constructor(data?: Partial) { + super(data); + } +} + +export interface KeycloakGroupRelations { + // describe navigational properties here +} + +export type KeycloakGroupWithRelations = KeycloakGroup & KeycloakGroupRelations; diff --git a/api/src/models/idp/keycloak-role.model.ts b/api/src/models/idp/keycloak-role.model.ts new file mode 100644 index 0000000..c68b640 --- /dev/null +++ b/api/src/models/idp/keycloak-role.model.ts @@ -0,0 +1,133 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'keycloak_role'}, + }, +}) +export class KeycloakRole extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + id: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'client_realm_constraint', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + clientRealmConstraint?: string; + + @property({ + type: 'number', + precision: 1, + postgresql: { + columnName: 'client_role', + dataType: 'bit', + dataLength: null, + dataPrecision: 1, + dataScale: null, + nullable: 'Y', + }, + }) + clientRole?: number; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'description', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + description?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'name', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + name: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'realm_id', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + realmId?: string; + + @property({ + type: 'string', + length: 36, + postgresql: { + columnName: 'client', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + client?: string; + + @property({ + type: 'string', + length: 36, + postgresql: { + columnName: 'realm', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + realm?: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface KeycloakRoleRelations { + // describe navigational properties here +} + +export type KeycloakRoleWithRelations = KeycloakRole & KeycloakRoleRelations; diff --git a/api/src/models/idp/offline-client-session.model.ts b/api/src/models/idp/offline-client-session.model.ts new file mode 100644 index 0000000..c021587 --- /dev/null +++ b/api/src/models/idp/offline-client-session.model.ts @@ -0,0 +1,134 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'offline_client_session'}, + }, +}) +export class OfflineClientSession extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'user_session_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + userSessionId: string; + + @property({ + type: 'string', + required: true, + length: 255, + id: 2, + postgresql: { + columnName: 'client_id', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + clientId: string; + + @property({ + type: 'string', + required: true, + length: 4, + id: 5, + postgresql: { + columnName: 'offline_flag', + dataType: 'varchar', + dataLength: 4, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + offlineFlag: string; + + @property({ + type: 'number', + precision: 10, + scale: 0, + postgresql: { + columnName: 'timestamp', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 'Y', + }, + }) + timestamp?: number; + + @property({ + type: 'string', + length: 4294967295, + postgresql: { + columnName: 'data', + dataType: 'longtext', + dataLength: 4294967295, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + data?: string; + + @property({ + type: 'string', + required: true, + length: 36, + id: 3, + postgresql: { + columnName: 'client_storage_provider', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + clientStorageProvider: string; + + @property({ + type: 'string', + required: true, + length: 255, + id: 4, + postgresql: { + columnName: 'external_client_id', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + externalClientId: string; + + // Define well-known properties here + + // Indexer property to allow additional data + // [prop: string]: any; + + constructor(data?: Partial) { + super(data); + } +} + +export interface OfflineClientSessionRelations { + // describe navigational properties here +} + +export type OfflineClientSessionWithRelations = OfflineClientSession & + OfflineClientSessionRelations; diff --git a/api/src/models/idp/offline-user-session.model.ts b/api/src/models/idp/offline-user-session.model.ts new file mode 100644 index 0000000..de924b4 --- /dev/null +++ b/api/src/models/idp/offline-user-session.model.ts @@ -0,0 +1,132 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'offline_user_session'}, + }, +}) +export class OfflineUserSession extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'user_session_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + userSessionId: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'user_id', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + userId?: string; + + @property({ + type: 'string', + required: true, + length: 36, + postgresql: { + columnName: 'realm_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + realmId: string; + + @property({ + type: 'number', + required: true, + precision: 10, + scale: 0, + postgresql: { + columnName: 'created_on', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 'N', + }, + }) + createdOn: number; + + @property({ + type: 'string', + required: true, + length: 4, + id: 2, + postgresql: { + columnName: 'offline_flag', + dataType: 'varchar', + dataLength: 4, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + offlineFlag: string; + + @property({ + type: 'string', + length: 4294967295, + postgresql: { + columnName: 'data', + dataType: 'longtext', + dataLength: 4294967295, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + data?: string; + + @property({ + type: 'number', + required: true, + precision: 10, + scale: 0, + postgresql: { + columnName: 'last_session_refresh', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 'N', + }, + }) + lastSessionRefresh: number; + + // Define well-known properties here + + // Indexer property to allow additional data + // [prop: string]: any; + + constructor(data?: Partial) { + super(data); + } +} + +export interface OfflineUserSessionRelations { + // describe navigational properties here +} + +export type OfflineUserSessionWithRelations = OfflineUserSession & + OfflineUserSessionRelations; diff --git a/api/src/models/idp/user-entity.model.ts b/api/src/models/idp/user-entity.model.ts new file mode 100644 index 0000000..473b6cd --- /dev/null +++ b/api/src/models/idp/user-entity.model.ts @@ -0,0 +1,215 @@ +import {Entity, model, property, hasMany} from '@loopback/repository'; + +import {KeycloakGroup, UserGroupMembership} from '..'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'user_entity'}, + }, +}) +export class UserEntity extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + id: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'email', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + email?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'email_constraint', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + emailConstraint?: string; + + @property({ + type: 'Binary', + required: true, + precision: 1, + postgresql: { + columnName: 'email_verified', + dataType: 'bit', + dataLength: null, + dataPrecision: 1, + dataScale: null, + nullable: 'N', + }, + }) + emailVerified: Boolean; + + @property({ + type: 'Binary', + required: true, + precision: 1, + postgresql: { + columnName: 'enabled', + dataType: 'bit', + dataLength: null, + dataPrecision: 1, + dataScale: null, + nullable: 'N', + }, + }) + enabled: Boolean; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'federation_link', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + federationLink?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'first_name', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + firstName?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'last_name', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + lastName?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'realm_id', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + realmId?: string; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'username', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + username?: string; + + @property({ + type: 'number', + precision: 19, + scale: 0, + postgresql: { + columnName: 'created_timestamp', + dataType: 'bigint', + dataLength: null, + dataPrecision: 19, + dataScale: 0, + nullable: 'Y', + }, + }) + createdTimestamp?: number; + + @property({ + type: 'string', + length: 255, + postgresql: { + columnName: 'service_account_client_link', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }) + serviceAccountClientLink?: string; + + @property({ + type: 'number', + required: true, + precision: 10, + scale: 0, + postgresql: { + columnName: 'not_before', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 'N', + }, + }) + notBefore: number; + + @hasMany(() => KeycloakGroup, { + through: {model: () => UserGroupMembership, keyFrom: 'userId', keyTo: 'groupId'}, + }) + keycloakGroups: KeycloakGroup[]; + + constructor(data?: Partial) { + super(data); + } +} + +export interface UserEntityRelations { + // describe navigational properties here +} + +export type UserEntityWithRelations = UserEntity & UserEntityRelations; diff --git a/api/src/models/idp/user-group-membership.model.ts b/api/src/models/idp/user-group-membership.model.ts new file mode 100644 index 0000000..e8cde74 --- /dev/null +++ b/api/src/models/idp/user-group-membership.model.ts @@ -0,0 +1,52 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'user_group_membership'}, + }, +}) +export class UserGroupMembership extends Entity { + @property({ + type: 'string', + required: true, + length: 36, + id: 1, + postgresql: { + columnName: 'group_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + groupId: string; + + @property({ + type: 'string', + required: true, + length: 36, + id: 2, + postgresql: { + columnName: 'user_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + userId: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface UserGroupMembershipRelations { + // describe navigational properties here +} + +export type UserGroupMembershipWithRelations = UserGroupMembership & + UserGroupMembershipRelations; diff --git a/api/src/models/idp/user-role-mapping.model.ts b/api/src/models/idp/user-role-mapping.model.ts new file mode 100644 index 0000000..50251c0 --- /dev/null +++ b/api/src/models/idp/user-role-mapping.model.ts @@ -0,0 +1,51 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + settings: { + idInjection: false, + postgresql: {schema: 'idp_db', table: 'user_role_mapping'}, + }, +}) +export class UserRoleMapping extends Entity { + @property({ + type: 'string', + required: true, + length: 255, + id: 1, + postgresql: { + columnName: 'role_id', + dataType: 'varchar', + dataLength: 255, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + roleId: string; + + @property({ + type: 'string', + required: true, + length: 36, + id: 2, + postgresql: { + columnName: 'user_id', + dataType: 'varchar', + dataLength: 36, + dataPrecision: null, + dataScale: null, + nullable: 'N', + }, + }) + userId: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface UserRoleMappingRelations { + // describe navigational properties here +} + +export type UserRoleMappingWithRelations = UserRoleMapping & UserRoleMappingRelations; diff --git a/api/src/models/incentive/incentive.model.ts b/api/src/models/incentive/incentive.model.ts new file mode 100644 index 0000000..b6fc162 --- /dev/null +++ b/api/src/models/incentive/incentive.model.ts @@ -0,0 +1,269 @@ +import {Entity, model, property} from '@loopback/repository'; + +import {SpecificField} from '../subscription/specificField.model'; +import {INCENTIVE_TYPE, TRANSPORTS} from '../../utils'; +import {Link} from '../links.model'; +import {Territory} from '../territory'; + +@model({ + settings: { + mongodb: {allowExtendedOperators: true}, + }, +}) +export class Incentive extends Entity { + @property({ + type: 'string', + id: true, + description: `Identifiant de l'aide`, + generated: true, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + required: true, + description: `Titre de l'aide`, + jsonSchema: { + example: `Le vélo électrique arrive à Mulhouse !`, + }, + }) + title: string; + + @property({ + type: 'string', + required: true, + description: `Description de l'aide`, + jsonSchema: { + example: `Sous conditions d'éligibilité,\ + Mulhouse met à disposition une aide au financement d'un vélo électrique`, + }, + }) + description: string; + + // TODO: REMOVING DEPRECATED territoryName. + @property({ + type: 'string', + required: false, + description: `Territoire de l'aide`, + jsonSchema: { + example: `Mulhouse`, + }, + }) + territoryName: string; + + @property({type: Territory, required: true}) + territory: Territory; + + @property({ + type: 'string', + required: true, + description: `Financeur de l'aide`, + jsonSchema: { + example: `Mulhouse`, + }, + }) + funderName: string; + + @property({ + type: 'string', + required: true, + description: `Type de l'aide`, + jsonSchema: { + example: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + enum: Object.values(INCENTIVE_TYPE), + }, + }) + incentiveType: string; + + @property({ + type: 'string', + required: true, + description: `Conditions d'octroi de l'aide`, + jsonSchema: { + example: `Fournir une preuve d'achat d'un vélo électrique`, + }, + }) + conditions: string; + + @property({ + type: 'string', + required: true, + description: 'Méthode du paiement', + jsonSchema: { + example: `Remboursement par virement`, + }, + }) + paymentMethod: string; + + @property({ + type: 'string', + required: true, + description: `Montant alloué à l'aide par le financeur`, + jsonSchema: { + example: `500 €`, + }, + }) + allocatedAmount: string; + + @property({ + type: 'string', + required: true, + description: 'Montant minimal attribué aux bénéficiaires', + jsonSchema: { + example: `50 €`, + }, + }) + minAmount: string; + + @property({ + type: 'array', + itemType: 'string', + required: true, + description: `Catégories de transport de l'aide`, + jsonSchema: { + example: TRANSPORTS.ELECTRIC, + enum: Object.values(TRANSPORTS), + }, + }) + transportList: string[]; + + @property({ + type: 'array', + itemType: 'string', + description: `Justificatifs demandés pour l'octroi de l'aide`, + jsonSchema: { + example: `Justificatif de domicile`, + }, + }) + attachments?: string[]; + + @property({ + type: 'string', + description: 'Informations supplémentaires', + jsonSchema: { + example: `Aide mise à disposition uniquement pour les habitants de Mulhouse`, + }, + }) + additionalInfos?: string; + + @property({ + type: 'string', + description: 'Coordonnées de contact', + jsonSchema: { + example: `Contactez le numéro vert au 05 206 308`, + }, + }) + contact?: string; + + @property({ + type: 'string', + description: `Durée de validité de l'aide`, + jsonSchema: { + example: `12 mois`, + }, + }) + validityDuration?: string; + + @property({ + type: 'date', + description: `Date de fin de validité de l'aide`, + jsonSchema: { + example: `2024-07-31`, + format: 'date', + }, + }) + validityDate?: string; + + @property({ + type: 'boolean', + description: 'Souscription possible à une aide via MCM', + default: false, + jsonSchema: { + example: true, + }, + }) + isMCMStaff: boolean; + + @property.array(SpecificField) + specificFields?: SpecificField[]; + + @property({ + type: 'object', + description: 'Équivalent des champs spécifiques au format JsonSchema', + hidden: true, + jsonSchema: { + example: { + properties: { + 'Statut marital': { + type: 'array', + maxItems: 1, + items: { + enum: ['Marié', 'Célibataire'], + }, + }, + }, + title: 'Le vélo électrique arrive à Mulhouse !', + type: 'object', + required: ['Statut marital'], + additionalProperties: false, + }, + }, + }) + jsonSchema?: object; + + @property({ + type: 'string', + description: `Lien externe de souscription si non possible via MCM`, + jsonSchema: { + example: `https://www.mulhouse.fr`, + pattern: + "^(?:http(s)?:\\/\\/)[\\w.-]+(?:\\.[\\w\\.-]+)+[\\w\\-\\._~:%/?#[\\]@!\\$&'\\(\\)*\\+,;=.]+$", + }, + }) + subscriptionLink?: string; + + @property({ + description: `Date de création de l'aide`, + type: 'date', + defaultFn: 'now', + jsonSchema: { + example: `2022-01-01 00:00:00.000Z`, + }, + }) + createdAt?: Date; + + @property({ + description: `Date de modification de l'aide`, + type: 'date', + defaultFn: 'now', + jsonSchema: { + example: `2022-01-02 00:00:00.000Z`, + }, + }) + updatedAt?: Date; + + @property({ + description: `L'identifiant du financeur de l'aide présent ou non en fonction de isMCMStaff`, + type: 'string', + jsonSchema: { + example: ``, + }, + }) + funderId?: string; + + @property.array(Link) + links?: Link[]; + + constructor(data?: Partial) { + super(data); + } +} + +export interface IncentiveRelations { + // describe navigational properties here +} + +export type IncentiveWithRelations = Incentive & IncentiveRelations; diff --git a/api/src/models/incentive/index.ts b/api/src/models/incentive/index.ts new file mode 100644 index 0000000..d4b1eaa --- /dev/null +++ b/api/src/models/incentive/index.ts @@ -0,0 +1 @@ +export * from './incentive.model'; diff --git a/api/src/models/index.ts b/api/src/models/index.ts new file mode 100644 index 0000000..a446e1d --- /dev/null +++ b/api/src/models/index.ts @@ -0,0 +1,15 @@ +export * from './keycloak.model'; +export * from './links.model'; +export * from './incentive'; +export * from './contact'; +export * from './collectivity'; +export * from './enterprise'; +export * from './funder'; +export * from './user'; +export * from './community'; +export * from './subscription'; +export * from './idp'; +export * from './citizen'; +export * from './error'; +export * from './cronJob'; +export * from './territory'; diff --git a/api/src/models/keycloak.model.ts b/api/src/models/keycloak.model.ts new file mode 100644 index 0000000..29a5b67 --- /dev/null +++ b/api/src/models/keycloak.model.ts @@ -0,0 +1,43 @@ +import {model, property, Entity} from '@loopback/repository'; + +import {emailRegexp} from '../constants'; + +@model() +export class Keycloak extends Entity { + @property({ + type: 'string', + description: `Email`, + required: true, + jsonSchema: { + example: `bob.rasovsky@example.com`, + pattern: emailRegexp, + }, + }) + email: string; + + @property({ + type: 'string', + description: `Prénom`, + required: true, + jsonSchema: { + example: `Bob`, + minLength: 2, + }, + }) + firstName: string; + + @property({ + type: 'string', + description: `Nom de famille`, + required: true, + jsonSchema: { + example: `Rasovsky`, + minLength: 2, + }, + }) + lastName: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/api/src/models/links.model.ts b/api/src/models/links.model.ts new file mode 100644 index 0000000..c1ea13b --- /dev/null +++ b/api/src/models/links.model.ts @@ -0,0 +1,40 @@ +import {Model, model, property} from '@loopback/repository'; +import {HTTP_METHOD} from '../utils'; + +@model({settings: {idInjection: false}}) +export class Link extends Model { + @property({ + type: 'string', + description: `Lien de redirection cible`, + required: true, + jsonSchema: { + example: + 'https://website.${env}.moncomptemobilite.fr/subscriptions/new?incentiveId=', + }, + }) + href: string; + + @property({ + type: 'string', + description: `Ressource liée à la redirection`, + required: true, + jsonSchema: { + example: `subscribe`, + }, + }) + rel: string; + + @property({ + type: 'string', + description: `Méthode HTTP nécessaire pour faire la redirection`, + required: true, + jsonSchema: { + example: HTTP_METHOD.GET, + }, + }) + method: HTTP_METHOD; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/api/src/models/subscription/attachmentMetadata.model.ts b/api/src/models/subscription/attachmentMetadata.model.ts new file mode 100644 index 0000000..e57f19f --- /dev/null +++ b/api/src/models/subscription/attachmentMetadata.model.ts @@ -0,0 +1,306 @@ +import {model, Model, property} from '@loopback/repository'; + +@model() +class ProductDetails extends Model { + @property({ + type: 'string', + description: 'Fréquence', + jsonSchema: { + example: `Mensuel`, + }, + }) + periodicity: string; + + @property({ + type: 'number', + description: 'Zone minimum', + jsonSchema: { + example: 1, + }, + }) + zoneMin: number; + + @property({ + type: 'number', + description: 'Zone maximum', + jsonSchema: { + example: 5, + }, + }) + zoneMax: number; + + @property({ + description: 'Date de début', + jsonSchema: { + example: `2021-03-01T00:00:00+01:00`, + }, + }) + validityStart: Date; + + @property({ + description: 'Date de fin', + jsonSchema: { + example: `2021-03-31T00:00:00+01:00`, + }, + }) + validityEnd: Date; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +class Address extends Model { + @property({ + type: 'number', + description: 'Code postal', + jsonSchema: { + example: 75018, + }, + }) + zipCode: number; + + @property({ + type: 'string', + description: 'Ville', + jsonSchema: { + example: `Paris`, + }, + }) + city: string; + + @property({ + type: 'string', + description: 'Rue', + jsonSchema: { + example: `6 rue Lepic`, + }, + }) + street: string; + + constructor(data?: Partial
) { + super(data); + } +} + +@model() +class EnterpriseData extends Model { + @property({ + type: 'string', + description: `Nom de l'entreprise`, + required: true, + jsonSchema: { + example: `IDFM`, + }, + }) + enterpriseName: string; + + @property({ + type: 'string', + description: `N° SIREN`, + jsonSchema: { + example: `362521879`, + }, + }) + sirenNumber: string; + + @property({ + type: 'string', + description: `N° SIRET`, + jsonSchema: { + example: `36252187900034`, + }, + required: true, + }) + siretNumber: string; + + @property({ + type: 'string', + description: `Code APE`, + jsonSchema: { + example: `4711D`, + }, + }) + apeCode: string; + + @property() enterpriseAddress: Address; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +class CustomerData extends Model { + @property({ + type: 'string', + description: `N° de client`, + required: true, + jsonSchema: { + example: ``, + }, + }) + customerId: string; + + @property({ + type: 'string', + description: `Nom du client`, + required: true, + jsonSchema: { + example: `RASOVSKY`, + }, + }) + customerName: string; + + @property({ + type: 'string', + description: `Prénom du client`, + required: true, + jsonSchema: { + example: `Bob`, + }, + }) + customerSurname: string; + + @property() customerAddress: Address; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +class TransactionData extends Model { + @property({ + type: 'string', + description: `N° de commande`, + jsonSchema: { + example: ``, + }, + required: true, + }) + orderId: string; + + @property({ + type: 'date', + description: `Date d'achat`, + required: true, + jsonSchema: { + example: `2021-03-03T14:54:18+01:00`, + }, + }) + purchaseDate: Date; + + @property({ + type: 'number', + description: `Prix TTC`, + required: true, + jsonSchema: { + example: 2100, + }, + }) + amountInclTaxes: number; + + @property({ + type: 'number', + description: `Prix HT`, + jsonSchema: { + example: 2100, + }, + }) + amountExclTaxes: number; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +class ProductData extends Model { + @property({ + type: 'string', + description: `Nom du produit`, + required: true, + jsonSchema: { + example: `Forfait Navigo Mois`, + }, + }) + productName: string; + + @property({ + type: 'number', + description: `Quantité`, + required: true, + jsonSchema: { + example: 1, + }, + }) + quantity: number; + + @property({ + type: 'number', + description: `Prix TTC`, + required: true, + jsonSchema: { + example: 7520, + }, + }) + amountInclTaxes: number; + + @property({ + type: 'number', + description: `Prix HT`, + jsonSchema: { + example: 7520, + }, + }) + amountExclTaxes: number; + + @property({ + type: 'number', + description: `Taux de TVA`, + jsonSchema: { + example: 10, + }, + }) + percentTaxes: number; + + @property() productDetails: ProductDetails; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +export class Invoice extends Model { + @property({required: true}) enterprise: EnterpriseData; + + @property({required: true}) customer: CustomerData; + + @property({required: true}) transaction: TransactionData; + + @property.array(ProductData, {required: true}) products: ProductData[]; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +export class AttachmentMetadata extends Model { + @property.array(Invoice, {required: true}) invoices: Invoice[]; + + @property({ + required: true, + jsonSchema: { + example: 1, + }, + }) + totalElements: number; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/api/src/models/subscription/index.ts b/api/src/models/subscription/index.ts new file mode 100644 index 0000000..90bf277 --- /dev/null +++ b/api/src/models/subscription/index.ts @@ -0,0 +1,8 @@ +export * from './subscription.model'; +export * from './subscriptionV1.model'; +export * from './subscriptionRejection.model'; +export * from './subscriptionValidation.model'; +export * from './specificField.model'; +export * from './attachmentMetadata.model'; +export * from './metadata.model'; +export * from './subscriptionHRIS.model'; diff --git a/api/src/models/subscription/metadata.model.ts b/api/src/models/subscription/metadata.model.ts new file mode 100644 index 0000000..b01b375 --- /dev/null +++ b/api/src/models/subscription/metadata.model.ts @@ -0,0 +1,59 @@ +import {model, property, Entity} from '@loopback/repository'; +import {AttachmentMetadata} from '.'; + +@model() +export class Metadata extends Entity { + @property({ + id: true, + description: `Identifiant des metadonnées`, + generated: false, + defaultFn: 'uuidv4', + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + required: true, + description: `Identifiant de l'aide pour laquelle on souhaite ajouter des métadonnées`, + jsonSchema: { + example: ``, + }, + }) + incentiveId: string; + + @property({ + description: `Identifiant du citoyen souhaitant ajouter des métadonnées`, + jsonSchema: { + example: ``, + }, + }) + citizenId: string; + + @property({ + required: true, + description: `Metadonnées ou preuves d'achat fournies \ + sous format JSON devant respecter le contrat d'interface`, + }) + attachmentMetadata: AttachmentMetadata; + + @property({ + type: 'date', + hidden: true, + description: `Date de création des métadonnées`, + defaultFn: 'now', + jsonSchema: { + example: `2022-01-01 00:00:00.000Z`, + }, + }) + createdAt: Date; + + constructor(data?: Partial) { + super(data); + } +} + +export interface MetadataRelations {} + +export type MetadataWithRelations = Metadata & MetadataRelations; diff --git a/api/src/models/subscription/specificField.model.ts b/api/src/models/subscription/specificField.model.ts new file mode 100644 index 0000000..5cd5ef3 --- /dev/null +++ b/api/src/models/subscription/specificField.model.ts @@ -0,0 +1,53 @@ +import {model, property} from '@loopback/repository'; +@model({settings: {idInjection: false}}) +export class InputChoiceList { + @property({ + description: `Choix possible`, + jsonSchema: { + example: 'Marié', + }, + }) + inputChoice: string; + + constructor() {} +} + +@model({settings: {idInjection: false}}) +export class ChoiceList { + @property({ + description: `Nombre de choix possible si la donnée à saisir est une liste de choix`, + jsonSchema: { + example: 1, + }, + }) + possibleChoicesNumber: number; + + @property.array(InputChoiceList) + inputChoiceList: InputChoiceList[]; + + constructor() {} +} + +@model({settings: {idInjection: false}}) +export class SpecificField { + @property({ + description: `Titre du champ spécifique`, + jsonSchema: { + example: 'Statut marital', + }, + }) + title: string; + + @property({ + description: `Type de la donnée à saisir`, + jsonSchema: { + example: 'listeChoix', + }, + }) + inputFormat: string; + + @property(ChoiceList) + choiceList?: ChoiceList; + + constructor() {} +} diff --git a/api/src/models/subscription/subscription.model.ts b/api/src/models/subscription/subscription.model.ts new file mode 100644 index 0000000..9dff66a --- /dev/null +++ b/api/src/models/subscription/subscription.model.ts @@ -0,0 +1,302 @@ +import {Entity, model, property} from '@loopback/repository'; + +import {emailRegexp} from '../../constants'; +import {SUBSCRIPTION_STATUS, INCENTIVE_TYPE, TRANSPORTS} from '../../utils'; +import {SubscriptionValidation} from './subscriptionValidation.model'; +import {SubscriptionRejection} from './subscriptionRejection.model'; +import {PrivateKeyAccess} from '../funder'; + +export interface AttachmentType { + originalName: string; + uploadDate: Date; + proofType: string; + mimeType: string; +} + +@model() +export class Subscription extends Entity { + @property({ + description: `Identifiant de la souscription`, + id: true, + generated: true, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + description: `Identifiant de l'aide souscrite`, + required: true, + jsonSchema: { + example: ``, + }, + }) + incentiveId: string; + + @property({ + description: `Nom du financeur de l'aide souscrite`, + required: true, + jsonSchema: { + example: `Mulhouse`, + }, + }) + funderName: string; + + @property({ + type: 'string', + description: `Type de l'aide souscrite`, + required: true, + jsonSchema: { + example: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + enum: Object.values(INCENTIVE_TYPE), + }, + }) + incentiveType: string; + + @property({ + description: `Titre de l'aide souscrite`, + required: true, + jsonSchema: { + example: `Le vélo électrique arrive à Mulhouse !`, + }, + }) + incentiveTitle: string; + + @property({ + type: 'array', + itemType: 'string', + description: `Catégories de transport de l'aide`, + required: true, + jsonSchema: { + example: TRANSPORTS.ELECTRIC, + enum: Object.values(TRANSPORTS), + }, + }) + incentiveTransportList: string[]; + + @property({ + description: `Identifiant du citoyen ayant souscrit à l'aide`, + required: true, + jsonSchema: { + example: ``, + }, + }) + citizenId: string; + + @property({ + description: `Nom de famille du citoyen ayant souscrit à l'aide`, + required: true, + jsonSchema: { + example: `Rasovsky`, + }, + }) + lastName: string; + + @property({ + description: `Prénom du citoyen ayant souscrit à l'aide`, + required: true, + jsonSchema: { + example: `Bob`, + }, + }) + firstName: string; + + @property({ + required: true, + description: `Email du citoyen ayant souscrit à l'aide`, + jsonSchema: { + example: `bob.rasovsky@example.com`, + format: 'email', + pattern: emailRegexp, + }, + }) + email: string; + + @property({ + type: 'string', + description: `Ville du citoyen`, + required: true, + jsonSchema: { + example: `Toulouse`, + }, + }) + city: string; + + @property({ + type: 'string', + description: `Code postal du citoyen`, + required: true, + jsonSchema: { + example: `31000`, + pattern: '[0-9]{5}', + }, + }) + postcode: string; + + @property({ + type: 'date', + description: `Date de naissance du citoyen`, + required: true, + jsonSchema: { + example: `1970-01-01`, + format: 'date', + }, + }) + birthdate: string; + + @property({ + description: `Identifiant de la communauté d'appartenance du citoyen`, + jsonSchema: { + example: ``, + }, + }) + communityId?: string; + + @property({ + description: `Consentement du citoyen au partage des données fournies dans la souscription`, + required: true, + hidden: true, + jsonSchema: { + example: true, + }, + }) + consent: boolean; + + @property({ + type: 'string', + description: `Statut de la souscription`, + required: true, + jsonSchema: { + example: SUBSCRIPTION_STATUS.TO_PROCESS, + enum: Object.values(SUBSCRIPTION_STATUS), + }, + }) + status: SUBSCRIPTION_STATUS; + + @property({ + type: 'array', + description: `Justificatifs attachés à la souscription`, + itemType: 'object', + jsonSchema: { + example: { + originalName: 'uploadedAttachment.pdf', + uploadDate: '2022-01-01 00:00:00.000Z', + proofType: 'Passport', + mimeType: 'application/pdf', + }, + }, + }) + attachments?: Array; + + @property({ + type: 'string', + description: `Clé de chiffrement symétrique chiffrée`, + jsonSchema: { + example: 'eFH6RizbEMmRV9QPrtHxjNXArDuPCLjl1BMQknX1erANNRLENCx', + }, + }) + encryptedAESKey: string; + + @property({ + type: 'string', + description: `Vecteur d'initialisation chiffré`, + jsonSchema: { + example: 'MjhvU1ZieHPvsxikp8Bxaz9KxrPHjkOZkPP2KGSiRkuA6+//aZ+2KIO', + }, + }) + encryptedIV: string; + + @property({ + type: 'string', + description: `Identifiant de la clé de chiffrement du financeur`, + jsonSchema: { + example: 'encryptionKeyId', + minLength: 1, + }, + }) + encryptionKeyId: string; + + @property({ + type: 'number', + description: `Version de la clé de chiffrement`, + jsonSchema: { + example: 1, + }, + }) + encryptionKeyVersion: number; + + @property() + privateKeyAccess?: PrivateKeyAccess; + + @property({ + type: 'date', + description: `Date de création de la souscription`, + defaultFn: 'now', + jsonSchema: { + example: `2022-01-01 00:00:00.000Z`, + }, + }) + createdAt: Date; + + @property({ + type: 'date', + description: `Date de modification de la souscription`, + defaultFn: 'now', + jsonSchema: { + example: `2022-01-02 00:00:00.000Z`, + }, + }) + updatedAt: Date; + + @property({ + type: 'string', + description: `Identifiant du financeur`, + jsonSchema: { + example: ``, + }, + }) + funderId: string; + + @property() + subscriptionValidation?: SubscriptionValidation; + + @property() + subscriptionRejection?: SubscriptionRejection; + + @property({ + type: 'object', + description: `Champs spécifiques liés à une aide remplis lors de la souscription`, + }) + specificFields?: {[prop: string]: any}; + + @property({ + type: 'boolean', + description: `Statut du compte citoyen`, + required: true, + jsonSchema: { + example: true, + }, + }) + isCitizenDeleted: boolean; + + @property({ + description: `Email professionnel du salarié ayant souscrit à l'aide`, + jsonSchema: { + example: `bob.rasovsky.pro@example.com`, + format: 'email', + pattern: emailRegexp, + }, + }) + enterpriseEmail?: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface SubscriptionRelations { + // describe navigational properties here +} + +export type SubscriptionWithRelations = Subscription & SubscriptionRelations; diff --git a/api/src/models/subscription/subscriptionHRIS.model.ts b/api/src/models/subscription/subscriptionHRIS.model.ts new file mode 100644 index 0000000..b14f379 --- /dev/null +++ b/api/src/models/subscription/subscriptionHRIS.model.ts @@ -0,0 +1,38 @@ +import {Model, model, property} from '@loopback/repository'; + +import {SUBSCRIPTION_STATUS} from '../../utils'; +@model({settings: {idInjection: false}}) +export class SubscriptionConsumePayload extends Model { + @property({ + description: `Identifiant du citoyen ayant souscrit à l'aide`, + required: true, + jsonSchema: { + example: ``, + }, + }) + citizenId: string; + @property({ + description: `Identifiant de la souscription`, + required: true, + jsonSchema: { + example: ``, + }, + }) + subscriptionId: string; + + @property({ + type: 'string', + description: `Statut de la souscription`, + required: true, + jsonSchema: { + additionalProperties: true, + example: SUBSCRIPTION_STATUS.TO_PROCESS, + enum: Object.values(SUBSCRIPTION_STATUS), + }, + }) + status: SUBSCRIPTION_STATUS; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/api/src/models/subscription/subscriptionRejection.model.ts b/api/src/models/subscription/subscriptionRejection.model.ts new file mode 100644 index 0000000..d0439f1 --- /dev/null +++ b/api/src/models/subscription/subscriptionRejection.model.ts @@ -0,0 +1,58 @@ +import {Model, model, property} from '@loopback/repository'; + +import {REJECTION_REASON} from '../../utils'; + +@model({settings: {idInjection: false}}) +export class CommonRejection extends Model { + @property({ + type: 'string', + description: `Motif du rejet de la demande`, + required: true, + jsonSchema: { + example: REJECTION_REASON.OTHER, + enum: Object.values(REJECTION_REASON), + }, + }) + type: string; + + @property({ + description: `Message indiquant un commentaire relatif au traitement de la demande`, + jsonSchema: { + example: `Le justificatif demandé pour la souscription de l'aide est obligatoire`, + }, + }) + comments?: string; + + constructor(data?: CommonRejection) { + super(data); + } +} + +@model({ + settings: {idInjection: false}, +}) +export class NoReason extends CommonRejection { + constructor(data: NoReason) { + super(data); + } +} +@model({ + settings: {idInjection: false}, +}) +export class OtherReason extends CommonRejection { + @property({ + description: `Message indiquant une raison autre que les motifs de rejet`, + required: true, + jsonSchema: { + maxLength: 80, + example: `Mauvaise communauté d'appartenance`, + }, + }) + other: string; + + constructor(data: OtherReason) { + super(data); + } +} + +export type SubscriptionRejection = OtherReason | NoReason; diff --git a/api/src/models/subscription/subscriptionV1.model.ts b/api/src/models/subscription/subscriptionV1.model.ts new file mode 100644 index 0000000..ceed040 --- /dev/null +++ b/api/src/models/subscription/subscriptionV1.model.ts @@ -0,0 +1,48 @@ +/* eslint-disable */ + +import {Model, model, property} from '@loopback/repository'; + +@model({ + settings: {strict: false}, +}) +export class CreateSubscription extends Model { + @property({ + description: `Identifiant de l'aide souscrite`, + required: true, + jsonSchema: { + example: ``, + }, + }) + incentiveId: string; + + @property({ + description: `Consentement du citoyen au partage des données fournies dans la souscription`, + required: true, + jsonSchema: { + example: true, + }, + }) + consent: boolean; + + @property({ + description: `Identifiant de la communauté d'appartenance du citoyen`, + jsonSchema: { + example: ``, + }, + }) + communityId?: string; + + // Indexer property to allow additional data + [prop: string]: any; + + constructor(data?: Partial) { + super(data); + } +} + +export interface CreateSubscriptionRelations { + // describe navigational properties here +} + +export type CreateSubscriptionWithRelations = CreateSubscription & + CreateSubscriptionRelations; diff --git a/api/src/models/subscription/subscriptionValidation.model.ts b/api/src/models/subscription/subscriptionValidation.model.ts new file mode 100644 index 0000000..ec68ebe --- /dev/null +++ b/api/src/models/subscription/subscriptionValidation.model.ts @@ -0,0 +1,105 @@ +import {Model, model, property} from '@loopback/repository'; +import {PAYMENT_FREQ, PAYMENT_MODE} from '../../utils'; + +@model({ + settings: {idInjection: false}, +}) +export class CommonValidation extends Model { + @property({ + type: 'string', + description: `Modalité du financement de l'aide`, + required: true, + jsonSchema: { + example: PAYMENT_MODE.UNIQUE, + enum: Object.values(PAYMENT_MODE), + }, + }) + mode: string; + + @property({ + description: `Message indiquant un commentaire relatif au traitement de la demande`, + jsonSchema: { + example: `Le montant de paiement est à titre indicatif et vous sera transmis plus tard`, + }, + }) + comments?: string; + constructor(data?: Partial) { + super(data); + } +} + +@model({ + settings: {idInjection: false}, +}) +export class ValidationSinglePayment extends CommonValidation { + @property({ + type: 'number', + description: `Montant en euros alloué à la demande`, + minimum: 0, + exclusiveMinimum: true, + jsonSchema: { + example: 50, + }, + }) + amount?: number; + + constructor(data?: Partial) { + super(data); + } +} + +@model({ + settings: {idInjection: false}, +}) +export class ValidationNoPayment extends CommonValidation { + constructor(data?: Partial) { + super(data); + } +} + +@model({ + settings: {idInjection: false}, +}) +export class ValidationMultiplePayment extends CommonValidation { + @property({ + type: 'string', + description: `Fréquence du versement`, + required: true, + jsonSchema: { + example: PAYMENT_FREQ.MONTHLY, + enum: Object.values(PAYMENT_FREQ), + }, + }) + frequency: string; + + @property({ + type: 'number', + description: `Montant en euros alloué à la demande`, + minimum: 0, + exclusiveMinimum: true, + jsonSchema: { + example: 50, + }, + }) + amount?: number; + + @property({ + type: 'date', + description: `Date du dernier versement`, + required: true, + jsonSchema: { + example: '2023-01-01', + format: 'date', + }, + }) + lastPayment: string; + + constructor(data?: Partial) { + super(data); + } +} + +export type SubscriptionValidation = + | ValidationMultiplePayment + | ValidationSinglePayment + | ValidationNoPayment; diff --git a/api/src/models/territory/index.ts b/api/src/models/territory/index.ts new file mode 100644 index 0000000..02ffd38 --- /dev/null +++ b/api/src/models/territory/index.ts @@ -0,0 +1 @@ +export * from './territory.model'; diff --git a/api/src/models/territory/territory.model.ts b/api/src/models/territory/territory.model.ts new file mode 100644 index 0000000..32e6ab9 --- /dev/null +++ b/api/src/models/territory/territory.model.ts @@ -0,0 +1,36 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Territory extends Entity { + @property({ + type: 'string', + id: true, + generated: true, + description: `Identifiant du territoire`, + jsonSchema: { + example: ``, + minLength: 1, + }, + }) + id: string; + + @property({ + type: 'string', + required: true, + description: `Nom du territoire`, + index: { + unique: true, + }, + jsonSchema: { + example: `Mulhouse Aglomération`, + minLength: 2, + }, + }) + name: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface TerritoryRelations {} diff --git a/api/src/models/user/index.ts b/api/src/models/user/index.ts new file mode 100644 index 0000000..5a670ce --- /dev/null +++ b/api/src/models/user/index.ts @@ -0,0 +1 @@ +export * from './user.model'; diff --git a/api/src/models/user/user.model.ts b/api/src/models/user/user.model.ts new file mode 100644 index 0000000..cc34a67 --- /dev/null +++ b/api/src/models/user/user.model.ts @@ -0,0 +1,68 @@ +import {model, property} from '@loopback/repository'; + +import {Keycloak} from '..'; + +@model() +export class User extends Keycloak { + @property({ + type: 'string', + description: `Identifiant de l'utilisateur financeur`, + id: true, + generated: false, + jsonSchema: { + example: ``, + }, + }) + id: string; + + @property({ + type: 'string', + description: `Identifiant du financeur de l'utilisateur`, + required: true, + jsonSchema: { + example: ``, + }, + }) + funderId: string; + + @property({ + type: 'array', + description: `Identifiants des communautés du périmètre\ + d'intervention de l'utilisateur financeur si gestionnaire`, + itemType: 'string', + jsonSchema: { + example: ``, + }, + }) + communityIds: string[]; + + @property({ + type: 'array', + description: `Rôles possibles pour un utilisateur financeur`, + itemType: 'string', + required: true, + jsonSchema: { + example: ``, + }, + }) + roles: string[]; + + @property({ + type: 'boolean', + description: `L'utilisateur reçoit les mails d'affiliation manuelle`, + jsonSchema: { + example: true, + }, + }) + canReceiveAffiliationMail?: boolean; + + constructor(data?: Partial) { + super(data); + } +} + +export interface UserRelations { + // describe navigational properties here +} + +export type UserWithRelations = User & UserRelations; diff --git a/api/src/openapi-spec.ts b/api/src/openapi-spec.ts new file mode 100644 index 0000000..7530a85 --- /dev/null +++ b/api/src/openapi-spec.ts @@ -0,0 +1,26 @@ +import {ApplicationConfig} from '@loopback/core'; + +import {App} from './application'; +import {logger} from './utils'; + +/** + * Export the OpenAPI spec from the application + */ +async function exportOpenApiSpec(): Promise { + const config: ApplicationConfig = { + rest: { + port: +(process.env.PORT ?? 3000), + host: process.env.HOST ?? 'localhost', + }, + }; + const outFile = process.argv[2] ?? ''; + const app = new App(config); + await app.boot(); + await app.exportOpenApiSpec(outFile); +} + +exportOpenApiSpec().catch(err => { + logger.error(`Fail to export OpenAPI spec from the application: ${err}`); + + process.exit(1); +}); diff --git a/api/src/providers/authorization.provider.ts b/api/src/providers/authorization.provider.ts new file mode 100644 index 0000000..c7450b6 --- /dev/null +++ b/api/src/providers/authorization.provider.ts @@ -0,0 +1,32 @@ +import { + AuthorizationContext, + AuthorizationDecision, + AuthorizationMetadata, + Authorizer, +} from '@loopback/authorization'; +import {Provider} from '@loopback/core'; +import {IUser} from '../utils'; + +export class AuthorizationProvider implements Provider { + constructor() {} + + /** + * @returns authenticateFn + */ + value(): Authorizer { + return this.authorize.bind(this); + } + + async authorize( + authorizationCtx: AuthorizationContext, + metadata: AuthorizationMetadata, + ) { + const user = authorizationCtx.principals[0] as IUser; + const userRoles = user.roles; + const allowedRoles = metadata.allowedRoles; + if (!allowedRoles || allowedRoles.some(roles => userRoles?.includes(roles))) { + return AuthorizationDecision.ALLOW; + } + return AuthorizationDecision.DENY; + } +} diff --git a/api/src/providers/index.ts b/api/src/providers/index.ts new file mode 100644 index 0000000..63703bf --- /dev/null +++ b/api/src/providers/index.ts @@ -0,0 +1 @@ +export * from './authorization.provider'; diff --git a/api/src/repositories/README.md b/api/src/repositories/README.md new file mode 100644 index 0000000..08638a7 --- /dev/null +++ b/api/src/repositories/README.md @@ -0,0 +1,3 @@ +# Repositories + +This directory contains code for repositories provided by this app. diff --git a/api/src/repositories/citizen.repository.ts b/api/src/repositories/citizen.repository.ts new file mode 100644 index 0000000..3ade328 --- /dev/null +++ b/api/src/repositories/citizen.repository.ts @@ -0,0 +1,28 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository, AnyObject} from '@loopback/repository'; +import {omit} from 'lodash'; + +export type EmployeesFind = { + funderId?: string; + user?: string; + lastName?: string; + firstName?: string; + status?: string; + skip?: number; + limit?: number; +}; + +import {MongoDsDataSource} from '../datasources'; +import {Citizen, CitizenRelations} from '../models'; + +export class CitizenRepository extends DefaultCrudRepository< + Citizen, + typeof Citizen.prototype.id, + CitizenRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + Citizen.definition.properties = omit(Citizen.definition.properties, ['password']); + + super(Citizen, dataSource); + } +} diff --git a/api/src/repositories/citizenMigration.repository.ts b/api/src/repositories/citizenMigration.repository.ts new file mode 100644 index 0000000..07e71fb --- /dev/null +++ b/api/src/repositories/citizenMigration.repository.ts @@ -0,0 +1,20 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {omit} from 'lodash'; + +import {MongoDsDataSource} from '../datasources'; +import {CitizenMigration, CitizenMigrationRelations} from '../models'; + +export class CitizenMigrationRepository extends DefaultCrudRepository< + CitizenMigration, + typeof CitizenMigration.prototype.id, + CitizenMigrationRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + CitizenMigration.definition.properties = omit( + CitizenMigration.definition.properties, + ['password'], + ); + super(CitizenMigration, dataSource); + } +} diff --git a/api/src/repositories/collectivity.repository.ts b/api/src/repositories/collectivity.repository.ts new file mode 100644 index 0000000..277dc08 --- /dev/null +++ b/api/src/repositories/collectivity.repository.ts @@ -0,0 +1,15 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; + +import {MongoDsDataSource} from '../datasources'; +import {Collectivity, CollectivityRelations} from '../models'; + +export class CollectivityRepository extends DefaultCrudRepository< + Collectivity, + typeof Collectivity.prototype.id, + CollectivityRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Collectivity, dataSource); + } +} diff --git a/api/src/repositories/community.repository.ts b/api/src/repositories/community.repository.ts new file mode 100644 index 0000000..213bf92 --- /dev/null +++ b/api/src/repositories/community.repository.ts @@ -0,0 +1,19 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {param} from '@loopback/rest'; + +import {MongoDsDataSource} from '../datasources'; +import {Community, CommunityRelations} from '../models'; + +export class CommunityRepository extends DefaultCrudRepository< + Community, + typeof Community.prototype.id, + CommunityRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Community, dataSource); + } + async findByFunderId(funderId: string): Promise { + return this.find({where: {funderId}, order: ['name ASC']}); + } +} diff --git a/api/src/repositories/cronJob.repository.ts b/api/src/repositories/cronJob.repository.ts new file mode 100644 index 0000000..869d4ea --- /dev/null +++ b/api/src/repositories/cronJob.repository.ts @@ -0,0 +1,15 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; + +import {MongoDsDataSource} from '../datasources'; +import {CronJob, CronJobRelations} from '../models'; + +export class CronJobRepository extends DefaultCrudRepository< + CronJob, + typeof CronJob.prototype.id, + CronJobRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(CronJob, dataSource); + } +} diff --git a/api/src/repositories/enterprise.repository.ts b/api/src/repositories/enterprise.repository.ts new file mode 100644 index 0000000..58b4e99 --- /dev/null +++ b/api/src/repositories/enterprise.repository.ts @@ -0,0 +1,23 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; + +import {MongoDsDataSource} from '../datasources'; +import {Enterprise, EnterpriseRelations} from '../models'; + +export class EnterpriseRepository extends DefaultCrudRepository< + Enterprise, + typeof Enterprise.prototype.id, + EnterpriseRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Enterprise, dataSource); + } + + /** + * Get all HRIS Enterprises + * @returns Promise[]> + */ + async getHRISEnterpriseNameList(): Promise[]> { + return this.find({where: {isHris: true}, fields: {name: true}}); + } +} diff --git a/api/src/repositories/idp/client-scope-client.repository.ts b/api/src/repositories/idp/client-scope-client.repository.ts new file mode 100644 index 0000000..e997a98 --- /dev/null +++ b/api/src/repositories/idp/client-scope-client.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {IdpDbDataSource} from '../../datasources'; +import {ClientScopeClient, ClientScopeClientRelations} from '../../models'; + +export class ClientScopeClientRepository extends DefaultCrudRepository< + ClientScopeClient, + typeof ClientScopeClient.prototype.clientId, + ClientScopeClientRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(ClientScopeClient, dataSource); + } +} diff --git a/api/src/repositories/idp/client-scope.repository.ts b/api/src/repositories/idp/client-scope.repository.ts new file mode 100644 index 0000000..1ab71b5 --- /dev/null +++ b/api/src/repositories/idp/client-scope.repository.ts @@ -0,0 +1,48 @@ +import {inject, Getter} from '@loopback/core'; +import { + DefaultCrudRepository, + repository, + HasManyThroughRepositoryFactory, +} from '@loopback/repository'; +import {IdpDbDataSource} from '../../datasources'; +import {ClientScope, ClientScopeRelations, Client, ClientScopeClient} from '../../models'; +import {SCOPES} from '../../utils'; +import {ClientScopeClientRepository} from './client-scope-client.repository'; +import {ClientRepository} from './client.repository'; + +export class ClientScopeRepository extends DefaultCrudRepository< + ClientScope, + typeof ClientScope.prototype.id, + ClientScopeRelations +> { + public readonly clients: HasManyThroughRepositoryFactory< + Client, + typeof Client.prototype.id, + ClientScopeClient, + typeof ClientScope.prototype.id + >; + + constructor( + @inject('datasources.idpdbDS') dataSource: IdpDbDataSource, + @repository.getter('ClientScopeClientRepository') + protected clientScopeClientRepositoryGetter: Getter, + @repository.getter('ClientRepository') + protected clientRepositoryGetter: Getter, + ) { + super(ClientScope, dataSource); + this.clients = this.createHasManyThroughRepositoryFactoryFor( + 'clients', + clientRepositoryGetter, + clientScopeClientRepositoryGetter, + ); + this.registerInclusionResolver('clients', this.clients.inclusionResolver); + } + + async getClients(): Promise { + const scopes = await this.findOne({ + include: ['clients'], + where: {name: SCOPES.FUNDERS_CLIENTS}, + }); + return scopes?.clients; + } +} diff --git a/api/src/repositories/idp/client.repository.ts b/api/src/repositories/idp/client.repository.ts new file mode 100644 index 0000000..5140088 --- /dev/null +++ b/api/src/repositories/idp/client.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {IdpDbDataSource} from '../../datasources'; +import {Client, ClientRelations} from '../../models'; + +export class ClientRepository extends DefaultCrudRepository< + Client, + typeof Client.prototype.id, + ClientRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(Client, dataSource); + } +} diff --git a/api/src/repositories/idp/group-role-mapping.repository.ts b/api/src/repositories/idp/group-role-mapping.repository.ts new file mode 100644 index 0000000..351fb2f --- /dev/null +++ b/api/src/repositories/idp/group-role-mapping.repository.ts @@ -0,0 +1,15 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; + +import {IdpDbDataSource} from '../../datasources'; +import {GroupRoleMapping, GroupRoleMappingRelations} from '../../models'; + +export class GroupRoleMappingRepository extends DefaultCrudRepository< + GroupRoleMapping, + typeof GroupRoleMapping.prototype.roleId, + GroupRoleMappingRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(GroupRoleMapping, dataSource); + } +} diff --git a/api/src/repositories/idp/index.ts b/api/src/repositories/idp/index.ts new file mode 100644 index 0000000..8f12a12 --- /dev/null +++ b/api/src/repositories/idp/index.ts @@ -0,0 +1,11 @@ +export * from './group-role-mapping.repository'; +export * from './keycloak-group.repository'; +export * from './keycloak-role.repository'; +export * from './user-entity.repository'; +export * from './user-role-mapping.repository'; +export * from './user-group-membership.repository'; +export * from './client.repository'; +export * from './offline-client-session.repository'; +export * from './offline-user-session.repository'; +export * from './client-scope-client.repository'; +export * from './client-scope.repository'; diff --git a/api/src/repositories/idp/keycloak-group.repository.ts b/api/src/repositories/idp/keycloak-group.repository.ts new file mode 100644 index 0000000..b59fea0 --- /dev/null +++ b/api/src/repositories/idp/keycloak-group.repository.ts @@ -0,0 +1,97 @@ +import {inject, Getter} from '@loopback/core'; +import { + DefaultCrudRepository, + repository, + HasManyThroughRepositoryFactory, +} from '@loopback/repository'; + +import {uniq} from 'lodash'; + +import {realmName} from '../../constants'; +import {IdpDbDataSource} from '../../datasources'; +import { + KeycloakGroup, + KeycloakGroupRelations, + KeycloakRole, + GroupRoleMapping, +} from '../../models'; +import {GROUPS} from '../../utils'; +import {GroupRoleMappingRepository, KeycloakRoleRepository} from './index'; + +export class KeycloakGroupRepository extends DefaultCrudRepository< + KeycloakGroup, + typeof KeycloakGroup.prototype.id, + KeycloakGroupRelations +> { + public readonly keycloakRoles: HasManyThroughRepositoryFactory< + KeycloakRole, + typeof KeycloakRole.prototype.id, + GroupRoleMapping, + typeof KeycloakGroup.prototype.id + >; + + constructor( + @inject('datasources.idpdbDS') dataSource: IdpDbDataSource, + @repository.getter('GroupRoleMappingRepository') + protected groupRoleMappingRepositoryGetter: Getter, + @repository.getter('KeycloakRoleRepository') + protected keycloakRoleRepositoryGetter: Getter, + ) { + super(KeycloakGroup, dataSource); + this.keycloakRoles = this.createHasManyThroughRepositoryFactoryFor( + 'keycloakRoles', + keycloakRoleRepositoryGetter, + groupRoleMappingRepositoryGetter, + ); + this.registerInclusionResolver('keycloakRoles', this.keycloakRoles.inclusionResolver); + } + + async getSubGroupFunder(): Promise<{id: string; name: string | undefined}[]> { + const groups = await this.find({ + where: {realmId: realmName}, + }); + + const funder: KeycloakGroup | undefined = groups.find( + ({name}) => name === GROUPS.funders, + ); + + const funderSubGroups = groups + .filter(({parentGroup}) => parentGroup === funder?.id) + .map(({id, name}) => ({id, name})); + + return funderSubGroups; + } + + async getSubGroupFunderRoles(): Promise { + const groups = await this.find({ + where: {realmId: realmName}, + }); + + const funder: KeycloakGroup | undefined = groups.find( + ({name}) => name === GROUPS.funders, + ); + + const funderSubGroups = groups.filter(({parentGroup}) => parentGroup === funder?.id); + + const funderRoles: string[] = await Promise.all( + funderSubGroups.map(({id}) => this.keycloakRoles(id).find({fields: {name: true}})), + ).then((result: KeycloakRole[][]) => + uniq(result.flat().map(({name}: {name: string}) => name)), + ); + + return funderRoles.sort(); + } + + /** + * Get citizens group + * @returns KeycloakGroup + */ + async getGroupByName( + groupName: string, + ): Promise<(KeycloakGroup & KeycloakGroupRelations) | null> { + const group: (KeycloakGroup & KeycloakGroupRelations) | null = await this.findOne({ + where: {and: [{realmId: realmName}, {name: groupName}]}, + }); + return group; + } +} diff --git a/api/src/repositories/idp/keycloak-role.repository.ts b/api/src/repositories/idp/keycloak-role.repository.ts new file mode 100644 index 0000000..58009e2 --- /dev/null +++ b/api/src/repositories/idp/keycloak-role.repository.ts @@ -0,0 +1,15 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; + +import {IdpDbDataSource} from '../../datasources'; +import {KeycloakRole, KeycloakRoleRelations} from '../../models'; + +export class KeycloakRoleRepository extends DefaultCrudRepository< + KeycloakRole, + typeof KeycloakRole.prototype.id, + KeycloakRoleRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(KeycloakRole, dataSource); + } +} diff --git a/api/src/repositories/idp/offline-client-session.repository.ts b/api/src/repositories/idp/offline-client-session.repository.ts new file mode 100644 index 0000000..be0e520 --- /dev/null +++ b/api/src/repositories/idp/offline-client-session.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {IdpDbDataSource} from '../../datasources'; +import {OfflineClientSession, OfflineClientSessionRelations} from '../../models'; + +export class OfflineClientSessionRepository extends DefaultCrudRepository< + OfflineClientSession, + typeof OfflineClientSession.prototype.userSessionId, + OfflineClientSessionRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(OfflineClientSession, dataSource); + } +} diff --git a/api/src/repositories/idp/offline-user-session.repository.ts b/api/src/repositories/idp/offline-user-session.repository.ts new file mode 100644 index 0000000..7028962 --- /dev/null +++ b/api/src/repositories/idp/offline-user-session.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {IdpDbDataSource} from '../../datasources'; +import {OfflineUserSession, OfflineUserSessionRelations} from '../../models'; + +export class OfflineUserSessionRepository extends DefaultCrudRepository< + OfflineUserSession, + typeof OfflineUserSession.prototype.userSessionId, + OfflineUserSessionRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(OfflineUserSession, dataSource); + } +} diff --git a/api/src/repositories/idp/user-entity.repository.ts b/api/src/repositories/idp/user-entity.repository.ts new file mode 100644 index 0000000..390690b --- /dev/null +++ b/api/src/repositories/idp/user-entity.repository.ts @@ -0,0 +1,67 @@ +import {inject, Getter} from '@loopback/core'; +import { + DefaultCrudRepository, + repository, + HasManyThroughRepositoryFactory, +} from '@loopback/repository'; + +import {IdpDbDataSource} from '../../datasources'; +import { + UserEntity, + UserEntityRelations, + KeycloakGroup, + UserGroupMembership, + KeycloakRole, +} from '../../models'; +import {UserGroupMembershipRepository, KeycloakGroupRepository} from '../../repositories'; + +export class UserEntityRepository extends DefaultCrudRepository< + UserEntity, + typeof UserEntity.prototype.id, + UserEntityRelations +> { + public readonly keycloakGroups: HasManyThroughRepositoryFactory< + KeycloakGroup, + typeof KeycloakGroup.prototype.id, + UserGroupMembership, + typeof UserEntity.prototype.id + >; + + constructor( + @inject('datasources.idpdbDS') dataSource: IdpDbDataSource, + @repository.getter('UserGroupMembershipRepository') + protected userGroupMembershipRepositoryGetter: Getter, + @repository.getter('KeycloakGroupRepository') + protected keycloakGroupRepositoryGetter: Getter, + @repository('KeycloakGroupRepository') + protected keycloakGroupRepository: KeycloakGroupRepository, + ) { + super(UserEntity, dataSource); + this.keycloakGroups = this.createHasManyThroughRepositoryFactoryFor( + 'keycloakGroups', + keycloakGroupRepositoryGetter, + userGroupMembershipRepositoryGetter, + ); + this.registerInclusionResolver( + 'keycloakGroups', + this.keycloakGroups.inclusionResolver, + ); + } + + async getUserRoles(id: string): Promise { + const groups = await this.keycloakGroups(id).find({fields: {id: true}}); + + return Promise.all( + groups.map(({id}) => + this.keycloakGroupRepository.keycloakRoles(id).find({fields: {name: true}}), + ), + ).then(res => res.flat().filter(x => x)); + } + async getServiceUser( + clientId: string, + ): Promise<(UserEntity & UserEntityRelations) | null> { + return this.findOne({ + where: {serviceAccountClientLink: clientId}, + }); + } +} diff --git a/api/src/repositories/idp/user-group-membership.repository.ts b/api/src/repositories/idp/user-group-membership.repository.ts new file mode 100644 index 0000000..c5546f0 --- /dev/null +++ b/api/src/repositories/idp/user-group-membership.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {IdpDbDataSource} from '../../datasources'; +import {UserGroupMembership, UserGroupMembershipRelations} from '../../models'; + +export class UserGroupMembershipRepository extends DefaultCrudRepository< + UserGroupMembership, + typeof UserGroupMembership.prototype.groupId, + UserGroupMembershipRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(UserGroupMembership, dataSource); + } +} diff --git a/api/src/repositories/idp/user-role-mapping.repository.ts b/api/src/repositories/idp/user-role-mapping.repository.ts new file mode 100644 index 0000000..1444c0d --- /dev/null +++ b/api/src/repositories/idp/user-role-mapping.repository.ts @@ -0,0 +1,15 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; + +import {IdpDbDataSource} from '../../datasources'; +import {UserRoleMapping, UserRoleMappingRelations} from '../../models'; + +export class UserRoleMappingRepository extends DefaultCrudRepository< + UserRoleMapping, + typeof UserRoleMapping.prototype.roleId, + UserRoleMappingRelations +> { + constructor(@inject('datasources.idpdbDS') dataSource: IdpDbDataSource) { + super(UserRoleMapping, dataSource); + } +} diff --git a/api/src/repositories/incentive.repository.ts b/api/src/repositories/incentive.repository.ts new file mode 100644 index 0000000..5d4dcd2 --- /dev/null +++ b/api/src/repositories/incentive.repository.ts @@ -0,0 +1,17 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {MongoDsDataSource} from '../datasources'; +import {Incentive, IncentiveRelations} from '../models'; + +export class IncentiveRepository extends DefaultCrudRepository< + Incentive, + typeof Incentive.prototype.id, + IncentiveRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Incentive, dataSource); + (this.modelClass as any).observe('persist', async (ctx: any) => { + ctx.data.updatedAt = new Date(); + }); + } +} diff --git a/api/src/repositories/index.ts b/api/src/repositories/index.ts new file mode 100644 index 0000000..cb2c4b8 --- /dev/null +++ b/api/src/repositories/index.ts @@ -0,0 +1,12 @@ +export * from './incentive.repository'; +export * from './citizen.repository'; +export * from './collectivity.repository'; +export * from './enterprise.repository'; +export * from './subscription.repository'; +export * from './community.repository'; +export * from './user.repository'; +export * from './metadata.repository'; +export * from './idp'; +export * from './cronJob.repository'; +export * from './territory.repository'; +export * from './citizenMigration.repository'; diff --git a/api/src/repositories/metadata.repository.ts b/api/src/repositories/metadata.repository.ts new file mode 100644 index 0000000..99a4fea --- /dev/null +++ b/api/src/repositories/metadata.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {MongoDsDataSource} from '../datasources'; +import {Metadata, MetadataRelations} from '../models'; + +export class MetadataRepository extends DefaultCrudRepository< + Metadata, + typeof Metadata.prototype.id, + MetadataRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Metadata, dataSource); + } +} diff --git a/api/src/repositories/subscription.repository.ts b/api/src/repositories/subscription.repository.ts new file mode 100644 index 0000000..dfb99d5 --- /dev/null +++ b/api/src/repositories/subscription.repository.ts @@ -0,0 +1,17 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {MongoDsDataSource} from '../datasources'; +import {Subscription, SubscriptionRelations} from '../models'; + +export class SubscriptionRepository extends DefaultCrudRepository< + Subscription, + typeof Subscription.prototype.id, + SubscriptionRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Subscription, dataSource); + (this.modelClass as any).observe('persist', async (ctx: any) => { + ctx.data.updatedAt = new Date(); + }); + } +} diff --git a/api/src/repositories/territory.repository.ts b/api/src/repositories/territory.repository.ts new file mode 100644 index 0000000..4d822da --- /dev/null +++ b/api/src/repositories/territory.repository.ts @@ -0,0 +1,14 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {Territory, TerritoryRelations} from '../models'; +import {MongoDsDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class TerritoryRepository extends DefaultCrudRepository< + Territory, + typeof Territory.prototype.id, + TerritoryRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + super(Territory, dataSource); + } +} diff --git a/api/src/repositories/user.repository.ts b/api/src/repositories/user.repository.ts new file mode 100644 index 0000000..ac604e3 --- /dev/null +++ b/api/src/repositories/user.repository.ts @@ -0,0 +1,17 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {omit} from 'lodash'; + +import {MongoDsDataSource} from '../datasources'; +import {User, UserRelations} from '../models'; + +export class UserRepository extends DefaultCrudRepository< + User, + typeof User.prototype.id, + UserRelations +> { + constructor(@inject('datasources.mongoDS') dataSource: MongoDsDataSource) { + User.definition.properties = omit(User.definition.properties, ['password', 'roles']); + super(User, dataSource); + } +} diff --git a/api/src/sequence.ts b/api/src/sequence.ts new file mode 100644 index 0000000..2fe7751 --- /dev/null +++ b/api/src/sequence.ts @@ -0,0 +1,3 @@ +import {MiddlewareSequence} from '@loopback/rest'; + +export class MySequence extends MiddlewareSequence {} diff --git a/api/src/services/authentication.service.ts b/api/src/services/authentication.service.ts new file mode 100644 index 0000000..2bbcd3c --- /dev/null +++ b/api/src/services/authentication.service.ts @@ -0,0 +1,235 @@ +import {injectable, BindingScope} from '@loopback/core'; +import {Request} from '@loopback/rest'; +import {securityId} from '@loopback/security'; +import axios from 'axios'; +import jwkToPem from 'jwk-to-pem'; +import jwt, {JwtPayload} from 'jsonwebtoken'; + +import {IDP_FQDN, realmName} from '../constants'; +import {StatusCode, logger, INCENTIVE_TYPE, Roles, IUser} from '../utils'; +import {ValidationError} from '../validationError'; + +const API_KEY = process.env.API_KEY ?? 'apikey'; +const cache: {key: jwkToPem.JWK[]} = {key: []}; + +@injectable({scope: BindingScope.TRANSIENT}) +export class AuthenticationService { + constructor() {} + /** + * Extract token from Bearer Token + * @param request Request + * @returns string + */ + extractCredentials(request: Request): string { + if (!request.headers.authorization) { + throw new ValidationError( + `Authorization header not found`, + '/authorization', + StatusCode.Unauthorized, + ); + } + + // for example : Bearer xxx.yyy.zzz + const authHeaderValue = request.headers.authorization; + + if (!authHeaderValue.startsWith('Bearer')) { + throw new ValidationError( + `Authorization header is not of type 'Bearer'.`, + '/authorization', + StatusCode.Unauthorized, + ); + } + + // split the string into 2 parts : 'Bearer ' and the `xxx.yyy.zzz` + const parts = authHeaderValue.split(' '); + if (parts.length !== 2) { + throw new ValidationError( + `Authorization header not valid`, + '/authorization', + StatusCode.Unauthorized, + ); + } + const token = parts[1]; + + return token; + } + + /** + * Extract apiKey from Bearer Token + * @param request Request + * @returns string + */ + extractApiKey(request: Request): string { + const apiKeyHeaderValue = String(request.headers?.['x-api-key']); + + if (!apiKeyHeaderValue) { + throw new ValidationError( + `Header is not of type 'X-API-Key'.`, + '/authorization', + StatusCode.Unauthorized, + ); + } + + if (apiKeyHeaderValue !== API_KEY) { + throw new ValidationError( + `Wrong API-KEY.`, + '/authorization', + StatusCode.Unauthorized, + ); + } + + return apiKeyHeaderValue; + } + + /** + * Verify token with KC-verify + * Throw an error if not valid + * Return decoded user if it is + * @param token string + * @returns any + */ + async verifyToken(token: string): Promise { + if (!token) { + throw new ValidationError( + `Error verifying token'.`, + '/authorization', + StatusCode.Unauthorized, + ); + } + try { + if (!cache['key'] || cache['key'].length === 0) { + cache['key'] = ( + await axios.get( + `${IDP_FQDN}/auth/realms/${realmName}/protocol/openid-connect/certs`, + ) + ).data.keys; + } + const decodedToken = jwt.decode(token, {complete: true}); + const publicKey = cache['key'].find( + (key: any) => key.kid === decodedToken?.header.kid, + ); + const publicKeyPEM = jwkToPem(publicKey!); + const user: string | JwtPayload = jwt.verify(token, publicKeyPEM); + return user as JwtPayload; + } catch (error) { + logger.error(`Error verifying token: ${error.message}`); + throw new ValidationError( + `Error verifying token`, + '/authorization', + StatusCode.Unauthorized, + ); + } + } + + /** + * Convert to user + * @param user any + * @returns User (implements UserProfile) + */ + convertToUser(user: JwtPayload): IUser { + const funderType: string | undefined = + user.membership && !user.client_id + ? this.getFunderType(user.membership) + : undefined; + const funderName: string | undefined = + user.membership && !user.client_id + ? this.getFunderName(user.membership) + : undefined; + const groups: string[] | undefined = user.membership + ? this.getFundersGroup(user.membership) + : undefined; + const incentiveType: string | undefined = funderType + ? this.getIncentiveType(funderType) + : undefined; + return { + [securityId]: user.sub!, + id: user.sub!, + emailVerified: user.email_verified, + clientName: user.maas_name || user.sirh_name, + funderType: funderType, + funderName: funderName, + incentiveType: incentiveType, + groups: groups, + roles: [ + ...user.realm_access.roles, + ...(user.resource_access?.[user.azp]?.roles ?? []), + ], + }; + } + + /** + * Convert to anonymous user + * @param apiKey string + * @returns User (implements UserProfile) + */ + convertToApiKeyUser(apiKey: string): IUser { + return { + [securityId]: '', + id: '', + emailVerified: false, + roles: [Roles.API_KEY], + key: apiKey, + }; + } + + /** + * Get funderType from token membership + * @param membershipList string[] + * @returns string + */ + private getFunderType(membershipList: string[]): string | undefined { + return membershipList + .find( + (membership: string) => + membership.includes('entreprises') || membership.includes('collectivités'), + ) + ?.split('/') + .filter((splittedMembership: string) => splittedMembership) + .shift(); + } + + /** + * Get funderType from token membership + * @param membershipList string[] + * @returns string[] + */ + private getFundersGroup(membershipList: string[]): string[] | undefined { + const groups: string[] = []; + membershipList.forEach(membership => { + membership.includes('entreprises') || membership.includes('collectivités'), + groups.push( + membership + .split('/') + .filter((splittedMembership: string) => splittedMembership) + .pop()!, + ); + }); + return groups; + } + /** + * Get funderName from token membership + * @param membershipList string[] + * @returns string + */ + private getFunderName(membershipList: string[]): string | undefined { + return membershipList + .find( + (membership: string) => + membership.includes('entreprises') || membership.includes('collectivités'), + ) + ?.split('/') + .filter((splittedMembership: string) => splittedMembership) + .pop(); + } + + /** + * Get incentiveType from token membership + * @param membershipList string[] + * @returns string + */ + private getIncentiveType(funderType: string): string | undefined { + return funderType === 'entreprises' + ? INCENTIVE_TYPE.EMPLOYER_INCENTIVE + : INCENTIVE_TYPE.TERRITORY_INCENTIVE; + } +} diff --git a/api/src/services/child_processes/connect.ts b/api/src/services/child_processes/connect.ts new file mode 100644 index 0000000..eda1360 --- /dev/null +++ b/api/src/services/child_processes/connect.ts @@ -0,0 +1,30 @@ +import amqp, {Channel, Connection} from 'amqplib'; + +import {RabbitmqConfig} from '../../config'; +import {logger} from '../../utils'; + +const rabbitmqConfig = new RabbitmqConfig(); + +/** + * Connect to RabbitMQ + */ +export async function connect(): Promise { + const connection: Connection = await amqp.connect( + rabbitmqConfig.getAmqpUrl(), + rabbitmqConfig.getUserLogin(), + ); + logger.info(`RabbitMQ connected to: ${rabbitmqConfig.getAmqpUrl()}`); + return connection; +} + +/** + * Create channel for given connection + */ +export async function createChannel( + connection: Connection, + isTestChannel: boolean = false, +): Promise { + const channel: Channel = await connection.createChannel(); + !isTestChannel && logger.info(`RabbitMQ channel created`); + return channel; +} diff --git a/api/src/services/child_processes/consume.ts b/api/src/services/child_processes/consume.ts new file mode 100644 index 0000000..4779f52 --- /dev/null +++ b/api/src/services/child_processes/consume.ts @@ -0,0 +1,220 @@ +import {Channel, Connection, ConsumeMessage, Replies} from 'amqplib'; +import {isEmpty} from 'lodash'; +import process from 'process'; + +import {RabbitmqConfig} from '../../config'; +import {EVENT_MESSAGE, IMessage, logger, UPDATE_MODE} from '../../utils'; +import {connect, createChannel} from './connect'; + +export class ConsumeProcess { + private connection: Connection; + + private channel: Channel; + + private rabbitmqConfig: RabbitmqConfig = new RabbitmqConfig(); + + private hashMapConsumerEnterprise: {[key: string]: string} = {}; + + constructor() {} + + /** + * Main function + */ + async start() { + try { + this.connection = await connect(); + this.channel = await createChannel(this.connection); + + if (this.connection && this.channel) { + process.send!({type: EVENT_MESSAGE.READY}); + + // Handle message reception from parent + process.on('message', async (message: IMessage) => { + logger.info( + `${ConsumeProcess.name} - Message from parent: ${JSON.stringify(message)}`, + ); + if (message.type === EVENT_MESSAGE.UPDATE) { + await this.bulkCancelConsumer(message.data[UPDATE_MODE.DELETE]); + await this.bulkAddConsumer(message.data[UPDATE_MODE.ADD]); + } + if (message.type === EVENT_MESSAGE.ACK) { + this.channel.ack(message.data); + } + }); + + this.addConnectionErrorListeners(); + this.addChannelErrorListeners(); + } + } catch (err) { + logger.error(`${ConsumeProcess.name} - ${err}`); + await this.retryConnection(); + } + } + + /** + * Add listener for errors on connection + */ + private addConnectionErrorListeners(): void { + this.connection.on('error', (err: any) => { + logger.error(`${ConsumeProcess.name} - Connection - Error : ${err}`); + }); + + this.connection.on('exit', async (code: number) => { + logger.error(`${ConsumeProcess.name} - Connection - Exited with code : ${code}`); + await this.retryConnection(); + }); + + this.connection.on('close', async (err: any) => { + logger.error(`${ConsumeProcess.name} - Connection - Closed : ${err}`); + await this.retryConnection(); + }); + } + + /** + * Add listener for errors on channel + */ + private addChannelErrorListeners(): void { + this.channel.on('error', async (err: any) => { + logger.error(`${ConsumeProcess.name} - Channel - Error : ${err}`); + await this.retryChannel(); + }); + + this.channel.on('exit', async (code: number) => { + logger.error(`${ConsumeProcess.name} - Channel - Exited with code: ${code}`); + await this.retryChannel(); + }); + + this.channel.on('close', (err: any) => { + logger.error(`${ConsumeProcess.name} - Channel - Closed: ${err}`); + }); + } + + /** + * Create added HRIS enterprises + * Check if queue exists before creating consumer + */ + private async bulkAddConsumer(enterpriseNameList: string[]): Promise { + await Promise.all( + enterpriseNameList.map(async (enterpriseName: string) => { + if (await this.checkExistingQueue(enterpriseName)) { + // Create consumer + const consumer: Replies.Consume = await this.channel.consume( + this.rabbitmqConfig.getConsumerQueue(enterpriseName), + (msg: ConsumeMessage | null) => { + logger.info(`${ + ConsumeProcess.name + } - RabbitMQ Consumer received message from: \ + ${this.rabbitmqConfig.getConsumerQueue(enterpriseName)}`); + process.send!({ + type: EVENT_MESSAGE.CONSUME, + data: msg, + }); + }, + ); + // HashMap object allow us to store consumerTag for the created consumer of the enterprise + Object.assign(this.hashMapConsumerEnterprise, { + [enterpriseName]: consumer.consumerTag, + }); + logger.info(`${ConsumeProcess.name} - RabbitMQ Consumer created: \ + ${consumer.consumerTag} - ${this.rabbitmqConfig.getConsumerQueue( + enterpriseName, + )}`); + } + }), + ).catch(err => { + logger.error(`${ConsumeProcess.name} - ${err}`); + }); + } + + /** + * Cancel deleted HRIS enterprises + * It will not kill the consumer and latest messages can still be ACK + */ + private async bulkCancelConsumer(enterpriseNameList: string[]): Promise { + await Promise.all( + enterpriseNameList.map(async (enterpriseName: string) => { + const consumerTag: string = this.hashMapConsumerEnterprise[enterpriseName]; + if (await this.checkExistingQueue(enterpriseName)) { + await this.channel.cancel(consumerTag); + delete this.hashMapConsumerEnterprise[enterpriseName]; + logger.info(`${ConsumeProcess.name} - RabbitMQ Consumer canceled: \ + ${consumerTag} - ${this.rabbitmqConfig.getConsumerQueue(enterpriseName)}`); + } else { + logger.info(`${ConsumeProcess.name} - RabbitMQ Consumer cannot be canceled : \ + ${consumerTag} - ${this.rabbitmqConfig.getConsumerQueue(enterpriseName)}`); + } + }), + ).catch(err => { + logger.error(`${ConsumeProcess.name} - ${err}`); + }); + } + + /** + * Check if queue exists + * @returns Promise + */ + private async checkExistingQueue(enterpriseName: string): Promise { + const channelTest: Channel = await createChannel(this.connection, true); + channelTest.on('error', (err: any) => { + logger.info(`${ConsumeProcess.name} - RabbitMQ Queue does not exists: \ + ${this.rabbitmqConfig.getConsumerQueue(enterpriseName)}`); + return false; + }); + + channelTest.on('exit', (code: number) => { + logger.info(`${ConsumeProcess.name} - RabbitMQ Queue does not exists: \ + ${this.rabbitmqConfig.getConsumerQueue(enterpriseName)}`); + return false; + }); + await channelTest.checkQueue(this.rabbitmqConfig.getConsumerQueue(enterpriseName)); + await channelTest.close(); + return true; + } + + /** + * Restart registered consumers in hashmap + */ + private async restartConsumers(): Promise { + if ( + !isEmpty(this.channel) && + !isEmpty(this.connection) && + this.hashMapConsumerEnterprise + ) { + await this.bulkAddConsumer(Object.keys(this.hashMapConsumerEnterprise)); + } + } + + /** + * Retry connection only for LANDSCAPE !== preview + */ + private async retryConnection(): Promise { + if (process.env.LANDSCAPE && process.env.LANDSCAPE !== 'preview') { + logger.info(`${ConsumeProcess.name} - Process will restart`); + setTimeout(async () => { + this.connection = {} as Connection; + this.channel = {} as Channel; + process.removeAllListeners('message'); + await this.start(); + await this.restartConsumers(); + }, 10000); + } + } + + /** + * Retry channel + */ + private async retryChannel(): Promise { + logger.info(`${ConsumeProcess.name} - Channel will restart`); + setTimeout(async () => { + this.channel = await createChannel(this.connection); + this.addChannelErrorListeners(); + await this.restartConsumers(); + }, 10000); + } +} + +// Start consumer process +new ConsumeProcess() + .start() + .then(() => {}) + .catch(() => {}); diff --git a/api/src/services/citizen.service.ts b/api/src/services/citizen.service.ts new file mode 100644 index 0000000..f81f3e7 --- /dev/null +++ b/api/src/services/citizen.service.ts @@ -0,0 +1,1010 @@ +import * as Excel from 'exceljs'; +import {injectable, BindingScope, service, inject} from '@loopback/core'; +import {repository, AnyObject} from '@loopback/repository'; +import {pick} from 'lodash'; +import axios from 'axios'; + +import {MailService} from './mail.service'; +import {KeycloakService} from './keycloak.service'; +import {ValidationError} from '../validationError'; +import { + UserEntity, + OfflineUserSession, + OfflineClientSession, + Client, + Citizen, + Enterprise, + Incentive, + Subscription, + AttachmentType, + User, + Affiliation, +} from '../models'; +import {formatDateInFrenchNotation} from '../interceptors/utils'; +import { + CitizenRepository, + EnterpriseRepository, + EmployeesFind, + UserRepository, + SubscriptionRepository, + UserEntityRepository, + ClientRepository, + OfflineClientSessionRepository, + OfflineUserSessionRepository, +} from '../repositories'; +import {AffiliationAccessTokenPayload, JwtService} from './jwt.service'; +import {SecurityBindings, UserProfile} from '@loopback/security'; +import { + StatusCode, + ResourceName, + AFFILIATION_STATUS, + SUBSCRIPTION_STATUS, + USER_STATUS, + ClientOfConsent, + GROUPS, + Roles, + GENDER, + User as UserInterface, +} from '../utils'; +import {WEBSITE_FQDN} from '../constants'; +import {capitalize} from 'lodash'; +import {differenceInMonths} from 'date-fns'; +import {RequiredActionAlias} from 'keycloak-admin/lib/defs/requiredActionProviderRepresentation'; + +// We specify queryParams for our employees search +type EmployeesQueryParams = { + status?: string; + lastName?: string; + skip?: number; + limit?: number; +}; + +export type Tab = { + title: string; + header: string[]; + rows: string[][]; +}; + +const SubscriptionStatus: Record = { + A_TRAITER: 'à traiter', + VALIDER: 'accepté', + REJETER: 'refusée', +}; + +// used to add style to sheet header cells +function styleHeaderCell(cell: Excel.Cell) { + cell.alignment = {vertical: 'middle', horizontal: 'center'}; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: {argb: '1ee146'}, + }; + cell.font = { + size: 10, + bold: true, + }; +} + +@injectable({scope: BindingScope.TRANSIENT}) +export class CitizenService { + constructor( + @repository(CitizenRepository) + public citizenRepository: CitizenRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @repository(UserRepository) + public userRepository: UserRepository, + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @service(JwtService) + public jwtService: JwtService, + @inject(SecurityBindings.USER, {optional: true}) + private currentUser: UserProfile, + @repository(UserEntityRepository) + public userEntityRepository: UserEntityRepository, + @repository(ClientRepository) + public clientRepository: ClientRepository, + @repository(OfflineClientSessionRepository) + public offlineClientSessionRepository: OfflineClientSessionRepository, + @repository(OfflineUserSessionRepository) + public offlineUserSessionRepository: OfflineUserSessionRepository, + @inject('services.KeycloakService') + public kcService: KeycloakService, + @inject('services.MailService') + public mailService: MailService, + ) {} + + /** + * Get salaries based on their affiliation status and lastName + * + * @param EmployeesQueryParams + */ + async findEmployees({ + status, + lastName, + skip, + limit, + }: EmployeesQueryParams): Promise<{employees: Citizen[]; employeesCount: number}> { + const {id} = this.currentUser; + const user = await this.userRepository.findById(id); + + const match: object[] = [ + { + 'affiliation.enterpriseId': user.funderId, + }, + ]; + + if (status) { + match.push({'affiliation.affiliationStatus': status}); + } + + if (lastName) { + match.push({ + 'identity.lastName.value': new RegExp('.*' + lastName + '.*', 'i'), + }); + } + + const employeesFacet: object[] = [ + { + $group: { + _id: { + id: '$_id', + lastName: {$toLower: '$identity.lastName.value'}, + firstName: {$toLower: '$identity.firstName.value'}, + }, + id: {$first: '$_id'}, + lastName: {$first: '$identity.lastName.value'}, + firstName: {$first: '$identity.firstName.value'}, + affiliation: {$first: '$affiliation'}, + email: {$first: '$email'}, + birthdate: {$first: '$identity.birthDate.value'}, + }, + }, + {$sort: {'_id.lastName': 1, '_id.firstName': 1, _id: 1}}, + { + $project: { + _id: 0, + }, + }, + {$skip: skip ?? 0}, + ]; + + if (limit) { + employeesFacet.push({$limit: limit}); + } + + const queryEmployees = await this.citizenRepository + .execute('Citizen', 'aggregate', [ + { + $match: { + $and: match, + }, + }, + { + $facet: { + salariesCount: [ + { + $group: { + _id: '$_id', + }, + }, + {$count: 'count'}, + ], + employees: employeesFacet, + }, + }, + { + $project: { + employeesCount: {$ifNull: [{$arrayElemAt: ['$salariesCount.count', 0]}, 0]}, + employees: 1, + }, + }, + ]) + .then((res: AnyObject) => res.get()) + .catch(err => err); + + return queryEmployees?.[0]; + } + + /** + * Send affiliation mail for salarie citizen + * + * @param mailService + * @param citizen + * @param funderNames + */ + async sendAffiliationMail( + mailService: MailService, + citizen: Citizen, + funderName: string, + ) { + const token = this.jwtService.generateAffiliationAccessToken(citizen); + const affiliationLink = `${WEBSITE_FQDN}/inscription/association?token=${token}`; + + await mailService.sendMailAsHtml( + citizen.affiliation!.enterpriseEmail!, + `Bienvenue dans votre communauté moB ${funderName}`, + 'citizen-affiliation', + { + funderName: funderName, + affiliationLink: affiliationLink, + username: capitalize(citizen.identity.firstName.value), + }, + ); + } + + /** + * Send disaffiliation mail for salarie citizen + * @param mailService + * @param citizen + * + */ + async sendDisaffiliationMail(mailService: MailService, citizen: Citizen) { + const incentiveLink = `${WEBSITE_FQDN}/recherche`; + await mailService.sendMailAsHtml( + citizen.email!, + 'Votre affiliation employeur vient d’être supprimée', + 'disaffiliation-citizen', + { + username: capitalize(citizen.identity.firstName.value), + incentiveLink: incentiveLink, + }, + ); + } + + /** + * send reject affiliation email + * @param citizen citizen data + * @param enterpriseName entreprise to be affiliated to + */ + async sendRejectedAffiliation(citizen: Citizen, enterpriseName: string) { + await this.mailService.sendMailAsHtml( + citizen.email!, + "Votre demande d'affiliation a été refusée", + 'affiliation-rejection', + { + username: capitalize(citizen.identity.firstName.value), + enterpriseName: capitalize(enterpriseName), + }, + ); + } + + /** + * send validated affiliation email + * @param citizen citizen data + * @param enterpriseName entreprise to be affiliated to + */ + async sendValidatedAffiliation(citizen: Citizen, enterpriseName: string) { + const websiteLink = `${WEBSITE_FQDN}/recherche`; + await this.mailService.sendMailAsHtml( + citizen.email!, + "Votre demande d'affiliation a été acceptée !", + 'affiliation-validation', + { + username: capitalize(citizen.identity.firstName.value), + enterpriseName: capitalize(enterpriseName), + websiteLink, + }, + ); + } + + /** + * Check that the citizen profesionnel email is aligned with the + * domains of the enterprise citizen want to be member + * @returns true/false + * @param emailCitizen string - citizen profesionnel email + * @param enterpriseEmails[]: string[] - email patterns of the enterprise citizen want to be member + */ + validateEmailPattern(emailCitizen: string, enterpriseEmails: string[]) { + const formatEmail: string = emailCitizen.replace(/^.+@/, '@'); + if (!enterpriseEmails.includes(formatEmail)) { + throw new ValidationError( + 'citizen.email.professional.error.format', + '/professionnalEmailBadFormat', + StatusCode.PreconditionFailed, + ResourceName.ProfessionalEmail, + ); + } + } + + /** + * Check Affiliation and return citizen if all checks are ok + * CheckList : + * verify token / + * verify citizen and enterprise in mongo / + * verify match between token and mongo + * verify affiliation status + */ + async checkAffiliation(token: string): Promise { + let citizen: Citizen, enterprise: Enterprise; + + // Verify token + if (!this.jwtService.verifyAffiliationAccessToken(token)) { + throw new ValidationError( + 'citizens.affiliation.not.valid', + '/citizensAffiliationNotValid', + StatusCode.UnprocessableEntity, + ResourceName.Affiliation, + ); + } + + const decodedToken: AffiliationAccessTokenPayload = + this.jwtService.decodeAffiliationAccessToken(token); + + try { + // Get from db + citizen = await this.citizenRepository.findById(decodedToken.id); + enterprise = await this.enterpriseRepository.findById(decodedToken.enterpriseId); + } catch (err) { + throw new ValidationError( + 'citizens.affiliation.not.valid', + '/citizensAffiliationNotValid', + StatusCode.UnprocessableEntity, + ResourceName.Affiliation, + ); + } + + // Check if citizen and enterprise exists + // Check if affiliation enterpriseId matches the token one + if ( + !citizen || + !citizen?.affiliation || + citizen.affiliation.enterpriseId !== decodedToken.enterpriseId || + !enterprise + ) { + throw new ValidationError( + 'citizens.affiliation.not.valid', + '/citizensAffiliationNotValid', + StatusCode.UnprocessableEntity, + ResourceName.Affiliation, + ); + } + + // Check Affiliation status + if (citizen.affiliation.affiliationStatus !== AFFILIATION_STATUS.TO_AFFILIATE) { + throw new ValidationError( + 'citizens.affiliation.bad.status', + '/citizensAffiliationBadStatus', + StatusCode.PreconditionFailed, + ResourceName.AffiliationBadStatus, + ); + } + + return citizen; + } + + /** + * Check Disaffiliation and return boolean if all checks are ok + * CheckList : + * verify citizen subscription + */ + async checkDisaffiliation(citizenId: string): Promise { + // Check Citizen demands + const withParams: AnyObject[] = [ + {funderName: this.currentUser.funderName}, + {incentiveType: this.currentUser.incentiveType}, + {status: SUBSCRIPTION_STATUS.TO_PROCESS}, + {citizenId: citizenId}, + ]; + + const userId = this.currentUser.id; + + let communityIds: '' | string[] | null | undefined = null; + + communityIds = + userId && (await this.userRepository.findOne({where: {id: userId}}))?.communityIds; + + if (communityIds && communityIds?.length > 0) { + withParams.push({communityId: {inq: communityIds}}); + } + + const subscriptions = await this.subscriptionRepository.find({ + where: { + and: withParams, + }, + }); + + return subscriptions?.length === 0; + } + + /** + * Generate Excel RGPD file that contains all user private data + * @param citizen - user informations + * @param companyName - company name + * @param subscriptions - list of user subscriptions + * @param incentives - list of incentives + * @param listMaas - list of MAAS services names + * @returns Excel Buffer + */ + async generateExcelRGPD( + citizen: Citizen, + companyName: string, + subscriptions: Subscription[], + incentives: Incentive[], + listMaas: string[], + ): Promise { + // Creation du excel book + const workbook = new Excel.Workbook(); + + // Creation du Sheet/onglet informations personnelles + this.addSheetInformationsPers(workbook, citizen, companyName, listMaas); + + // Generate one Sheet/Tab for each incentive, each one contains list of user subscription by incentive + this.addSheetSubscriptions(workbook, subscriptions, incentives); + + // send buffer + return workbook.xlsx.writeBuffer(); + } + + /** + * Creation da Sheet/Tab for user informations + * @param workbook - user to create sheet/tab + * @param citizen - user informations + * @param companyName - company name + * @param listMaas - list of MAAS services names + * @returns workbook with new Sheet/Tab that contains user informations + */ + addSheetInformationsPers( + workbook: Excel.Workbook, + citizen: Citizen, + companyName: string, + listMaas: string[], + ): Excel.Workbook { + const sheet: Excel.Worksheet = workbook.addWorksheet('Informations personnelles'); + + // prepare data to be exposed inside the Excel sheet for user informations + const data: Record = { + Nom: citizen.identity.lastName.value, + Prénom: citizen.identity.firstName.value, + 'Date de naissance': citizen.identity.birthDate.value + ? formatDateInFrenchNotation(new Date(citizen.identity.birthDate.value)) + : '', + 'Code postal': citizen.postcode, + Ville: citizen.city, + 'Statut professionnel': citizen.status ? USER_STATUS[citizen.status] : '', + "Entreprise d'affiliation": companyName || '', + 'Adresse email professionnelle': citizen?.affiliation?.enterpriseEmail || '', + 'Adresse email personnelle': citizen.email, + 'Liste des affiliations MaaS': listMaas?.length ? listMaas.join(', ') : '', + }; + + // loop data lign by lign and insert each one in the Excel sheet + let rowIndex: number = 1; + for (const col of Object.keys(data)) { + const row = sheet.getRow(rowIndex); + const value = data[col]; + row.values = [col, value]; + rowIndex++; + } + + return workbook; + } + + /** + * Generate one Sheet/Tab for each incentive, each one contains list of user subscription by incentive + * @param workbook - user to create sheet/tab + * @param subscriptions - list of user subscriptions + * @param incentives - list of incentives + * @returns workbook with new Sheet/Tab that contains user informations + */ + addSheetSubscriptions( + workbook: Excel.Workbook, + subscriptions: Subscription[], + incentives: Incentive[], + ): Excel.Workbook { + if (!subscriptions?.length || !incentives?.length) { + return workbook; + } + + const tabs: Tab[] = this.generateTabsDataStructure(subscriptions, incentives); + + for (const tab of tabs) { + const {title, header, rows} = tab; + + const sheet = workbook.addWorksheet(title); + + const firstRowHeader = sheet.getRow(1); + firstRowHeader.values = [...header]; + firstRowHeader.eachCell(styleHeaderCell); + + let rowIndexe = 2; + for (const row of rows) { + const currentRow = sheet.getRow(rowIndexe); + currentRow.values = [...row]; + rowIndexe++; + } + } + + return workbook; + } + + /** + * Prepare Data required to create sheets/tabs for each incentive + * @param subscriptions - list of user subscriptions + * @param incentives - list of incentives + * @returns Data required to create sheets/tabs for each incentive + */ + generateTabsDataStructure( + subscriptions: Subscription[], + incentives: Incentive[], + ): Tab[] { + const incentivesHashMap: Record = incentives.reduce( + (hashMap: Record, incentive: Incentive) => { + if (incentive.id) { + hashMap[incentive.id] = incentive; + } + return hashMap; + }, + {}, + ); + + const tabsHashMap: Record = subscriptions.reduce( + (tabsHashMap: Record, subscription: Subscription) => { + const {incentiveId} = subscription; + if (!incentiveId) { + return tabsHashMap; + } + if (!(incentiveId in tabsHashMap)) { + const incentive: Incentive = incentivesHashMap[incentiveId]; + tabsHashMap[incentiveId] = { + title: incentive.id || incentiveId, + header: this.generateHeader(incentive), + rows: [], + }; + } + const row: string[] = this.generateRow( + subscription, + tabsHashMap[incentiveId].header, + ); + tabsHashMap[incentiveId].rows.push(row); + return tabsHashMap; + }, + {}, + ); + + return Object.values(tabsHashMap); + } + + /** + * generate colomns names by incentive, with specificFields names + * @param incentive - list of incentives + * @returns array of colomns names by incentive, with specificFields names + */ + generateHeader(incentive: Incentive): string[] { + const header = [ + 'Date de la demande', + "Nom de l'aide", + 'Financeur', + 'Statut', + 'Nom des justificatifs transmis', + ]; + // Gestion des specifics fields + if (incentive?.specificFields?.length) { + const titles = incentive?.specificFields?.map(sf => sf.title); + return [...header, ...titles]; + } + return header; + } + + /** + * Generate one row of cell data per user subscription + * @param subscription - one user subscription + * @param header - array of colomns names by incentive, with specificFields names + * @returns one row data per user subscription + */ + generateRow(subscription: Subscription, header: string[]): string[] { + return header.map((colName: string) => { + switch (colName) { + case 'Date de la demande': + return subscription.createdAt + ? formatDateInFrenchNotation(subscription.createdAt) + : ''; + case "Nom de l'aide": + return subscription.incentiveTitle || ''; + case 'Financeur': + return subscription.funderName || ''; + case 'Statut': + return SubscriptionStatus[subscription.status] || ''; + case 'Nom des justificatifs transmis': + return ( + subscription.attachments + ?.map((attachment: AttachmentType) => attachment.originalName) + ?.join(', ') || '' + ); + default: + // specificFields data + return subscription.specificFields?.[colName] || ''; + } + }); + } + + /** + * return list of MAAS services attached to one user + * @param email of user + * @returns list of MAAS services names attached to one user + */ + async getListMaasNames(email: string): Promise { + const userEntity: UserEntity | null = await this.userEntityRepository.findOne({ + where: {email}, + fields: {id: true, email: true, username: true}, + }); + + const userId: string = userEntity?.id || this.currentUser.id; + + // get all user offline sessions + const offlineUserSessions: OfflineUserSession[] = + await this.offlineUserSessionRepository.find({ + where: {userId}, + fields: {userId: true, userSessionId: true}, + }); + + const userSessionIds: string[] = offlineUserSessions?.map(ous => ous.userSessionId); + + // get all client offline sessions + const offlineClientSessions: OfflineClientSession[] = + await this.offlineClientSessionRepository.find({ + where: {userSessionId: {inq: userSessionIds}}, + fields: {userSessionId: true, clientId: true}, + }); + + const clientIds: string[] = offlineClientSessions?.map(ocs => ocs.clientId); + + // get all concerned clients + const clients: Client[] = await this.clientRepository.find({ + where: {id: {inq: clientIds}}, + fields: {id: true, clientId: true, name: true}, + }); + + const listMaasNames: string[] = clients + ?.filter(ocs => !!ocs.name) + ?.map(ocs => `${ocs.name}`); + + return listMaasNames; + } + + /** + * Send mail for citizen account deletion + * @param mailService + * @param citizen + * @param deletionDate + * + */ + async sendDeletionMail( + mailService: MailService, + citizen: Citizen, + deletionDate: string, + ) { + const incentiveLink = `${WEBSITE_FQDN}/recherche`; + await mailService.sendMailAsHtml( + citizen.email!, + 'Votre compte a bien été supprimé', + 'deletion-account-citizen', + { + username: capitalize(citizen.identity.firstName.value), + deletionDate: deletionDate, + incentiveLink: incentiveLink, + }, + ); + } + + /** + * Check if the professional email is unique + * @param professionalEmail + * + */ + async checkProEmailExistence(professionalEmail: string): Promise { + const withParams: AnyObject = { + 'affiliation.enterpriseEmail': professionalEmail, + }; + const result = await this.citizenRepository.findOne({ + where: withParams, + }); + if (result) { + throw new ValidationError( + 'citizen.email.error.unique', + '/affiliation.enterpriseEmail', + StatusCode.UnprocessableEntity, + ResourceName.UniqueProfessionalEmail, + ); + } + } + + async getClientList(): Promise { + const clients = await this.clientRepository.find({ + fields: {clientId: true, name: true}, + }); + return clients; + } + + /** + * Send account deletion mail for citizen + * + * @param mailService + * @param citizen + */ + async sendNonActivatedAccountDeletionMail(mailService: MailService, user: AnyObject) { + await mailService.sendMailAsHtml( + user.email!, + 'Votre compte moB vient d’être supprimé', + 'nonActivated-account-deletion', + { + username: capitalize(user.firstName), + }, + ); + } + + /** + * deletion of every non_activated account after six months from its creation date + sending an email to the citizen/funder + */ + async accountDeletionService(): Promise { + // keycloak users list + const userList = await this.kcService.listUsers(); + + // get only non_activated accounts that was created six months ago + const sixMonthNonActivatedAccounts = userList.filter((user: AnyObject) => { + const currentDate = new Date(); + const monthsPassed = differenceInMonths( + currentDate, + new Date(user.createdTimestamp), + ); + + return user.emailVerified === false && monthsPassed >= 6; + }); + + if (sixMonthNonActivatedAccounts.length) { + for (const account of sixMonthNonActivatedAccounts) { + // get the list of User groups + const group: object[] = await this.kcService.listUserGroups(account.id); + + // check if the user is a citizen + const citizenGroup: object[] = group.filter((group: AnyObject) => { + return group.name === GROUPS.citizens; + }); + + // check if the user is a funder + const funderGroup: object[] = group.filter((group: AnyObject) => { + return group.name === Roles.MANAGERS || group.name === Roles.SUPERVISORS; + }); + + // citizen account deletion + sending mail + if (citizenGroup.length !== 0) { + const citizen: Citizen = await this.citizenRepository.findById(account.id); + await this.citizenRepository.deleteById(account.id); + await this.kcService.deleteUserKc(account.id); + await this.sendNonActivatedAccountDeletionMail(this.mailService, { + ...citizen, + firstName: citizen.identity.firstName.value, + }); + } + + // funder account deletion + sending mail + if (funderGroup.length !== 0) { + const funder: User = await this.userRepository.findById(account.id); + await this.userRepository.deleteById(account.id); + await this.kcService.deleteUserKc(account.id); + await this.sendNonActivatedAccountDeletionMail(this.mailService, funder); + } + } + } + } + + /** + * If the company accepts a manual affiliation, send an affiliation mail to that company's funders. + * @param citizen + * @param enterprise + * + */ + async sendManualAffiliationMail( + citizen: AnyObject, + enterprise: Enterprise, + ): Promise { + // Get list of the enterprise funders + const enterpriseFunders = await this.userRepository.find({ + where: { + funderId: enterprise.id, + canReceiveAffiliationMail: true, + }, + }); + + // List of funders who accept manual affiliation and who have an activated account + const verifiedFunders: User[] = []; + + const creationDate = formatDateInFrenchNotation(new Date()); + const manualAffiliationLink = `${WEBSITE_FQDN}/gerer-salaries?tab=A_AFFILIER`; + + await Promise.all( + // Loop through the existing funders in MongoDb and get the ones that have verified emails from keycloak. + enterpriseFunders.map(async (el: User) => { + const user = await this.kcService.getUser(el.id); + if (user.emailVerified) { + verifiedFunders.push(el); + } + }), + ); + + await Promise.all( + // Send an affiliation mail to each funder whose email address has been verified and who accept manual affiliation. + verifiedFunders.map(async singleFunder => + this.mailService.sendMailAsHtml( + singleFunder.email, + `Vous avez une nouvelle demande d'affiliation !`, + 'funder-manual-affiliation-notification', + { + funderName: capitalize(enterprise.name), + funderFirstName: capitalize(singleFunder.firstName), + firstName: capitalize(citizen.identity.firstName.value), + lastName: capitalize(citizen.identity.lastName.value), + creationDate: creationDate, + manualAffiliationLink: manualAffiliationLink, + }, + ), + ), + ); + } + + /** + * Create Citizen + * @param register + * @param citizenId + * @returns + */ + async createCitizen( + register: Omit, + citizenId?: string, + ): Promise<{id: string} | undefined> { + let keycloakResult; + let enterprise = new Enterprise(); + + try { + const citizen: AnyObject = pick(register, [ + 'email', + 'identity', + 'city', + 'postcode', + 'status', + 'tos1', + 'tos2', + 'affiliation', + 'dgfipInformation', + ]); + + // Enterprise Verification + if (citizen.affiliation?.enterpriseId) { + enterprise = await this.enterpriseRepository.findById( + citizen.affiliation.enterpriseId, + ); + } + + // Check if the professional email is unique + if (citizen.affiliation?.enterpriseId && citizen.affiliation?.enterpriseEmail) { + await this.checkProEmailExistence(citizen?.affiliation?.enterpriseEmail); + } + + // Verification the employee's professional email format + if (citizen.affiliation?.enterpriseId && citizen.affiliation?.enterpriseEmail) { + this.validateEmailPattern( + citizen?.affiliation?.enterpriseEmail, + enterprise?.emailFormat, + ); + } + + const actions: RequiredActionAlias[] = [RequiredActionAlias.VERIFY_EMAIL]; + + if (citizenId) { + // Update the citizen attributes on KC + await this.kcService.updateCitizenAttributes(citizenId, {...citizen.identity}); + + // Update the citizen role on KC + await this.kcService.updateCitizenRole(citizenId, GROUPS.citizens); + } else { + // Initialize user creation in KC + const newRegister: UserInterface = { + ...register, + group: [GROUPS.citizens], + gender: register.identity.gender.value === 1 ? GENDER.MALE : GENDER.FEMALE, + lastName: register.identity.lastName.value, + firstName: register.identity.firstName.value, + birthdate: register.identity.birthDate.value, + }; + + keycloakResult = await this.kcService.createUserKc( + { + ...newRegister, + }, + actions, + ); + } + + if ((keycloakResult && keycloakResult.id) || citizenId) { + if (citizenId) { + // Set Citizen Id + citizen.id = citizenId; + } else if (keycloakResult) { + citizen.id = keycloakResult.id; + } + + // Create object : affiliation + const affiliation: Affiliation = new Affiliation(citizen.affiliation); + + // Check if one of the enterpriseId or/and enterpriseEmail are provided + if ( + (!affiliation.enterpriseId && affiliation.enterpriseEmail) || + (affiliation.enterpriseId && + !affiliation.enterpriseEmail && + !enterprise?.hasManualAffiliation) || + (!affiliation.enterpriseId && !affiliation.enterpriseEmail) + ) { + affiliation.enterpriseId = !affiliation.enterpriseId + ? null + : affiliation.enterpriseId; + affiliation.enterpriseEmail = !affiliation.enterpriseEmail + ? null + : affiliation.enterpriseEmail; + affiliation.affiliationStatus = AFFILIATION_STATUS.UNKNOWN; + } + + if ( + (affiliation.enterpriseId && affiliation.enterpriseEmail) || + (affiliation.enterpriseId && + !affiliation.enterpriseEmail && + enterprise?.hasManualAffiliation) + ) { + affiliation.affiliationStatus = AFFILIATION_STATUS.TO_AFFILIATE; + } + + citizen.affiliation = affiliation; + + let result; + + if (!citizenId) { + result = await this.citizenRepository.create({ + ...citizen, + ...keycloakResult, + }); + // Send verification mail + await this.kcService.sendExecuteActionsEmailUserKc(result.id, actions); + } else { + // Completion Mode + result = await this.citizenRepository.create({ + ...citizen, + }); + } + + // Send a manual affiliaton mail to the company's funders accepting the manual affiliation + if ( + citizen.affiliation?.enterpriseId && + !citizen.affiliation?.enterpriseEmail && + enterprise.hasManualAffiliation + ) { + await this.sendManualAffiliationMail(citizen, enterprise); + } + + // Send the affiliation mail to citizens with a professional email + if ( + (result?.affiliation?.affiliationStatus !== AFFILIATION_STATUS.UNKNOWN && + !enterprise?.hasManualAffiliation) || + (result?.affiliation?.affiliationStatus === AFFILIATION_STATUS.TO_AFFILIATE && + enterprise?.hasManualAffiliation && + result?.affiliation?.enterpriseEmail) + ) { + await this.sendAffiliationMail(this.mailService, result, enterprise!.name); + } + + return { + id: result.id, + }; + } + + return keycloakResult; + } catch (error) { + if (keycloakResult && keycloakResult.id) { + await this.citizenRepository.deleteById(keycloakResult.id); + await this.kcService.deleteUserKc(keycloakResult.id); + } else if (citizenId) { + await this.kcService.updateCitizenRole(citizenId, GROUPS.citizens, true); + await this.citizenRepository.deleteById(citizenId); + } + throw error; + } + } +} diff --git a/api/src/services/clamav.service.ts b/api/src/services/clamav.service.ts new file mode 100644 index 0000000..4e2e1bb --- /dev/null +++ b/api/src/services/clamav.service.ts @@ -0,0 +1,98 @@ +import {BindingScope, injectable} from '@loopback/core'; +import {Readable} from 'stream'; +import {Express} from 'express'; +import NodeClam from 'clamscan'; + +import {ValidationError} from '../validationError'; +import {logger, ResourceName, StatusCode} from '../utils'; +import {ClamAvConfig} from '../config'; +@injectable({scope: BindingScope.TRANSIENT}) +export class ClamavService { + private clamScan: NodeClam; + + private clamAvConfig: ClamAvConfig; + + constructor() { + this.clamAvConfig = new ClamAvConfig(); + } + + /** + * Init ClamAv Socket + */ + private async initClamAvSocket() { + try { + this.clamScan = await new NodeClam().init( + this.clamAvConfig.getClamAvConfiguration(), + ); + } catch (err) { + logger.error(`${ClamAvConfig.name} - INIT - ${err}`); + throw new ValidationError( + 'Error init', + '/antivirus', + StatusCode.InternalServerError, + ResourceName.Antivirus, + ); + } + } + + /** + * Call clamAv to scan if the file is corrupted + * @param file + * @returns Promise + */ + async fileScan(file: Express.Multer.File): Promise { + try { + logger.info(`${ClamavService.name} - ${this.fileScan.name} - ${file.originalname}`); + + const stream = Readable.from(file.buffer.toString()); + + const scanResult = await this.clamScan.scanStream(stream); + + logger.info( + `${ClamavService.name} - Scan result ${file.originalname} - ${scanResult.isInfected}`, + ); + + return scanResult.isInfected; + } catch (err) { + logger.error(`${ClamavService.name} - ${this.fileScan.name} - ${err}`); + throw new ValidationError( + 'Error during file scan', + '/antivirus', + StatusCode.InternalServerError, + ResourceName.Antivirus, + ); + } + } + + /** + * Loop through the files to check if at least one of them is corrupted + * @param fileList + * @returns boolean + */ + async checkCorruptedFiles(fileList: Express.Multer.File[]): Promise { + try { + await this.initClamAvSocket(); + + logger.info(`${ClamavService.name} - ${this.checkCorruptedFiles.name}`); + + let isSafe = true; + + for (const file of fileList) { + const res = await this.fileScan(file); + if (res) { + isSafe = false; + break; + } + } + return isSafe; + } catch (err) { + logger.error(`${ClamavService.name} - ${this.checkCorruptedFiles.name} - ${err}`); + throw new ValidationError( + 'Error during file list check', + '/antivirus', + StatusCode.InternalServerError, + ResourceName.Antivirus, + ); + } + } +} diff --git a/api/src/services/contact.service.ts b/api/src/services/contact.service.ts new file mode 100644 index 0000000..0aa8780 --- /dev/null +++ b/api/src/services/contact.service.ts @@ -0,0 +1,53 @@ +import {injectable, BindingScope} from '@loopback/core'; +import {Contact} from '../models'; +import {MailService} from '../services'; +import {capitalize} from 'lodash'; + +const EMAIL_TO = process.env.SENDGRID_EMAIL_CONTACT; + +const LIB_KEYS_LABELS_ORDERER = { + userType: 'Vous êtes', + lastName: 'Nom', + firstName: 'Prénom', + email: 'Email', + postcode: 'Code Postal', + message: 'Message', + tos: 'CGU', +}; + +@injectable({scope: BindingScope.TRANSIENT}) +export class ContactService { + public _to: string | undefined; + /** + * Envoie le mail de la demande de contact. + * + * @param mailService + * @param contact + * @param contactDate + */ + + async sendMailClient( + mailService: MailService, + contact: Contact, + contactDate: string, + ): Promise { + const to = this._to ? this._to : EMAIL_TO; + + await mailService.sendMailAsHtml( + contact.email!, + 'Nous traitons votre demande !', + 'client-contact', + { + username: capitalize(contact.firstName), + contactDate: contactDate, + }, + ); + + if (to) { + await mailService.sendMailAsHtml(to, 'Soumission de formulaire MOB', 'contact', { + contact: contact, + keys: LIB_KEYS_LABELS_ORDERER, + }); + } + } +} diff --git a/api/src/services/cronJob.service.ts b/api/src/services/cronJob.service.ts new file mode 100644 index 0000000..e8c76c4 --- /dev/null +++ b/api/src/services/cronJob.service.ts @@ -0,0 +1,61 @@ +import {injectable, BindingScope} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {CronJobRepository} from '../repositories'; +import {CronJob} from '../models'; + +import {logger} from '../utils'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class CronJobService { + constructor( + @repository(CronJobRepository) + public cronJobRepository: CronJobRepository, + ) {} + + /** + * Get cron jobs logs + * @returns an array with cron jobs id + */ + async getCronsLog(): Promise { + const activeCrons: CronJob[] = await this.cronJobRepository.find({}); + return activeCrons; + } + + /** + * create a new cron log + * @returns cron jobs data + */ + async createCronLog(type: string): Promise { + try { + const createdCron: CronJob = await this.cronJobRepository.create({type}); + return createdCron; + } catch (error) { + logger.error(`Failed to create a cron log: ${error}`); + throw new Error(`An error occurred: ${error}`); + } + } + + /** + * delete cron log + */ + async delCronLog(type: string): Promise { + try { + await this.cronJobRepository.deleteAll({type: type}); + } catch (error) { + logger.error(`Failed to delete a cron log: ${error}`); + throw new Error(`An error occurred: ${error}`); + } + } + + /** + * delete cron log by id + */ + async delCronLogById(id: string): Promise { + try { + await this.cronJobRepository.deleteById(id); + } catch (error) { + logger.error(`Failed to delete a cron log: ${error}`); + throw new Error(`An error occurred: ${error}`); + } + } +} diff --git a/api/src/services/funder.service.ts b/api/src/services/funder.service.ts new file mode 100644 index 0000000..7399764 --- /dev/null +++ b/api/src/services/funder.service.ts @@ -0,0 +1,69 @@ +import {injectable, /* inject, */ BindingScope} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {orderBy, head} from 'lodash'; + +import { + Collectivity, + CollectivityRelations, + Enterprise, + EnterpriseRelations, +} from '../models'; + +import {CollectivityRepository} from '../repositories'; +import {EnterpriseRepository} from '../repositories'; + +import {FUNDER_TYPE, IFunder} from '../utils'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class FunderService { + constructor( + @repository(CollectivityRepository) + public collectivityRepository: CollectivityRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + ) {} + async getFunders() { + const enterprises: IFunder[] = await this.enterpriseRepository.find({ + fields: {name: true, id: true, emailFormat: true, hasManualAffiliation: true}, + }); + const colletivites: (Collectivity & CollectivityRelations)[] = + await this.collectivityRepository.find({ + fields: {name: true, id: true}, + }); + + const funders = enterprises + .map((elt: IFunder) => ({...elt, funderType: FUNDER_TYPE.enterprise})) + .concat( + colletivites.map((elt: any) => ({ + ...elt, + funderType: FUNDER_TYPE.collectivity, + })), + ); + + return orderBy(funders, ['name', 'funderType'], ['asc']); + } + + async getFunderByName(name: string, funderType: FUNDER_TYPE) { + if (funderType === FUNDER_TYPE.enterprise) { + const enterprises: any[] = ( + await this.enterpriseRepository.find({ + where: {name}, + fields: {name: true, id: true, emailFormat: true}, + }) + ).map((elt: any) => ({...elt, funderType: FUNDER_TYPE.enterprise})); + return head(enterprises); + } + + const colletivites: Collectivity[] | {funderType: FUNDER_TYPE}[] = ( + await this.collectivityRepository.find({ + where: {name}, + fields: {name: true, id: true}, + }) + ).map((elt: Collectivity & CollectivityRelations) => ({ + ...elt, + funderType: FUNDER_TYPE.collectivity, + })); + + return head(colletivites); + } +} diff --git a/api/src/services/incentive.service.ts b/api/src/services/incentive.service.ts new file mode 100644 index 0000000..b51db16 --- /dev/null +++ b/api/src/services/incentive.service.ts @@ -0,0 +1,59 @@ +import {injectable, BindingScope} from '@loopback/core'; +@injectable({scope: BindingScope.TRANSIENT}) +export class IncentiveService { + /** + * Convert specific fields into json schema + * Specific Fields must not be empty else return empty object + * Return object + * @params title: string + * @params specificFields: any[] + */ + convertSpecificFields(title: string, specificFields: any[]): Object { + const jsonSchema: any = { + properties: {}, + }; + const requiredObj: string[] = []; + if (specificFields.length > 0) { + specificFields.forEach( + (field: { + inputFormat: string; + title: string; + choiceList: { + possibleChoicesNumber: number; + inputChoiceList: [{inputChoice: string}]; + }; + }) => { + const obj: any = {}; + if (field.inputFormat === 'Date') { + obj[field.title] = {type: 'string', format: 'date'}; + } else if (field.inputFormat === 'Numerique') { + obj[field.title] = {type: 'number'}; + } else if (field.inputFormat === 'Texte') { + obj[field.title] = {type: 'string', minLength: 1}; + } else if (field.inputFormat === 'listeChoix') { + obj[field.title] = { + type: 'array', + maxItems: field.choiceList.possibleChoicesNumber, + items: [{enum: []}], + }; + field.choiceList.inputChoiceList.forEach(element => { + obj[field.title].items[0].enum.push(element.inputChoice); + }); + } + if (obj[field.title]) { + jsonSchema.properties[field.title] = obj[field.title]; + requiredObj.push(field.title); + } + }, + ); + // jsonSchema["$schema"] = "http://json-schema.org/draft-07/schema#"; + // jsonSchema["$id"] = "http://yourdomain.com/schemas/myschema.json"; + jsonSchema['title'] = title; + jsonSchema['type'] = 'object'; + jsonSchema['required'] = requiredObj; + jsonSchema['additionalProperties'] = false; + return jsonSchema; + } + return {}; + } +} diff --git a/api/src/services/index.ts b/api/src/services/index.ts new file mode 100644 index 0000000..c1b06b7 --- /dev/null +++ b/api/src/services/index.ts @@ -0,0 +1,16 @@ +export * from './keycloak.service'; +export * from './contact.service'; +export * from './incentive.service'; +export * from './s3.service'; +export * from './subscription.service'; +export * from './mail.service'; +export * from './jwt.service'; +export * from './citizen.service'; +export * from './authentication.service'; +export * from './maas.authorizor'; +export * from './user.authorizor'; +export * from './funder.service'; +export * from './clamav.service'; +export * from './rabbitmq.service'; +export * from './parentProcess.service'; +export * from './cronJob.service'; diff --git a/api/src/services/jwt.service.ts b/api/src/services/jwt.service.ts new file mode 100644 index 0000000..c167906 --- /dev/null +++ b/api/src/services/jwt.service.ts @@ -0,0 +1,71 @@ +import {injectable, BindingScope} from '@loopback/core'; +import jwt from 'jsonwebtoken'; + +import {Citizen} from '../models'; +import {ResourceName, StatusCode} from '../utils'; +import {ValidationError} from '../validationError'; + +export interface AffiliationAccessTokenPayload { + id: string; + enterpriseId: string; +} + +const ALGORITHM = 'HS512'; + +const TOKEN_KEY = process.env.AFFILIATION_JWS_KEY || 'tokenKey'; +@injectable({scope: BindingScope.TRANSIENT}) +export class JwtService { + constructor() {} + /** + * Generate Affiliation accessToken + * @param citizen Citizen + * @returns string + */ + generateAffiliationAccessToken = (citizen: Citizen): string => { + if (!citizen?.affiliation) { + throw new ValidationError( + 'jwt.error.no.affiliation', + '/jwtNoAffiliation', + StatusCode.PreconditionFailed, + ResourceName.Affiliation, + ); + } + const citizenAccessTokenPayloads: AffiliationAccessTokenPayload = { + id: citizen.id, + enterpriseId: citizen.affiliation.enterpriseId!, + }; + return jwt.sign(citizenAccessTokenPayloads, TOKEN_KEY, {algorithm: ALGORITHM}); + }; + + /** + * Verify affiliation access token based on secret and payload information + * @param token string + * @returns boolean + */ + verifyAffiliationAccessToken = (token: string): boolean => { + try { + const decodedToken: any = jwt.verify(token, TOKEN_KEY, {algorithms: [ALGORITHM]}); + return Boolean(decodedToken.id && decodedToken.enterpriseId); + } catch (err) { + return false; + } + }; + + /** + * Decode token to retrieve informations + * /!\ Does not verify the payload token, use verifyAffiliationAccessToken before this method. + * @param token string + * @returns AffiliationAccessTokenPayload + */ + decodeAffiliationAccessToken = (token: string): AffiliationAccessTokenPayload => { + try { + const decodedToken: any = jwt.verify(token, TOKEN_KEY, {algorithms: [ALGORITHM]}); + return Object.assign( + {}, + {id: decodedToken.id, enterpriseId: decodedToken.enterpriseId}, + ); + } catch (err) { + throw new Error(err); + } + }; +} diff --git a/api/src/services/keycloak.service.ts b/api/src/services/keycloak.service.ts new file mode 100644 index 0000000..d3d4eb7 --- /dev/null +++ b/api/src/services/keycloak.service.ts @@ -0,0 +1,311 @@ +import {injectable, BindingScope} from '@loopback/core'; +import {repository} from '@loopback/repository'; + +import KcAdminClient from 'keycloak-admin'; +import {RequiredActionAlias} from 'keycloak-admin/lib/defs/requiredActionProviderRepresentation'; +import UserRepresentation from 'keycloak-admin/lib/defs/userRepresentation'; +import {head, startCase} from 'lodash'; + +import {KeycloakGroup, KeycloakGroupRelations} from '../models'; +import {baseUrl, realmName, credentials} from '../constants'; +import { + ResourceName, + StatusCode, + GROUPS, + IDP_EMAIL_TEMPLATE, + Consent, + User, + IUser, +} from '../utils'; +import {ValidationError} from '../validationError'; +import {KeycloakGroupRepository} from '../repositories'; +import {Identity} from '../models/citizen/identity.model'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class KeycloakService { + keycloakAdmin: KcAdminClient; + + constructor( + @repository(KeycloakGroupRepository) + public keycloakGroupRepository: KeycloakGroupRepository, + ) { + this.keycloakAdmin = new KcAdminClient({ + baseUrl, + realmName, + }); + } + + createUserKc(user: User, actions: RequiredActionAlias[]): Promise<{id: string}> { + const {email, firstName, lastName, password, group, funderName, birthdate, gender} = + user; + + return this.keycloakAdmin + .auth(credentials) + .then(() => + this.keycloakAdmin.users.create({ + username: email, + email, + firstName, + lastName, + emailVerified: false, + enabled: true, + groups: group, + attributes: { + emailTemplate: group.some(group => group.includes(GROUPS.citizens)) + ? IDP_EMAIL_TEMPLATE.CITIZEN + : IDP_EMAIL_TEMPLATE.FUNDER, + funderName: startCase(funderName), + birthdate: birthdate, + gender: gender, + 'identity.gender': JSON.stringify(user?.identity?.gender), + 'identity.lastName': JSON.stringify(user?.identity?.lastName), + 'identity.firstName': JSON.stringify(user?.identity?.firstName), + 'identity.birthDate': JSON.stringify(user?.identity?.birthDate), + }, + credentials: password + ? [ + { + temporary: false, + type: 'password', + value: password, + }, + ] + : undefined, + requiredActions: actions, + }), + ) + .catch(err => { + if (err && err.response) { + const {status, data} = err.response; + + if (status === StatusCode.Conflict) + throw new ValidationError( + `email.error.unique`, + '/email', + StatusCode.Conflict, + ResourceName.Account, + ); + else if ( + status === 400 && + data && + data.errorMessage === 'Password policy not met' + ) + throw new ValidationError( + `password.error.format`, + '/password', + StatusCode.PreconditionFailed, + ResourceName.Account, + ); + } + throw new ValidationError(`cannot connect to IDP or add user`, ''); + }); + } + + async updateUserGroupsKc(id: string, roles: string[]): Promise { + const groups = await this.keycloakGroupRepository.getSubGroupFunder(); + const newRoles = roles.map(role => { + return groups.find(group => group.name === role); + }); + return this.keycloakAdmin + .auth(credentials) + .then(() => { + return Promise.all( + groups.map(async group => { + await this.keycloakAdmin.users.delFromGroup({id, groupId: group.id}); + }), + ); + }) + .then(() => { + return Promise.all( + newRoles.map(async newRole => { + newRole && (await this.addUserGroupMembership(id, newRole.id)); + }), + ); + }) + .catch(err => err); + } + + deleteUserKc(id: string): Promise<{id: string} | string> { + return this.keycloakAdmin + .auth(credentials) + .then(() => this.keycloakAdmin.users.del({id})) + .catch(err => err); + } + + addUserGroupMembership(id: string, groupId: string): Promise { + return this.keycloakAdmin + .auth(credentials) + .then(() => this.keycloakAdmin.users.addToGroup({id, groupId})) + .catch(err => err); + } + + updateUser(id: string, newUser: UserRepresentation): Promise { + return this.keycloakAdmin + .auth(credentials) + .then(() => + this.keycloakAdmin.users.update( + {id}, + { + firstName: newUser.firstName, + lastName: newUser.lastName, + }, + ), + ) + .catch(err => err); + } + + createGroupKc(name: string, type: GROUPS): Promise<{id: string}> { + return this.keycloakAdmin + .auth(credentials) + .then(() => + this.keycloakAdmin.groups.find({ + search: type, + }), + ) + .then((res): Promise<{id: string}> => { + const topGroup = head(res); + + if (!!topGroup && topGroup.id) + return this.keycloakAdmin.groups.setOrCreateChild({id: topGroup.id}, {name}); + else + throw new ValidationError( + `${type}.error.topgroup`, + `/${type}`, + StatusCode.PreconditionFailed, + ResourceName.Enterprise, + ); + }) + .catch(err => { + if (err && err.response) { + const {status} = err.response; + + if (status === StatusCode.Conflict) + throw new ValidationError( + `${type}.error.name.unique`, + `/${type}`, + StatusCode.Conflict, + ResourceName.Enterprise, + ); + } + if (err instanceof ValidationError) throw err; + + throw new ValidationError(`cannot connect to IDP or add group`, ''); + }); + } + + deleteGroupKc(id: string): Promise { + return this.keycloakAdmin + .auth(credentials) + .then(() => this.keycloakAdmin.groups.del({id})) + .catch(err => err); + } + + sendExecuteActionsEmailUserKc( + id: string, + actions: RequiredActionAlias[], + ): Promise { + return this.keycloakAdmin + .auth(credentials) + .then(() => this.keycloakAdmin.users.executeActionsEmail({id, actions})) + .catch(err => err); + } + + async disableUserKc(id: string): Promise { + return this.keycloakAdmin + .auth(credentials) + .then(() => this.keycloakAdmin.users.update({id}, {enabled: false})) + .catch(err => err); + } + + async listConsents(id: string): Promise { + return this.keycloakAdmin + .auth(credentials) + + .then(() => this.keycloakAdmin.users.listConsents({id})) + + .catch(err => err); + } + + async deleteConsent(id: string, clientId: string): Promise { + return this.keycloakAdmin + .auth(credentials) + + .then(() => this.keycloakAdmin.users.revokeConsent({id, clientId})) + + .catch(err => err); + } + + async listUsers(): Promise<[{id: string}]> { + return this.keycloakAdmin + .auth(credentials) + + .then(() => this.keycloakAdmin.users.find({max: 9999999})) + + .catch(err => err); + } + + async listUserGroups(id: string): Promise<[{id: string}]> { + return this.keycloakAdmin + .auth(credentials) + + .then(() => this.keycloakAdmin.users.listGroups({id})) + + .catch(err => err); + } + + async getUser(id: string): Promise { + return this.keycloakAdmin + .auth(credentials) + .then(() => this.keycloakAdmin.users.findOne({id})) + .catch(err => err); + } + + /** + * Update Citizen Role + * @param id Citizen Id + * @param actionDelete Action for delete + */ + async updateCitizenRole( + id: string, + groupName: string, + actionDelete?: boolean, + ): Promise { + const group: (KeycloakGroup & KeycloakGroupRelations) | null = + await this.keycloakGroupRepository.getGroupByName(groupName); + return this.keycloakAdmin + .auth(credentials) + .then(async () => { + if (group && group.id) { + if (actionDelete) { + await this.keycloakAdmin.users.delFromGroup({id, groupId: group.id}); + } else { + await this.keycloakAdmin.users.addToGroup({id, groupId: group.id}); + } + } + }) + .catch(err => err); + } + + async updateCitizenAttributes(id: string, newCitizen: Identity): Promise { + const user: IUser = await this.getUser(id); + return this.keycloakAdmin + .auth(credentials) + .then(() => + this.keycloakAdmin.users.update( + {id}, + { + attributes: { + ...user.attributes, + 'identity.gender': JSON.stringify(newCitizen?.gender), + 'identity.lastName': JSON.stringify(newCitizen?.lastName), + 'identity.firstName': JSON.stringify(newCitizen?.firstName), + 'identity.birthDate': JSON.stringify(newCitizen?.birthDate), + 'identity.birthPlace': JSON.stringify(newCitizen?.birthPlace), + 'identity.birthCountry': JSON.stringify(newCitizen?.birthCountry), + }, + }, + ), + ) + .catch(err => err); + } +} diff --git a/api/src/services/maas.authorizor.ts b/api/src/services/maas.authorizor.ts new file mode 100644 index 0000000..14687fc --- /dev/null +++ b/api/src/services/maas.authorizor.ts @@ -0,0 +1,28 @@ +import { + AuthorizationContext, + AuthorizationDecision, + AuthorizationMetadata, +} from '@loopback/authorization'; + +import {IUser, Roles} from '../utils'; +/** + * Check if user from maas is needed to access data + * @param authorizationCtx AuthorizationContext + * @param metadata AuthorizationMetadata + * @returns AuthorizationDecision + */ +export async function checkMaas( + authorizationCtx: AuthorizationContext, + metadata: AuthorizationMetadata, +): Promise { + if ( + !(authorizationCtx.principals[0] as IUser).clientName && + [Roles.MAAS, Roles.MAAS_BACKEND].some(maasRoles => { + return (authorizationCtx.principals[0] as IUser).roles?.includes(maasRoles); + }) + ) { + return AuthorizationDecision.DENY; + } else { + return AuthorizationDecision.ALLOW; + } +} diff --git a/api/src/services/mail.service.ts b/api/src/services/mail.service.ts new file mode 100644 index 0000000..63b5f14 --- /dev/null +++ b/api/src/services/mail.service.ts @@ -0,0 +1,50 @@ +import {injectable, BindingScope} from '@loopback/core'; +import {MailConfig} from '../config'; +import {generateTemplateAsHtml, ResourceName, StatusCode} from '../utils'; +import {ValidationError} from '../validationError'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class MailService { + private mailConfig: MailConfig; + + /** + * Constructeur. + * Utilisation de l'API_KEY sendgrid + */ + constructor() { + this.mailConfig = new MailConfig(); + } + + /** + * Envoi d'un mail dont le corps est en html. + * + * @param to destinataire du mail + * @param subject sujet du mail + * @param templateName nom du template de l'email + * @param data variable data in email + */ + async sendMailAsHtml( + to: string, + subject: string, + templateName: string, + data?: Object, + ): Promise { + try { + const html = await generateTemplateAsHtml(templateName, data); + const mailerInfos = this.mailConfig.configMailer(); + await mailerInfos.mailer.sendMail({ + from: mailerInfos.from, + to: to, + subject: subject, + html: html, + }); + } catch (err) { + throw new ValidationError( + `email.server.error`, + '/emailSend', + StatusCode.InternalServerError, + ResourceName.Email, + ); + } + } +} diff --git a/api/src/services/parentProcess.service.ts b/api/src/services/parentProcess.service.ts new file mode 100644 index 0000000..eb9ff46 --- /dev/null +++ b/api/src/services/parentProcess.service.ts @@ -0,0 +1,53 @@ +import {BindingScope, injectable} from '@loopback/core'; +import * as path from 'path'; +import {ChildProcess, fork} from 'child_process'; + +import {EVENT_MESSAGE, IMessage, logger} from '../utils'; +import EventEmitter from 'events'; + +@injectable({scope: BindingScope.SINGLETON}) +export class ParentProcessService extends EventEmitter { + private child: ChildProcess; + + constructor() { + super(); + this.createChildProcess(); + this.on(EVENT_MESSAGE.UPDATE, this.sendMessageToChild); + this.on(EVENT_MESSAGE.ACK, this.sendMessageToChild); + } + + private createChildProcess(): void { + try { + this.child = fork(path.join(__dirname, 'child_processes/consume')); + logger.info( + `${ParentProcessService.name} - Child process started: ${this.child.pid}`, + ); + + this.child.on('error', (err: Error) => { + logger.error( + `${ParentProcessService.name} - Child process - ${this.child.pid} - Error : ${err}`, + ); + throw new Error('A problem occurred'); + }); + this.child.on('close', (code: number) => { + logger.error( + `${ParentProcessService.name} - Child process - ${this.child.pid} - Exited with code : ${code}`, + ); + throw new Error('A problem occurred'); + }); + this.child.on('message', (msg: IMessage) => { + logger.info( + `${ParentProcessService.name} - Child process - ${this.child.pid} - \ + Message from child ${JSON.stringify(msg)}`, + ); + this.emit(msg.type, msg.data); + }); + } catch (err) { + throw new Error('A problem occurred'); + } + } + + private sendMessageToChild(msg: boolean): void { + this.child.send(msg); + } +} diff --git a/api/src/services/rabbitmq.service.ts b/api/src/services/rabbitmq.service.ts new file mode 100644 index 0000000..18bb912 --- /dev/null +++ b/api/src/services/rabbitmq.service.ts @@ -0,0 +1,227 @@ +import {BindingScope, inject, injectable, service} from '@loopback/core'; +import amqp, {Channel, Connection, ConsumeMessage, credentials, Options} from 'amqplib'; +import {KeycloakService} from '../services/keycloak.service'; +import {credentials as kcCredentials} from '../constants'; +import {repository} from '@loopback/repository'; + +import {RabbitmqConfig} from '../config'; +import { + EVENT_MESSAGE, + ISubscriptionPublishPayload, + ResourceName, + StatusCode, +} from '../utils'; + +import {ValidationError} from '../validationError'; +import {logger} from '../utils'; +import {ParentProcessService} from './parentProcess.service'; +import {EnterpriseRepository} from '../repositories'; +import {Enterprise} from '../models'; +import {SubscriptionService} from './subscription.service'; + +@injectable({scope: BindingScope.SINGLETON}) +export class RabbitmqService { + private rabbitmqConfig: RabbitmqConfig; + private connection: Connection; + + constructor( + @service(SubscriptionService) + public subscriptionService: SubscriptionService, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + @inject('services.ParentProcessService') + public parentProcessService: ParentProcessService, + @service(KeycloakService) + public keycloakService: KeycloakService, + ) { + this.rabbitmqConfig = new RabbitmqConfig(); + this.parentProcessService.on(EVENT_MESSAGE.CONSUME, async (msg: ConsumeMessage) => { + try { + await this.consumeMessage(msg); + } catch (err) { + logger.error(`${RabbitmqService.name} - ${err}`); + } + }); + } + + /** + * Get an array for HRIS enterprise name + * @returns string[] + */ + public async getHRISEnterpriseNameList(): Promise { + try { + return (await this.enterpriseRepository.getHRISEnterpriseNameList()).map( + (enterprise: Pick) => { + return enterprise.name.toLowerCase(); + }, + ); + } catch (err) { + throw new ValidationError( + `rabbitmq error getting HRIS enterprises`, + '/rabbitmq', + StatusCode.InternalServerError, + ResourceName.rabbitmq, + ); + } + } + + /** + * Connect to RabbitMQ + */ + public async connect(): Promise { + try { + await this.keycloakService.keycloakAdmin.auth(kcCredentials); + this.connection = await amqp.connect( + this.rabbitmqConfig.getAmqpUrl(), + this.rabbitmqConfig.getLogin(this.keycloakService.keycloakAdmin.accessToken), + ); + logger.info( + `${ + RabbitmqService.name + } - RabbitMQ connected to: ${this.rabbitmqConfig.getAmqpUrl()}`, + ); + } catch (err) { + throw new ValidationError( + `rabbitmq init connection error`, + '/rabbitmq', + StatusCode.InternalServerError, + ResourceName.rabbitmq, + ); + } + } + + /** + * Disconnect to RabbitMQ + */ + public async disconnect(): Promise { + try { + await this.connection.close(); + logger.info(`${RabbitmqService.name} - \ + RabbitMQ closed connection for: ${this.rabbitmqConfig.getAmqpUrl()}`); + } catch (err) { + throw new ValidationError( + `rabbitmq disconnect error`, + '/rabbitmq', + StatusCode.InternalServerError, + ResourceName.rabbitmq, + ); + } + } + + /** + * Opens the channel to rabbitmq. + */ + public async openConnectionChannel(): Promise { + try { + const channel: Channel = await this.connection.createChannel(); + return channel; + } catch (err) { + throw new ValidationError( + `rabbitmq connect to channel error`, + '/rabbitmq', + StatusCode.InternalServerError, + ResourceName.rabbitmq, + ); + } + } + + /** + * Close the connection and the channel to rabbitmq. + * @param channel + */ + + public async closeConnectionChannel(channel: Channel): Promise { + try { + await channel.close(); + } catch (err) { + throw new ValidationError( + `rabbitmq close channel error`, + '/rabbitmq', + StatusCode.InternalServerError, + ResourceName.rabbitmq, + ); + } + } + + /** + * Calls rabbitmq config to open channel, send the message and closes the channel. + * @param subscriptionPayload + * @param enterpriseName + */ + async publishMessage( + subscriptionPayload: ISubscriptionPublishPayload, + enterpriseName: string, + ): Promise { + try { + // Create the connection and the channel + await this.connect(); + const channel = await this.openConnectionChannel(); + // Publish the Payload + // Options + const options: Options.Publish = { + headers: this.rabbitmqConfig.getPublishQueue(enterpriseName.toLowerCase()) + .headers, + deliveryMode: 2, + contentEncoding: 'utf-8', + contentType: 'application/json', + }; + channel.publish( + this.rabbitmqConfig.getExchange(), + '', + Buffer.from(JSON.stringify(subscriptionPayload)), + options, + ); + logger.info(`Message published for enterprise : ${enterpriseName}`); + // Close the connection + await this.closeConnectionChannel(channel); + await this.disconnect(); + } catch (err) { + throw new ValidationError( + `rabbitmq publish message error`, + '/rabbitmq', + StatusCode.PreconditionFailed, + ResourceName.rabbitmq, + ); + } + } + + /** + * Calls rabbitmq to consume the SIRH sent payload and checks for errors. + * @param message ConsumeMessage + */ + async consumeMessage(message: ConsumeMessage): Promise { + let parsedData: any; + try { + parsedData = JSON.parse(Buffer.from(message.content).toString()); + await this.subscriptionService.handleMessage(parsedData); + // Send message to acknowledge channel that the message has been treated + this.parentProcessService.emit(EVENT_MESSAGE.ACK, { + type: EVENT_MESSAGE.ACK, + data: message, + }); + } catch (err) { + if ( + err && + (err.statusCode === StatusCode.PreconditionFailed || + err.statusCode === StatusCode.Forbidden) + ) { + const payload = await this.subscriptionService.getSubscriptionPayload( + parsedData!.subscriptionId, + {message: err.message, property: err.property, code: err.statusCode}, + ); + await this.publishMessage(payload.subscription, payload.enterprise); + // delete message ACK + this.parentProcessService.emit(EVENT_MESSAGE.ACK, { + type: EVENT_MESSAGE.ACK, + data: message, + }); + } + throw new ValidationError( + `rabbitmq consume message error`, + '/rabbitmq', + StatusCode.PreconditionFailed, + ResourceName.rabbitmq, + ); + } + } +} diff --git a/api/src/services/s3.service.ts b/api/src/services/s3.service.ts new file mode 100644 index 0000000..4468e19 --- /dev/null +++ b/api/src/services/s3.service.ts @@ -0,0 +1,239 @@ +import {injectable, BindingScope} from '@loopback/core'; + +import { + CreateBucketCommand, + PutObjectCommand, + S3Client, + HeadBucketCommand, + GetObjectCommand, + DeleteObjectsCommand, + ListObjectsV2Command, + _Object, + DeleteObjectsCommandOutput, +} from '@aws-sdk/client-s3'; // ES Modules import +import {logger, streamToString, StatusCode} from '../utils'; +import {Readable} from 'stream'; +import {Express} from 'express'; +import {S3Config} from '../config'; +import {ValidationError} from '../validationError'; + +export interface FileToUpload { + fileName: string; + buffer: Buffer; +} + +@injectable({scope: BindingScope.TRANSIENT}) +export class S3Service extends S3Config { + private s3Client: S3Client; + + private NB_MAX_UPLOAD_FILE = 10; + + private ALLOWED_MIME_TYPE_LIST = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'application/pdf', + 'application/octet-stream', + ]; + + private ALLOWED_FILE_SIZE = 10000000; + + constructor() { + super(); + this.s3Client = new S3Client(this.getConfiguration()); + } + + /** + * Upload multiple files into the same bucket + * @param bucketName string + * @param fileDirectory string + * @param fileList Express.Multer.File[] + * @returns + */ + async uploadFileListIntoBucket( + bucketName: string, + fileDirectory: string, + fileList: Express.Multer.File[], + ): Promise { + try { + // Create bucket if it does not exist + if (!(await this.checkBucketExists(bucketName))) { + await this.createBucket(bucketName); + } + // Upload all files + return await Promise.all( + fileList.map(async (file: Express.Multer.File) => { + await this.uploadFile( + bucketName, + `${fileDirectory}/${file.originalname}`, + Buffer.from(file.buffer), + ); + return `${fileDirectory}/${file.originalname}`; + }), + ); + } catch (error) { + logger.error(`uploadFileListIntoBucket : ${error}`); + throw new Error(`An error occurred: ${error}`); + } + } + + /** + * Check if bucket exists in S3 + * S3 return $metadata if exists but throw an error if not + * @param bucketName + * @returns Boolean + */ + async checkBucketExists(bucketName: string): Promise { + try { + await this.s3Client.send( + new HeadBucketCommand({ + Bucket: bucketName, + }), + ); + return true; + } catch (error) { + return false; + } + } + + /** + * Create bucket with parameter as name + * @param bucketName string + * @returns Promise + */ + async createBucket(bucketName: string): Promise { + try { + return await this.s3Client.send( + new CreateBucketCommand({ + Bucket: bucketName, + }), + ); + } catch (error) { + throw new Error(`An error occurred: ${error}`); + } + } + + /** + * Upload a file + * @param bucketName string + * @param filePath string + * @param file Buffer + * @returns Promise + */ + + async uploadFile(bucketName: string, filePath: string, file: Buffer): Promise { + try { + return await this.s3Client.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: filePath, + Body: file, + ContentEncoding: 'base64', + }), + ); + } catch (error) { + throw new Error(`An error occurred: ${error}`); + } + } + + /** + * function to delete folder from minio + * @param bucketName string + * @param filePath string + * @returns Promise + */ + + async deleteObjectFile( + bucketName: string, + filePath: string, + ): Promise { + const listParams = { + Bucket: bucketName, + Prefix: filePath, + }; + + const justifObjects = await this.s3Client.send(new ListObjectsV2Command(listParams)); + + if (justifObjects.Contents?.length === 0) return; + + const deleteParams = { + Bucket: bucketName, + Delete: {Objects: [] as any}, + }; + + justifObjects.Contents?.forEach((content: _Object) => { + deleteParams.Delete.Objects.push({Key: content.Key}); + }); + + try { + return await this.s3Client.send(new DeleteObjectsCommand(deleteParams)); + } catch (error) { + throw new Error(`Could not delete folder from S3`); + } + } + + /** + * to get the downloadable file buffer of the file + * @param fileDirectory key of the file to be fetched + * @param bucket name of the bucket containing the file + * @param file specific file + * @returns Promise + */ + + async downloadFileBuffer( + bucket: string, + fileDirectory: string, + file: string, + ): Promise<{}> { + try { + const getParams: {Bucket: string; Key: string} = { + Bucket: bucket, + Key: `${fileDirectory}/${file}`, + }; + + const downloadResult = await this.s3Client.send(new GetObjectCommand(getParams)); + // We destructure our body that contains base64 from here + const {Body} = downloadResult; + + const bodyContents = await streamToString(Body as Readable); + return bodyContents; + } catch (error) { + throw new ValidationError( + 'Filename does not exist', + '/attachments', + StatusCode.NotFound, + ); + } + } + + /** + * Check Number max of files exceeded + * NB_MAX_UPLOAD_FILE is defined in this service + * @param fileList Express.Multer.File + */ + hasCorrectNumberOfFiles(fileList: Express.Multer.File[]): Boolean { + return fileList.length <= this.NB_MAX_UPLOAD_FILE; + } + + /** + * Check if all files have a valid mimeType + * ALLOWED_MIME_TYPE_LIST is defined in this service + * @param fileList Express.Multer.File + */ + hasValidMimeType(fileList: Express.Multer.File[]): Boolean { + return fileList.every((file: Express.Multer.File) => + this.ALLOWED_MIME_TYPE_LIST.includes(file.mimetype), + ); + } + + /** + * Check if all files have a valid file size + * ALLOWED_FILE_SIZE is defined in this service + * @param fileList Express.Multer.File + */ + hasValidFileSize(fileList: Express.Multer.File[]): Boolean { + return fileList.every( + (file: Express.Multer.File) => file.size <= this.ALLOWED_FILE_SIZE, + ); + } +} diff --git a/api/src/services/subscription.service.ts b/api/src/services/subscription.service.ts new file mode 100644 index 0000000..94d1a47 --- /dev/null +++ b/api/src/services/subscription.service.ts @@ -0,0 +1,696 @@ +import {BindingScope, inject, injectable, service} from '@loopback/core'; +import {repository, AnyObject, Model} from '@loopback/repository'; +import {getJsonSchema} from '@loopback/repository-json-schema'; + +import {compareAsc, add, sub, parse} from 'date-fns'; +import * as Excel from 'exceljs'; +import {Express} from 'express'; +import _ from 'lodash'; +import { + CitizenRepository, + CommunityRepository, + EnterpriseRepository, + SubscriptionRepository, +} from '../repositories'; +import { + CommonRejection, + OtherReason, + Subscription, + SubscriptionConsumePayload, + SubscriptionRejection, + SubscriptionValidation, + ValidationMultiplePayment, + ValidationSinglePayment, + ValidationNoPayment, +} from '../models'; +import { + canAccessHisSubscriptionData, + CONSUMER_ERROR, + FUNDER_TYPE, + IPublishPayload, + ISubscriptionBusError, + ISubscriptionPublishPayload, + IDataInterface, + logger, + PAYMENT_MODE, + REASON_REJECT_TEXT, + REJECTION_REASON, + ResourceName, + SEND_MODE, + StatusCode, + SUBSCRIPTION_STATUS, +} from '../utils'; +import {ValidationError} from '../validationError'; +import {formatDateInFrenchNotation} from '../interceptors/utils'; +import {MailService} from './mail.service'; +import {API_FQDN, WEBSITE_FQDN} from '../constants'; +import {getFunderTypeAndListEmails} from '../controllers/utils/helpers'; +import {formatDateInTimezone} from '../utils/date'; +import {S3Service} from './s3.service'; +import {Schema, Validator, ValidatorResult} from 'jsonschema'; +import {BusError} from '../busError'; +import {capitalize} from 'lodash'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class SubscriptionService { + constructor( + @service(S3Service) + private s3Service: S3Service, + @repository(SubscriptionRepository) + public subscriptionRepository: SubscriptionRepository, + @service(CitizenRepository) + private citizenRepository: CitizenRepository, + @inject('services.MailService') + public mailService: MailService, + @repository(CommunityRepository) + public communityRepository: CommunityRepository, + @repository(EnterpriseRepository) + public enterpriseRepository: EnterpriseRepository, + ) {} + + /** + * Check if the payment object contains errors + * @param data :SubscriptionValidation + */ + checkPayment(data: SubscriptionValidation) { + if (data.mode === PAYMENT_MODE.NONE) { + this.compareJsonSchema(data, ValidationNoPayment); + } + if (data.mode === PAYMENT_MODE.MULTIPLE) { + this.compareJsonSchema(data, ValidationMultiplePayment); + + if ('lastPayment' in data) { + const parsedDate = parse(data.lastPayment.toString(), 'yyyy-MM-dd', new Date()); + const minimalDate = add(new Date(), {months: 2}); + if (compareAsc(parsedDate, minimalDate) !== 1) { + throw new BusError( + CONSUMER_ERROR.DATE_ERROR, + 'lastPayment', + '/lastPayment', + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + } + } + if (data.mode === PAYMENT_MODE.UNIQUE) { + this.compareJsonSchema(data, ValidationSinglePayment); + } + return data; + } + + /** + * Identify the difference between two JSON schemas + * @param data :SubscriptionValidation + * @param schema :Function + */ + compareJsonSchema(data: SubscriptionValidation, schema: Function) { + const schemaValidator = new Validator(); + + const resultCompare: ValidatorResult = schemaValidator.validate(data, { + ...getJsonSchema(schema, {includeRelations: true}), + additionalProperties: false, + } as Schema); + + this.validatorError(resultCompare); + } + + /** + * Check if the reject object contains errors + * @param data :SubscriptionRejection + */ + checkRefusMotif(data: SubscriptionRejection) { + const schemaValidator = new Validator(); + let resultCompare: ValidatorResult; + resultCompare = schemaValidator.validate({type: data.type}, { + ...getJsonSchema(CommonRejection), + additionalProperties: false, + } as Schema); + this.validatorError(resultCompare); + if (data.type === REJECTION_REASON.OTHER) { + const reason = data as OtherReason; + resultCompare = schemaValidator.validate(reason, { + ...getJsonSchema(OtherReason, {includeRelations: true}), + additionalProperties: false, + } as Schema); + + this.validatorError(resultCompare); + } + return data; + } + async generateExcelValidatedIncentives(subscriptionList: any[]) { + // Creation du excel book + if (subscriptionList && subscriptionList.length > 0) { + const workbook = new Excel.Workbook(); + // Creation de la Sheet (pour chaque incentiveId) + const ListOfSheets: any[] = []; + for (const subscription of subscriptionList) { + if (ListOfSheets.indexOf(subscription.incentiveId.toString()) === -1) + ListOfSheets.push(subscription.incentiveId.toString()); + } + for (const sheet of ListOfSheets) { + // Ajouter une nouvelle Sheet + const newSheet = workbook.addWorksheet(sheet); + let actualRow = newSheet.rowCount; + // Ajouter les incentives + for (const subscription of subscriptionList) { + if (subscription.incentiveId.toString() === sheet) { + // Gestion des specifics fields + // Header + const mainCols = [ + "NOM DE L'AIDE", + 'NOM DU CITOYEN', + 'PRENOM DU CITOYEN', + 'DATE DE NAISSANCE', + 'DATE DE LA DEMANDE', + 'DATE DE LA VALIDATION', + 'MONTANT ACCORDE', + 'FREQUENCE DE VERSEMENT', + 'DATE DU DERNIER VERSEMENT', + ]; + if (subscription && subscription.specificFields) { + const keysSpecs = Object.keys(subscription.specificFields); + const specFields = keysSpecs.map(key => key.toUpperCase()); + mainCols.push(...specFields); + } + const headerRowFirst = newSheet.getRow(1); + headerRowFirst.values = [...mainCols]; + headerRowFirst.eachCell((cell: Excel.Cell) => { + cell.alignment = { + vertical: 'middle', + horizontal: 'center', + }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: {argb: '1ee146'}, + }; + cell.font = { + size: 10, + bold: true, + }; + }); + const subscriptionRow = newSheet.getRow(actualRow + 2); + const colToAdd = []; + colToAdd.push( + subscription.incentiveTitle, + subscription.firstName, + subscription.lastName, + subscription.birthdate, + formatDateInFrenchNotation(subscription.createdAt), + formatDateInFrenchNotation(subscription.updatedAt), + subscription.subscriptionValidation?.amount + ? subscription.subscriptionValidation.amount + : '', + subscription.subscriptionValidation?.frequency + ? subscription.subscriptionValidation.frequency + : '', + subscription.subscriptionValidation?.lastPayment + ? subscription.subscriptionValidation.lastPayment + : '', + ); + if (subscription.specificFields) { + colToAdd.push(...Object.values(subscription.specificFields)); + } + subscriptionRow.values = [...colToAdd]; + actualRow++; + } + } + } + // send buffer + const buffer = await workbook.xlsx.writeBuffer(); + return buffer; + } else { + throw new ValidationError( + 'subscriptions.error.bad.buffer', + '/subscriptionBadBuffer', + StatusCode.PreconditionFailed, + ResourceName.Buffer, + ); + } + } + + /** + * get citizens with at least one subscription & total count + * @param match params + * @param skip pagination + * @returns object of citizens and total citizens + */ + async getCitizensWithSubscription(match: object[], skip: number | undefined) { + const queryAllSubscriptions = await this.subscriptionRepository + .execute('Subscription', 'aggregate', [ + { + $match: { + $and: match, + }, + }, + { + $facet: { + citizensTotal: [ + { + $group: { + _id: '$citizenId', + }, + }, + {$count: 'count'}, + ], + citizensData: [ + { + $group: { + _id: { + citizenId: '$citizenId', + lastName: {$toLower: '$lastName'}, + firstName: {$toLower: '$firstName'}, + isCitizenDeleted: '$isCitizenDeleted', + }, + citizenId: {$first: '$citizenId'}, + lastName: {$first: '$lastName'}, + firstName: {$first: '$firstName'}, + isCitizenDeleted: {$first: '$isCitizenDeleted'}, + }, + }, + {$sort: {'_id.lastName': 1, '_id.firstName': 1}}, + { + $project: { + _id: 0, + }, + }, + {$skip: skip ?? 0}, + {$limit: 10}, + ], + }, + }, + { + $project: { + // Get total from the first element of the citizensTotal array + totalCitizens: {$ifNull: [{$arrayElemAt: ['$citizensTotal.count', 0]}, 0]}, + citizensData: 1, + }, + }, + ]) + .then((res: AnyObject) => res.get()) + .catch(err => err); + return queryAllSubscriptions?.[0]; + } + + /** + * Send subscription validation/rejection mail for citizen + * + * @param mode + * @param mailService + * @param incentiveTitle + * @param date + * @param funderName + * @param funderType + * @param email + * @param motif + * @param firstName + * + */ + async sendValidationOrRejectionMail( + mode: SEND_MODE, + mailService: MailService, + incentiveTitle: string, + date: string, + funderName: string, + funderType: FUNDER_TYPE | null, + email: string, + firstName: string, + motif?: string | null, + comments?: string | null, + amount?: number | null, + ) { + const subscriptionsLink = `${WEBSITE_FQDN}/mon-dashboard`; + + await mailService.sendMailAsHtml( + email!, + `Votre demande d’aide a été ${mode}`, + mode === SEND_MODE.VALIDATION + ? 'subscription-validation' + : 'subscription-rejection', + { + incentiveTitle: incentiveTitle, + date: date, + funderName: funderName, + funderType: funderType, + subscriptionsLink: subscriptionsLink, + motif: motif, + comment: comments, + amount: amount, + username: capitalize(firstName), + }, + ); + } + + /** + * Generate a formated list of attachment files + * @param files :Express.Multer.File + * @returns list of attachment files + */ + formatAttachments(files: Express.Multer.File[]): Express.Multer.File[] { + const namesCounter: Record = {}; + files = files.map((file: Express.Multer.File) => { + const [oldName, extension] = file['originalname'].split(/\.(?=[^\.]+$)/); + let fileName: string = oldName; + + if (!(fileName in namesCounter)) { + namesCounter[fileName] = 1; + } else { + const count: number = namesCounter[fileName]++; + fileName = `${fileName}(${count})`; + } + + return { + ...file, + originalname: extension ? `${fileName}.${extension}` : fileName, + }; + }); + return files; + } + + /** + * Handle the payload of the consume message + * @param data :any + */ + + async handleMessage(data: IDataInterface): Promise { + try { + const initialSubscription = new SubscriptionConsumePayload({ + citizenId: data.citizenId, + subscriptionId: data.subscriptionId, + status: data.status, + }); + // Check if the subscription exists + const subscription = await this.subscriptionRepository.findById( + data.subscriptionId, + ); + // Check if user has access to his subscription + if (!canAccessHisSubscriptionData(subscription.citizenId, data.citizenId)) { + throw new BusError( + 'CitizenID does not match', + 'citizenId', + '/citizenIdError', + StatusCode.Forbidden, + ResourceName.Subscription, + ); + } + if (data.status === SUBSCRIPTION_STATUS.ERROR) { + const motif = { + type: REJECTION_REASON.OTHER, + other: CONSUMER_ERROR.ERROR_MESSAGE, + comments: CONSUMER_ERROR.ERROR_MESSAGE, + }; + await this.rejectSubscription(motif as OtherReason, subscription); + } else { + const resultCompare = new Validator().validate(initialSubscription, { + ...getJsonSchema(SubscriptionConsumePayload), + } as Schema); + this.validatorError(resultCompare); + if (subscription.status !== SUBSCRIPTION_STATUS.TO_PROCESS) { + throw new BusError( + 'subscriptions.error.bad.status', + 'status', + '/subscriptionBadStatus', + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + if (data.status === SUBSCRIPTION_STATUS.VALIDATED) { + const payment: AnyObject = { + mode: data.mode, + frequency: data.frequency, + amount: data.amount, + lastPayment: data.lastPayment, + comments: data.comments, + }; + + Object.keys(payment).forEach( + key => payment[key] === undefined && delete payment[key], + ); + + const dataPayment = payment as SubscriptionValidation; + const paymentPayload = this.checkPayment(dataPayment); + await this.validateSubscription(paymentPayload, subscription); + } + if (data.status === SUBSCRIPTION_STATUS.REJECTED) { + const motif = { + type: data.type, + other: data.other, + comments: data.comments, + }; + const rejection = motif as OtherReason; + const reasonPayload = this.checkRefusMotif(rejection); + await this.rejectSubscription(reasonPayload, subscription); + } + } + } catch (error) { + logger.error(`Failed to handle the payload: ${error}`); + throw error; + } + } + + /** + * Handle the validated subscription and send the notifications + * @param result :any + * @param subscription :Subscription + */ + async validateSubscription(result: any, subscription: Subscription): Promise { + // Mise à jour du statut de la subscription + subscription.status = SUBSCRIPTION_STATUS.VALIDATED; + // Mise à jour des informations de versement + subscription.subscriptionValidation = result; + await this.subscriptionRepository.updateById( + subscription.id, + _.pickBy(subscription, _.identity), + ); + + /** + * format date as [DD/MM/YYYY] à [HH h MM] + */ + const date = formatDateInTimezone( + new Date(subscription.createdAt!), + "dd/MM/yyyy à H'h'mm", + ); + + /** + * get funderType based on incentive type and get the list of emails + */ + const {listEmails, funderType} = getFunderTypeAndListEmails(subscription); + + /** + * send the Validation mail for each email on the list + */ + if (funderType) { + listEmails.forEach(async email => + this.sendValidationOrRejectionMail( + SEND_MODE.VALIDATION, + this.mailService, + subscription.incentiveTitle, + date, + subscription.funderName, + funderType, + email, + subscription.firstName, + null, + result?.comments, + result?.amount, + ), + ); + } + } + + /** + * Handle the rejected subscription and send the notifications + * @param result :SubscriptionRejection + * @param subscription :Subscription + */ + async rejectSubscription( + result: SubscriptionRejection, + subscription: Subscription, + ): Promise { + // Mise à jour du statut de la subscription + subscription.status = SUBSCRIPTION_STATUS.REJECTED; + // Delete specific fields && prooflist from subscription object && from bucket + subscription.attachments && + delete subscription.attachments && + (await this.s3Service.deleteObjectFile(subscription.citizenId, subscription.id)) && + subscription.specificFields && + delete subscription.specificFields; + // Mise à jour des informations du motif + subscription.subscriptionRejection = result; + await this.subscriptionRepository.updateById( + subscription.id, + _.pickBy(subscription, _.identity), + ); + + /** + * format date as [DD/MM/YYYY] à [HH h MM] + */ + const date = formatDateInTimezone( + new Date(subscription.createdAt!), + "dd/MM/yyyy à H'h'mm", + ); + + /** + * get funderType based on incentive type and get the list of emails + */ + const {listEmails, funderType} = getFunderTypeAndListEmails(subscription); + + /** + * get the rejection motif + */ + let subscriptionRejectionMessage: string | undefined; + switch (subscription.subscriptionRejection!.type) { + case REJECTION_REASON.CONDITION: + subscriptionRejectionMessage = REASON_REJECT_TEXT.CONDITION; + break; + case REJECTION_REASON.INVALID_PROOF: + subscriptionRejectionMessage = REASON_REJECT_TEXT.INVALID_PROOF; + break; + case REJECTION_REASON.MISSING_PROOF: + subscriptionRejectionMessage = REASON_REJECT_TEXT.MISSING_PROOF; + break; + case REJECTION_REASON.OTHER: + { + const data = result as OtherReason; + subscriptionRejectionMessage = data.other; + } + break; + default: + throw new ValidationError( + 'subscriptionRejection.type.not.found', + '/subscriptionRejectionNotFound', + StatusCode.NotFound, + ResourceName.Affiliation, + ); + } + /** + * send the Rejection mail for each email on the list + */ + if (funderType) { + listEmails.forEach(async email => + this.sendValidationOrRejectionMail( + SEND_MODE.REJECTION, + this.mailService, + subscription.incentiveTitle, + date, + subscription.funderName, + funderType, + email, + subscription.firstName, + subscriptionRejectionMessage, + result.comments, + ), + ); + } + } + + /** + * Build a new payload with the error object using the subscription id in order to publish it + * @param subscriptionId :string + * @param errorMessage :ISubscriptionBusError + */ + async getSubscriptionPayload( + subscriptionId: string, + errorMessage: ISubscriptionBusError, + ): Promise { + const subscription = await this.subscriptionRepository.findById(subscriptionId); + const enterprise = await this.enterpriseRepository.findById(subscription.funderId); + const payload = await this.preparePayLoad(subscription, errorMessage); + return { + subscription: payload, + enterprise: enterprise.name, + }; + } + + /** + * Prepared the payload and add the object error + * @param subscription :Subscription + * @param errorMessage :ISubscriptionBusError + */ + + async preparePayLoad( + subscription: Subscription, + errorMessage?: ISubscriptionBusError, + ): Promise { + // build demande data + const community = subscription.communityId + ? await this.communityRepository.findById(subscription.communityId) + : ''; + const {affiliation} = await this.citizenRepository.findById(subscription.citizenId); + const urlAttachmentsList: string[] = []; + if (subscription.attachments && subscription.attachments.length !== 0) { + subscription.attachments!.forEach(attachment => + urlAttachmentsList.push( + `${API_FQDN}/v1/subscriptions/${subscription.id}/attachments/${attachment.originalName}`, + ), + ); + } + const subscriptionPayload: ISubscriptionPublishPayload = { + lastName: subscription.lastName, + firstName: subscription.firstName, + birthdate: subscription.birthdate, + citizenId: subscription.citizenId, + incentiveId: subscription.incentiveId, + subscriptionId: subscription.id, + email: affiliation.enterpriseEmail ? affiliation.enterpriseEmail : '', + status: SUBSCRIPTION_STATUS.TO_PROCESS, + communityName: community ? community.name : '', + specificFields: subscription.specificFields + ? JSON.stringify(subscription.specificFields) + : '', + attachments: urlAttachmentsList, + error: errorMessage, + encryptionKeyId: subscription.encryptionKeyId, + encryptionKeyVersion: subscription.encryptionKeyVersion, + encryptedAESKey: subscription.encryptedAESKey, + encryptedIV: subscription.encryptedIV, + }; + return subscriptionPayload; + } + /** + * Check the validator errors and throws them + * @param resultCompare :resultCompare + */ + validatorError(resultCompare: ValidatorResult) { + if (resultCompare.errors.length > 0) { + throw new BusError( + resultCompare.errors[0].message, + Array.isArray(resultCompare.errors[0]?.argument) + ? resultCompare.errors[0].path[0].toString() + : resultCompare.errors[0].argument, + resultCompare.errors[0].path?.toString(), + StatusCode.PreconditionFailed, + ResourceName.Subscription, + ); + } + } + + /** + * Delete subscription older than 3 years + */ + async deleteSubscription(): Promise { + const olderSubscriptions: Subscription[] = await this.subscriptionRepository.find({ + where: { + createdAt: { + lt: sub(new Date(), { + years: 3, + }), + }, + }, + }); + if (olderSubscriptions.length) { + await Promise.all( + olderSubscriptions.map(async subscription => { + if (subscription.attachments?.length) { + await this.s3Service.deleteObjectFile( + subscription.citizenId, + subscription.id, + ); + } + await this.subscriptionRepository.deleteById(subscription.id); + }), + ); + } + } +} diff --git a/api/src/services/templates/affiliation-rejection.ejs b/api/src/services/templates/affiliation-rejection.ejs new file mode 100644 index 0000000..29187e1 --- /dev/null +++ b/api/src/services/templates/affiliation-rejection.ejs @@ -0,0 +1,21 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+

Bonjour <%= username %>,

+

+ Votre demande d'affiliation à <%= enterpriseName %> vient d'être refusée. +

+

+ En cas de questions nous vous invitons à vous rapprocher de votre gestionnaire. +

+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/affiliation-validation.ejs b/api/src/services/templates/affiliation-validation.ejs new file mode 100644 index 0000000..6e449be --- /dev/null +++ b/api/src/services/templates/affiliation-validation.ejs @@ -0,0 +1,29 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+

Bonjour <%= username %>,

+

+ Félicitations, votre demande d'affiliation à <%= enterpriseName %> vient d'être + acceptée ! +

+

+ Votre catalogue d'aides s'enrichit : vous pouvez désormais bénéficier des aides de + votre employeur. +

+

+ Connectez-vous sur Mon Compte Mobilité pour en bénéficier. +

+
+ <%- include('commons/button', {link: websiteLink, text: 'Je me connecte'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/citizen-affiliation.ejs b/api/src/services/templates/citizen-affiliation.ejs new file mode 100644 index 0000000..bfd6bda --- /dev/null +++ b/api/src/services/templates/citizen-affiliation.ejs @@ -0,0 +1,23 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+

Bonjour <%= username %>,

+

Bienvenue dans votre communauté moB de <%= funderName %> !

+

+ Cliquez sur le bouton ci-dessous pour confirmer votre employeur et bénéficier des + aides à la mobilité mises à votre disposition. +

+
+ <%- include('commons/button', {link: `${affiliationLink}`, text: 'Confirmer mon employeur'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+ +<%- include('commons/footer'); %> \ No newline at end of file diff --git a/api/src/services/templates/client-contact.ejs b/api/src/services/templates/client-contact.ejs new file mode 100644 index 0000000..985a592 --- /dev/null +++ b/api/src/services/templates/client-contact.ejs @@ -0,0 +1,26 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+
+

Bonjour <%= username %>,

+
+

+ Merci de nous avoir contacté. Nous avons bien reçu votre demande en date du <%= + contactDate%>. +

+

Nous mettons tout en œuvre pour vous répondre dans les meilleurs délais.

+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/commons/button.ejs b/api/src/services/templates/commons/button.ejs new file mode 100644 index 0000000..cba030c --- /dev/null +++ b/api/src/services/templates/commons/button.ejs @@ -0,0 +1,26 @@ + + + + + +
+ + <%= text %> + +
diff --git a/api/src/services/templates/commons/footer.ejs b/api/src/services/templates/commons/footer.ejs new file mode 100644 index 0000000..123d1a9 --- /dev/null +++ b/api/src/services/templates/commons/footer.ejs @@ -0,0 +1,22 @@ +<%- include('utils'); %> + +<% const webSiteFQDN = process.env.WEBSITE_FQDN %> + <% const websiteLink = webSiteFQDN ? `https://${webSiteFQDN}/mentions-legales-cgu` + : 'http://localhost:8000/mentions-legales-cgu' %> + +<% const footerLink = getLink("mob-footer.png") %> + + + + +
+

+ © Mon Compte Mobilité - Tous droits réservés - + Mentions légales + +

+ mob footer +
diff --git a/api/src/services/templates/commons/header.ejs b/api/src/services/templates/commons/header.ejs new file mode 100644 index 0000000..a0b84ad --- /dev/null +++ b/api/src/services/templates/commons/header.ejs @@ -0,0 +1,10 @@ +<%- include('utils'); %> + + <% const headerLink = getLink("logo-with-baseline.png") %> + + + + +
+ mob header +
\ No newline at end of file diff --git a/api/src/services/templates/commons/utils.ejs b/api/src/services/templates/commons/utils.ejs new file mode 100644 index 0000000..d30e917 --- /dev/null +++ b/api/src/services/templates/commons/utils.ejs @@ -0,0 +1,18 @@ +<% +const landscape = process.env.LANDSCAPE +const baseDomain = process.env.BASE_DOMAIN + +getLink = function(imgName) { + if (landscape !== null && landscape !== undefined) { + if (landscape === "production") { + return `https://static.${baseDomain}/assets/${imgName}` + } else if (landscape === "testing") { + return `https://static.preview.${baseDomain}/assets/${imgName}` + } else { + return `https://static.${landscape}.${baseDomain}/assets/${imgName}` + } + } else { + return `https://static.preview.${baseDomain}/assets/${imgName}` + } +} +%> \ No newline at end of file diff --git a/api/src/services/templates/contact.ejs b/api/src/services/templates/contact.ejs new file mode 100644 index 0000000..59f8939 --- /dev/null +++ b/api/src/services/templates/contact.ejs @@ -0,0 +1,16 @@ + + <% for(const [key, value] of Object.entries(keys)) { %> + + + <% } %> +
+ <%= value %> : + + + <% if (contact[key]) { %> + <%= contact[key] %> + <%} else { %> + Non renseigné + <% } %> + +
diff --git a/api/src/services/templates/css/style.ejs b/api/src/services/templates/css/style.ejs new file mode 100644 index 0000000..a88ee41 --- /dev/null +++ b/api/src/services/templates/css/style.ejs @@ -0,0 +1,147 @@ + diff --git a/api/src/services/templates/deletion-account-citizen.ejs b/api/src/services/templates/deletion-account-citizen.ejs new file mode 100644 index 0000000..154ea1d --- /dev/null +++ b/api/src/services/templates/deletion-account-citizen.ejs @@ -0,0 +1,35 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= username %>,

+
+
+

+ Nous vous confirmons la suppression de votre compte mobilité le <%= + deletionDate%>. Vous n’avez plus accès à votre compte moB ni à vos demandes + d’aides. +

+
+
+

+ A noter, vous pouvez toujours accéder au moteur de recherche d’aides moB + librement accessible sur notre site! +

+
+
+ <%- include('commons/button', {link: `${incentiveLink}`, text: 'Trouver une aide'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/deletion-inactivated-account-notification.ejs b/api/src/services/templates/deletion-inactivated-account-notification.ejs new file mode 100644 index 0000000..b53c496 --- /dev/null +++ b/api/src/services/templates/deletion-inactivated-account-notification.ejs @@ -0,0 +1,41 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= username %>,

+
+
+

+ Vous nous manquez ! Votre compte est inactif depuis le <%= inactifDate %>, date + de votre dernière connexion +

+
+
+

+ Sans connexion de votre part d'ici le <%= delaiDate %> votre compte Mobilité + sera désactivé et définitivement supprimé. +

+
+
+

+ Pour éviter cela, connectez-vous dès à présent à votre Compte Mobilité pour + accéder aux différents dispositifs d'aides à la mobilité disponibles proche de + chez vous. +

+
+
+ <%- include('commons/button', {link: `${connectionLink}`, text: 'Je me connecte'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/deletion-inactivated-account.ejs b/api/src/services/templates/deletion-inactivated-account.ejs new file mode 100644 index 0000000..2563564 --- /dev/null +++ b/api/src/services/templates/deletion-inactivated-account.ejs @@ -0,0 +1,25 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= username %>,

+
+

+ Votre compte étant inactif depuis le <%= inactifDate %>, date de votre dernière + connexion, nous avons procédé à la suppression de votre compte moB. A noter, dans + le respect des normes RGPD en vigueur, l’ensemble de vos données personnelles ont + été supprimées. +

+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/disaffiliation-citizen.ejs b/api/src/services/templates/disaffiliation-citizen.ejs new file mode 100644 index 0000000..95df434 --- /dev/null +++ b/api/src/services/templates/disaffiliation-citizen.ejs @@ -0,0 +1,42 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= username %>,

+
+
+

+ Nous vous informons que votre employeur a supprimé votre affiliation. Vous ne + pouvez donc plus accéder aux aides de votre entreprise*. +

+
+
+

+ A noter, vous pouvez toujours accéder au moteur de recherche d’aides moB + librement accessible sur notre site ! +

+
+
+ <%- include('commons/button', {link: `${incentiveLink}`, text: 'Trouver une aide'}) %> +
+
+

+ * NB : vous pourrez toujours retrouver les demandes traitées dans votre suivi + des demandes. Si vous pensez qu'il s'agit d'une erreur, nous vous invitons à + contacter votre employeur. +

+
+ +
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/funder-manual-affiliation-notification.ejs b/api/src/services/templates/funder-manual-affiliation-notification.ejs new file mode 100644 index 0000000..e100823 --- /dev/null +++ b/api/src/services/templates/funder-manual-affiliation-notification.ejs @@ -0,0 +1,32 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= funderFirstName %>,

+
+
+

Vous avez une nouvelle demande d’affiliation à votre entreprise :

+
+
+

Nom & Prénom du salarié : <%= lastName %> <%= firstName %>

+

Date de la demande : <%= creationDate %>

+
+
+

Rendez-vous sur votre espace gestionnaire afin d’administrer cette demande.

+
+
+ <%- include('commons/button', {link: `${manualAffiliationLink}`, text: 'Administrer la demande d’affiliation'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/invoice.ejs b/api/src/services/templates/invoice.ejs new file mode 100644 index 0000000..075edad --- /dev/null +++ b/api/src/services/templates/invoice.ejs @@ -0,0 +1,182 @@ +<%- include('commons/utils'); %> <% const headerLink=getLink("logo-with-baseline.svg") %> +<% const footerLink=getLink("mob-footer.png") %> + + +
+
+

Justificatif d'achat

+ mob-logo +
+
+

+ <%= invoice.enterprise.enterpriseName %> +

+ <% if (invoice.enterprise.sirenNumber) { %> +

N° SIREN : <%= invoice.enterprise.sirenNumber %>

+ <% } %> +

N° SIRET : <%= invoice.enterprise.siretNumber %>

+ <% if (invoice.enterprise.apeCode) { %> +

CODE APE : <%= invoice.enterprise.apeCode %>

+ <% } %> <% if (invoice.enterprise.enterpriseAddress) { %> <% if + (invoice.enterprise.enterpriseAddress.street) { %> +

<%= invoice.enterprise.enterpriseAddress.street %>

+ <% } %> <% if (invoice.enterprise.enterpriseAddress.zipCode || + invoice.enterprise.enterpriseAddress.city) { %> +

+ <% if (invoice.enterprise.enterpriseAddress.zipCode) { %> <%= + invoice.enterprise.enterpriseAddress.zipCode %> <% } %> <% if + (invoice.enterprise.enterpriseAddress.city) { %> <%= + invoice.enterprise.enterpriseAddress.city %> <% } %> +

+ <% } %> <% } %> +
+
+

+ FACTURÉ A <%= (invoice.customer.customerSurname).charAt(0).toUpperCase() + + (invoice.customer.customerSurname.slice(1)).toLowerCase() %> <%= + (invoice.customer.customerName).toUpperCase() %> +

+
+

N° de client : <%= invoice.customer.customerId %>

+ <% if (invoice.customer.customerAddress) { %> <% if + (invoice.customer.customerAddress.street) { %> +

<%= invoice.customer.customerAddress.street %>

+ <% } %> <% if (invoice.customer.customerAddress.zipCode || + invoice.customer.customerAddress.city) { %> +

+ <% if (invoice.customer.customerAddress.zipCode) { %> <%= + invoice.customer.customerAddress.zipCode %> <% } %> <% if + (invoice.customer.customerAddress.city) { %> <%= + invoice.customer.customerAddress.city %> <% } %> +

+ <% } %> <% } %> +
+
+
+ DATE + + <%= formatDate(invoice.transaction.purchaseDate, 'dd/MM/yyyy' ) %> + +
+
+ COMMANDE N° + <%= invoice.transaction.orderId %> +
+
+
+
+ + + + + + + + + + + <% invoice.products.forEach(function(product) { %> + + + + + + + <% if (product.productDetails) { %> <% if (product.productDetails.periodicity) { + %> + + + + + <% } %> <% if (product.productDetails.zoneMin && product.productDetails.zoneMax) { + %> + + + + + <%} else if (product.productDetails.zoneMin || product.productDetails.zoneMax) { + %> <% if (product.productDetails.zoneMin) { %> + + + + + <% } %> <% if (product.productDetails.zoneMax) { %> + + + + + <% } %> <% } %> <% if (product.productDetails.validityStart) { %> + + + + + <% } %> <% if (product.productDetails.validityEnd) { %> + + + + + <% } %> <% } %> <% }) %> + + + + + + + + + +
QTEDESIGNATIONPRIX UNIT. (TTC)MONTANT
<%= product.quantity %><%= product.productName %><%= ((product.amountInclTaxes)/100).toFixed(2) %><%= ((product.quantity)*(product.amountInclTaxes)/100).toFixed(2) %>
<%= product.productDetails.periodicity %>
+ Zone <%= product.productDetails.zoneMin %> - <%= + product.productDetails.zoneMax %> +
Zone <%= product.productDetails.zoneMin %>
Zone <%= product.productDetails.zoneMax %>
+ Date de début <%= formatDate(product.productDetails.validityStart, + 'dd/MM/yyyy' ) %> +
+ Date de fin <%= formatDate(product.productDetails.validityEnd, 'dd/MM/yyyy' ) + %> +
TOTAL (TTC) + + <%= ((invoice.transaction.amountInclTaxes)/100).toFixed(2) %> € + +
+
+
+

Le taux de TVA est de 10% depuis le 01/01/2014

+
+
+
+

+ Document édité par Mon Compte Mobilité à partir des éléments automatiquement transmis + depuis le compte <%= invoice.enterprise.enterpriseName %> de l'utilisateur +

+
+ mob-logo-footer +
+
diff --git a/api/src/services/templates/nonActivated-account-deletion.ejs b/api/src/services/templates/nonActivated-account-deletion.ejs new file mode 100644 index 0000000..ccf5f32 --- /dev/null +++ b/api/src/services/templates/nonActivated-account-deletion.ejs @@ -0,0 +1,33 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+

Bonjour <%= username %>,

+
+
+

Votre compte moB créé il y a 6 mois n’a toujours pas été activé.

+
+
+

+ Nous vous informons que votre compte est désormais considéré inactif et a été + supprimé. +

+
+
+

+ A noter, dans le respect des normes RGPD en vigueur, l’ensemble de vos données + personnelles ont été supprimées. +

+
+ +
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/requests-to-process.ejs b/api/src/services/templates/requests-to-process.ejs new file mode 100644 index 0000000..c93f593 --- /dev/null +++ b/api/src/services/templates/requests-to-process.ejs @@ -0,0 +1,33 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= username %>,

+
+
+

+ Votre demande a bien été transmise à votre gestionnaire <%= funderName %>. +

+
+
+

+ Retrouvez votre demande et suivez son avancée facilement depuis votre tableau de + bord. +

+
+
+ <%- include('commons/button', {link: `${dashboardLink}`, text: 'Je suis ma demande'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/subscription-rejection.ejs b/api/src/services/templates/subscription-rejection.ejs new file mode 100644 index 0000000..85554c7 --- /dev/null +++ b/api/src/services/templates/subscription-rejection.ejs @@ -0,0 +1,39 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+
+

Bonjour <%= username %>,

+
+
+

+ Votre demande d’aide <%- incentiveTitle %> en date du <%- date %> a été refusée + par votre gestionnaire pour le motif suivant : <%- motif %> +

+
+
+ <%if (comment) { %> +

Commentaire : <%= comment %>

+ <% } %> +
+
+

+ Retrouvez l’ensemble de vos demandes et corrigez votre demande d’aide + directement depuis votre Compte Mobilité +

+
+
+ <%- include('commons/button', {link: `${subscriptionsLink}`, text: 'Je corrige ma demande'}) %> +
+
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/templates/subscription-validation.ejs b/api/src/services/templates/subscription-validation.ejs new file mode 100644 index 0000000..2ab65b7 --- /dev/null +++ b/api/src/services/templates/subscription-validation.ejs @@ -0,0 +1,37 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + + + + + +
+
+

Bonjour <%= username %>,

+
+
+

+ Bonne nouvelle ! Votre demande d’aide "<%- incentiveTitle %>" <%if (amount) { %> + d’un montant de <%- amount %>€ <% } %> en date du <%- date %> vient d’être + validée par votre administrateur. Votre financeur va procéder au versement ou à + la mise à disposition de l'aide. Vous pouvez le contacter si besoin. +

+
+
+ <%if (comment) { %> +

Commentaire : <%= comment %>

+ <% } %> +
+
+

+ Retrouvez l’ensemble des informations relatives à vos demandes depuis votre Compte + Mobilité +

+
+ <%- include('commons/button', {link: `${subscriptionsLink}`, text: `Mes demandes d'aides`}) %> +
+

Merci pour votre confiance,

+

L’équipe Mon Compte Mobilité

+
+
+ +<%- include('commons/footer'); %> diff --git a/api/src/services/territory.service.ts b/api/src/services/territory.service.ts new file mode 100644 index 0000000..043ba0b --- /dev/null +++ b/api/src/services/territory.service.ts @@ -0,0 +1,44 @@ +import {injectable, BindingScope, service} from '@loopback/core'; +import {removeWhiteSpace} from '../controllers/utils/helpers'; +import {Territory} from '../models'; +import {TerritoryRepository} from '../repositories'; +import {ResourceName, StatusCode} from '../utils'; + +import {ValidationError} from '../validationError'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class TerritoryService { + constructor( + @service(TerritoryRepository) + private territoryRepository: TerritoryRepository, + ) {} + + async createTerritory(territory: Omit): Promise { + /** + * Removing white spaces. + * Exemple : " Mulhouse aglo " returns "Mulhouse aglo". + */ + territory.name = removeWhiteSpace(territory.name); + + /** + * Perform a case-insensitive search. + */ + const result: Territory | null = await this.territoryRepository.findOne({ + where: {name: {regexp: `/^${territory.name}$/i`}}, + }); + + /** + * Throw an error if the territory name is duplicated. + * Otherwise create the territory. + */ + if (result) { + throw new ValidationError( + 'territory.name.error.unique', + '/territoryName', + StatusCode.UnprocessableEntity, + ResourceName.Territory, + ); + } + return this.territoryRepository.create(territory); + } +} diff --git a/api/src/services/user.authorizor.ts b/api/src/services/user.authorizor.ts new file mode 100644 index 0000000..1f00b44 --- /dev/null +++ b/api/src/services/user.authorizor.ts @@ -0,0 +1,25 @@ +import { + AuthorizationContext, + AuthorizationDecision, + AuthorizationMetadata, +} from '@loopback/authorization'; +import {IUser} from '../utils'; + +/** + * Compare user id and request id to determine if user can access his own data + * @param authorizationCtx AuthorizationContext + * @param metadata AuthorizationMetadata + * @returns AuthorizationDecision + */ +export async function canAccessHisOwnData( + authorizationCtx: AuthorizationContext, + metadata: AuthorizationMetadata, +): Promise { + if ( + (authorizationCtx.principals[0] as IUser).id === + authorizationCtx.invocationContext.args[0] + ) { + return AuthorizationDecision.ALLOW; + } + return AuthorizationDecision.DENY; +} diff --git a/api/src/strategies/api-key.strategy.ts b/api/src/strategies/api-key.strategy.ts new file mode 100644 index 0000000..6772788 --- /dev/null +++ b/api/src/strategies/api-key.strategy.ts @@ -0,0 +1,24 @@ +import {AuthenticationStrategy} from '@loopback/authentication'; +import {Request} from 'express'; +import {AuthenticationService} from '../services/authentication.service'; +import {service} from '@loopback/core'; +import {AUTH_STRATEGY, IUser} from '../utils'; + +export class ApiKeyAuthenticationStrategy implements AuthenticationStrategy { + name = AUTH_STRATEGY.API_KEY; + + constructor( + @service(AuthenticationService) + private authenticationService: AuthenticationService, + ) {} + + async authenticate(request: Request): Promise { + // Extract token from Bearer + const apiKey: string = this.authenticationService.extractApiKey(request); + + // Convert to UserProfile type + const user: IUser = this.authenticationService.convertToApiKeyUser(apiKey); + + return user; + } +} diff --git a/api/src/strategies/index.ts b/api/src/strategies/index.ts new file mode 100644 index 0000000..55c3eb5 --- /dev/null +++ b/api/src/strategies/index.ts @@ -0,0 +1,2 @@ +export * from './keycloak.strategy'; +export * from './api-key.strategy'; diff --git a/api/src/strategies/keycloak.strategy.ts b/api/src/strategies/keycloak.strategy.ts new file mode 100644 index 0000000..fcdfaaf --- /dev/null +++ b/api/src/strategies/keycloak.strategy.ts @@ -0,0 +1,38 @@ +import {AuthenticationStrategy} from '@loopback/authentication'; +import {Request} from 'express'; +import {service} from '@loopback/core'; +import {JwtPayload} from 'jsonwebtoken'; + +import {AuthenticationService} from '../services/authentication.service'; +import {ValidationError} from '../validationError'; +import {AUTH_STRATEGY, IUser, StatusCode} from '../utils'; + +export class KeycloakAuthenticationStrategy implements AuthenticationStrategy { + name = AUTH_STRATEGY.KEYCLOAK; + + constructor( + @service(AuthenticationService) + private authenticationService: AuthenticationService, + ) {} + + async authenticate(request: Request): Promise { + // Extract token from Bearer + const token: string = this.authenticationService.extractCredentials(request); + + // Verify & decode Token + const decodedToken: JwtPayload = await this.authenticationService.verifyToken(token); + + // Convert to UserProfile type + const user: IUser = this.authenticationService.convertToUser(decodedToken); + // Check emailVerified + + if (!user.clientName && !user.emailVerified) { + throw new ValidationError( + `Email not verified`, + '/authorization', + StatusCode.Unauthorized, + ); + } + return user; + } +} diff --git a/api/src/utils/accessRules.ts b/api/src/utils/accessRules.ts new file mode 100644 index 0000000..601a094 --- /dev/null +++ b/api/src/utils/accessRules.ts @@ -0,0 +1,8 @@ +const canAccessHisSubscriptionData = ( + tokenUserId: string, + subscriptionUserId: string, +): boolean => { + return tokenUserId === subscriptionUserId; +}; + +export {canAccessHisSubscriptionData}; diff --git a/api/src/utils/affiliation.ts b/api/src/utils/affiliation.ts new file mode 100644 index 0000000..91fc7b4 --- /dev/null +++ b/api/src/utils/affiliation.ts @@ -0,0 +1,36 @@ +import {Citizen} from '../models'; +import {AFFILIATION_STATUS, FUNDER_TYPE} from './enum'; + +const isEnterpriseAffilitation = ({ + citizen, + funderMatch, + inputFunderId, +}: { + citizen: Citizen | null; + funderMatch: + | '' + | { + funderType: FUNDER_TYPE; + id?: string; + name: string; + siretNumber?: number | undefined; + emailFormat?: string[]; + employeesCount?: number | undefined; + budgetAmount?: number | undefined; + isHris?: boolean; + } + | undefined; + inputFunderId?: string | undefined; +}) => { + return ( + funderMatch && + !!citizen && + !!inputFunderId && + citizen.affiliation && + citizen.affiliation.enterpriseId === inputFunderId && + citizen.affiliation.affiliationStatus === AFFILIATION_STATUS.AFFILIATED && + funderMatch.funderType === FUNDER_TYPE.enterprise + ); +}; + +export {isEnterpriseAffilitation}; diff --git a/api/src/utils/date.ts b/api/src/utils/date.ts new file mode 100644 index 0000000..47179e3 --- /dev/null +++ b/api/src/utils/date.ts @@ -0,0 +1,33 @@ +import {formatInTimeZone, format} from 'date-fns-tz'; +import {differenceInMinutes, add} from 'date-fns'; + +export const TIMEZONE = 'Europe/Paris'; + +export const formatDateInTimezone = (date: Date, dateFormat: string) => { + return formatInTimeZone(date.getTime(), TIMEZONE, dateFormat); +}; + +/** + * Check if date value + hoursIntervale is after this instant + * @param dateValue date to compare + * @param hoursIntervale numbre of hour to add to dateValue + * @returns boolean + */ +export const isAfterDate = (dateValue: Date, hoursIntervale: number) => { + const futureDate = add(dateValue, { + hours: hoursIntervale, + }); + return differenceInMinutes(new Date(), futureDate) > 0; +}; + +/** + * Check if date to verify is before reference date + * @param dateToVerify date to verify + * @param referenceDate reference date + * @returns boolean + */ +export const isExpired = (dateToVerify: Date, referenceDate: Date) => { + const dateToVerifyTime = dateToVerify.getTime(); + const referenceDateTime = referenceDate.getTime(); + return dateToVerifyTime < referenceDateTime; +}; diff --git a/api/src/utils/encryption.ts b/api/src/utils/encryption.ts new file mode 100644 index 0000000..5707164 --- /dev/null +++ b/api/src/utils/encryption.ts @@ -0,0 +1,21 @@ +import * as crypto from 'crypto'; + +const AES_ALGORITHM = 'AES-256-CBC'; + +export const generateAESKey = (): {key: Buffer; iv: Buffer} => { + const key = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + return {key, iv}; +}; + +export const encryptAESKey = (publicKey: string, key: Buffer, iv: Buffer) => { + const encryptKey = crypto.publicEncrypt(publicKey, Buffer.from(key)); + const encryptIV = crypto.publicEncrypt(publicKey, Buffer.from(iv)); + return {encryptKey, encryptIV}; +}; + +export const encryptFileHybrid = (file: Buffer, key: Buffer, iv: Buffer) => { + const cipher = crypto.createCipheriv(AES_ALGORITHM, key, iv); + const encryptedFile: Buffer = Buffer.concat([cipher.update(file), cipher.final()]); + return encryptedFile; +}; diff --git a/api/src/utils/enum.ts b/api/src/utils/enum.ts new file mode 100644 index 0000000..11fadf5 --- /dev/null +++ b/api/src/utils/enum.ts @@ -0,0 +1,183 @@ +/** COMMONS */ + +export enum HTTP_METHOD { + GET = 'GET', + POST = 'POST', + PATCH = 'PATCH', + PUT = 'PUT', + DELETE = 'DELETE', +} + +/** EVENT EMITTER */ +export enum EVENT_MESSAGE { + READY = 'READY', + UPDATE = 'UPDATE', + ACK = 'ACKNOWLEDGE', + CONSUME = 'CONSUME', +} + +/** IDP */ +export enum GROUPS { + citizens = 'citoyens', + funders = 'financeurs', + admins = 'admins', + collectivities = 'collectivités', + enterprises = 'entreprises', +} + +export enum FUNDER_TYPE { + enterprise = 'entreprise', + collectivity = 'collectivit\u00E9', +} + +export enum Roles { + CONTENT_EDITOR = 'content_editor', // admin_fonctionnel + FUNDERS = 'financeurs', + MANAGERS = 'gestionnaires', + SUPERVISORS = 'superviseurs', + MAAS = 'maas', + MAAS_BACKEND = 'service_maas', + SIRH_BACKEND = 'service_sirh', + PLATFORM = 'platform', // used to determine user from website + API_KEY = 'api-key', + CITIZENS = 'citoyens', + CITIZENS_FC = 'citoyens_fc', // France connect Citizen + VAULT_BACKEND = 'service_vault', +} + +export enum IDP_EMAIL_TEMPLATE { + FUNDER = 'financeur', + CITIZEN = 'citoyen', +} + +/** INCENTIVES */ +export enum INCENTIVE_TYPE { + NATIONAL_INCENTIVE = 'AideNationale', + TERRITORY_INCENTIVE = 'AideTerritoire', + EMPLOYER_INCENTIVE = 'AideEmployeur', +} + +export enum TRANSPORTS { + PUBLIC_TRANSPORT = 'transportsCommun', + BIKE = 'velo', + CAR = 'voiture', + SHARE_SERVICE = 'libreService', + ELECTRIC = 'electrique', + CAR_SHARING = 'autopartage', + CARPOOLING = 'covoiturage', +} + +/** CONTACT */ +export enum USERTYPE { + CITIZEN = 'citoyen', + EMPLOYER = 'employeur', + COLLECTIVITY = 'collectivite', + MOBILITY_OPERATOR = 'operateurMobilite', +} + +/** CITIZENS */ +export enum AFFILIATION_STATUS { + TO_AFFILIATE = 'A_AFFILIER', + AFFILIATED = 'AFFILIE', + DISAFFILIATED = 'DESAFFILIE', + UNKNOWN = 'UNKNOWN', +} + +export enum CITIZEN_STATUS { + EMPLOYEE = 'salarie', + STUDENT = 'etudiant', + INDEPENDANT_LIBERAL = 'independantLiberal', + RETIRED = 'retraite', + UNEMPLOYED = 'sansEmploi', +} + +export enum GENDER { + MALE = 'male', + FEMALE = 'female', +} + +/** SUBSCRIPTIONS */ + +export enum SUBSCRIPTION_STATUS { + ERROR = 'ERREUR', + TO_PROCESS = 'A_TRAITER', + VALIDATED = 'VALIDEE', + REJECTED = 'REJETEE', + DRAFT = 'BROUILLON', +} +export enum HRIS_SUBSCRIPTION_ERROR { + ERROR = 'ERREUR', +} + +export enum REJECTION_REASON { + CONDITION = 'ConditionsNonRespectees', + MISSING_PROOF = 'JustificatifManquant', + INVALID_PROOF = 'JustificatifInvalide', + OTHER = 'Autre', +} + +export enum PAYMENT_MODE { + NONE = 'aucun', + UNIQUE = 'unique', + MULTIPLE = 'multiple', +} + +export enum PAYMENT_FREQ { + MONTHLY = 'mensuelle', + QUARTERLY = 'trimestrielle', +} + +export enum USER_STATUS { + salarie = 'Salarié', + etudiant = 'Étudiant', + independantLiberal = 'Indépendant / Profession libérale', + retraite = 'Retraité', + sansEmploi = 'Sans emploi', +} + +export enum GET_INCENTIVES_INFORMATION_MESSAGES { + // eslint-disable-next-line + CITIZEN_AFFILIATED_WITHOUT_INCENTIVES = "Le Citoyen est bien affilié à son employeur, mais il ne dispose pas d'aides.", + CITIZEN_NOT_AFFILIATED = "Le Citoyen n'est pas affilié à son employeur.", +} + +export enum AUTH_STRATEGY { + KEYCLOAK = 'keycloak', + API_KEY = 'api-key', +} + +export enum REASON_REJECT_TEXT { + CONDITION = "Conditions d'éligibilité non respectées", + MISSING_PROOF = 'Justificatif manquant', + INVALID_PROOF = 'Justificatif invalide ou non lisible', +} + +export enum SEND_MODE { + VALIDATION = 'validée', + REJECTION = 'refusée', +} + +/** RABBITMQ */ +export enum UPDATE_MODE { + ADD = 'ADD', + DELETE = 'DELETE', +} + +export enum CONSUMER_ERROR { + DATE_ERROR = 'The date of the last payment must be greater than two months from the validation date', + ERROR_MESSAGE = "Votre employeur n'a pas pu traiter votre demande", +} + +export enum CRON_TYPES { + DELETE_SUBSCRIPTION = 'Delete_subscription', +} + +export enum GENDER_TYPE { + UNKNOWN = 0, + MALE = 1, + FEMALE = 2, + NOT_APPLICABLE = 9, +} +export enum SCOPES { + FUNDERS_CLIENTS = 'funders-clients', +} diff --git a/api/src/utils/errors.ts b/api/src/utils/errors.ts new file mode 100644 index 0000000..261d299 --- /dev/null +++ b/api/src/utils/errors.ts @@ -0,0 +1,45 @@ +export enum StatusCode { + Success = 200, + Created = 201, + NoContent = 204, + ContentDifferent = 210, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + Conflict = 409, + PreconditionFailed = 412, + UnprocessableEntity = 422, + InternalServerError = 500, +} + +export enum ResourceName { + Enterprise = 'Enterprise', + Collectivity = 'Collectivity', + Community = 'Community', + Citizen = 'Citizen', + Client = 'Client', + Account = 'Account', + Affiliation = 'Affiliation', + Disaffiliation = 'Disaffiliation', + Funder = 'Funder', + EncryptionKey = 'Encryption Key', + Subscription = 'Subscription', + Incentive = 'Incentive', + Attachments = 'Subscription Attachments', + AttachmentsType = 'Type of subscription Attachments', + ProfessionalEmail = 'Professional Email', + PersonalEmail = 'Personal Email', + Reason = 'Reason', + Payment = 'Payment', + User = 'User', + Buffer = 'Buffer', + Antivirus = 'Antivirus', + Email = 'Email', + Metadata = 'Metadata', + Contact = 'Contact', + rabbitmq = 'Rabbitmq', + ResendAffiliation = 'Resend Affiliation', + UniqueProfessionalEmail = 'Unique Professional Email', + AffiliationBadStatus = 'Affiliation Already Treated', + Territory = 'Territoire', +} diff --git a/api/src/utils/file-conversion.ts b/api/src/utils/file-conversion.ts new file mode 100644 index 0000000..798e3a0 --- /dev/null +++ b/api/src/utils/file-conversion.ts @@ -0,0 +1,42 @@ +import pdf from 'html-pdf'; +import ejs from 'ejs'; +import path from 'path'; +import {logger} from '.'; + +/** + * Generate PDF Buffer from HTML + * @param html string + * @returns Buffer + */ +export const generatePdfBufferFromHtml = async (html: string): Promise => { + return new Promise((resolve, reject) => { + // Ignore ssl errors to show images in pdf + pdf + .create(html, {phantomArgs: ['--ignore-ssl-errors=yes']}) + .toBuffer(async (err, buffer) => { + if (err) reject(err); + resolve(buffer); + }); + }); +}; + +/** + * Generate Template has HTML with ejs.renderFile + * @param templateName string + * @param data Object + * @returns string + */ +export const generateTemplateAsHtml = async ( + templateName: string, + data?: Object | undefined, +): Promise => { + try { + return await ejs.renderFile( + path.join(__dirname, `../services/templates/${templateName}.ejs`), + data ?? {}, + ); + } catch (err) { + logger.error(`generateTemplateHtml : ${err}`); + throw new Error(`${err}`); + } +}; diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts new file mode 100644 index 0000000..54bc6ce --- /dev/null +++ b/api/src/utils/index.ts @@ -0,0 +1,10 @@ +export * from './enum'; +export * from './interface'; +export * from './s3-stream'; +export * from './errors'; +export * from './affiliation'; +export * from './accessRules'; +export * from './logger'; +export * from './file-conversion'; +export * from './security-spec'; +export * from './date'; diff --git a/api/src/utils/interface.ts b/api/src/utils/interface.ts new file mode 100644 index 0000000..4b6d2f9 --- /dev/null +++ b/api/src/utils/interface.ts @@ -0,0 +1,188 @@ +import {SUBSCRIPTION_STATUS, EVENT_MESSAGE, FUNDER_TYPE, GENDER} from '.'; + +import {SubscriptionRejection, SubscriptionValidation} from '../models'; + +import {securityId, UserProfile} from '@loopback/security'; +import {Identity} from '../models/citizen/identity.model'; + +/** DASHBOARDS */ +export interface IDashboardCitizenIncentiveList { + incentiveId: string; + incentiveTitle: string; + totalSubscriptionsCount: number; + validatedSubscriptionPercentage: number; +} + +export interface IDashboardCitizen { + incentiveList: IDashboardCitizenIncentiveList[]; + totalCitizensCount: number; +} + +export interface IDashboardSubscriptionResult { + status: SUBSCRIPTION_STATUS; + count: number; +} + +export interface IDashboardSubscription { + result: IDashboardSubscriptionResult[]; + totalCount: number; + totalPending: { + count: number; + }; +} + +/** RABBITMQ */ +export interface ISubscriptionPublishPayload { + lastName: string; + firstName: string; + birthdate: string; + citizenId: string; + incentiveId: string; + subscriptionId: string; + email: string | null; + status: SUBSCRIPTION_STATUS; + communityName: string; + specificFields: string; + attachments: string[]; + error?: ISubscriptionBusError; + encryptionKeyId: string; + encryptionKeyVersion: number; + encryptedAESKey: string; + encryptedIV: string; +} + +/** EVENT EMITTER */ +export interface IMessage { + type: EVENT_MESSAGE; + data?: any; +} + +export interface ClientOfConsent { + clientId?: string; + name?: string; +} + +export interface Consent { + clientId?: string; +} +export interface IPublishPayload { + subscription: ISubscriptionPublishPayload; + enterprise: string; +} + +export interface ISubscriptionBusError { + message?: string; + property?: string; + code?: string; +} + +/** USER CONTROLLER */ +export interface IUsersResult { + funderType: string; + communityName: string | null; + funderName: string | undefined; + roles: string; + id: string; + funderId: string; + communityIds: string[]; + email: string; + firstName: string; + lastName: string; +} + +export interface IFunder { + funderType?: FUNDER_TYPE; + id?: string; + name?: string; + siretNumber?: number; + emailFormat?: string[]; + employeesCount?: number; + budgetAmount?: number; + isHris?: boolean; + hasManualAffiliation?: boolean; +} + +export interface ICreate { + id: string | undefined; + email?: string; + lastName?: string; + firstName?: string; +} + +/** Incentive Controller */ +export interface IScore { + score: { + $meta: string; + }; + updatedAt?: undefined; +} + +export interface IUpdateAt { + updatedAt: number; + score?: undefined; +} + +/** Funder Controller */ +export interface IFindCommunities { + funderType: string; + funderName: string | undefined; + id: string; + name: string; + funderId: string; +} + +/** Subscription Service */ + +export interface IDataInterface { + citizenId: string; + subscriptionId: string; + status: SUBSCRIPTION_STATUS; + mode: string; + frequency: string; + amount: number; + lastPayment: string; + comments: string; + type: string; + other: string; +} + +/** SubscriptionV1 Controller */ +export interface MaasSubscriptionList { + id: string; + incentiveId: string; + incentiveTitle: string; + funderName: string; + status: SUBSCRIPTION_STATUS; + createdAt: Date; + updatedAt: Date; + subscriptionValidation?: SubscriptionValidation; + subscriptionRejection?: SubscriptionRejection; + contact: string | undefined; +} + +/** Authentication Service */ +export interface IUser extends UserProfile { + [securityId]: string; + id: string; + emailVerified: boolean; + funderName?: string; + funderType?: string; + incentiveType?: string; + clientName?: string; + roles?: string[]; + key?: string; + groups?: string[]; +} + +/** Keycloak Service */ +export interface User { + email: string; + password?: string; + funderName?: string; + lastName: string; + firstName: string; + group: string[]; + birthdate?: string; + gender?: GENDER; + identity?: Identity; +} diff --git a/api/src/utils/invoice.ts b/api/src/utils/invoice.ts new file mode 100644 index 0000000..9adb8b8 --- /dev/null +++ b/api/src/utils/invoice.ts @@ -0,0 +1,37 @@ +import {Invoice} from '../models'; +import {generatePdfBufferFromHtml, generateTemplateAsHtml} from '../utils'; +import {formatDateInTimezone} from './date'; +import {Express} from 'express'; + +export const generatePdfInvoices = async ( + invoices: Invoice[], +): Promise => { + const invoicesPdf: Express.Multer.File[] = []; + for (const invoice of invoices) { + const html = await generateTemplateAsHtml('invoice', { + invoice: invoice, + formatDate: formatDateInTimezone, + }); + const invoicePdfBuffer = await generatePdfBufferFromHtml(html); + invoicesPdf.push({ + originalname: getInvoiceFilename(invoice), + buffer: invoicePdfBuffer, + mimetype: 'application/pdf', + fieldname: 'invoice', + } as Express.Multer.File); + } + return invoicesPdf; +}; + +export const getInvoiceFilename = (invoice: Invoice): string => { + const productName = `${invoice.products[0].productName}`; + const customerSurname = `${invoice.customer.customerSurname}`; + const customerName = `${invoice.customer.customerName}`; + + const purchaseDate = formatDateInTimezone( + invoice.transaction.purchaseDate, + 'dd-MM-yyyy', + ); + const fileName = `${purchaseDate}_${productName}_${customerSurname}_${customerName}.pdf`; + return fileName.replace(/ /g, '_'); +}; diff --git a/api/src/utils/logger/index.ts b/api/src/utils/logger/index.ts new file mode 100644 index 0000000..5c09644 --- /dev/null +++ b/api/src/utils/logger/index.ts @@ -0,0 +1,18 @@ +import {createLogger, format, transports} from 'winston'; + +export const logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + exitOnError: false, + format: format.combine( + format.cli(), + format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss.SSS', + }), + format.printf( + info => + `[${info.timestamp}]-[${info.level}]: ${info.message}` + + (info.splat !== undefined ? `${info.splat}` : ' '), + ), + ), + transports: [new transports.Console()], +}); diff --git a/api/src/utils/s3-stream.ts b/api/src/utils/s3-stream.ts new file mode 100644 index 0000000..a84623a --- /dev/null +++ b/api/src/utils/s3-stream.ts @@ -0,0 +1,17 @@ +import {Readable} from 'stream'; + +/** + * function to convert ReadableStream to a string related to the download method. + * @param stream stream file streamed + * @returns Promise + */ + +export const streamToString = async (stream: Readable): Promise => { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + stream.on('start', resolve); + stream.on('data', chunk => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('binary'))); + }); +}; diff --git a/api/src/utils/security-spec.ts b/api/src/utils/security-spec.ts new file mode 100644 index 0000000..5f47b66 --- /dev/null +++ b/api/src/utils/security-spec.ts @@ -0,0 +1,53 @@ +import {SecuritySchemeObject, ReferenceObject} from '@loopback/openapi-v3'; +import {baseUrl, realmName} from '../constants'; + +export const SECURITY_SPEC_API_KEY = [{ApiKey: []}]; +export const SECURITY_SPEC_KC_PASSWORD = [{KCPassword: []}]; +export const SECURITY_SPEC_KC_CREDENTIALS = [{KCCredentials: []}]; +export const SECURITY_SPEC_JWT = [{JwtCredentials: []}]; +export const SECURITY_SPEC_JWT_KC_CREDENTIALS = [{JwtCredentials: [], KCCredentials: []}]; +export const SECURITY_SPEC_JWT_KC_PASSWORD = [{JwtCredentials: [], KCPassword: []}]; +export const SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD = [ + {KCCredentials: [], KCPassword: []}, +]; +export const SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS = [ + {JwtCredentials: [], KCPassword: [], KCCredentials: []}, +]; +export const SECURITY_SPEC_ALL = [ + {ApiKey: [], KCPassword: [], JwtCredentials: [], KCCredentials: []}, +]; + +export type SecuritySchemeObjects = { + [securityScheme: string]: SecuritySchemeObject | ReferenceObject; +}; + +export const SECURITY_SCHEME_SPEC: SecuritySchemeObjects = { + ApiKey: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + KCPassword: { + type: 'oauth2', + flows: { + password: { + tokenUrl: `${baseUrl}/realms/${realmName}/protocol/openid-connect/token`, + scopes: {}, + }, + }, + }, + KCCredentials: { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: `${baseUrl}/realms/${realmName}/protocol/openid-connect/token`, + scopes: {}, + }, + }, + }, + JwtCredentials: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, +}; diff --git a/api/src/validationError.ts b/api/src/validationError.ts new file mode 100644 index 0000000..720025c --- /dev/null +++ b/api/src/validationError.ts @@ -0,0 +1,15 @@ +import {logger} from './utils'; + +export class ValidationError extends Error { + path: string | undefined; + statusCode?: number; + resourceName?: string | undefined; + + constructor(message: string, path: string, statusCode = 500, resourceName = '') { + super(message); + this.statusCode = statusCode; + this.path = path; + this.resourceName = resourceName !== '' ? resourceName : undefined; + logger.error(message); + } +} diff --git a/api/src/validationErrorExternal.ts b/api/src/validationErrorExternal.ts new file mode 100644 index 0000000..3962477 --- /dev/null +++ b/api/src/validationErrorExternal.ts @@ -0,0 +1,32 @@ +import {HttpErrors} from '@loopback/rest'; +import {ResourceName, StatusCode} from './utils'; +import {ValidationError} from './validationError'; +import Ajv from 'ajv'; + +interface Error { + code?: string; + entityName?: string; + entityId?: string; +} + +export const validationErrorExternalHandler = (error: Error): HttpErrors.HttpError => { + const {code, entityName, entityId} = error; + + if (code === 'ENTITY_NOT_FOUND' && entityName && entityId) { + throw new HttpErrors.NotFound( + `The Id '${entityId}' does not exists in the entity '${entityName}'`, + ); + } + throw error; +}; + +export const validator = (data: object, validate: Ajv.ValidateFunction) => { + if (!validate(data)) { + throw new ValidationError( + validate.errors![0].message!, + validate.errors![0].dataPath.toString(), + StatusCode.UnprocessableEntity, + ResourceName.Subscription, + ); + } +}; diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..260d6c4 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "lib": ["es5", "es6", "es2020", "dom", "dom.iterable","es2016"], + "experimentalDecorators": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/bus/.gitlab-ci.yml b/bus/.gitlab-ci.yml new file mode 100644 index 0000000..e5ba0ba --- /dev/null +++ b/bus/.gitlab-ci.yml @@ -0,0 +1,18 @@ +include: + - local: "bus/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "bus/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + +.bus-base: + variables: + MODULE_NAME: bus + MODULE_PATH: ${MODULE_NAME} + BUS_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/bitnami/rabbitmq:3.9.15 + only: + changes: + - "*" + - "commons/**/*" + - "bus/**/*" diff --git a/bus/.gitlab-ci/preview.yml b/bus/.gitlab-ci/preview.yml new file mode 100644 index 0000000..0aabaa2 --- /dev/null +++ b/bus/.gitlab-ci/preview.yml @@ -0,0 +1,11 @@ +bus_preview_deploy: + extends: + - .preview-deploy-job + - .bus-base + environment: + on_stop: bus_preview_cleanup + +bus_preview_cleanup: + extends: + - .commons_preview_cleanup + - .bus-base diff --git a/bus/.gitlab-ci/testing.yml b/bus/.gitlab-ci/testing.yml new file mode 100644 index 0000000..9b07061 --- /dev/null +++ b/bus/.gitlab-ci/testing.yml @@ -0,0 +1,4 @@ +bus_testing_deploy: + extends: + - .testing-deploy-job + - .bus-base diff --git a/bus/Chart.yaml b/bus/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/bus/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/bus/README.md b/bus/README.md index 4482c05..e6d8b01 100644 --- a/bus/README.md +++ b/bus/README.md @@ -2,21 +2,30 @@ Le service bus se base sur la brique logicielle **[RabbitMQ](https://www.rabbitmq.com/)** -Cette brique de message broker nous permet de communiquer avec les SIRH sur le sujet des demandes d'aides effectuées par un citoyen. +Cette brique de message broker permet au produit de s'interface avec les SIRH existants back-office des financeurs qu'ils utilisent déjà pour traiter les demandes d'aides à la mobilité. Ainsi, le financeur s'appuiera sur son SI existant pour les gérer et n'utilisera pas le back-office proposé par le produit. -Nous utilisons le protocol amqp pour échanger des messages. +Ci-dessous une vue globale du processus métier : -L'authentification via oAuth2 a été activée pour les environnements distants permettant ainsi d'utiliser un jeton d'authentification émis par notre IDP pour pouvoir publier ou consommer des messages. +![busHRISprocess](bus/docs/assets/busHRISprocess.png) -L'utilisation du fichier 'definition.json' présent dans le dossier overlays permet de définir les configurations nécessaires au bon fonctionnement de l'échange des messages. +Concernant les échanges entre moB et le bus, le protocol amqp pour échanger des messages via la librairie [amqplib](https://www.npmjs.com/package/amqplib). [Lien](https://amqp-node.github.io/amqplib/) vers la documentation complète. -(Voir relation avec les autres services) +L'authentification via OAuth2 a été configuré pour les environnements distants permettant ainsi d'utiliser un jeton d'authentification émis par _idp_ pour pouvoir publier ou consommer des messages. Le plugin [rabbitmq_auth_backend_oauth2](https://github.com/rabbitmq/rabbitmq-server/tree/main/deps/rabbitmq_auth_backend_oauth2) a été utilisé pour cela, sa configuration est visible dans _bus/overlays/config/custom.conf_. -# Installation en local +![busHRISauthentication](bus/docs/assets/busHRISauthentication.png) + +L'utilisation du fichier _definition.json_ présent dans le dossier _overlays_ permet de définir les configurations nécessaires au bon fonctionnement de l'échange des messages. +Elles correspondent à la solution d'architecture technique détaillée du bus ci-dessous mise en place : -`docker run -d --name rabbitmq -p 15672:15672 -p 5672:5672 -e RABBITMQ_USERNAME=${BUS_ADMIN_USER} -e RABBITMQ_PASSWORD=${BUS_ADMIN_PASSWORD} bitnami/rabbitmq:latest` +![busSolutionArchitecture](bus/docs/assets/busSolutionArchitecture.png) -Récupérer le fichier overlays/definition.json +![busQueueingArchitectureAndExchangeType](bus/docs/assets/busQueueingArchitectureAndExchangeType.png) + +# Installation en local +```sh +docker run -d --name rabbitmq -p 15672:15672 -p 5672:5672 -e RABBITMQ_USERNAME=${BUS_ADMIN_USER} -e RABBITMQ_PASSWORD=${BUS_ADMIN_PASSWORD} bitnami/rabbitmq:latest +``` +Récupérer le fichier _overlays/definition.json_ ## Variables @@ -29,7 +38,7 @@ Récupérer le fichier overlays/definition.json | IDP_API_CLIENT_SECRET | Client secret du client confidentiel IDP pour l'API | Oui | | CAPGEMINI_SECRET_KEY | Client secret du client confidentiel CAPGEMINI pour l'API | Non | -Importer, via l'interface RabbitMQ, le fichier definition.json modifié avec les valeurs de variables mentionnées ci-dessus. +Importer, via l'interface RabbitMQ, le fichier _definition.json_ modifié avec les valeurs de variables mentionnées ci-dessus. ![interfaceAdmin](docs/assets/interfaceRabbitMQ.png) @@ -58,16 +67,15 @@ Le deploiement du bus est de type statefulSet. # Relation avec les autres services -Comme présenté dans le schéma global de l'architecture ci-dessus (# TODO) - -L'api possède un child process qui au démarrage de l'application permet d'écouter les messages provenant de rabbitmq sur la queue de consommation. +Comme présenté dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png), _api_ possède un [child process](https://nodejs.org/api/child_process.html) qui au démarrage de l'application permet d'écouter les messages disponibles sur les queues de consommation. -L'api effectue une requête amqp pour envoyer les données de souscriptions sur la queue de publication. +- _api_ effectue une requête amqp vers _bus_ pour envoyer les données de souscriptions, messages à destination des HRIS +- _api_ effectue des requêtes périodiquement vers _bus_ pour récupérer les données de statut des souscriptions, messages publiés par les HRIS **Bilan des relations:** -- Consommation des messages -- Publication de messages +- Publication de messages sur les queues _mob.subscriptions.put.*_ +- Consommation des messages sur les queues _mob.subscriptions.status.*_ # Tests Unitaires diff --git a/bus/bus-testing-values.yaml b/bus/bus-testing-values.yaml new file mode 100644 index 0000000..3c946bb --- /dev/null +++ b/bus/bus-testing-values.yaml @@ -0,0 +1,164 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: bus-custom-conf + data: + custom.conf: "bus/custom.conf" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: bus + name: bus + spec: + ports: + - name: "5672" + port: 5672 + targetPort: 5672 + - name: "15672" + port: 15672 + targetPort: 15672 + selector: + io.kompose.service: bus + type: ClusterIP + status: + loadBalancer: {} + +headlessService: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: bus + name: bus-headless + spec: + clusterIP: None + ports: + - name: "5672" + port: 5672 + targetPort: 5672 + - name: "15672" + port: 15672 + targetPort: 15672 + publishNotReadyAddresses: true + selector: + io.kompose.service: bus + sessionAffinity: None + type: ClusterIP + +statefulSet: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: bus + name: bus + spec: + podManagementPolicy: OrderedReady + replicas: 1 + selector: + matchLabels: + io.kompose.service: bus + updateStrategy: + type: RollingUpdate + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: bus + spec: + containers: + env: + IDP_FQDN: ${IDP_FQDN} + MCM_IDP_REALM: ${IDP_MCM_REALM} + RABBITMQ_ERL_COOKIE: KxAzS-=mFrXvRU9m + RABBITMQ_PASSWORD: ${TESTING_BUS_ADMIN_PASSWORD} + addressType: hostname + RABBITMQ_PLUGINS: rabbitmq_management,rabbitmq_auth_backend_oauth2,rabbitmq_prometheus,rabbitmq_peer_discovery_k8s + RABBITMQ_USERNAME: ${TESTING_BUS_ADMIN_USER} + K8S_SERVICE_NAME: bus + RABBITMQ_NODE_NAME: rabbit@$(MY_POD_NAME).bus.$(MY_POD_NAMESPACE).svc.cluster.local + K8S_HOSTNAME_SUFFIX: .bus.$(MY_POD_NAMESPACE).svc.cluster.local + RABBITMQ_USE_LONGNAME: true + RABBITMQ_ULIMIT_NOFILES: 65536 + RABBITMQ_DISK_FREE_ABSOLUTE_LIMIT: 256MB + RABBITMQ_MNESIA_DIR: /bitnami/rabbitmq/mnesia/bus-def + K8S_ADDRESS_TYPE: hostname + RABBITMQ_FORCE_BOOT: yes + image: ${BUS_IMAGE_NAME} + name: bus + ports: + - containerPort: 5672 + - containerPort: 15672 + resources: {} + volumeMounts: + - name: bus-config + mountPath: /bitnami/rabbitmq/conf/custom.conf + subPath: custom.conf + - name: bus-data + mountPath: /bitnami/rabbitmq/mnesia + securityContext: + runAsUser: 1001 + runAsNonRoot: true + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + terminationGracePeriodSeconds: 10 + securityContext: + fsGroup: 1001 + restartPolicy: Always + volumes: + - name: bus-config + configMap: + name: bus-custom-conf + volumeClaimTemplates: + - metadata: + name: bus-data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: "azurefile-${LANDSCAPE}" + resources: + requests: + storage: 1Gi + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: bus + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${ADMIN_BUS_FQDN}`) + services: + - kind: Service + name: bus + port: 15672 diff --git a/bus/docs/assets/busHRISauthentication.png b/bus/docs/assets/busHRISauthentication.png new file mode 100644 index 0000000..f5e41df Binary files /dev/null and b/bus/docs/assets/busHRISauthentication.png differ diff --git a/bus/docs/assets/busHRISprocess.png b/bus/docs/assets/busHRISprocess.png new file mode 100644 index 0000000..d486e76 Binary files /dev/null and b/bus/docs/assets/busHRISprocess.png differ diff --git a/bus/docs/assets/busQueueingArchitectureAndExchangeType.png b/bus/docs/assets/busQueueingArchitectureAndExchangeType.png new file mode 100644 index 0000000..c2a9090 Binary files /dev/null and b/bus/docs/assets/busQueueingArchitectureAndExchangeType.png differ diff --git a/bus/docs/assets/busSolutionArchitecture.png b/bus/docs/assets/busSolutionArchitecture.png new file mode 100644 index 0000000..b28a5c8 Binary files /dev/null and b/bus/docs/assets/busSolutionArchitecture.png differ diff --git a/bus/docs/assets/interfaceRabbitMQ.png b/bus/docs/assets/interfaceRabbitMQ.png new file mode 100644 index 0000000..be17fcf Binary files /dev/null and b/bus/docs/assets/interfaceRabbitMQ.png differ diff --git a/bus/kompose.yml b/bus/kompose.yml new file mode 100644 index 0000000..46d0f25 --- /dev/null +++ b/bus/kompose.yml @@ -0,0 +1,16 @@ +version: "3" + +services: + bus: + image: ${BUS_IMAGE_NAME} + environment: + - RABBITMQ_ERL_COOKIE=KxAzS-=mFrXvRU9m + - RABBITMQ_PLUGINS=rabbitmq_management,rabbitmq_auth_backend_oauth2,rabbitmq_prometheus + - RABBITMQ_LOAD_DEFINITIONS=yes + - RABBITMQ_DEFINITIONS_FILE=/tmp/definition.json + ports: + - "5672" + - "15672" + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" \ No newline at end of file diff --git a/bus/overlays/bus-certificate.yml b/bus/overlays/bus-certificate.yml new file mode 100644 index 0000000..bcf8e3b --- /dev/null +++ b/bus/overlays/bus-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: bus-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: clusterissuer-mcm-dev # dev-mcm-issuer + secretName: bus-tls diff --git a/bus/overlays/bus-ingressroute.yml b/bus/overlays/bus-ingressroute.yml new file mode 100644 index 0000000..4b56ef1 --- /dev/null +++ b/bus/overlays/bus-ingressroute.yml @@ -0,0 +1,15 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: bus +spec: + entryPoints: + - web + routes: + - match: Host(`${ADMIN_BUS_FQDN}`) + kind: Rule + services: + - kind: Service + name: bus + port: 15672 + diff --git a/bus/overlays/bus_configmap_volumes.yml b/bus/overlays/bus_configmap_volumes.yml new file mode 100644 index 0000000..71c8d28 --- /dev/null +++ b/bus/overlays/bus_configmap_volumes.yml @@ -0,0 +1,25 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bus +spec: + template: + spec: + containers: + - name: bus + volumeMounts: + - name: bus-custom-conf + mountPath: /bitnami/rabbitmq/conf/custom.conf + subPath: custom-dev.conf + - name: bus-custom-definition + mountPath: /tmp/definition.json + subPath: definition.json + securityContext: + fsGroup: 1000 + volumes: + - name: bus-custom-conf + configMap: + name: bus-custom-conf + - name: bus-custom-definition + configMap: + name: bus-custom-definition diff --git a/bus/overlays/config/custom-dev.conf b/bus/overlays/config/custom-dev.conf new file mode 100644 index 0000000..6e7feeb --- /dev/null +++ b/bus/overlays/config/custom-dev.conf @@ -0,0 +1,18 @@ +loopback_users.guest = false +# Authentication and authorizarion - oauth2 otherwise internal (avoid internal as much as possible) - See https://www.rabbitmq.com/access-control.html +auth_backends.1 = rabbit_auth_backend_oauth2 +auth_backends.2 = rabbit_auth_backend_internal +auth_oauth2.resource_server_id = rabbitmq +auth_oauth2.jwks_url = https://${IDP_FQDN}/auth/realms/${IDP_MCM_REALM}/protocol/openid-connect/certs + +# Logging - See https://www.rabbitmq.com/logging.html#connection-lifecycle-events +log.default.level = info +log.connection.level = debug + +# Kubernetes discovery to set up a real cluster - See https://www.rabbitmq.com/cluster-formation.html#peer-discovery-k8s +#cluster_formation.peer_discovery_backend = rabbit_peer_discovery_k8s + +# Monitoring with Prometheus and Grafana - See https://www.rabbitmq.com/prometheus.html + +# For production use +# disk_free_limit.relative = 1.5 diff --git a/bus/overlays/config/custom.conf b/bus/overlays/config/custom.conf new file mode 100644 index 0000000..6479e3e --- /dev/null +++ b/bus/overlays/config/custom.conf @@ -0,0 +1,26 @@ +loopback_users.guest = false +# Authentication and authorizarion - oauth2 otherwise internal (avoid internal as much as possible) - See https://www.rabbitmq.com/access-control.html +auth_backends.1 = rabbit_auth_backend_oauth2 +auth_backends.2 = rabbit_auth_backend_internal +auth_oauth2.resource_server_id = rabbitmq +auth_oauth2.jwks_url = https://${IDP_FQDN}/auth/realms/${IDP_MCM_REALM}/protocol/openid-connect/certs + +# Logging - See https://www.rabbitmq.com/logging.html#connection-lifecycle-events +log.default.level = info +log.connection.level = debug + +# Kubernetes discovery to set up a real cluster - See https://www.rabbitmq.com/cluster-formation.html#peer-discovery-k8s +#cluster_formation.peer_discovery_backend = rabbit_peer_discovery_k8s + +# Monitoring with Prometheus and Grafana - See https://www.rabbitmq.com/prometheus.html + +# For production use +# disk_free_limit.relative = 1.5 + +## Clustering +## +cluster_formation.peer_discovery_backend = rabbit_peer_discovery_k8s +cluster_formation.k8s.host = kubernetes.default.svc.cluster.local +cluster_formation.node_cleanup.interval = 10 +cluster_formation.node_cleanup.only_log_warning = true +cluster_partition_handling = autoheal diff --git a/bus/overlays/definition.json b/bus/overlays/definition.json new file mode 100644 index 0000000..398b0ec --- /dev/null +++ b/bus/overlays/definition.json @@ -0,0 +1,136 @@ +{ + "users": [ + { + "name": "${BUS_ADMIN_USER}", + "password": "${BUS_ADMIN_PASSWORD}", + "tags": ["administrator"], + "limits": {} + }, + { + "name": "${BUS_MCM_CONSUME_USER}", + "password": "${BUS_MCM_CONSUME_PASSWORD}", + "tags": ["management"], + "limits": {} + } + ], + "vhosts": [ + { + "name": "/" + } + ], + "permissions": [ + { + "user": "${BUS_ADMIN_USER}", + "vhost": "/", + "configure": ".*", + "write": ".*", + "read": ".*" + }, + { + "user": "${BUS_MCM_CONSUME_USER}", + "vhost": "/", + "configure": "", + "write": "", + "read": "^mob\\.subscriptions\\.status\\.*" + } + ], + "queues": [ + { + "name": "mob.subscriptions.put.simulation-sirh", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": { + "x-queue-type": "quorum", + "x-single-active-consumer": false + } + }, + { + "name": "mob.subscriptions.status.simulation-sirh", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": { + "x-queue-type": "quorum", + "x-single-active-consumer": true + } + }, + { + "name": "mob.subscriptions.put.capgemini", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": { + "x-queue-type": "quorum", + "x-single-active-consumer": false + } + }, + { + "name": "mob.subscriptions.status.capgemini", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": { + "x-queue-type": "quorum", + "x-single-active-consumer": true + } + } + ], + "exchanges": [ + { + "name": "mob.headers", + "vhost": "/", + "type": "headers", + "durable": true, + "auto_delete": false, + "internal": false, + "arguments": {} + } + ], + "bindings": [ + { + "source": "mob.headers", + "vhost": "/", + "destination": "mob.subscriptions.put.simulation-sirh", + "destination_type": "queue", + "routing_key": "", + "arguments": { + "message_type": "subscriptions.put.simulation-sirh", + "secret_key": "${IDP_API_CLIENT_SECRET}" + } + }, + { + "source": "mob.headers", + "vhost": "/", + "destination": "mob.subscriptions.status.simulation-sirh", + "destination_type": "queue", + "routing_key": "", + "arguments": { + "message_type": "subscriptions.status.simulation-sirh", + "secret_key": "${IDP_API_CLIENT_SECRET}" + } + }, + { + "source": "mob.headers", + "vhost": "/", + "destination": "mob.subscriptions.put.capgemini", + "destination_type": "queue", + "routing_key": "", + "arguments": { + "message_type": "subscriptions.put.capgemini", + "secret_key": "${IDP_API_CLIENT_SECRET}" + } + }, + { + "source": "mob.headers", + "vhost": "/", + "destination": "mob.subscriptions.status.capgemini", + "destination_type": "queue", + "routing_key": "", + "arguments": { + "message_type": "subscriptions.status.capgemini", + "secret_key": "${CAPGEMINI_SECRET_KEY}" + } + } + ] +} diff --git a/bus/overlays/kustomization.yaml b/bus/overlays/kustomization.yaml new file mode 100644 index 0000000..c3c8ba1 --- /dev/null +++ b/bus/overlays/kustomization.yaml @@ -0,0 +1,18 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - bus-ingressroute.yml + # - bus-certificate.yml +patchesStrategicMerge: + - bus_configmap_volumes.yml + +configMapGenerator: + - name: bus-custom-conf + files: + - config/custom-dev.conf + - name: bus-custom-definition + files: + - definition.json diff --git a/bus/overlays/web_nw_networkpolicy_namespaceselector.yml b/bus/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..32da467 --- /dev/null +++ b/bus/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: 'true' diff --git a/commons/.gitlab-ci.yml b/commons/.gitlab-ci.yml new file mode 100644 index 0000000..08dba7e --- /dev/null +++ b/commons/.gitlab-ci.yml @@ -0,0 +1,318 @@ +include: + - local: "commons/kubetools/.gitlab-ci.yml" + - local: "commons/sonarqube/.gitlab-ci.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "commons/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "commons/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + - local: "commons/.gitlab-ci/helm.yml" + rules: + - if: $CI_PIPELINE_SOURCE == "trigger" + +validate_env: + stage: .pre + extends: + - .only-branches + - .except-release + image: ${NEXUS_DOCKER_REPOSITORY_URL}/ubuntu:22.04 + script: + - | + if [ -z ${BASE_DOMAIN} ]; then echo "BASE_DOMAIN must be set"; exit 1; fi + +.cd-to-module: &cd-to-module | + export MODULE_PATH=${MODULE_PATH:-${MODULE_NAME}} + [ "${GIT_STRATEGY}" != "none" ] && [ "${MODULE_PATH}" != "" ] && cd ${MODULE_PATH} + +.commons: + variables: + NAMESPACE: ${MODULE_NAME}-${CI_COMMIT_REF_SLUG}-${LANDSCAPE} + GIT_STRATEGY: clone + MODULE_NAME: "" + MODULE_PATH: "" + COMMON_NAME: common + SAST_IMAGE_NAME: ${CI_REGISTRY_IMAGE}/sast-audit:1.0 + #DOCKER_TLS_CERTDIR: "" + #DOCKER_DRIVER: overlay2 + KUBETOOLS_IMAGE_TAGNAME: ${CI_REGISTRY}/${CI_PROJECT_PATH}/kubetools:${CI_COMMIT_REF_SLUG}-${CI_PIPELINE_ID} + IMAGE_PULL_SECRET_PREFIX: gitlab-registry + PROXY_IMAGE_PULL_SECRET_PREFIX: nexus-registry + GITLAB_PROJECT_PATH: ${CI_PROJECT_PATH} + GITLAB_BRANCH: ${CI_COMMIT_REF_NAME} + GITLAB_URL: ${CI_SERVER_URL} + BRANCH_NAME: ${CI_COMMIT_REF_SLUG} + REGISTRY_USER: ${CI_REGISTRY_USER} + REGISTRY_PASSWORD: ${CI_REGISTRY_PASSWORD} + REGISTRY_BASE_NAME: ${CI_REGISTRY_IMAGE} + REGISTRY_URL: ${CI_REGISTRY} + NEXUS_DOCKER_REGISTRY_URL: ${NEXUS_DOCKER_REPOSITORY_URL} + NEXUS_USER_NAME: ${NEXUS_DEV_USER} + NEXUS_USER_PWD: ${NEXUS_DEV_PASSWORD} + NEXUS_DOCKER_REGISTRY_HANDOVER_URL: ${NEXUS_DOCKER_REGISTRY_HANDOVER_URL} + BUILD_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/node:16.14.2-alpine + VERIFY_IMAGE_NAME: ${CI_REGISTRY_IMAGE}/mcm-verify:${CI_COMMIT_REF_SLUG}-${CI_PIPELINE_ID} + TEST_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/node:16.14.2-alpine + IMAGE_TAG_NAME: ${CI_COMMIT_REF_SLUG}-${CI_PIPELINE_ID} + before_script: + - *cd-to-module + +.subdomains: + variables: + api_subdomain: "api-${CI_COMMIT_REF_SLUG}" + analytics_subdomain: "analytics-${CI_COMMIT_REF_SLUG}" + s3_subdomain: "s3-${CI_COMMIT_REF_SLUG}" + admin_subdomain: "admin-${CI_COMMIT_REF_SLUG}" + idp_subdomain: "idp-${CI_COMMIT_REF_SLUG}" + vault_subdomain: "vault-${CI_COMMIT_REF_SLUG}" + website_subdomain: "website-${CI_COMMIT_REF_SLUG}" + maas_subdomain: "simulation-mass-${CI_COMMIT_REF_SLUG}" + mailhog_subdomain: "mailhog-${CI_COMMIT_REF_SLUG}" + +.no-git-clone: + variables: + GIT_STRATEGY: none + +.no-dependencies: + dependencies: [] + +.no-needs: + needs: [] + +.manual: + when: manual + +.only-master: + only: + refs: + - master + +.only-branches: + only: + refs: + - branches + +# Schedule pipeline +.predicate-schedule: &predicate-schedule $CI_PIPELINE_SOURCE == "schedule" + +.only-schedule: + only: + variables: + - *predicate-schedule + +.predicate-skip-tests-verify-yes: &predicate-skip-tests-verify-yes $SKIP_TEST == "yes" + +.predicate-clean-yes: &predicate-clean-yes $CLEAN_DATA == "yes" + +.only-clean: + only: + variables: + - *predicate-clean-yes + +.except-clean-or-release: + except: + variables: + - *predicate-clean-yes + refs: + - triggers + +.except-release: + except: + refs: + - triggers + +.except-all: + except: + variables: + - *predicate-clean-yes + - *predicate-skip-tests-verify-yes + refs: + - triggers + +# Helper templates to specify intents of jobs, and select appropriate runners + +.build-job-tags: + tags: + - os:linux + - task:build + +.build-n-sast-job-tags: + tags: + - os:linux + - task:build-n-sast + +.image-job-tags: + tags: + - os:linux + - task:image + +.test-job-tags: + tags: + - os:linux + - task:test + +.build-job: + extends: + - .commons + - .build-job-tags + - .except-clean-or-release + - .only-branches + stage: build + image: ${BUILD_IMAGE_NAME} + +.declare-image-functions: &declare-image-functions | + function image { + docker login -u ${REGISTRY_USER} -p ${REGISTRY_PASSWORD} ${REGISTRY_URL} + docker login -u ${NEXUS_USER_NAME} -p ${NEXUS_USER_PWD} ${NEXUS_DOCKER_REGISTRY_URL} + docker-compose --file ${COMPOSE_FILENAME} build + docker-compose --file ${COMPOSE_FILENAME} push + } + +.image-job: + extends: + - .commons + - .image-job-tags + - .only-branches + - .except-clean-or-release + stage: image + variables: + COMPOSE_FILENAME: kompose.yml + image: ${NEXUS_DOCKER_REGISTRY_URL}/tiangolo/docker-with-compose:2021-09-18 + before_script: + - *cd-to-module + - *declare-image-functions + script: + - | + image + +.default-image-job: + variables: + IMAGE_TAG_NAME: ${CI_COMMIT_REF_SLUG}-${CI_PIPELINE_ID} + extends: + - .image-job + +.test-job: + stage: test + extends: + - .commons + - .test-job-tags + - .only-branches + - .except-all + artifacts: + paths: + - ${MODULE_PATH:-${MODULE_NAME}}/junit.xml + - ${MODULE_PATH:-${MODULE_NAME}}/coverage + when: always + reports: + junit: + - ${MODULE_PATH:-${MODULE_NAME}}/junit.xml + expire_in: 5 days + +.archive_sast_script: &archive_sast_script | + # Check if node_modules exists + if [ ! -d node_modules ]; then + echo "node_modules is not there, SAST analysis can not be performed" && exit 1 + fi + # Create an archive with data to scan + echo "Create archive "${RELEASE_NAME}.zip" with data to scan..." + zip -u -qq -r ${RELEASE_NAME}.zip . + echo "...archive created" + +.run_sast_script: &run_sast_script | + # Create a new FoD release if it not exists + [ -z '${SDLC_STATUS}' ] && export SDLC_STATUS=3 + + touch ${SAST_LOG_FILE} + touch ${SAST_RESULT_FILE} + + ls -altr . + + createCommandExit="" + runCommandExit="" + + if [ "$COPY_FROM_RELEASE" = "" ] + then + createCommandExit=`java -jar /usr/lib/FodAPIClient.jar ${FOD_AUTHENTICATION_ARGS} create-release -an ${FORTIFY_APPLICATION_NAME} -rn ${RELEASE_NAME} -sdlc ${SDLC_STATUS} -rd '${RELEASE_DESCRIPTION}' | awk -F ',' '{print($2)}'` + else + createCommandExit=`java -jar /usr/lib/FodAPIClient.jar ${FOD_AUTHENTICATION_ARGS} create-release -an ${FORTIFY_APPLICATION_NAME} -rn ${RELEASE_NAME} -sdlc ${SDLC_STATUS} -rd '${RELEASE_DESCRIPTION}' -copy -copyRelease ${COPY_FROM_RELEASE} | awk -F ',' '{print($2)}'` + fi + + echo $createCommandExit >> ${SAST_LOG_FILE} + releaseId=`echo $createCommandExit | awk -F '=' '{print($2)}'` + + optionArgs="" + + [ ${ALLOW_POLICY_FAIL} = 'true' ] && optionArgs="${optionArgs} -policy" + [ ${INCLUDE_THIRD_PARTY_LIBS} = 'true' ] && optionArgs="${optionArgs} -itp" + [ ${RUN_SONATYPE_SCAN} = 'true' ] && optionArgs="${optionArgs} -oss" + + # Run the scan + runCommandExit=`java -jar /usr/lib/FodAPIClient.jar ${FOD_AUTHENTICATION_ARGS} static-scan -z ${RELEASE_NAME}.zip -tid ${FORTIFY_TENANT_ID} -rid $releaseId -I ${POLLING_INTERVAL}${optionArgs} -pp ${IN_PROGRESS_SCAN_ACTION_TYPE} -rp ${REMEDIATION_SCAN_PREFERENCE_TYPE} -tech ${TECHNOLOGY_TYPE} -o ${SAST_RESULT_FILE}` + + echo "Processing output..." + scanId=`echo $runCommandExit | grep -o -P "Scan.?[0-9]*" | awk -F 'Scan ' '{print($2)}'` + echo "scanId=$scanId" >> ${SAST_LOG_FILE} + echo "Download FPR at ${FORTIFY_PORTAL_URL}/Releases/$releaseId/Scans" >> ${SAST_LOG_FILE} + + cat ${SAST_RESULT_FILE} + + ls -altr . + + echo "Done..." + +# .send_email: &send_email | +# echo "Convert rapport to base64" +# BASE64_RESULT_FILE="$(base64 -w ${SAST_RESULT_FILE})" + +# echo "Create variables for email" +# PERSONALIZATIONS=[{"to": [{ "email": "${HANDOVER_GITLAB_EMAIL}" }]}] +# FROM={ "email": "gitlab@moncomptemobilite.fr", "name": "Gitlab"} +# SUBJECT="SAST REPORT ${MODULE_NAME}-${CI_COMMIT_REF}" +# CONTENT=[{"type": "text/html", "value": "

This is an automatic email sent after SAST analysis is being performed

"}] +# ATTACHMENTS=[{"content": "$BASE64_RESULT_FILE", "filename": "SAST_REPORT_${MODULE_NAME}.json", "type": "text/plain", "disposition": "attachment"}] + +# curl -X POST "https://api.sendgrid.com/v3/mail/send" \ +# --header "Authorization: Bearer ${SENDGRID_API_KEY}" \ +# --header "Content-Type: application/json" \ +# --data '{ "personalizations": $PERSONALIZATIONS, "from": $FROM, "subject": $SUBJECT, "content": $CONTENT, "attachments": $ATTACHMENTS }' + +.sast-job: + stage: utils + extends: + - .commons + - .build-n-sast-job-tags + - .only-schedule + image: ${SAST_IMAGE_NAME} + variables: + FOD_AUTHENTICATION_ARGS: -ac ${FORTIFY_API_KEY} ${FORTIFY_API_ACCESS_KEY} -tc ${FORTIFY_TENANT_CODE} -aurl ${FORTIFY_API_URL} -purl ${FORTIFY_PORTAL_URL} + # Should be the name of an existing release. Keep empty to create a proper new version + COPY_FROM_RELEASE: "" + RELEASE_DESCRIPTION: "Scan for ${MODULE_NAME} from ${CI_COMMIT_REF_SLUG}" + RELEASE_NAME: ${MODULE_NAME}-${CI_COMMIT_REF_SLUG} + # [1:Production, 2:QA, 3:Development, 4:Retired] + SDLC_STATUS: 3 + SAST_RESULT_FILE: result_summary.json + SAST_LOG_FILE: sast_data_${RELEASE_NAME}.log + # Polling interval for retrieving results. If 0, the scan will be performed asynchronously. Value in minutes + POLLING_INTERVAL: 2 + ALLOW_POLICY_FAIL: "true" + # [0:DoNotStartScan, 1:CancelInProgressScan, 2:Queue] + IN_PROGRESS_SCAN_ACTION_TYPE: 2 + INCLUDE_THIRD_PARTY_LIBS: "false" + RUN_SONATYPE_SCAN: "true" + # [0:RemediationScanIfAvailable, 1:RemediationScanOnly, 2:NonRemediationScanOnly] + REMEDIATION_SCAN_PREFERENCE_TYPE: 2 + TECHNOLOGY_TYPE: JS/TS/HTML + script: + - *archive_sast_script + - *run_sast_script + # - *send_email + artifacts: + paths: + # The result file may be empty regarding to the value passed to POLLING_INTERVAL + # whereas the log file will contain the release ID and the scan ID + - ${MODULE_PATH:-${MODULE_NAME}}/${SAST_LOG_FILE} + - ${MODULE_PATH:-${MODULE_NAME}}/${SAST_RESULT_FILE} + expire_in: 5 days diff --git a/commons/.gitlab-ci/helm.yml b/commons/.gitlab-ci/helm.yml new file mode 100644 index 0000000..3d35858 --- /dev/null +++ b/commons/.gitlab-ci/helm.yml @@ -0,0 +1,285 @@ +.declare-helm-functions: &declare-helm-functions | + function get_rc_name { + echo "### GET RC NAME FROM TRIGGER PAYLOAD ###" + export RC_VERSION=$(cat $TRIGGER_PAYLOAD | jq -r '.tag') + echo $RC_VERSION + } + + function get_release_name { + echo "### FORMAT RELEASE NAME (ex: rc-v1-0-0 => v1.0.0) ###" + export RELEASE_VERSION=$(cat $TRIGGER_PAYLOAD | jq -r '.tag | sub("rc-";"") | gsub("-";".")') + echo $RELEASE_VERSION + } + + function get_release_name_slug { + echo "### FORMAT RELEASE NAME (ex: rc-v1-0-0 => v1-0-0) ###" + export RELEASE_VERSION_SLUG=$(cat $TRIGGER_PAYLOAD | jq -r '.tag | sub("rc-";"")') + echo $RELEASE_VERSION_SLUG + } + + function push_to_gitlab { + echo "### CREATE FOLDER TO PUSH TO GITLAB ###" + mkdir handover + + echo "### GIT CLONE DISTANT REPO ###" + git clone -b ${HANDOVER_GITLAB_TARGET_BRANCH} --single-branch "https://${HANDOVER_GITLAB_USERNAME}:${HANDOVER_GITLAB_TOKEN}@${HANDOVER_GITLAB_URL}" handover + + echo "### GO TO HANDOVER FOLDER ###" + cd handover + + echo "### GIT CREATE BRANCH ###" + git checkout -b ${RELEASE_VERSION_SLUG} + + echo "### GIT PUSH CREATE REMOTE ###" + git push --set-upstream origin ${RELEASE_VERSION_SLUG} + + echo "# CREATE NEEDED DIRECTORY IF NOT EXISTING" + [[ ! -d .gitlab-ci ]] && mkdir .gitlab-ci + [[ ! -d test ]] && mkdir test + [[ ! -d reports ]] && mkdir reports + [[ ! -d configs ]] && mkdir configs + + echo "### CP GITLAB CI HELM TO OUTPUT DIRECTORY ###" + cp ../helm-chart/.gitlab-ci.yml .gitlab-ci.yml + + echo "### CP GITLAB FOLDER HELM TO OUTPUT DIRECTORY ###" + cp -rT ../helm-chart/.gitlab-ci .gitlab-ci + + echo "### CP TEST FOLDER HELM TO OUTPUT DIRECTORY ###" + cp -rT ../helm-chart/test test + + echo "### CP IDP REALMS MULTIPLE FILES TO OUTPUT DIRECTORY ###" + for FILE in ../idp/overlays/realms/*-realm-${RELEASE_VERSION_SLUG}-*.json; do cp $FILE configs;done + + echo "### CP BUS CONFIG MULTIPLE FILES TO OUTPUT DIRECTORY ###" + for FILE in ../bus/overlays/definition-${RELEASE_VERSION_SLUG}-*.json; do cp $FILE configs;done + + echo "### CP SCRIPT FOLDER HELM TO OUTPUT DIRECTORY ###" + cp -rT ../helm-chart/scripts scripts + + echo "### CP EXISTING API INDEXATION SCRIPT TO OUTPUT DIRECTORY ###" + cp ../api/mongo/databaseConfig/setup.js scripts/mongodb/ + + echo "### GET ALL ARTIFACTS FROM RELEASE ###" + for link in $(cat $TRIGGER_PAYLOAD | jq -c '.assets.links | map({url: .url, name: .name}) | .[]') + do + echo "### DOWNLOAD ARTIFACTS ###" + echo $link + curl $(echo $link | jq -r '.url') -o "reports/"$(echo $link | jq -r '.name')"-${RELEASE_VERSION_SLUG}.zip" -H "JOB-TOKEN: ${CI_JOB_TOKEN}" -L + done + + echo "### GIT ADD CHANGES ###" + git add . + + echo "### SHOW CHANGES ###" + git status + + echo "### GIT COMMIT CHANGES ###" + git -c user.name=${HANDOVER_GITLAB_USERNAME} -c user.email=${HANDOVER_GITLAB_EMAIL} commit -m ${RELEASE_VERSION} + + echo "### GIT PUSH AND MR ###" + git push -o merge_request.create -o merge_request.remove_source_branch -o merge_request.target=${HANDOVER_GITLAB_TARGET_BRANCH} -o ci.skip + + echo "### CHANGE RELEASE VARIABLES IN DISTANT REPO ###" + curl --request PUT -H "PRIVATE-TOKEN: ${HANDOVER_GITLAB_TOKEN}" \ + "${CI_API_V4_URL}/projects/${HANDOVER_PROJECT_ID}/variables/RELEASE_VERSION" --form "value=${RELEASE_VERSION}" + + curl --request PUT -H "PRIVATE-TOKEN: ${HANDOVER_GITLAB_TOKEN}" \ + "${CI_API_V4_URL}/projects/${HANDOVER_PROJECT_ID}/variables/RELEASE_VERSION_SLUG" --form "value=${RELEASE_VERSION_SLUG}" + + } + + function push_images_to_nexus { + echo "### GET IMAGE TAG NAME FOR LATEST PIPELINE ###" + LATEST_IMAGE_TAG=$(docker images ${CI_REGISTRY_IMAGE}/${MODULE_NAME}:${RC_VERSION}-* --format {{.Tag}} | sort -nr | head -n 1 ) + echo $LATEST_IMAGE_TAG + + echo "### PULL IMAGE FROM GITLAB REGISTRY ###" + docker login -u ${REGISTRY_USER} -p ${REGISTRY_PASSWORD} ${REGISTRY_URL} + docker image pull ${CI_REGISTRY_IMAGE}/${MODULE_NAME}:${LATEST_IMAGE_TAG} + + echo "### LOGIN TO NEXUS ###" + docker login -u ${NEXUS_ROOT_USER} -p ${NEXUS_ROOT_PASSWORD} ${NEXUS_DOCKER_REGISTRY_HANDOVER_URL} + + echo "### TAG GITLAB IMAGES FOR NEXUS ###" + docker tag ${CI_REGISTRY_IMAGE}/${MODULE_NAME}:${LATEST_IMAGE_TAG} ${NEXUS_DOCKER_REGISTRY_HANDOVER_URL}/platform/${RELEASE_VERSION}/${MODULE_NAME}:latest + + echo "### PUSH IMAGES TO NEXUS REGISTRY ###" + docker push ${NEXUS_DOCKER_REGISTRY_HANDOVER_URL}/platform/${RELEASE_VERSION}/${MODULE_NAME}:latest + } + + function set_config_files { + # --- IDP ---- # + + # Create uppercase env variable (pprd => PPRD / prod => PROD) + UPPERCASE_ENV=$(echo $1 | tr '[:lower:]' '[:upper:]') + + # Create path variables + MCM_REALM_PATH=idp/overlays/realms/mcm-realm-${RELEASE_VERSION_SLUG}-$1.json + MASTER_REALM_PATH=idp/overlays/realms/master-realm-${RELEASE_VERSION_SLUG}-$1.json + + cp idp/overlays/realms/mcm-realm.json ${MCM_REALM_PATH} + cp idp/overlays/realms/master-realm.json ${MASTER_REALM_PATH} + + # MCM REALM + sed -i "s/IDP_API_CLIENT_SECRET/${UPPERCASE_ENV}_IDP_API_CLIENT_SECRET/g" ${MCM_REALM_PATH} + sed -i "s/IDP_SIMULATION_MAAS_CLIENT_SECRET/${UPPERCASE_ENV}_IDP_SIMULATION_MAAS_CLIENT_SECRET/g" ${MCM_REALM_PATH} + + # SMTP + export SMTP_AUTH=true + + sed -i "s/MAIL_API_KEY/${UPPERCASE_ENV}_SENDGRID_API_KEY/g" ${MCM_REALM_PATH} + sed -i "s/MAIL_HOST/${UPPERCASE_ENV}_SENDGRID_HOST/g" ${MCM_REALM_PATH} + sed -i "s/EMAIL_FROM_KC/${UPPERCASE_ENV}_SENDGRID_EMAIL_FROM_KC/g" ${MCM_REALM_PATH} + sed -i "s/MAIL_PORT/${UPPERCASE_ENV}_SENDGRID_PORT/g" ${MCM_REALM_PATH} + sed -i "s/MAIL_USER/${UPPERCASE_ENV}_SENDGRID_USER/g" ${MCM_REALM_PATH} + + ### IDENTITY PROVIDER ### + sed -i "s/FRANCE_CONNECT_IDP_PROVIDER_CLIENT_ID/${UPPERCASE_ENV}_FRANCE_CONNECT_IDP_PROVIDER_CLIENT_ID/g" ${MCM_REALM_PATH} + sed -i "s/FRANCE_CONNECT_IDP_PROVIDER_CLIENT_SECRET/${UPPERCASE_ENV}_FRANCE_CONNECT_IDP_PROVIDER_CLIENT_SECRET/g" ${MCM_REALM_PATH} + sed -i "s/IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID/${UPPERCASE_ENV}_IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID/g" ${MCM_REALM_PATH} + sed -i "s/IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET/${UPPERCASE_ENV}_IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET/g" ${MCM_REALM_PATH} + sed -i "s/IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID/${UPPERCASE_ENV}_IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID/g" ${MASTER_REALM_PATH} + sed -i "s/IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET/${UPPERCASE_ENV}_IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET/g" ${MASTER_REALM_PATH} + + # --- RABBITMQ ---- # + + # Create path variables + BUS_DEFINITION_PATH=bus/overlays/definition-${RELEASE_VERSION_SLUG}-$1.json + + cp bus/overlays/definition.json ${BUS_DEFINITION_PATH} + + sed -i "s/BUS_ADMIN_USER/${UPPERCASE_ENV}_BUS_ADMIN_USER/g" ${BUS_DEFINITION_PATH} + sed -i "s/BUS_ADMIN_PASSWORD/${UPPERCASE_ENV}_BUS_ADMIN_PASSWORD/g" ${BUS_DEFINITION_PATH} + sed -i "s/BUS_MCM_CONSUME_USER/${UPPERCASE_ENV}_BUS_MCM_CONSUME_USER/g" ${BUS_DEFINITION_PATH} + sed -i "s/BUS_MCM_CONSUME_PASSWORD/${UPPERCASE_ENV}_BUS_MCM_CONSUME_PASSWORD/g" ${BUS_DEFINITION_PATH} + + sed -i "s/IDP_API_CLIENT_SECRET/${UPPERCASE_ENV}_IDP_API_CLIENT_SECRET/g" ${BUS_DEFINITION_PATH} + sed -i "s/CAPGEMINI_SECRET_KEY/${UPPERCASE_ENV}_CAPGEMINI_SECRET_KEY/g" ${BUS_DEFINITION_PATH} + + for FILE in ${MCM_REALM_PATH} ${MASTER_REALM_PATH} ${BUS_DEFINITION_PATH} + do + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < $FILE > $FILE.tmp && mv $FILE.tmp $FILE + done + + } + + function apply_env { + # APPLY ENV VALUES + source commons/.gitlab-ci/.env.commons + source commons/.gitlab-ci/.env.$1 + } + + function push_package_to_nexus { + + # Configure helm package name according to env + export HELM_PACKAGE_NAME=platform-$1 + envsubst '${HELM_PACKAGE_NAME}' < helm-chart/Chart.yaml > helm-chart/Chart.yaml.tmp && mv helm-chart/Chart.yaml.tmp helm-chart/Chart.yaml + + echo ### push configMap on nexus ### + echo "# Is a release candidate - create and push configMap on nexus #" + # Loops to create configMap in helm template + for DIR in * + do + if [ -d "$DIR/overlays/config/" ] + then + # ENVSUBS + for FILE in $DIR/overlays/config/* + do + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < $FILE > $FILE.tmp && mv $FILE.tmp $FILE + done + + # Copy configMap in helm-chart + cp -a $DIR/overlays/config/. helm-chart/$DIR + fi + + # IDP config map + if [ $DIR == "idp" ] + then + MASTER_REALM_PATH=$DIR/overlays/realms/master-realm.json + sed -i "s/IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID/${1^^}_IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID/g" ${MASTER_REALM_PATH} + sed -i "s/IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET/${1^^}_IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET/g" ${MASTER_REALM_PATH} + [[ ! -d helm-chart/$DIR ]] && mkdir helm-chart/idp && cp ${MASTER_REALM_PATH} helm-chart/$DIR/ + fi + done + + # SUBSTITUTE VARIABLES FOR ALL *-VALUES FILES AND CHANGE ALL CLOUD VARIABLE TO ENV_CLOUD + for FILE in helm-chart/*-values.yaml + do + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < $FILE > $FILE.tmp && mv $FILE.tmp $FILE + sed -i "s/CLOUD/${1^^}/g" $FILE + cat $FILE + done + + helm package helm-chart --app-version ${RELEASE_VERSION} --version ${RELEASE_VERSION} + curl -v --upload-file ${HELM_PACKAGE_NAME}-${RELEASE_VERSION}.tgz -u ${NEXUS_ROOT_USER}:${NEXUS_ROOT_PASSWORD} ${NEXUS_HELM_REPOSITORY_URL}/ + } + +# Job template to push image to nexus +.helm-push-image-job: + extends: + - .commons + - .image-job-tags + stage: helm-push-image + image: ${NEXUS_DOCKER_REPOSITORY_URL}/tiangolo/docker-with-compose:2021-09-18 + before_script: + - *declare-helm-functions + script: + - | + cd ${MODULE_PATH} + apk add jq + get_rc_name + get_release_name + push_images_to_nexus + +# Job to push the gitlab ci and artifacts to cloud repository +helm_gitlab: + stage: helm-gitlab + image: + name: ${NEXUS_DOCKER_REPOSITORY_URL}/alpine/git:v2.32.0 + entrypoint: [""] + before_script: + - *declare-helm-functions + script: + - | + apk add jq curl gettext + get_release_name + get_release_name_slug + apply_env pprd + set_config_files pprd + apply_env prod + set_config_files prod + push_to_gitlab + +# Job template to create helm package +.helm_package_job: + stage: helm-package + extends: + - .commons + image: ${NEXUS_DOCKER_REPOSITORY_URL}/dtzar/helm-kubectl + +# Job to create preprod helm package +helm_package_preprod: + extends: + - .helm_package_job + before_script: + - *declare-helm-functions + script: + - | + get_release_name + get_release_name_slug + apply_env pprd + push_package_to_nexus pprd + +# Job to create prod helm package +helm_package_prod: + extends: + - .helm_package_job + before_script: + - *declare-helm-functions + script: + - | + get_release_name + get_release_name_slug + apply_env prod + push_package_to_nexus prod diff --git a/commons/.gitlab-ci/preview.yml b/commons/.gitlab-ci/preview.yml new file mode 100644 index 0000000..a209051 --- /dev/null +++ b/commons/.gitlab-ci/preview.yml @@ -0,0 +1,303 @@ +.preview-fqdn: + variables: + ADMIN_FQDN: admin-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + API_FQDN: api-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + ADMIN_BUS_FQDN: admin-bus-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + S3_FQDN: s3.${LANDSCAPE}.${BASE_DOMAIN} + IDP_FQDN: idp-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + MATOMO_FQDN: analytics-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + VAULT_FQDN: vault-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + WEBSITE_FQDN: website-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + SIMULATION_MAAS_FQDN: simulation-maas-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + MAILHOG_FQDN: mailhog-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN} + +.preview-env-vars: + variables: + LANDSCAPE: "preview" + landscape_subdomain: "preview.${BASE_DOMAIN}" + SECRET_NAME: ${MODULE_NAME}-tls + CLUSTER_ISSUER: clusterissuer-mcm-dev + extends: + - .preview-fqdn + +.preview-deploy-tags: + tags: + - os:linux + - platform:dev + - task:deploy + +.preview-image-job: + extends: + - .preview-env-vars + - .image-job + - .only-branches + - .except-clean-or-release + +.auto-stop-preview: + environment: + auto_stop_in: 3 days + +.declare-deployment-functions: &declare-deployment-functions | + function deploy { + + echo "deploying: ${MODULE_NAME} for ${CI_ENVIRONMENT_TIER}, subdomain ${LANDSCAPE}..." + + # CREATE NAMESPACE ENV + echo "This is your namespace: ${NAMESPACE}" + kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f - + + export GITLAB_IMAGE_PULL_SECRET_NAME=${IMAGE_PULL_SECRET_PREFIX}-${MODULE_NAME} + export PROXY_IMAGE_PULL_SECRET_NAME=${PROXY_IMAGE_PULL_SECRET_PREFIX}-${MODULE_NAME} + kubectl create secret docker-registry ${GITLAB_IMAGE_PULL_SECRET_NAME} --docker-server="$CI_REGISTRY" --docker-username="$CI_DEPLOY_USER" --docker-password="$CI_DEPLOY_PASSWORD" --docker-email="$GITLAB_USER_EMAIL" -o yaml --dry-run=client --namespace=${NAMESPACE} | kubectl apply -f - + kubectl create secret docker-registry ${PROXY_IMAGE_PULL_SECRET_NAME} --docker-server="$NEXUS_DOCKER_REGISTRY_URL" --docker-username="$NEXUS_USER_NAME" --docker-password="$NEXUS_USER_PWD" --docker-email="$GITLAB_USER_EMAIL" -o yaml --dry-run=client --namespace=${NAMESPACE} | kubectl apply -f - + chmod 777 /usr/local/bin/a + chmod 777 /usr/local/bin/k + mkdir -p ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE} && ENV_PATH=${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/params.env KOMPOSE_FILE=$1 k | tee ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/template.yml + echo "Special condition for ${MODULE_NAME}" + if [ ${MODULE_NAME} = "idp" ] + then + sed -i 's/"${/##/g' ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/template.yml + sed -i 's/access.${/%%/g' ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/template.yml + cat ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/template.yml | envsubst | tee ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml + sed -i 's/##/"${/g' ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml + sed -i 's/%%/access.${/g' ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml + else + cat ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/template.yml | envsubst | tee ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml + fi + cat ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml + + kubectl apply -f ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml --namespace=${NAMESPACE} + + cat ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/params.env | envsubst > ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/values.env + } + function pod_namespace { + kubectl get pods -o wide --all-namespaces | grep -- ${BRANCH_NAME}-${LANDSCAPE} | grep $1 | awk '{ print $1 }' | head -n 1 + } + function config_volume { + PV_CHECK=$(kubectl get pv -o wide --all-namespaces | grep ${BRANCH_NAME} | grep $1 | awk '{ print $1 }') + if [ $PV_CHECK != "" ] + then + echo "### Patch the persistent volume to retain than delete for $1 ###" + kubectl patch pv $PV_CHECK -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' + fi + } + function wait_pod { + while [[ $(pod_namespace $1) == "" ]] + do + echo "Wait $1 try" + sleep 10 + done + } + +.verify-job: + extends: + - .commons + - .test-job-tags + - .only-branches + - .except-all + image: ${VERIFY_IMAGE_NAME} + stage: verify + variables: + SONAR_SOURCES: . + SONAR_EXCLUSIONS: "sonar.exclusions=**/node_modules/**,dist/**,databaseConfig/**,public/**,coverage/**,**/__tests__/**,**.yml,**.json,**.md,eslintrc.js" + SONAR_CPD_EXCLUSIONS: "**/__tests__/**,src/datasources/**,src/models/**,src/repositories/**" + needs: ["sonarqube-verify-image-build"] + script: + - | + cd /opt/mcm-verify/ + ./verify.sh + artifacts: + paths: + - ${MODULE_PATH:-${MODULE_NAME}}/sonarqube_quality_gate_report + - ${MODULE_PATH:-${MODULE_NAME}}/sonarqube_issues + expire_in: 5 days + +.verify-all-script: &verify-all-script /opt/mcm-verify/convert_sonarqube_issues.sh + +verify-all: + extends: + - .commons + - .test-job-tags + - .only-branches + - .except-all + stage: verify-all + image: ${VERIFY_IMAGE_NAME} + script: + - *verify-all-script + artifacts: + paths: + - gl-code-quality-report.json + reports: + codequality: gl-code-quality-report.json + expire_in: 5 days + +.preview-deploy-job: + extends: + - .commons + - .subdomains + - .preview-env-vars + - .auto-stop-preview + - .except-clean-or-release + - .only-branches + - .no-dependencies + image: ${KUBETOOLS_IMAGE_TAGNAME} + stage: deploy-preview + before_script: + - | + cd ${MODULE_PATH} + - *declare-deployment-functions + - | + echo "ENV_URL=https://${MODULE_NAME}-${CI_COMMIT_REF_SLUG}.${LANDSCAPE}.${BASE_DOMAIN}" > deployment.env + script: + - | + deploy + artifacts: + paths: + - ${MODULE_PATH:-${MODULE_NAME}}/k8s/${LANDSCAPE} + reports: + dotenv: ${MODULE_PATH:-${MODULE_NAME}}/deployment.env + environment: + name: ${CI_COMMIT_REF_SLUG}-${LANDSCAPE}/${MODULE_NAME} + url: ${ENV_URL} + +kubernetes-deploy-env: + stage: utils + extends: + - .preview-deploy-job + script: + - | + echo "### Init environement to trigger kubernetes cleaner ###" + environment: + name: ${NAMESPACE} + on_stop: kubernetes_preview_cleanup + +.declare-undeployment-functions: &declare-undeployment-functions | + function undeploy { + kubectl delete namespace ${NAMESPACE} + } + function delete_volume { + PV_TO_DELETE=$(kubectl get pv -o wide --all-namespaces | grep ${BRANCH_NAME} | grep $1 | awk '{ print $1 }') + if [ $PV_TO_DELETE != "" ] + then + echo "### Patch the persistent volume to delete than retain for $1 ###" + kubectl patch pv $PV_TO_DELETE -p '{"spec":{"persistentVolumeReclaimPolicy":"Delete"}}' + fi + } + +.commons_preview_cleanup: + stage: cleanup + extends: + - .commons + - .manual + - .except-clean-or-release + - .no-git-clone + - .preview-env-vars + - .preview-deploy-tags + image: ${KUBETOOLS_IMAGE_TAGNAME} + before_script: + - cd ${MODULE_PATH} + - *declare-undeployment-functions + script: + - | + kubectl -n ${NAMESPACE} delete -f ${CI_PROJECT_DIR}/${MODULE_PATH}/k8s/${LANDSCAPE}/resources.yml + undeploy + environment: + name: ${CI_COMMIT_REF_SLUG}-${LANDSCAPE}/${MODULE_NAME} + action: stop + +kubernetes_preview_cleanup: + extends: + - .commons_preview_cleanup + script: + - | + delete_volume postgres-keycloak-data + delete_volume mongo-data + kubectl delete secret ${GITLAB_IMAGE_PULL_SECRET_NAME} + kubectl delete secret ${PROXY_IMAGE_PULL_SECRET_NAME} + environment: + name: ${NAMESPACE} + +# UTILS +.cleanup_mongo: &cleanup_mongo | + echo "MONGO CLEANING START" + echo ${BRANCH_NAME} + MONGO_POD_NAME=$(kubectl get pods -o wide -A | grep ${BRANCH_NAME} | grep mongo | awk '{ print $2 }') + MONGO_NAMESPACE=$(kubectl get pods -o wide -A | grep ${BRANCH_NAME} | grep mongo | awk '{ print $1 }') + kubectl exec $MONGO_POD_NAME -n $MONGO_NAMESPACE -- sh -c \ + "mongosh --username ${MONGO_SERVICE_USER} --password ${MONGO_SERVICE_PASSWORD} --authenticationDatabase ${MONGO_DB_NAME} --authenticationMechanism SCRAM-SHA-256 --eval \"db=db.getSiblingDB('${MONGO_DB_NAME}'); db.getCollectionNames().forEach(function(c) { if (c.indexOf('system.') == -1) db[c].deleteMany({}); }); quit(0);\"exit" + echo "MONGO CLEANING STOP" + +.cleanup_postgres: &cleanup_postgres | + echo "POSTGRES IDP CLEANING START" + echo ${BRANCH_NAME} + IDP_POD_NAME=$(kubectl get pods -o wide -A | grep ${BRANCH_NAME} | grep idp | awk '{ print $2 }') + IDP_NAMESPACE=$(kubectl get pods -o wide -A | grep ${BRANCH_NAME} | grep idp | awk '{ print $1 }') + echo "INIT CONNECTION" + kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c \ + "/opt/jboss/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080/auth --realm master --user ${IDP_ADMIN_USER} --password ${IDP_ADMIN_PASSWORD}" + echo "---------" + echo "GET USERS" + USER_LIST_ID=$(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh get users -r mcm --fields id" | jq -r '.[] | .id') + echo $USER_LIST_ID + if [ -z "$USER_LIST_ID" ] + then + echo "No user to delete" + else + echo "---------" + echo "DELETE USERS" + for i in $USER_LIST_ID + do + $(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh delete users/$i -r mcm") + done + fi + echo "---------" + echo "GET GROUPS" + GROUP_LIST_ID=$(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh get groups -r mcm" | jq -r '.[] | select( .name == "entreprises" or .name == "collectivités") | .subGroups | .[] | .id') + echo $GROUP_LIST_ID + if [ -z "$GROUP_LIST_ID" ] + then + echo "No group to delete" + else + echo "---------" + echo "DELETE GROUPS" + for i in $GROUP_LIST_ID + do + $(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh delete groups/$i -r mcm") + done + fi + echo "POSTGRES IDP CLEANING STOP" + +# UTILS +.mongo-clean-data: + stage: utils + extends: + - .commons + - .test-job-tags + - .only-branches + - .except-release + - .no-dependencies + image: ${KUBETOOLS_IMAGE_TAGNAME} + script: + - *cleanup_mongo + +api_clean_data: + extends: + - .mongo-clean-data + - .manual + +.postgres-clean-data: + stage: utils + extends: + - .commons + - .test-job-tags + - .only-branches + - .except-release + - .no-dependencies + image: ${KUBETOOLS_IMAGE_TAGNAME} + script: + - *cleanup_postgres + +idp_clean_data: + extends: + - .postgres-clean-data + - .manual diff --git a/commons/.gitlab-ci/testing.yml b/commons/.gitlab-ci/testing.yml new file mode 100644 index 0000000..e526854 --- /dev/null +++ b/commons/.gitlab-ci/testing.yml @@ -0,0 +1,313 @@ +.testing-fqdn: + variables: + ADMIN_FQDN: admin.${LANDSCAPE}.${BASE_DOMAIN} + API_FQDN: api.${LANDSCAPE}.${BASE_DOMAIN} + ADMIN_BUS_FQDN: admin-bus.${LANDSCAPE}.${BASE_DOMAIN} + S3_FQDN: s3.${LANDSCAPE}.${BASE_DOMAIN} + IDP_FQDN: idp.${LANDSCAPE}.${BASE_DOMAIN} + MATOMO_FQDN: analytics.${LANDSCAPE}.${BASE_DOMAIN} + VAULT_FQDN: vault.${LANDSCAPE}.${BASE_DOMAIN} + WEBSITE_FQDN: website.${LANDSCAPE}.${BASE_DOMAIN} + SIMULATION_MAAS_FQDN: simulation-maas.${LANDSCAPE}.${BASE_DOMAIN} + MAILHOG_FQDN: mailhog.${LANDSCAPE}.${BASE_DOMAIN} + +.testing-env-vars: + variables: + LANDSCAPE: "testing" + landscape_subdomain: "testing.${BASE_DOMAIN}" + SECRET_NAME: ${MODULE_NAME}-tls + CLUSTER_ISSUER: clusterissuer-mcm-dev + + extends: + - .testing-fqdn + +#### TAGS #### +.testing-deploy-tags: + tags: + - os:linux + - platform:testing + - task:deploy + +#### IMAGE JOB #### +.testing-image-job: + extends: + - .testing-env-vars + - .image-job + +#### PREPARE #### +.version_update: &version_update | + echo "### FORMAT RELEASE NAME (ex: rc-v1-0-0 => 1.0.0) ###" + FIRST_FORMAT=${CI_COMMIT_BRANCH:4} + export PACKAGE_VERSION_NEW=${FIRST_FORMAT//-/.} + echo "### CHANGE PACKAGE VERSION VARIABLES ###" + + curl --request PUT -H "PRIVATE-TOKEN: ${MCM_GITLAB_TOKEN}" \ + "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/variables/PACKAGE_VERSION_SAVE" --form "value=${PACKAGE_VERSION}" + + curl --request PUT -H "PRIVATE-TOKEN: ${MCM_GITLAB_TOKEN}" \ + "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/variables/PACKAGE_VERSION" --form "value=${PACKAGE_VERSION_NEW}" + +# UPDATE PACKAGE VERSION WITH CI COMMIT BRANCH +update_version: + stage: prepare + image: ${KUBETOOLS_IMAGE_TAGNAME} + extends: + - .commons + - .test-job-tags + - .testing-env-vars + script: + - *version_update + needs: ['commons-kubetools-image'] + +.configure_script: &configure_script | + # Create config folder + [[ ! -d configs ]] && mkdir configs + + # Copy files to configure + cp idp/overlays/realms/mcm-realm.json configs/master-realm-${CI_COMMIT_BRANCH}-${LANDSCAPE}.json + cp idp/overlays/realms/mcm-realm.json configs/mcm-realm-${CI_COMMIT_BRANCH}-${LANDSCAPE}.json + cp bus/overlays/definition.json configs/definition-${CI_COMMIT_BRANCH}-${LANDSCAPE}.json + + # Define variables + + IDP_API_CLIENT_SECRET=${TESTING_IDP_API_CLIENT_SECRET} + + # idp + IDP_SIMULATION_MAAS_CLIENT_SECRET=${TESTING_IDP_SIMULATION_MAAS_CLIENT_SECRET} + export MAIL_HOST=mailhog.mailhog-${LANDSCAPE}.svc.cluster.local + export EMAIL_FROM_KC=${MAILHOG_EMAIL_FROM_KC} + export MAIL_PORT=1025 + export SMTP_AUTH=false + + # rabbitmq + BUS_ADMIN_USER=${TESTING_BUS_ADMIN_USER} + BUS_ADMIN_PASSWORD=${TESTING_BUS_ADMIN_PASSWORD} + BUS_MCM_CONSUME_USER=${TESTING_BUS_MCM_CONSUME_USER} + BUS_MCM_CONSUME_PASSWORD=${TESTING_BUS_MCM_CONSUME_PASSWORD} + + for FILE in configs/*-${CI_COMMIT_BRANCH}-${LANDSCAPE}.json + do + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < $FILE > $FILE.tmp && mv $FILE.tmp $FILE + done + + +# CONFIGURE FILES TO INIT TESTING ENV +configure_testing: + stage: prepare + extends: + - .commons + - .test-job-tags + - .testing-env-vars + - .manual + image: ${KUBETOOLS_IMAGE_TAGNAME} + script: + - *configure_script + needs: ['commons-kubetools-image'] + artifacts: + expire_in: 3 days + when: always + paths: + - configs/*-${CI_COMMIT_BRANCH}-${LANDSCAPE}.json + +#### DEPLOY #### +.declare-deployment-testing-functions: &declare-deployment-testing-functions | + function deploy_testing { + + echo "Deploying ${MODULE_NAME} for ${CI_ENVIRONMENT_NAME}, subdomain ${LANDSCAPE}..." + + export GITLAB_IMAGE_PULL_SECRET_NAME=${IMAGE_PULL_SECRET_PREFIX}-${MODULE_NAME} + export PROXY_IMAGE_PULL_SECRET_NAME=${PROXY_IMAGE_PULL_SECRET_PREFIX}-${MODULE_NAME} + + # CREATE NAMESPACE ENV + export TESTING_HELM_DEPLOY_NAMESPACE=${MODULE_NAME}-${LANDSCAPE} + + kubectl create namespace ${TESTING_HELM_DEPLOY_NAMESPACE} --dry-run=client -o yaml | kubectl apply -f - + + kubectl create secret docker-registry ${GITLAB_IMAGE_PULL_SECRET_NAME} --docker-server="$CI_REGISTRY" --docker-username="$CI_DEPLOY_USER" --docker-password="$CI_DEPLOY_PASSWORD" --docker-email="$GITLAB_USER_EMAIL" --namespace=${TESTING_HELM_DEPLOY_NAMESPACE} -o yaml --dry-run=client | kubectl apply -f - + + kubectl create secret docker-registry ${PROXY_IMAGE_PULL_SECRET_NAME} --docker-server="$NEXUS_DOCKER_REGISTRY_URL" --docker-username="$NEXUS_DEV_USER" --docker-password="$NEXUS_DEV_PASSWORD" --docker-email="$GITLAB_USER_EMAIL" --namespace=${TESTING_HELM_DEPLOY_NAMESPACE} -o yaml --dry-run=client | kubectl apply -f - + + # SUBSTITUTE VARIABLES FOR ALL MODULE_NAME-LANDSCAPE-VALUES FILE + VALUE_FILE="${MODULE_PATH}-${LANDSCAPE}-values.yaml" + CHART_FILE=Chart.yaml + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < ${VALUE_FILE} > ${VALUE_FILE}.tmp && mv ${VALUE_FILE}.tmp ${VALUE_FILE} + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < ${CHART_FILE} > ${CHART_FILE}.tmp && mv ${CHART_FILE}.tmp ${CHART_FILE} + + # CREATE DEPLOY PACKAGE + [[ ! -d deploy ]] && mkdir deploy + + cp -R ../helm-chart/templates deploy/ + cp Chart.yaml deploy/ + cp ${VALUE_FILE} deploy/ + + # COPY OVERLAYS + if [ -d "overlays/config/" ] + then + # ENVSUBS + for FILE in overlays/config/* + do + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < $FILE > $FILE.tmp && mv $FILE.tmp $FILE + done + + # Copy configMap in helm-chart + cp -a overlays/config/. deploy/${MODULE_PATH} + fi + + ls -lah deploy/ + + # CREATE HELM PACKAGE FROM DEPLOY + helm package deploy --version ${PACKAGE_VERSION} + + ls -lah + + # HELM UPGRADE NEW PACKAGE + helm upgrade ${MODULE_PATH} ${MODULE_PATH}-${PACKAGE_VERSION}.tgz --install --version ${PACKAGE_VERSION} -n ${TESTING_HELM_DEPLOY_NAMESPACE} -f ${VALUE_FILE} --debug + + } + +.testing-deploy-job: + extends: + - .commons + - .subdomains + - .testing-env-vars + - .manual + - .no-dependencies + stage: deploy-testing + image: ${NEXUS_DOCKER_REGISTRY_URL}/dtzar/helm-kubectl + before_script: + - | + cd ${MODULE_PATH} + - *declare-deployment-testing-functions + - | + echo "ENV_URL=https://${MODULE_NAME}.${LANDSCAPE}.${BASE_DOMAIN}" > deployment.env + script: + - | + deploy_testing + artifacts: + paths: + - ${MODULE_PATH:-${MODULE_NAME}}/k8s/${LANDSCAPE} + reports: + dotenv: ${MODULE_PATH:-${MODULE_NAME}}/deployment.env + environment: + name: ${CI_COMMIT_REF_SLUG}-${LANDSCAPE}/${MODULE_NAME} + url: ${ENV_URL} + + +#### UTILS #### +.testing_cleanup_mongo: &testing_cleanup_mongo | + echo "MONGO CLEANING START" + mongosh "mongodb+srv://${TESTING_MONGO_HOST}" --username ${TESTING_MONGO_SERVICE_USER} --password ${TESTING_MONGO_SERVICE_PASSWORD} --tls --eval "db=db.getSiblingDB('${TESTING_MONGO_DB_NAME}'); db.getCollectionNames().forEach(function(c) { if (c.indexOf('system.') == -1) db[c].deleteMany({}); }); quit(0);" + echo "MONGO CLEANING STOP" + +# GET MONGO ATLAS URL TO DELETE COLLECTION DATA +mongo_testing_clean_data: + stage: utils + extends: + - .commons + - .test-job-tags + - .manual + - .no-dependencies + - .no-needs + image: ${KUBETOOLS_IMAGE_TAGNAME} + script: + - *testing_cleanup_mongo + +.testing_cleanup_postgres: &testing_cleanup_postgres | + echo "POSTGRES IDP CLEANING START" + IDP_NAMESPACE=idp-${LANDSCAPE} + IDP_POD_NAME=$(kubectl get pods -o wide -A | grep ${IDP_NAMESPACE} | grep idp | awk '{ print $2 }') + echo "INIT CONNECTION" + kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c \ + "/opt/jboss/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080/auth --realm master --user ${TESTING_PGSQL_ADMIN_USER} --password ${TESTING_PGSQL_ADMIN_PASSWORD}" + echo "---------" + echo "GET USERS" + USER_LIST_ID=$(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh get users -r mcm --fields id" | jq -r '.[] | .id') + echo $USER_LIST_ID + if [ -z "$USER_LIST_ID" ] + then + echo "No user to delete" + else + echo "---------" + echo "DELETE USERS" + for i in $USER_LIST_ID + do + $(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh delete users/$i -r mcm") + done + fi + echo "---------" + echo "GET GROUPS" + GROUP_LIST_ID=$(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh get groups -r mcm" | jq -r '.[] | select( .name == "entreprises" or .name == "collectivités") | .subGroups | .[] | .id') + echo $GROUP_LIST_ID + if [ -z "$GROUP_LIST_ID" ] + then + echo "No group to delete" + else + echo "---------" + echo "DELETE GROUPS" + for i in $GROUP_LIST_ID + do + $(kubectl exec $IDP_POD_NAME -n $IDP_NAMESPACE -c idp -- sh -c "/opt/jboss/keycloak/bin/kcadm.sh delete groups/$i -r mcm") + done + fi + echo "POSTGRES IDP CLEANING STOP" + +# GET IDP POD TO EXECUTE KC SCRIPTS +idp_testing_clean_data: + stage: utils + extends: + - .commons + - .test-job-tags + - .testing-env-vars + - .manual + - .no-dependencies + - .no-needs + image: ${KUBETOOLS_IMAGE_TAGNAME} + script: + - *testing_cleanup_postgres + +.testing_cleanup_s3: &testing_cleanup_s3 | + echo "S3 CLEANING START" + /bin/sh -c " + bash +o history; + chmod +x /usr/bin/mc; + until (mc alias set s3alias http://s3.s3-${LANDSCAPE}.svc.cluster.local:9000 ${TESTING_S3_ROOT_USER} ${TESTING_S3_ROOT_PASSWORD} --api S3v4) do echo '...waiting...' && sleep 1; done; + until (mc admin info s3alias) do echo '...waiting...' && sleep 1; done; + mc rb --force --dangerous s3alias; + bash -o history; + " + echo "S3 CLEANING STOP" + + +# GET S3 SVC TO DELETE FILES +s3_testing_clean_data: + stage: utils + extends: + - .commons + - .testing-env-vars + - .test-job-tags + - .manual + - .no-dependencies + - .no-needs + image: ${NEXUS_DOCKER_REPOSITORY_URL}/minio/mc:RELEASE.2022-04-16T21-11-21Z + script: + - *testing_cleanup_s3 + +.version_rollback: &version_rollback | + echo "### ROLLBACK AND DELETE FOR PACKAGE VERSION VARIABLES ###" + + curl --request PUT -H "PRIVATE-TOKEN: ${MCM_GITLAB_TOKEN}" \ + "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/variables/PACKAGE_VERSION" --form "value=${PACKAGE_VERSION_SAVE}" + +# ROLLBACK PACKAGE VERSION IN CASE OF FAILURE +version_rollback: + stage: utils + when: on_failure + image: ${KUBETOOLS_IMAGE_TAGNAME} + extends: + - .commons + - .test-job-tags + - .testing-env-vars + - .manual + - .no-needs + script: + - *version_rollback diff --git a/commons/README.md b/commons/README.md index 1057c7d..26c23b7 100644 --- a/commons/README.md +++ b/commons/README.md @@ -1,24 +1,24 @@ # Description -Dans ce dossier, plusieurs fichiers CI sont présents. Il regroupe les parties communes à tous les déploiements. +Dans ce dossier, plusieurs fichiers CI sont présents. Il regroupe les parties communes à tous les déploiements, basés sur la solution [GitLab](https://about.gitlab.com/). -Le .gitlab-ci.yml à la racine décrit les étapes communes à la pipeline des environnements ainsi que les rules pour les déclencher. +Le _.gitlab-ci.yml_ à la racine décrit les étapes communes à la pipeline des environnements ainsi que les rules pour les déclencher. -Chaque service contient un fichier .gitlab-ci.yml à leur racine ainsi qu'un dossier .gitlab-ci pour décrire les jobs spécifiques associés aux environnements. +Chaque service contient un fichier _.gitlab-ci.yml_ à leur racine ainsi qu'un dossier _.gitlab-ci_ pour décrire les jobs spécifiques associés aux environnements. Les configurations des services selon les environnements peuvent différer. -Dans le dossier overlays/config se trouvent des fichiers de configuration nécessaires au deploiement du service pour les environnements distants. +Dans le dossier _overlays/config_ se trouvent des fichiers de configuration nécessaires au déploiement du service pour les environnements distants. ## Preview -La pipeline de preview est déclenchée pour chaque branche de notre projet. +La pipeline de preview est déclenchée pour chaque branche du projet. -Les déploiements de la pipeline de preview sont fait grâce à kubectl via le fichier kompose.yml et des overlays qui accompagnent chaque service. +Les déploiements de la pipeline de preview sont fait avec la commande `kubectl` via le fichier _kompose.yml_ et des _overlays_ qui accompagnent chaque service. -Dans le dossier overlays se trouvent des configurations d'objets kubernetes à appliquer dans le déploiement et non descriptibles dans le kompose.yml. +Dans le dossier _overlays_ se trouvent des configurations d'objets Kubernetes à appliquer dans le déploiement et non descriptibles dans le _kompose.yml_. -Certains services ne sont configurés que sur la branche master et les autres branches de déploiement s'appuient dessus (s3/antivirus/vault/analytics) +Certains services ne sont configurés que sur la branche principale et les autres branches de déploiement s'appuient dessus (s3/antivirus/vault/analytics) ## Testing @@ -26,19 +26,19 @@ La pipeline de testing est déclenchée à la création d'un branche de Release Elle se rapproche au maximum du déploiement fait pour les environnements de PPRD et PROD. -Les déploiements de la pipeline de testing sont fait grâce à helm via des fichiers Chart.yaml & ${MODULE_NAME}-testing-values.yaml qui accompagnent chaque service. +Les déploiements de la pipeline de testing sont fait grâce à [Helm](https://helm.sh/) via des fichiers _Chart.yaml_ & ${MODULE_NAME}-testing-values.yaml qui accompagnent chaque service. # Helm La pipeline "helm" est déclenchée à la création d'une Release sur le tag assiocié à la release candidate. -Celle-ci permet de livrer le code à l'équipe Cloud en charge des déploiements PPRD et PROD. +Celle-ci permet de livrer le code à l'équipe OPS en charge des déploiements PPRD et PROD. Ainsi, nous leur fournissons la pipeline de déploiement, un package helm par environnement, les images buildées de la dernière version du code de certaines images. # Variables -Certaines variables comme les FQDN ou les images docker sont spécifiées dans les fichiers *gilab-ci.yml de ce dossier. +Certaines variables comme les FQDN ou les images docker sont spécifiées dans les fichiers _gilab-ci.yml_ de ce dossier. ## Variables CI Communes preview & testing diff --git a/commons/kubetools/.gitlab-ci.yml b/commons/kubetools/.gitlab-ci.yml new file mode 100644 index 0000000..0eb73ee --- /dev/null +++ b/commons/kubetools/.gitlab-ci.yml @@ -0,0 +1,8 @@ +commons-kubetools-image: + extends: + - .default-image-job + - .except-release + stage: prepare + variables: + MODULE_NAME: kubetools + MODULE_PATH: commons/kubetools \ No newline at end of file diff --git a/commons/kubetools/Dockerfile b/commons/kubetools/Dockerfile new file mode 100644 index 0000000..962ab81 --- /dev/null +++ b/commons/kubetools/Dockerfile @@ -0,0 +1,14 @@ +ARG BASE_IMAGE_UBUNTU +FROM ${BASE_IMAGE_UBUNTU} + +RUN apt-get update && \ + apt-get install -y wget curl gettext-base jq && \ + cd /usr/local/bin && \ + wget https://downloads.mongodb.com/compass/mongodb-mongosh_1.4.2_amd64.deb && \ + apt install -y ./mongodb-mongosh_1.4.2_amd64.deb && \ + curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash && \ + curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl && \ + chmod +x ./kubectl && \ + curl -L https://github.com/kubernetes/kompose/releases/download/v1.22.0/kompose-linux-amd64 -o kompose && \ + chmod +x ./kompose +ADD ./scripts/* /usr/local/bin/ diff --git a/commons/kubetools/kompose.yml b/commons/kubetools/kompose.yml new file mode 100644 index 0000000..8185004 --- /dev/null +++ b/commons/kubetools/kompose.yml @@ -0,0 +1,9 @@ +version: "3" + +services: + kubetools: + image: ${KUBETOOLS_IMAGE_TAGNAME} + build: + context: . + args: + BASE_IMAGE_UBUNTU: ${NEXUS_DOCKER_REPOSITORY_URL}/ubuntu:22.04 diff --git a/commons/kubetools/scripts/a b/commons/kubetools/scripts/a new file mode 100644 index 0000000..47d4d15 --- /dev/null +++ b/commons/kubetools/scripts/a @@ -0,0 +1,2 @@ +#!/bin/bash +kubectl -f - ${*:-apply} diff --git a/commons/kubetools/scripts/k b/commons/kubetools/scripts/k new file mode 100644 index 0000000..192c171 --- /dev/null +++ b/commons/kubetools/scripts/k @@ -0,0 +1,59 @@ +#!/bin/bash + +# Retrieve parameters or default values +KOMPOSE_FILE=${KOMPOSE_FILE:-./kompose.yml} +OVERLAYS_PATH=${OVERLAYS_PATH:-./overlays} +BUILD_PATH=${BUILD_PATH:-$(mktemp -d -t s2kXXXXXXXXXX)} +ENV_PATH=${ENV_PATH:-${BUILD_PATH}/params.env} + +# Create output directory +mkdir -p $BUILD_PATH/ +if [ -d "$BUILD_PATH/.base" ]; then mv $BUILD_PATH/.base $BUILD_PATH/.base.bak; fi +mkdir -p $BUILD_PATH/.base/ + +# Identify environment variables used in compose (kompose) file +VARS= +while IFS= read -r -a VAR_NAME; do + if [[ ${VARS} != *"${VAR_NAME}"* ]];then + VARS="${VARS} $VAR_NAME" + fi +done < <(grep -Eo '\$\{[0-9A-Za-z_-]+}' $KOMPOSE_FILE | grep -Eo '[0-9A-Za-z_-]+' -) + +# Initialize environment variables and .env file +echo >${ENV_PATH} +for VAR_NAME in $VARS +do + echo "export $VAR_NAME=\${${VAR_NAME}}" >>${ENV_PATH} + eval "export $VAR_NAME=\$\{${VAR_NAME}\}" +done +# Convert base Docker compose file +kompose convert --with-kompose-annotation=false --suppress-warnings --file $KOMPOSE_FILE -o $BUILD_PATH/.base/ + +cat > $BUILD_PATH/.base/kustomization.tmp <(cd $BUILD_PATH/.base/ ; echo "resources:" ; for MANIFEST in $(find . \( -name '*.yml' -o -name '*.yaml' \)) +do + echo - \"$MANIFEST\" +done +) +mv $BUILD_PATH/.base/kustomization.tmp $BUILD_PATH/.base/kustomization.yaml +# Copy overlay files to build location +cp -R $OVERLAYS_PATH/* $BUILD_PATH/ +# Edit kustomization.yaml to include overlays +echo -e "bases:\n- .base\n\n$(cat $BUILD_PATH/kustomization.yaml)" > $BUILD_PATH/kustomization.yaml +# DEBUGGING: dump $BUILD_PATH/kustomization.yaml and $BUILD_PATH/.base/kustomization.yaml +>&2 echo "**** current path:" +>&2 pwd +>&2 echo "**** value of BUILD_PATH:" +>&2 echo $BUILD_PATH +>&2 echo "*** files in BUILD_PATH" +>&2 ls -la ${BUILD_PATH} +>&2 echo "*** files in BUILD_PATH/.base" +>&2 ls -la ${BUILD_PATH}/.base +>&2 echo "******* BUILD_PATH/kustomization.yaml (overlays):" +>&2 cat ${BUILD_PATH}/kustomization.yaml +>&2 echo "******* BUILD_PATH/.base/kustomization.yaml (converted base):" +>&2 cat ${BUILD_PATH}/.base/kustomization.yaml + + +# Apply customizations and print end-result to stdout +kubectl kustomize $BUILD_PATH/ + diff --git a/commons/sonarqube/.gitignore b/commons/sonarqube/.gitignore new file mode 100644 index 0000000..21d3209 --- /dev/null +++ b/commons/sonarqube/.gitignore @@ -0,0 +1,2 @@ +# Gitlab CodeQuality +gl-code-quality-report*.json diff --git a/commons/sonarqube/.gitlab-ci.yml b/commons/sonarqube/.gitlab-ci.yml new file mode 100644 index 0000000..141cfc8 --- /dev/null +++ b/commons/sonarqube/.gitlab-ci.yml @@ -0,0 +1,12 @@ +sonarqube-verify-image-build: + extends: + - .preview-image-job + - .only-branches + stage: prepare + variables: + MODULE_NAME: verify + MODULE_PATH: commons/sonarqube + except: + variables: + - $SKIP_TEST_N_VERIFY == "yes" + - $CLEAN_DATA == "yes" diff --git a/commons/sonarqube/Dockerfile b/commons/sonarqube/Dockerfile new file mode 100644 index 0000000..2d52779 --- /dev/null +++ b/commons/sonarqube/Dockerfile @@ -0,0 +1,32 @@ +ARG BASE_IMAGE_SONAR +FROM ${BASE_IMAGE_SONAR} + +WORKDIR /opt/mcm-verify/ + +# Add yarn to install sonarqube-verify module +# because npm install can not manage postinstall stage +# Add jq to tranform Sonarqube analysis report to Codeclimate subset report +# Add curl to request Sonarqube API +# Add gettext to install envsubst +RUN apk add yarn jq curl gettext openjdk8-jre + +# Copy node.js code +COPY patch-package-json.js . + +# Install modules +COPY package.json . +RUN yarn install + +# Set a link to avoid npm plugin sonarqube-verify to download sonar-scanner +# whereas it is provided in the docker image +#RUN ln -s /opt/sonar-scanner/bin/sonar-scanner /usr/local/bin +RUN cat /opt/sonar-scanner/bin/sonar-scanner +RUN mkdir -p /tmp/.sonar/native-sonar-scanner/sonar-scanner-4.5.0.2216-linux/bin/ +RUN ln -s /opt/sonar-scanner/bin/sonar-scanner /tmp/.sonar/native-sonar-scanner/sonar-scanner-4.5.0.2216-linux/bin/sonar-scanner + +# Add scripts to verify +COPY verify.sh . +RUN chmod u+x verify.sh + +COPY convert_sonarqube_issues.sh . +RUN chmod u+x convert_sonarqube_issues.sh diff --git a/commons/sonarqube/codeclimate.sample b/commons/sonarqube/codeclimate.sample new file mode 100644 index 0000000..f8bdc23 --- /dev/null +++ b/commons/sonarqube/codeclimate.sample @@ -0,0 +1,12 @@ +[ + { + "description": "'unused' is assigned a value but never used.", + "fingerprint": "7815696ecbf1c96e6894b779456d330e", + "location": { + "path": "lib/index.js", + "lines": { + "begin": 42 + } + } + } +] diff --git a/commons/sonarqube/convert_sonarqube_issues.sh b/commons/sonarqube/convert_sonarqube_issues.sh new file mode 100644 index 0000000..75068d1 --- /dev/null +++ b/commons/sonarqube/convert_sonarqube_issues.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Recherche les fichiers sonarqube_issues +MODULES="" +for MODULE in api website +do + if [ -f $MODULE/sonarqube_issues ]; then + NB_ISSUES=`jq '.issues | length' $MODULE/sonarqube_issues` + [ $NB_ISSUES -gt 0 -a -z $MODULE ] && export MODULES=$MODULE + [ $NB_ISSUES -gt 0 -a ! -z $MODULE ] && export MODULES=$MODULE" "$MODULES + fi +done + +# Convertit les fichiers sonarqube_issues en sous-ensemble du modèle CodeClimate +echo "MODULES with issues="$MODULES +for MODULE in $MODULES +do + jq --arg moduleName "$MODULE" '[ .components as $components | .issues[] | .component as $componentName | { description: (.message + " severity:" + .severity + " type:" + .type + " rule:" + .rule + " effort:" + .effort + " debt:" + .debt), fingerprint: (.component + ":" +.rule + ":" + .hash + ":" + (.textRange.startLine|tostring) + ":" + (.textRange.endLine|tostring) + ":" + (.textRange.startOffset|tostring)+ ":" + (.textRange.endOffset|tostring)), severity: (.severity|ascii_downcase), location: { path: ($moduleName + "/" + ( $components[] | select (.key == $componentName) | .longName ) ), lines : { begin : .line } } } ]' $MODULE/sonarqube_issues >> gl-code-quality-report-$MODULE.json +done + +# Aggrège les fichiers sous-ensemble du modèle CodeClimate +jq -s '[ .[] | .[] ]' gl-code-quality-report-*.json > gl-code-quality-report-all.json + +# Calcule le fingerprint +jq -c '.[]' gl-code-quality-report-all.json | +while IFS= read -r obj; do + md5sum=$( printf '%s' "$obj" | jq '.fingerprint' | md5sum | cut -d' ' -f1) + jq -c --arg md5 "$md5sum" '.fingerprint = $md5' <<<"$obj" +done | jq -s '.'> gl-code-quality-report.json diff --git a/commons/sonarqube/kompose.yml b/commons/sonarqube/kompose.yml new file mode 100644 index 0000000..01b889f --- /dev/null +++ b/commons/sonarqube/kompose.yml @@ -0,0 +1,9 @@ +version: "3" + +services: + verify: + image: ${VERIFY_IMAGE_NAME} + build: + context: . + args: + BASE_IMAGE_SONAR: ${NEXUS_DOCKER_REPOSITORY_URL}/sonarsource/sonar-scanner-cli:4 diff --git a/commons/sonarqube/package.json b/commons/sonarqube/package.json new file mode 100644 index 0000000..54cafa4 --- /dev/null +++ b/commons/sonarqube/package.json @@ -0,0 +1,13 @@ +{ + "name": "mcm-verify", + "author": "", + "description": "", + "version": "1.0.0", + "license": "ISC", + "scripts": { + "verify": "node ./patch-package-json.js ; cd ${PROJECT_PATH} ; sonarqube-verify" + }, + "devDependencies": { + "sonarqube-verify": "^1.0.2" + } +} diff --git a/commons/sonarqube/patch-package-json.js b/commons/sonarqube/patch-package-json.js new file mode 100644 index 0000000..d53b22d --- /dev/null +++ b/commons/sonarqube/patch-package-json.js @@ -0,0 +1,5 @@ +const pkg = require(process.env.PROJECT_PATH+"/package.json") +pkg.name = process.env.PROJECT_KEY +var json = JSON.stringify(pkg); +var fs = require('fs'); +fs.writeFileSync(process.env.PROJECT_PATH+'/package.json', json); diff --git a/commons/sonarqube/verify.sh b/commons/sonarqube/verify.sh new file mode 100644 index 0000000..9bc7563 --- /dev/null +++ b/commons/sonarqube/verify.sh @@ -0,0 +1,71 @@ +#!/bin/bash +export SONAR_LOGIN=${SONAR_TOKEN} + +# Determine absolute path of the current script and CD to this location +SCRIPT_PATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" + +# Determine project location +if [ "$CI" == "true" ] +then + LOCATION=cicd +else + LOCATION=local +fi + +NEW_GITLAB_BRANCH=$(tr -s / _ <<< "$GITLAB_BRANCH") + + +# Determine project path and key +export PROJECT_PATH=${CI_PROJECT_DIR}/${MODULE_PATH} +export PROJECT_KEY=${MODULE_NAME}_${NEW_GITLAB_BRANCH}_${LOCATION} + +# Get the sonar-project.properties on the project module +SONAR_EXCLUSIONS=$(cat ${PROJECT_PATH}/sonar-project.properties | grep sonar.exclusions | sed 's/^.*=//' ) +SONAR_CPD_EXCLUSIONS=$(cat ${PROJECT_PATH}/sonar-project.properties | grep sonar.cpd.exclusions | sed 's/^.*=//' ) + +# Generate sonar-project.properties file inside project home directory +(cat - << EOF +sonar.projectName=${MODULE_NAME} +sonar.projectBaseDir=${PROJECT_PATH} +sonar.sources=${SONAR_SOURCES} +sonar.sourceEncoding=UTF-8 +sonar.working.directory=${PROJECT_PATH}/.scannerwork +sonar.exclusions=${SONAR_EXCLUSIONS} +sonar.cpd.exclusions=${SONAR_CPD_EXCLUSIONS} +sonar.javascript.lcov.reportPaths=coverage/lcov.info +EOF +) | envsubst > ${PROJECT_PATH}/sonar-project.properties + +# Log content of file for debugging purposes +echo "[beginning of sonar-project.properties file]" +cat ${PROJECT_PATH}/sonar-project.properties +echo "[end of sonar-project.properties file]" + +# Avoid exiting when a failed quality gate is returned through 'yarn verify' +# then, to be able to get Sonarqube Quality Gate report +cd ${SCRIPT_PATH} ; yarn verify + +# Get the ok/nok status returned by Sonarqube Quality Gate +RETURN=`echo $?` + +# Move to the scanned project directory +cd ${PROJECT_PATH} + +# Get the project key to request Sonarqube API +PROJECT_KEY=`head -1 ${PROJECT_PATH}/.scannerwork/report-task.txt | awk -F= '{print $2}'` +ls ${PROJECT_PATH}/.scannerwork + +# API Sonarqube Issues pagination + curl look not to work well, +# indeed when p=nb (page number) or ps=size (page size) is added as a new parameter then +# curl is not responding at all +# so the first 100 issues will be displayed +echo "Project key: ${PROJECT_KEY}" +echo "curl -u SONAR_TOKEN: ${SONAR_URL}/api/issues/search?componentKeys=${PROJECT_KEY}" +curl -u ${SONAR_TOKEN}: ${SONAR_URL}/api/issues/search?componentKeys=${PROJECT_KEY} > ${PROJECT_PATH}/sonarqube_issues + +# API Sonarqube Quality Gate +echo "curl -u SONAR_TOKEN: ${SONAR_URL}/api/qualitygates/project_status?projectKey=${PROJECT_KEY}" +curl -u ${SONAR_TOKEN}: ${SONAR_URL}/api/qualitygates/project_status?projectKey=${PROJECT_KEY} > ${PROJECT_PATH}/sonarqube_quality_gate_report +cat ${PROJECT_PATH}/sonarqube_quality_gate_report + +exit $RETURN diff --git a/docs/DIN-MCM_moB_KeyManager_Vault_V1.4.pdf b/docs/DIN-MCM_moB_KeyManager_Vault_V1.4.pdf new file mode 100644 index 0000000..2fde86f Binary files /dev/null and b/docs/DIN-MCM_moB_KeyManager_Vault_V1.4.pdf differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d1762cf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +# Documentation publique Mon Compte Mobilité + +## Architecture technique + +Ci-dessous, le schéma représentant l'architecture technique détaillée ainsi que les intéractions entre les différents services. Il est extrait du [Document d'Architecture Technique](DAT-MCM_moB_v1.1.pdf) + +![technicalArchitecture](assets/MOB-CME_Archi_technique_detaillee.png) diff --git a/docs/assets/MOB-CME_Archi_technique_detaillee.png b/docs/assets/MOB-CME_Archi_technique_detaillee.png new file mode 100644 index 0000000..7be71da Binary files /dev/null and b/docs/assets/MOB-CME_Archi_technique_detaillee.png differ diff --git a/helm-chart/.gitlab-ci.yml b/helm-chart/.gitlab-ci.yml new file mode 100644 index 0000000..3cdd420 --- /dev/null +++ b/helm-chart/.gitlab-ci.yml @@ -0,0 +1,156 @@ +# STANDALONE GITLAB CI FOR DEPLOYMENT PURPOSES IN PREPROD & PROD +# THIS FILE CONTAINS ALL GLOBAL INFORMATIONS, SCRIPTS AND JOBS NEEDED FOR PREPROD AND PROD STAGES + +# Include needed gitlab-ci files +include: + - local: ".gitlab-ci/preprod/commons.yml" + - local: ".gitlab-ci/production/commons.yml" + +# Declare stages +stages: + - configure-preprod + - deploy-preprod + - test-preprod + - configure-production + - deploy-production + - test-production + +# Base variables needed for all stages +.helm-chart-base: + variables: + PROXY_IMAGE_PULL_SECRET_PREFIX: nexus-registry + PROXY_IMAGE_PULL_SECRET_HANDOVER_PREFIX: nexus-registry-handover + NEXUS_DOCKER_REGISTRY_URL: ${NEXUS_DOCKER_REPOSITORY_URL} + NEXUS_USER_NAME: ${NEXUS_DEV_USER} + NEXUS_USER_PWD: ${NEXUS_DEV_PASSWORD} + NEXUS_ROOT_USER_NAME: ${NEXUS_ROOT_USER} + NEXUS_ROOT_USER_PWD: ${NEXUS_ROOT_PASSWORD} + NEXUS_HELM_REPOSITORY_URL: ${NEXUS_HELM_REPOSITORY_URL} + NEXUS_DOCKER_REGISTRY_HANDOVER_URL: ${NEXUS_DOCKER_REGISTRY_HANDOVER_URL} + CYPRESS_IMAGE_NAME: ${NEXUS_DOCKER_REGISTRY_URL}/cypress/browsers:node16.14.0-chrome99-ff97 + +# Manual trigger for jobs +.manual: + when: manual + +# No dependencies +.no-dependencies: + dependencies: [] + +# --- Function helpers to apply variables on all ENV conf files --- +.helm-configure-script: &helm-configure-script | + for FILE in configs/*-${RELEASE_VERSION_SLUG}-${TRUNCATED_LANDSCAPE}.json + do + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < $FILE > $FILE.tmp && mv $FILE.tmp $FILE + done + +# --- Job template to apply variables on all ENV conf files --- +.helm-configure-job: + image: ${NEXUS_DOCKER_REGISTRY_URL}/dtzar/helm-kubectl + extends: + - .helm-chart-base + - .manual + - .no-dependencies + script: + - *helm-configure-script + artifacts: + expire_in: 3 days + when: always + paths: + - configs/*-${RELEASE_VERSION_SLUG}-${TRUNCATED_LANDSCAPE}.json + +# --- Function helpers to deploy modules --- +.helm-deploy-function: &helm-deploy-function | + function helm_deploy { + echo "Deploying ${MODULE_NAME} for ${CI_ENVIRONMENT_NAME}, subdomain ${LANDSCAPE}..." + + export PROXY_IMAGE_PULL_SECRET_NAME=${PROXY_IMAGE_PULL_SECRET_PREFIX}-${MODULE_NAME} + export PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME=${PROXY_IMAGE_PULL_SECRET_HANDOVER_PREFIX}-${MODULE_NAME} + export MODULE_PATH=${MODULE_NAME} + + # CREATE NAMESPACE ENV + ENV_NAMESPACE="${TRUNCATED_LANDSCAPE^^}_HELM_DEPLOY_NAMESPACE" + kubectl create namespace ${MODULE_NAME}-${!ENV_NAMESPACE} --dry-run=client -o yaml | kubectl apply -f - + + kubectl create secret docker-registry ${PROXY_IMAGE_PULL_SECRET_NAME} --docker-server="$NEXUS_DOCKER_REGISTRY_URL" --docker-username="$NEXUS_ROOT_USER_NAME" --docker-password="$NEXUS_ROOT_USER_PWD" --docker-email="$GITLAB_USER_EMAIL" --namespace=${MODULE_NAME}-${!ENV_NAMESPACE} -o yaml --dry-run=client | kubectl apply -f - + + kubectl create secret docker-registry ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} --docker-server="$NEXUS_DOCKER_HANDOVER_REGISTRY_URL" --docker-username="$NEXUS_ROOT_USER_NAME" --docker-password="$NEXUS_ROOT_USER_PWD" --docker-email="$GITLAB_USER_EMAIL" --namespace=${MODULE_NAME}-${!ENV_NAMESPACE} -o yaml --dry-run=client | kubectl apply -f - + + # HELM ADD REPO + helm repo add Helm-chart-repo ${NEXUS_HELM_REPOSITORY_URL} --username "$NEXUS_USER_NAME" --password "$NEXUS_USER_PWD" + + # HELM PULL CHART + helm pull ${NEXUS_HELM_REPOSITORY_URL}/${HELM_PACKAGE_NAME}-${RELEASE_VERSION}.tgz --version ${RELEASE_VERSION} --untar --username "$NEXUS_USER_NAME" --password "$NEXUS_USER_PWD" + + # SUBSTITUTE VARIABLES FOR ALL MODULE_NAME-VALUES FILE + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < ${HELM_PACKAGE_NAME}/${MODULE_NAME}-values.yaml > ${HELM_PACKAGE_NAME}/${MODULE_NAME}-values.yaml.tmp && mv ${HELM_PACKAGE_NAME}/${MODULE_NAME}-values.yaml.tmp ${HELM_PACKAGE_NAME}/${MODULE_NAME}-values.yaml + + # HELM UPDATE REPO WITH LOCAL CHART + helm repo update + + # HELM UPGRADE NEW PACKAGE + helm upgrade --install --version ${RELEASE_VERSION} ${HELM_PACKAGE_NAME} Helm-chart-repo/${HELM_PACKAGE_NAME} -n ${MODULE_NAME}-${!ENV_NAMESPACE} -f ${HELM_PACKAGE_NAME}/${MODULE_NAME}-values.yaml + } + +# --- Function helpers to wait for module dependencies--- +.helm-deploy-helpers: &helm-deploy-helpers | + function pod_namespace { + kubectl get pods -o wide --all-namespaces | grep $1 | awk '{ print $1 }' | head -n 1 + } + function wait_pod { + while [[ $(pod_namespace $1) == "" ]] + do + echo "Wait $1 try" + sleep 10 + done + } + +# --- Job template to deploy helm chart with helm and kubectl image --- +.helm-deploy-job: + image: ${NEXUS_DOCKER_REGISTRY_URL}/dtzar/helm-kubectl + extends: + - .helm-chart-base + - .manual + - .no-dependencies + before_script: + - *helm-deploy-function + - *helm-deploy-helpers + - | + echo "ENV_URL=https://${MODULE_NAME}.${LANDSCAPE}.${BASE_DOMAIN}" > ${MODULE_NAME}-${LANDSCAPE}-deployment.env + script: + - | + helm_deploy + artifacts: + reports: + dotenv: ${MODULE_PATH:-${MODULE_NAME}}-${LANDSCAPE}-deployment.env + environment: + name: ${CI_COMMIT_REF_SLUG}-${LANDSCAPE}/${MODULE_NAME} + url: ${ENV_URL} + +# --- Smoke tests script to launch cypress --- +.smoke-tests-script: &smoke-tests-script | + npm i + npx cypress run \ + --spec "cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js" \ + --config-file cypress-smoke.json \ + --browser chrome | tee cypress-smoke-tests.log + cat cypress-smoke-tests.log + +# --- Job template to implement smoke tests --- +.smoke-tests-job: + variables: + MODULE_PATH: test + extends: + - .helm-chart-base + - .manual + - .no-dependencies + image: ${CYPRESS_IMAGE_NAME} + script: + - cd test + - *smoke-tests-script + artifacts: + expire_in: 3 days + when: always + paths: + - ${MODULE_PATH}/mochawesome-report + diff --git a/helm-chart/.gitlab-ci/preprod/commons.yml b/helm-chart/.gitlab-ci/preprod/commons.yml new file mode 100644 index 0000000..f5a329d --- /dev/null +++ b/helm-chart/.gitlab-ci/preprod/commons.yml @@ -0,0 +1,57 @@ +# Include needed gitlab-ci files +include: + - local: ".gitlab-ci/preprod/configure.yml" + - local: ".gitlab-ci/preprod/deploy.yml" + - local: ".gitlab-ci/preprod/test.yml" + +# Init base variables +.helm-chart-preprod-base: + variables: + LANDSCAPE: preprod + TRUNCATED_LANDSCAPE: pprd + HELM_PACKAGE_NAME: platform-${TRUNCATED_LANDSCAPE} + +# Job template for preprod job configure +.helm-configure-preprod-job: + stage: configure-preprod + extends: + - .helm-chart-preprod-base + - .helm-configure-preprod-tags + - .helm-configure-job + +# Job template for preprod job deploy +.helm-deploy-preprod-job: + stage: deploy-preprod + extends: + - .helm-chart-preprod-base + - .helm-deploy-preprod-tags + - .helm-deploy-job + +# Job template for preprod job test +.helm-test-preprod-job: + stage: test-preprod + extends: + - .helm-chart-preprod-base + - .helm-test-preprod-tags + - .smoke-tests-job + +# Tags to used the good runner gitlab in pprd +.helm-configure-preprod-tags: + tags: + - os:linux + - platform:preprod + - task:configure + +# Tags to used the good runner gitlab in pprd +.helm-deploy-preprod-tags: + tags: + - os:linux + - platform:preprod + - task:deploy + +# Tags to used the good runner gitlab in pprd +.helm-test-preprod-tags: + tags: + - os:linux + - platform:preprod + - task:test diff --git a/helm-chart/.gitlab-ci/preprod/configure.yml b/helm-chart/.gitlab-ci/preprod/configure.yml new file mode 100644 index 0000000..c8c76e0 --- /dev/null +++ b/helm-chart/.gitlab-ci/preprod/configure.yml @@ -0,0 +1,4 @@ +# --- Configure conf files --- +configure_files_preprod: + extends: + - .helm-configure-preprod-job \ No newline at end of file diff --git a/helm-chart/.gitlab-ci/preprod/deploy.yml b/helm-chart/.gitlab-ci/preprod/deploy.yml new file mode 100644 index 0000000..dec81aa --- /dev/null +++ b/helm-chart/.gitlab-ci/preprod/deploy.yml @@ -0,0 +1,78 @@ +# --- Deploy each modules --- + +# Admin module to deploy React Admin +# Interactions with modules: api, idp +admin_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: administration + +# Analytics module to deploy Matomo - in charge of tracking website, api & idp data +# Interactions with modules: website (for now) +analytics_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: analytics + +# Antivirus module to deploy Clamav - in charge of analysing files when a user is subscribing to an incentive +# Interactions with modules: api +antivirus_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: antivirus + +# Bus module to deploy RabbitMQ - in charge of handling amqp message interactions between SIRH and MCM +# Interactions with modules: api +bus_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: bus + +# IDP module to deploy Keycloak - in charge of handling user session, token and access of the entire app +# Interactions with modules: website, administration, api +idp_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: idp + +# S3 module to deploy Minio - in charge of storing all users documents for subscription +# Interactions with modules: api +s3_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: s3 + +# Simulation maas module to deploy vanilla js app - in charge of testing specific needs for MaaS +# Interactions with modules: idp, api +simulation_maas_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: simulation-maas + +# Api module to deploy Loopback 4 - in charge of business backend logic +# Interactions with : bus, idp, s3, administration, website, antivirus +api_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: api + script: + - | + echo "Wait for idp for offline token verification" + wait_pod idp + helm_deploy + +# Website module to deploy Gatsby 4 - in charge of business frontend logic +# Interactions with : idp, api +website_helm_deploy_preprod: + extends: + - .helm-deploy-preprod-job + variables: + MODULE_NAME: website \ No newline at end of file diff --git a/helm-chart/.gitlab-ci/preprod/test.yml b/helm-chart/.gitlab-ci/preprod/test.yml new file mode 100644 index 0000000..e056ad7 --- /dev/null +++ b/helm-chart/.gitlab-ci/preprod/test.yml @@ -0,0 +1,12 @@ +.smoke-test-preprod-base: + variables: + CYPRESS_ADMIN_FQDN: admin.${LANDSCAPE}.${BASE_DOMAIN} + CYPRESS_IDP_FQDN: idp.${LANDSCAPE}.${BASE_DOMAIN} + CYPRESS_WEBSITE_FQDN: website.${LANDSCAPE}.${BASE_DOMAIN} + CYPRESS_API_FQDN: api.${LANDSCAPE}.${BASE_DOMAIN} + +# --- Launch smoke tests --- +smoke_tests_preprod: + extends: + - .smoke-test-preprod-base + - .helm-test-preprod-job \ No newline at end of file diff --git a/helm-chart/.gitlab-ci/production/commons.yml b/helm-chart/.gitlab-ci/production/commons.yml new file mode 100644 index 0000000..dcfee55 --- /dev/null +++ b/helm-chart/.gitlab-ci/production/commons.yml @@ -0,0 +1,57 @@ +# Include needed gitlab-ci files +include: + - local: ".gitlab-ci/production/configure.yml" + - local: ".gitlab-ci/production/deploy.yml" + - local: ".gitlab-ci/production/test.yml" + +# Init base variables +.helm-chart-production-base: + variables: + LANDSCAPE: production + TRUNCATED_LANDSCAPE: prod + HELM_PACKAGE_NAME: platform-${TRUNCATED_LANDSCAPE} + +# Job template for production job configure +.helm-configure-production-job: + stage: configure-production + extends: + - .helm-chart-production-base + - .helm-configure-production-tags + - .helm-configure-job + +# Job template for production job deploy +.helm-deploy-production-job: + stage: deploy-production + extends: + - .helm-chart-production-base + - .helm-deploy-production-tags + - .helm-deploy-job + +# Job template for production job test +.helm-test-production-job: + stage: test-production + extends: + - .helm-chart-production-base + - .helm-test-production-tags + - .smoke-tests-job + +# Tags to used the good runner gitlab in production +.helm-configure-production-tags: + tags: + - os:linux + - platform:production + - task:configure + +# Tags to used the good runner gitlab in production +.helm-deploy-production-tags: + tags: + - os:linux + - platform:production + - task:deploy + +# Tags to used the good runner gitlab in production +.helm-test-production-tags: + tags: + - os:linux + - platform:production + - task:test \ No newline at end of file diff --git a/helm-chart/.gitlab-ci/production/configure.yml b/helm-chart/.gitlab-ci/production/configure.yml new file mode 100644 index 0000000..ce957ff --- /dev/null +++ b/helm-chart/.gitlab-ci/production/configure.yml @@ -0,0 +1,4 @@ +# --- Configure conf files --- +configure_files_production: + extends: + - .helm-configure-production-job \ No newline at end of file diff --git a/helm-chart/.gitlab-ci/production/deploy.yml b/helm-chart/.gitlab-ci/production/deploy.yml new file mode 100644 index 0000000..466e8da --- /dev/null +++ b/helm-chart/.gitlab-ci/production/deploy.yml @@ -0,0 +1,71 @@ + +# --- Deploy each modules --- + +# Admin module to deploy React Admin +# Interactions with modules: api, idp +admin_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: administration + +# Analytics module to deploy Matomo - in charge of tracking website, api & idp data +# Interactions with modules: website (for now) +analytics_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: analytics + +# Antivirus module to deploy Clamav - in charge of analysing files when a user is subscribing to an incentive +# Interactions with modules: api +antivirus_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: antivirus + +# Bus module to deploy RabbitMQ - in charge of handling amqp message interactions between SIRH and MCM +# Interactions with modules: api +bus_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: bus + +# IDP module to deploy Keycloak - in charge of handling user session, token and access of the entire app +# Interactions with modules: website, administration, api +idp_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: idp + +# S3 module to deploy Minio - in charge of storing all users documents for subscription +# Interactions with modules: api +s3_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: s3 + +# Api module to deploy Loopback 4 - in charge of business backend logic +# Interactions with : bus, idp, s3, administration, website, antivirus +api_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: api + script: + - | + echo "Wait for idp for offline token verification" + wait_pod idp + helm_deploy + +# Website module to deploy Gatsby 4 - in charge of business frontend logic +# Interactions with : idp, api +website_helm_deploy_production: + extends: + - .helm-deploy-production-job + variables: + MODULE_NAME: website \ No newline at end of file diff --git a/helm-chart/.gitlab-ci/production/test.yml b/helm-chart/.gitlab-ci/production/test.yml new file mode 100644 index 0000000..6743265 --- /dev/null +++ b/helm-chart/.gitlab-ci/production/test.yml @@ -0,0 +1,12 @@ +.smoke-test-production-base: + variables: + CYPRESS_ADMIN_FQDN: admin.${BASE_DOMAIN} + CYPRESS_IDP_FQDN: idp.${BASE_DOMAIN} + CYPRESS_WEBSITE_FQDN: ${BASE_DOMAIN} + CYPRESS_API_FQDN: api.${BASE_DOMAIN} + +# --- Launch smoke tests --- +smoke_tests_production: + extends: + - .smoke-test-production-base + - .helm-test-production-job diff --git a/helm-chart/.helmignore b/helm-chart/.helmignore new file mode 100644 index 0000000..5313308 --- /dev/null +++ b/helm-chart/.helmignore @@ -0,0 +1,31 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +.gitlab-ci.yml + +# Custom ignore +.gitlab-ci/ +test/ +scripts/ + +Readme.md diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml new file mode 100644 index 0000000..f63c754 --- /dev/null +++ b/helm-chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${HELM_PACKAGE_NAME} +type: application +version: 1.0.0 diff --git a/helm-chart/README.md b/helm-chart/README.md index 10fedf8..27c4750 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -1,9 +1,12 @@ # Description -Dans ce dossier, correspond à la partie "helm" des pipelines et constitue donc la livraison à l'équipe Cloud de notre code afin de le déployer en PPRD & PROD. +Ce dossier correspond à la partie [Helm Chart](https://helm.sh/) des pipelines et constitue donc la livraison à l'équipe OPS de notre code afin de le déployer sur un environnement sécurisé de préproduction et production. -Ainsi, nous leur fournissons la pipeline de déploiement, un package helm par environnement, les images buildées de la dernière version du code de certaines images. +Ainsi, sont fournis aux OPS : +- la pipeline de déploiement +- un package helm par environnement +- les images buildées de la dernière version du code de certaines images -On peut donc retrouver les templates et les fichiers values servant au deploiement Helm. +On peut donc retrouver les templates et les fichiers values servant au déploiement Helm. -Ils sont variabalisés pour correspondondre soit à l'environnement PPRD soit à celui PROD. \ No newline at end of file +Ils sont variabilisés pour correspondre aux environnements PPRD/PROD. diff --git a/helm-chart/administration-values.yaml b/helm-chart/administration-values.yaml new file mode 100644 index 0000000..8201ded --- /dev/null +++ b/helm-chart/administration-values.yaml @@ -0,0 +1,122 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: admin-keycloak-config + data: + keycloak.json: "administration/keycloak.json" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: admin + name: admin + spec: + ports: + - name: "8081" + port: 8081 + targetPort: 8081 + selector: + io.kompose.service: admin + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: admin + name: admin + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: admin + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/web-nw: "true" + io.kompose.service: admin + spec: + containers: + env: + API_FQDN: ${API_FQDN} + IDP_FQDN: ${IDP_FQDN} + MCM_IDP_CLIENTID_ADMIN: ${IDP_MCM_ADMIN_CLIENT_ID} + MCM_IDP_REALM: ${IDP_MCM_REALM} + image: ${ADMIN_IMAGE_NAME} + name: admin + ports: + - containerPort: 8081 + resources: {} + volumeMounts: + - mountPath: /usr/share/nginx/html/keycloak.json + name: keycloak-config + subPath: keycloak.json + imagePullSecrets: + - name: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: admin-keycloak-config + name: keycloak-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: admin + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${ADMIN_FQDN}`) + services: + - kind: Service + name: admin + port: 8081 diff --git a/helm-chart/analytics-values.yaml b/helm-chart/analytics-values.yaml new file mode 100644 index 0000000..ccb7c68 --- /dev/null +++ b/helm-chart/analytics-values.yaml @@ -0,0 +1,151 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: analytics + name: analytics + spec: + ports: + - name: "8082" + port: 8082 + targetPort: 8080 + selector: + io.kompose.service: analytics + type: ClusterIP + status: + loadBalancer: {} + +persistentVolumeClaim: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: matomo-data + name: matomo-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi + status: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: analytics + name: analytics + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: analytics + strategy: + type: Recreate + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/maria-matomo-nw: "true" + io.kompose.network/web-nw: "true" + io.kompose.service: analytics + spec: + containers: + env: + MATOMO_DATABASE_HOST: ${CLOUD_ANALYTICS_DB_HOST} + MATOMO_DATABASE_NAME: ${CLOUD_ANALYTICS_DB_NAME} + MATOMO_DATABASE_PASSWORD: ${CLOUD_ANALYTICS_DB_DEV_PASSWORD} + MATOMO_DATABASE_PORT_NUMBER: ${CLOUD_ANALYTICS_DB_PORT} + MATOMO_DATABASE_USER: ${CLOUD_ANALYTICS_DB_DEV_USER} + MATOMO_EMAIL: ${CLOUD_ANALYTICS_SUPER_EMAIL} + MATOMO_PASSWORD: ${CLOUD_ANALYTICS_SUPER_PASSWORD} + MATOMO_USERNAME: ${CLOUD_ANALYTICS_SUPER_USER} + MATOMO_WEBSITE_HOST: ${WEBSITE_FQDN} + MATOMO_WEBSITE_NAME: ${WEBSITE_FQDN} + image: ${MATOMO_IMAGE_NAME} + name: analytics + ports: + - containerPort: 8080 + resources: {} + volumeMounts: + - mountPath: /bitnami/matomo + name: matomo-data + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - name: matomo-data + persistentVolumeClaim: + claimName: matomo-data + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: analytics + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${MATOMO_FQDN}`) + middlewares: + - name: analytics-headers + services: + - kind: Service + name: analytics + port: 8082 + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: analytics-headers + spec: + headers: + customRequestHeaders: + X-Forwarded-Proto: https diff --git a/helm-chart/antivirus-values.yaml b/helm-chart/antivirus-values.yaml new file mode 100644 index 0000000..0389298 --- /dev/null +++ b/helm-chart/antivirus-values.yaml @@ -0,0 +1,65 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kompose.volume.size: 200Mi + creationTimestamp: null + labels: + io.kompose.service: clamav + name: clamav + spec: + ports: + - name: "3310" + port: 3310 + targetPort: 3310 + selector: + io.kompose.service: clamav + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kompose.volume.size: 200Mi + creationTimestamp: null + labels: + io.kompose.service: clamav + name: clamav + spec: + replicas: 3 + selector: + matchLabels: + io.kompose.service: clamav + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.volume.size: 200Mi + kompose.service.type: clusterip + creationTimestamp: null + labels: + io.kompose.service: clamav + spec: + containers: + image: ${ANTIVIRUS_IMAGE_NAME} + name: clamav + ports: + - containerPort: 3310 + resources: + requests: + memory: 4Gi + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + status: {} \ No newline at end of file diff --git a/helm-chart/api-values.yaml b/helm-chart/api-values.yaml new file mode 100644 index 0000000..a1d424e --- /dev/null +++ b/helm-chart/api-values.yaml @@ -0,0 +1,178 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: api + name: api + spec: + ports: + - name: "3000" + port: 3000 + targetPort: 3000 + selector: + io.kompose.service: api + type: ClusterIP + status: + loadBalancer: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: api + name: api + spec: + replicas: 3 + selector: + matchLabels: + io.kompose.service: api + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/storage-nw: "true" + io.kompose.network/web-nw: "true" + io.kompose.service: api + spec: + containers: + env: + AFFILIATION_JWS_KEY: ${CLOUD_AFFILIATION_JWS_KEY} + API_FQDN: ${API_FQDN} + API_KEY: ${CLOUD_API_KEY} + BUS_HOST: bus.bus-${CLOUD_HELM_DEPLOY_NAMESPACE}.svc.cluster.local + BUS_MCM_HEADERS: ${BUS_MCM_HEADERS} + BUS_MCM_MESSAGE_TYPE: ${BUS_MCM_MESSAGE_TYPE} + BUS_CONSUMER_QUEUE: mob.subscriptions.status + BUS_MCM_CONSUME_PASSWORD: ${CLOUD_BUS_MCM_CONSUME_PASSWORD} + BUS_MCM_CONSUME_USER: ${CLOUD_BUS_MCM_CONSUME_USER} + CLIENT_SECRET_KEY_KEYCLOAK_API: ${CLOUD_IDP_API_CLIENT_SECRET} + CLAMAV_HOST: clamav.antivirus-${CLOUD_HELM_DEPLOY_NAMESPACE}.svc.cluster.local + CLAMAV_PORT: "3310" + IDP_FQDN: ${IDP_FQDN} + IDP_DB_HOST: ${CLOUD_PGSQL_FLEX_ADDRESS} + IDP_DB_PORT: ${CLOUD_PGSQL_DB_PORT} + IDP_DB_AUTH_SOURCE: ${CLOUD_PGSQL_NAME} + IDP_DB_DATABASE: ${CLOUD_PGSQL_NAME} + IDP_DB_SERVICE_USER: ${CLOUD_PGSQL_SERVICE_USER} + IDP_DB_SERVICE_PASSWORD: ${CLOUD_PGSQL_SERVICE_PASSWORD} + LANDSCAPE: ${LANDSCAPE} + BASE_DOMAIN: ${BASE_DOMAIN} + MONGO_AUTH_SOURCE: ${CLOUD_MONGO_AUTH_SOURCE} + MONGO_HOST: ${CLOUD_MONGO_HOST} + MONGO_PORT: ${CLOUD_MONGO_PORT} + MONGO_SERVICE_USER: ${CLOUD_MONGO_SERVICE_USER} + MONGO_SERVICE_PASSWORD: ${CLOUD_MONGO_SERVICE_PASSWORD} + MONGO_DATABASE: ${CLOUD_MONGO_DB_NAME} + S3_HOST: s3.s3-${CLOUD_HELM_DEPLOY_NAMESPACE}.svc.cluster.local + S3_PORT: "9000" + S3_SERVICE_PASSWORD: ${CLOUD_S3_SERVICE_PASSWORD} + S3_SERVICE_USER: ${CLOUD_S3_SERVICE_USER} + SENDGRID_API_KEY: ${CLOUD_SENDGRID_API_KEY} + SENDGRID_EMAIL_CONTACT: ${CLOUD_SENDGRID_EMAIL_CONTACT} + SENDGRID_EMAIL_FROM: ${CLOUD_SENDGRID_EMAIL_FROM} + SENDGRID_HOST: ${CLOUD_SENDGRID_HOST} + SENDGRID_USER: ${CLOUD_SENDGRID_USER} + WEBSITE_FQDN: ${WEBSITE_FQDN} + PGSQL_FLEX_SSL_CERT: ${CLOUD_PGSQL_FLEX_SSL_CERT} + image: ${API_IMAGE_NAME} + name: api + ports: + - containerPort: 3000 + resources: {} + imagePullSecrets: + - name: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + status: {} + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: api-headers-middleware + spec: + headers: + customRequestHeaders: + X-Forwarded-Port: "443" + X-Forwarded-Proto: https + + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: api-inflightreq-middleware + spec: + inFlightReq: + amount: 100 + + # - metadata: + # annotations: + # app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + # app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + # kubernetes.io/ingress.class: traefik + # name: api-ratelimit-middleware + # spec: + # rateLimit: + # average: 30 + # burst: 50 + # period: 1s + # sourceCriterion: + # ipStrategy: + # depth: 2 + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: api + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${API_FQDN}`) + middlewares: + - name: api-headers-middleware + # - name: api-ratelimit-middleware + - name: api-inflightreq-middleware + services: + - kind: Service + name: api + port: 3000 diff --git a/helm-chart/bus-values.yaml b/helm-chart/bus-values.yaml new file mode 100644 index 0000000..eacd982 --- /dev/null +++ b/helm-chart/bus-values.yaml @@ -0,0 +1,164 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: bus-custom-conf + data: + custom.conf: "bus/custom.conf" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: bus + name: bus + spec: + ports: + - name: "5672" + port: 5672 + targetPort: 5672 + - name: "15672" + port: 15672 + targetPort: 15672 + selector: + io.kompose.service: bus + type: ClusterIP + status: + loadBalancer: {} + +headlessService: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: bus + name: bus-headless + spec: + clusterIP: None + ports: + - name: "5672" + port: 5672 + targetPort: 5672 + - name: "15672" + port: 15672 + targetPort: 15672 + publishNotReadyAddresses: true + selector: + io.kompose.service: bus + sessionAffinity: None + type: ClusterIP + +statefulSet: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: bus + name: bus + spec: + podManagementPolicy: OrderedReady + replicas: 2 + selector: + matchLabels: + io.kompose.service: bus + updateStrategy: + type: RollingUpdate + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: bus + spec: + containers: + env: + IDP_FQDN: ${IDP_FQDN} + MCM_IDP_REALM: ${IDP_MCM_REALM} + RABBITMQ_ERL_COOKIE: KxAzS-=mFrXvRU9m + RABBITMQ_PASSWORD: ${CLOUD_BUS_ADMIN_PASSWORD} + addressType: hostname + RABBITMQ_PLUGINS: rabbitmq_management,rabbitmq_auth_backend_oauth2,rabbitmq_prometheus,rabbitmq_peer_discovery_k8s + RABBITMQ_USERNAME: ${CLOUD_BUS_ADMIN_USER} + K8S_SERVICE_NAME: bus + RABBITMQ_NODE_NAME: rabbit@$(MY_POD_NAME).bus.$(MY_POD_NAMESPACE).svc.cluster.local + K8S_HOSTNAME_SUFFIX: .bus.$(MY_POD_NAMESPACE).svc.cluster.local + RABBITMQ_USE_LONGNAME: true + RABBITMQ_ULIMIT_NOFILES: 65536 + RABBITMQ_DISK_FREE_ABSOLUTE_LIMIT: ${CLOUD_RABBITMQ_DISK_LIMIT} + RABBITMQ_MNESIA_DIR: /bitnami/rabbitmq/mnesia/bus-def + K8S_ADDRESS_TYPE: hostname + RABBITMQ_FORCE_BOOT: yes + image: ${BUS_IMAGE_NAME} + name: bus + ports: + - containerPort: 5672 + - containerPort: 15672 + resources: {} + volumeMounts: + - name: bus-config + mountPath: /bitnami/rabbitmq/conf/custom.conf + subPath: custom.conf + - name: bus-data + mountPath: /bitnami/rabbitmq/mnesia + securityContext: + runAsUser: 1001 + runAsNonRoot: true + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + terminationGracePeriodSeconds: 10 + securityContext: + fsGroup: 1001 + restartPolicy: Always + volumes: + - name: bus-config + configMap: + name: bus-custom-conf + volumeClaimTemplates: + - metadata: + name: bus-data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: "azurefile-${TRUNCATED_LANDSCAPE}" + resources: + requests: + storage: ${CLOUD_RABBITMQ_STORAGE} + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: bus + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${ADMIN_BUS_FQDN}`) + services: + - kind: Service + name: bus + port: 15672 diff --git a/helm-chart/idp-values.yaml b/helm-chart/idp-values.yaml new file mode 100644 index 0000000..cee7adc --- /dev/null +++ b/helm-chart/idp-values.yaml @@ -0,0 +1,181 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: master-realm-config + data: + master-realm.json: "idp/master-realm.json" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: idp + name: idp + spec: + ports: + - name: "8080" + port: 8080 + targetPort: 8080 + selector: + io.kompose.service: idp + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: idp + name: idp + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: idp + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/web-nw: "true" + io.kompose.service: idp + spec: + containers: + args: + - -b + - 0.0.0.0 + - -Djboss.modules.system.pkgs=org.jboss.logmanager + - -Dkeycloak.migration.action=import + - -Dkeycloak.migration.provider=singleFile + - -Dkeycloak.migration.file=/tmp/master-realm.json + - -Dkeycloak.migration.strategy=IGNORE_EXISTING + env: + KEYCLOAK_USER: ${CLOUD_IDP_ADMIN_USER} + KEYCLOAK_PASSWORD: ${CLOUD_IDP_ADMIN_PASSWORD} + PROXY_ADDRESS_FORWARDING: "true" + WEBSITE_FQDN: ${WEBSITE_FQDN} + API_FQDN: ${API_FQDN} + DB_SCHEMA: ${CLOUD_PGSQL_NAME} + DB_ADDR: ${CLOUD_PGSQL_FLEX_ADDRESS} + DB_PORT: ${CLOUD_PGSQL_DB_PORT} + DB_VENDOR: postgres + DB_DATABASE: ${CLOUD_PGSQL_NAME} + DB_USER: ${CLOUD_IDP_DEV_USER} + DB_PASSWORD: ${CLOUD_IDP_DEV_PASSWORD} + LANDSCAPE: ${LANDSCAPE} + BASE_DOMAIN: ${BASE_DOMAIN} + MATOMO_FQDN: ${MATOMO_FQDN} + image: ${KEYCLOAK_IMAGE_NAME} + name: idp + ports: + - containerPort: 8080 + resources: {} + volumeMounts: + - mountPath: /tmp + name: realm-config + imagePullSecrets: + - name: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: master-realm-config + name: realm-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: idp + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${IDP_FQDN}`) + middlewares: + - name: idp-headers-middleware + # - name: idp-ratelimit-middleware + - name: idp-inflightreq-middleware + services: + - name: idp + port: 8080 + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: idp-headers-middleware + spec: + headers: + customRequestHeaders: + X-Forwarded-Port: "443" + X-Forwarded-Proto: https + + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: idp-inflightreq-middleware + spec: + inFlightReq: + amount: 100 + + # - metadata: + # annotations: + # app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + # app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + # kubernetes.io/ingress.class: traefik + # name: idp-ratelimit-middleware + # spec: + # rateLimit: + # average: 30 + # burst: 50 + # period: 1s + # sourceCriterion: + # ipStrategy: + # depth: 3 diff --git a/helm-chart/s3-values.yaml b/helm-chart/s3-values.yaml new file mode 100644 index 0000000..837f2ca --- /dev/null +++ b/helm-chart/s3-values.yaml @@ -0,0 +1,128 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + creationTimestamp: null + labels: + io.kompose.service: s3 + name: s3 + spec: + ports: + - name: "9001" + port: 9001 + targetPort: 9001 + - name: "9000" + port: 9000 + targetPort: 9000 + selector: + io.kompose.service: s3 + type: ClusterIP + status: + loadBalancer: {} + +headlessService: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + creationTimestamp: null + labels: + io.kompose.service: s3 + name: s3-headless + spec: + clusterIP: None + ports: + - name: "9001" + protocol: TCP + port: 9001 + targetPort: 9001 + - name: "9000" + protocol: TCP + port: 9000 + targetPort: 9000 + publishNotReadyAddresses: true + selector: + io.kompose.service: s3 + sessionAffinity: None + type: ClusterIP + +statefulSet: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + labels: + io.kompose.service: s3 + name: s3 + spec: + podManagementPolicy: OrderedReady + selector: + matchLabels: + io.kompose.service: s3 + updateStrategy: + type: RollingUpdate + replicas: 4 + serviceName: s3 + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + labels: + io.kompose.service: s3 + spec: + containers: + name: s3 + env: + MINIO_ROOT_PASSWORD: ${CLOUD_S3_ROOT_PASSWORD} + MINIO_ROOT_USER: ${CLOUD_S3_ROOT_USER} + MINIO_DISTRIBUTED_MODE_ENABLED: "yes" + MINIO_DISTRIBUTED_NODES: s3-{0...3} + image: ${MINIO_IMAGE_NAME} + args: + - server + - http://s3-{0...3}.s3.$(MY_POD_NAMESPACE).svc.cluster.local/data{1...2} + - --console-address + - :9001 + ports: + - containerPort: 9001 + - containerPort: 9000 + # These volume mounts are persistent. Each pod in the StatefulSet + # gets a volume mounted based on this field. + volumeMounts: + - name: data1 + mountPath: /data1 + - name: data2 + mountPath: /data2 + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + # These are converted to volume claims by the controller + # and mounted at the paths mentioned above. + volumeClaimTemplates: + - metadata: + name: data1 + spec: + accessModes: + - ReadWriteOnce + storageClassName: "azurefile-${TRUNCATED_LANDSCAPE}-s3" + resources: + requests: + storage: ${CLOUD_S3_STORAGE} + - metadata: + name: data2 + spec: + accessModes: + - ReadWriteOnce + storageClassName: "azurefile-${TRUNCATED_LANDSCAPE}-s3" + resources: + requests: + storage: ${CLOUD_S3_STORAGE} \ No newline at end of file diff --git a/helm-chart/scripts/mariadb/createMatomoUser.sql b/helm-chart/scripts/mariadb/createMatomoUser.sql new file mode 100644 index 0000000..d7aa6e9 --- /dev/null +++ b/helm-chart/scripts/mariadb/createMatomoUser.sql @@ -0,0 +1,9 @@ +/* + * CREATE ROOT USER + * REPLACE VARIABLES WITH ONE FROM GITLAB CI + * /!\ DB PASSWORD MUST NOT CONTAIN SPECIAL CHAR + */ + +CREATE USER ${ENV_ANALYTICS_DB_DEV_USER} IDENTIFIED BY '${ENV_ANALYTICS_DB_DEV_PASSWORD}'; +GRANT ALL PRIVILEGES ON ${ENV_ANALYTICS_DB_NAME}.* TO ${ENV_ANALYTICS_DB_DEV_USER}; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/helm-chart/scripts/mariadb/initDB.sql b/helm-chart/scripts/mariadb/initDB.sql new file mode 100644 index 0000000..14bb3c2 --- /dev/null +++ b/helm-chart/scripts/mariadb/initDB.sql @@ -0,0 +1,6 @@ +/* + * INIT MARIADB BY CREATING DB + * REPLACE VARIABLES WITH ONE FROM GITLAB CI + */ + +CREATE DATABASE ${ENV_ANALYTICS_DB_NAME}; \ No newline at end of file diff --git a/helm-chart/scripts/mongodb/createServiceUser.js b/helm-chart/scripts/mongodb/createServiceUser.js new file mode 100644 index 0000000..00d51ce --- /dev/null +++ b/helm-chart/scripts/mongodb/createServiceUser.js @@ -0,0 +1,16 @@ +/* + * CREATE MONGODB SERVICE USER WITH ACCESS READ/WRITE ON DEDICATED DB + * REPLACE VARIABLES WITH ONE FROM GITLAB CI + */ + +const username = _getEnv('ENV_MONGO_SERVICE_USER'); +const password = _getEnv('ENV_MONGO_SERVICE_PASSWORD'); +const dbname = _getEnv('ENV_MONGO_DB_NAME'); + +db.createUser( + { + user: username, + pwd: password, + roles: [{ role: "readWrite", db: dbname }] + } +); \ No newline at end of file diff --git a/helm-chart/scripts/pgsql/createIdpUser.sh b/helm-chart/scripts/pgsql/createIdpUser.sh new file mode 100644 index 0000000..3a691ff --- /dev/null +++ b/helm-chart/scripts/pgsql/createIdpUser.sh @@ -0,0 +1,10 @@ +# CREATE IDP USER FOR KEYCLOAK WITH ADMIN ON SCHEMA +# REPLACE VARIABLES WITH ONE FROM GITLAB CI + +set -e + +psql -v ON_ERROR_STOP=1 --username "$ENV_PGSQL_ROOT_USER" --dbname "$ENV_PGSQL_ROOT_DB" <<-EOSQL + CREATE USER $ENV_IDP_DEV_USER WITH PASSWORD '$ENV_IDP_DEV_PASSWORD'; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA $ENV_PGSQL_SCHEMA TO $ENV_IDP_DEV_USER; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA $ENV_PGSQL_SCHEMA TO $ENV_IDP_DEV_USER; +EOSQL \ No newline at end of file diff --git a/helm-chart/scripts/pgsql/createReadAccessRole.sh b/helm-chart/scripts/pgsql/createReadAccessRole.sh new file mode 100644 index 0000000..1959d4c --- /dev/null +++ b/helm-chart/scripts/pgsql/createReadAccessRole.sh @@ -0,0 +1,13 @@ +# CREATE READ ACCESS ROLE ON SCHEMA +# REPLACE VARIABLES WITH ONE FROM GITLAB CI + +set -e + +psql -v ON_ERROR_STOP=1 --username "$ENV_PGSQL_ROOT_USER" --dbname "$ENV_PGSQL_ROOT_DB" <<-EOSQL + CREATE ROLE readaccess; + GRANT CONNECT ON DATABASE $ENV_PGSQL_SCHEMA TO readaccess; + GRANT USAGE ON SCHEMA $ENV_PGSQL_SCHEMA TO readaccess; + GRANT SELECT ON ALL TABLES IN SCHEMA $ENV_PGSQL_SCHEMA TO readaccess; + GRANT SELECT ON ALL SEQUENCES IN SCHEMA $ENV_PGSQL_SCHEMA TO readaccess; + ALTER DEFAULT PRIVILEGES IN SCHEMA $ENV_PGSQL_SCHEMA GRANT SELECT ON TABLES TO readaccess; +EOSQL \ No newline at end of file diff --git a/helm-chart/scripts/pgsql/createServiceUser.sh b/helm-chart/scripts/pgsql/createServiceUser.sh new file mode 100644 index 0000000..4b2b36e --- /dev/null +++ b/helm-chart/scripts/pgsql/createServiceUser.sh @@ -0,0 +1,9 @@ +# CREATE SERVICE USER WITH READ ACCESS RIGHT ON SCHEMA +# REPLACE VARIABLES WITH ONE FROM GITLAB CI + +set -e + +psql -v ON_ERROR_STOP=1 --username "$ENV_PGSQL_ROOT_USER" --dbname "$ENV_PGSQL_ROOT_DB" <<-EOSQL + CREATE USER $ENV_PGSQL_SERVICE_USER WITH PASSWORD '$ENV_PGSQL_SERVICE_PASSWORD'; + GRANT readaccess TO $ENV_PGSQL_SERVICE_USER; +EOSQL \ No newline at end of file diff --git a/helm-chart/scripts/pgsql/initDB.sh b/helm-chart/scripts/pgsql/initDB.sh new file mode 100644 index 0000000..9554a79 --- /dev/null +++ b/helm-chart/scripts/pgsql/initDB.sh @@ -0,0 +1,8 @@ +# INIT PGSQL BY ALTERING PUBLIC SCHEMA +# REPLACE VARIABLES WITH ONE FROM GITLAB CI + +set -e + +psql -v ON_ERROR_STOP=1 --username "$ENV_PGSQL_ROOT_USER" --dbname "$ENV_PGSQL_ROOT_DB" <<-EOSQL + ALTER SCHEMA public RENAME TO $ENV_PGSQL_SCHEMA; +EOSQL \ No newline at end of file diff --git a/helm-chart/simulation-maas-values.yaml b/helm-chart/simulation-maas-values.yaml new file mode 100644 index 0000000..90e165c --- /dev/null +++ b/helm-chart/simulation-maas-values.yaml @@ -0,0 +1,121 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: simulation-maas-config + data: + config.json: "simulation-maas/config.json" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: simulation-maas + name: simulation-maas + spec: + ports: + - name: "8888" + port: 8888 + targetPort: 8888 + selector: + io.kompose.service: simulation-maas + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: simulation-maas + name: simulation-maas + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: simulation-maas + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/web-nw: "true" + io.kompose.service: simulation-maas + spec: + containers: + env: + API_FQDN: ${API_FQDN} + IDP_FQDN: ${IDP_FQDN} + MCM_IDP_CLIENTID_MAAS: simulation-maas + MCM_IDP_REALM: ${IDP_MCM_REALM} + image: ${SIMULATION_MAAS_IMAGE_NAME} + name: simulation-maas + ports: + - containerPort: 8888 + resources: {} + volumeMounts: + - mountPath: /usr/share/nginx/html/static/config.json + name: maas-config + subPath: config.json + imagePullSecrets: + - name: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: simulation-maas-config + name: maas-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: simulation-maas + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${SIMULATION_MAAS_FQDN}`) + services: + - name: simulation-maas + port: 8888 diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..62acb56 --- /dev/null +++ b/helm-chart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "plateform.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "plateform.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "plateform.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "plateform.labels" -}} +helm.sh/chart: {{ include "plateform.chart" . }} +{{ include "plateform.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "plateform.selectorLabels" -}} +app.kubernetes.io/name: {{ include "plateform.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "plateform.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "plateform.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/helm-chart/templates/configmap.yaml b/helm-chart/templates/configmap.yaml new file mode 100644 index 0000000..1f9627d --- /dev/null +++ b/helm-chart/templates/configmap.yaml @@ -0,0 +1,16 @@ +{{- range .Values.configMaps }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +data: + {{- range $key, $val := .data }} + {{ $key }}: | + {{ $.Files.Get $val | nindent 4}} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml new file mode 100644 index 0000000..0ef2b75 --- /dev/null +++ b/helm-chart/templates/deployment.yaml @@ -0,0 +1,92 @@ +{{- range .Values.deployments }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + name: {{ .metadata.name }} +spec: + replicas: {{ .spec.replicas }} + selector: + matchLabels: + {{- with .spec.selector.matchLabels }} + {{- toYaml . | nindent 6 }} + {{- end }} + strategy: + {{- with .spec.strategy }} + {{- toYaml . | nindent 4 }} + {{- end }} + template: + metadata: + annotations: + {{- with .spec.template.metadata.annotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- with .spec.template.metadata.labels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + containers: + - args: + {{ if (.spec.template.spec.containers.args) }} + {{- range $k, $v := .spec.template.spec.containers.args }} + - {{ $v | quote -}} + {{ end }} + {{- end}} + env: + {{- range $k, $v := .spec.template.spec.containers.env }} + - name: {{ $k }} + value: {{ $v | quote -}} + {{ end }} + image: {{ .spec.template.spec.containers.image }} + {{ if (.spec.template.spec.containers.command) }} + command: + {{- range $k, $v := .spec.template.spec.containers.command }} + - {{ $v | quote -}} + {{ end }} + {{- end }} + name: {{ .spec.template.spec.containers.name }} + ports: + {{- with .spec.template.spec.containers.ports }} + {{- toYaml . | nindent 8 }} + {{- end }} + resources: + {{- with .spec.template.spec.containers.resources }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{ if (.spec.template.spec.containers.volumeMounts) }} + volumeMounts: + {{- with .spec.template.spec.containers.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{ end }} + {{- end }} + imagePullSecrets: + {{- with .spec.template.spec.imagePullSecrets }} + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .spec.template.spec.restartPolicy }} + {{ if (.spec.template.spec.securityContext) }} + securityContext: + {{- with .spec.template.spec.securityContext }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{ if (.spec.template.spec.volumes) }} + volumes: + {{- with .spec.template.spec.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} +status: +{{- with .status }} +{{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/helm-chart/templates/headlessService.yaml b/helm-chart/templates/headlessService.yaml new file mode 100644 index 0000000..b68c70f --- /dev/null +++ b/helm-chart/templates/headlessService.yaml @@ -0,0 +1,20 @@ +{{- range .Values.headlessService }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .spec }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} + diff --git a/helm-chart/templates/ingressroutes.yaml b/helm-chart/templates/ingressroutes.yaml new file mode 100644 index 0000000..aed1c46 --- /dev/null +++ b/helm-chart/templates/ingressroutes.yaml @@ -0,0 +1,20 @@ +{{- range .Values.ingressRoutes }} +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + entryPoints: + {{- with .spec.entryPoints }} + {{- toYaml . | nindent 3 }} + {{- end }} + routes: + {{- with .spec.routes }} + {{- toYaml . | nindent 3 }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/job.yaml b/helm-chart/templates/job.yaml new file mode 100644 index 0000000..298a3f5 --- /dev/null +++ b/helm-chart/templates/job.yaml @@ -0,0 +1,43 @@ +{{- range .Values.jobs }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .metadata.name }}-job + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + # This is what defines this resource as a hook. Without this line, the + # job is considered part of the release. + "helm.sh/hook": post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + metadata: + name: {{ .metadata.name }}-job + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + restartPolicy: Never + containers: + - name: {{ .metadata.name }}-job + image: {{ .spec.template.spec.containers.image }} + {{ if (.spec.template.spec.containers.command) }} + command: + {{- range $k, $v := .spec.template.spec.containers.command }} + - {{ $v | quote -}} + {{ end }} + {{- end }} + imagePullSecrets: + {{- with .spec.template.spec.imagePullSecrets }} + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/middleware.yaml b/helm-chart/templates/middleware.yaml new file mode 100644 index 0000000..b8065e1 --- /dev/null +++ b/helm-chart/templates/middleware.yaml @@ -0,0 +1,16 @@ +{{- range .Values.middlewares }} +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .spec }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} + diff --git a/helm-chart/templates/networkpolicies.yaml b/helm-chart/templates/networkpolicies.yaml new file mode 100644 index 0000000..4b3459f --- /dev/null +++ b/helm-chart/templates/networkpolicies.yaml @@ -0,0 +1,15 @@ +{{- range .Values.networkPolicies }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .spec }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/persistentVolumeClaim.yaml b/helm-chart/templates/persistentVolumeClaim.yaml new file mode 100644 index 0000000..9c58e02 --- /dev/null +++ b/helm-chart/templates/persistentVolumeClaim.yaml @@ -0,0 +1,23 @@ +{{- range .Values.persistentVolumeClaim }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .spec }} + {{- toYaml . | nindent 4 }} + {{- end }} +status: + {{- with .status }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/pod.yaml b/helm-chart/templates/pod.yaml new file mode 100644 index 0000000..a7f2505 --- /dev/null +++ b/helm-chart/templates/pod.yaml @@ -0,0 +1,44 @@ +{{- range .Values.pods }} +--- +apiVersion: apps/v1 +kind: Pod +metadata: + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + name: {{ .metadata.name }} +spec: + containers: + {{ if (.spec.containers.command) }} + command: + {{- range $k, $v := .spec.containers.command }} + - {{ $v | quote -}} + {{ end }} + {{- end }} + image: {{ .spec.containers.image }} + name: {{ .spec.containers.name }} + {{- with .spec.containers.resources }} + resources: + {{- toYaml . | nindent 4 }} + {{- end }} + imagePullSecrets: + {{- with .spec.imagePullSecrets }} + {{- toYaml . | nindent 4 }} + {{- end }} + restartPolicy: {{ .spec.restartPolicy }} + {{ if (.spec.volumes) }} + volumes: + {{- with .spec.volumes }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +status: +{{- with .status }} +{{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/helm-chart/templates/services.yaml b/helm-chart/templates/services.yaml new file mode 100644 index 0000000..d0d30db --- /dev/null +++ b/helm-chart/templates/services.yaml @@ -0,0 +1,23 @@ +{{- range .Values.services }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .metadata.name }} + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .spec }} + {{- toYaml . | nindent 4 }} + {{- end }} +status: + {{- with .status }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/statefulSet.yaml b/helm-chart/templates/statefulSet.yaml new file mode 100644 index 0000000..0e1c434 --- /dev/null +++ b/helm-chart/templates/statefulSet.yaml @@ -0,0 +1,114 @@ +{{- range .Values.statefulSet }} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: + {{- with .metadata.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- with .metadata.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + name: {{ .metadata.name }} +spec: + podManagementPolicy: {{ .spec.podManagementPolicy }} + replicas: {{ .spec.replicas }} + selector: + matchLabels: + {{- with .spec.selector.matchLabels }} + {{- toYaml . | nindent 6 }} + {{- end }} + {{- if .spec.updateStrategy }} + updateStrategy: + {{- with .spec.updateStrategy }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + serviceName: {{ .metadata.name }} + template: + metadata: + annotations: + {{- with .spec.template.metadata.annotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- with .spec.template.metadata.labels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + containers: + - env: + - name: MY_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: MY_POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- range $k, $v := .spec.template.spec.containers.env }} + - name: {{ $k }} + value: {{ $v | quote -}} + {{- end }} + {{ if (.spec.template.spec.containers.args) }} + args: + {{- range $k, $v := .spec.template.spec.containers.args }} + - {{ $v | quote -}} + {{- end }} + {{- end}} + name: {{ .spec.template.spec.containers.name }} + image: {{ .spec.template.spec.containers.image }} + {{ if (.spec.template.spec.containers.command) }} + command: + {{- range $k, $v := .spec.template.spec.containers.command }} + - {{ $v | quote -}} + {{- end }} + {{- end }} + ports: + {{- with .spec.template.spec.containers.ports }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{ if (.spec.template.spec.containers.volumeMounts) }} + volumeMounts: + {{- with .spec.template.spec.containers.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{ if (.spec.template.spec.containers.securityContext) }} + securityContext: + {{- with .spec.template.spec.containers.securityContext }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{ if (.spec.template.spec.containers.terminationGracePeriodSeconds) }} + terminationGracePeriodSeconds: {{.spec.template.spec.terminationGracePeriodSeconds }} + {{- end }} + {{ if (.spec.template.spec.containers.securityContext) }} + securityContext: + {{- with .spec.template.spec.securityContext }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{ if (.spec.template.spec.imagePullSecrets) }} + imagePullSecrets: + {{- with .spec.template.spec.imagePullSecrets }} + {{- toYaml . | nindent 6 }} + {{- end }} + {{- end }} + {{ if (.spec.template.spec.volumes) }} + {{- range $k, $v := .spec.template.spec.volumes }} + volumes: + - name: {{ $v.name }} + {{ if ($v.configMap) }} + configMap: + name: {{ $v.configMap.name }} + {{- end}} + {{- end }} + {{- end }} + volumeClaimTemplates: + {{- with .spec.volumeClaimTemplates }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm-chart/test/cypress-smoke.json b/helm-chart/test/cypress-smoke.json new file mode 100644 index 0000000..b43f4df --- /dev/null +++ b/helm-chart/test/cypress-smoke.json @@ -0,0 +1,16 @@ +{ + "integrationFolder": "./cypress/integration/smoke-tests/test-mcm/", + "screenshotsFolder": "./cypress/integration/smoke-tests/screenshots/", + "screenshotOnRunFailure": true, + "videosFolder": "./cypress/integration/smoke-tests/videos", + "supportFile": "./cypress/support/index.js", + "pluginsFile": "./cypress/plugins/index.js", + "failOnStatusCode": false, + "chromeWebSecurity": false, + "redirectionLimit": 20, + "retries": { + "runMode": 5, + "openMode": 2 + }, + "reporter": "mochawesome" +} \ No newline at end of file diff --git a/helm-chart/test/cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js b/helm-chart/test/cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js new file mode 100644 index 0000000..93fd260 --- /dev/null +++ b/helm-chart/test/cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js @@ -0,0 +1,32 @@ +describe("Smoke Test MOB", () => { + context("Test des différentes urls du landscape master", () => { + it("Test sur l'url IDP", () => { + cy.assertApplicationIsLoaded("IDP_FQDN", '[class="welcome-header"]').then( + () => { + cy.checkRequest("IDP_FQDN"); + } + ); + }); + it("Test sur l'url API", () => { + cy.assertApplicationIsLoaded("API_FQDN", '[class="info"]').then(() => { + cy.checkRequest("API_FQDN"); + }); + }); + it("Test sur l'url Website", () => { + cy.assertApplicationIsLoaded( + "WEBSITE_FQDN", + '[class="mcm-hero__actions"]' + ).then(() => { + cy.justVisit("WEBSITE_FQDN"); + }); + }); + it("Test sur l'url Admin", () => { + cy.assertApplicationIsLoaded( + "ADMIN_FQDN", + '[class="login-pf-header"]' + ).then(() => { + cy.checkRequest("ADMIN_FQDN"); + }); + }); + }); +}); diff --git a/helm-chart/test/cypress/plugins/index.js b/helm-chart/test/cypress/plugins/index.js new file mode 100644 index 0000000..38dfaff --- /dev/null +++ b/helm-chart/test/cypress/plugins/index.js @@ -0,0 +1,20 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars + +module.exports = (on, config) => {} \ No newline at end of file diff --git a/helm-chart/test/cypress/support/commands.js b/helm-chart/test/cypress/support/commands.js new file mode 100644 index 0000000..7be4eea --- /dev/null +++ b/helm-chart/test/cypress/support/commands.js @@ -0,0 +1,44 @@ +/* +function checkRequest : +Cypress Command, sends a GET request, then checks response status is equal to 200. +Params : + - app : idp / admin / website etc... + - landscape : master, [branch name], etc.. + - ENVIRONNEMENT : prod, préprod, testing, etc... +*/ +Cypress.Commands.add("checkRequest", (FQDN) => { + cy.request({ + method: "GET", + url: `https://${Cypress.env(FQDN)}`, + failOnStatusCode: false, + redirectionLimit: 20, + }).should((response) => { + expect(response.status).to.eq(200); + }); +}); + +/* +fonction assertApplicationIsLoaded : + * cy.visit() : goes to mentionned URL. + * cy.get(assertionWitness).should("be.visible") : searchs for an element of the page called assertionWitness, and check it's available +the function asserts that the app is loaded, while checking the homepage is available. +*/ +Cypress.Commands.add("assertApplicationIsLoaded", (FQDN, assertionWitness) => { + cy.visit(`https://${Cypress.env(FQDN)}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }); + cy.get(assertionWitness, { timeout: 5000 }).should("be.visible"); +}); + +/* +fonction justVisit : + * cy.visit() : goes to mentionned URL. +the function asserts that the app is loaded, while checking the homepage is available. +*/ +Cypress.Commands.add("justVisit", (FQDN) => { + cy.visit(`https://${Cypress.env(FQDN)}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }); +}); diff --git a/helm-chart/test/cypress/support/index.js b/helm-chart/test/cypress/support/index.js new file mode 100644 index 0000000..d076cec --- /dev/null +++ b/helm-chart/test/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import "./commands"; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/helm-chart/test/package.json b/helm-chart/test/package.json new file mode 100644 index 0000000..924e4ab --- /dev/null +++ b/helm-chart/test/package.json @@ -0,0 +1,20 @@ +{ + "name": "cypress-mcm", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "cypress: open": "./node_modules/.bin/cypress open", + "cypress: run": "./node_modules/.bin/cypress run --spec ** / *. spec.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@faker-js/faker": "6.2.0", + "cypress": "^9.5.0", + "random-email": "^1.0.3", + "mochawesome": "^7.1.3" + } +} diff --git a/helm-chart/website-values.yaml b/helm-chart/website-values.yaml new file mode 100644 index 0000000..9e60f70 --- /dev/null +++ b/helm-chart/website-values.yaml @@ -0,0 +1,175 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: website-keycloak-config + data: + keycloak.json: "website/keycloak.json" + + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: website-matomo-config + data: + analytics.json: "website/analytics.json" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: website + name: website + spec: + ports: + - name: "80" + port: 80 + targetPort: 80 + selector: + io.kompose.service: website + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: website + name: website + spec: + replicas: 3 + selector: + matchLabels: + io.kompose.service: website + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/storage-nw: "true" + io.kompose.network/web-nw: "true" + io.kompose.service: website + spec: + containers: + env: + IDP_FQDN: ${IDP_FQDN} + MATOMO_FQDN: ${MATOMO_FQDN} + MATOMO_ID: ${ANALYTICS_MCM_WEBSITE_ID} + API_KEY: ${CLOUD_API_KEY} + LANDSCAPE: ${LANDSCAPE} + image: ${WEBSITE_IMAGE_NAME} + name: website + ports: + - containerPort: 80 + resources: {} + volumeMounts: + - mountPath: /usr/share/nginx/html/keycloak.json + name: keycloak-config + subPath: keycloak.json + - mountPath: /usr/share/nginx/html/analytics.json + name: matomo-config + subPath: analytics.json + imagePullSecrets: + - name: ${PROXY_HANDOVER_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: website-keycloak-config + name: keycloak-config + - configMap: + name: website-matomo-config + name: matomo-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + creationTimestamp: null + name: storage-nw + spec: + ingress: + - from: + - podSelector: + matchLabels: + io.kompose.network/storage-nw: "true" + podSelector: + matchLabels: + io.kompose.network/storage-nw: "true" + + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: website + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${WEBSITE_FQDN}`) + services: + - name: website + port: 80 + - kind: Rule + match: Host(`${WEBSITE_FQDN}`) && PathPrefix(`/api/`) + middlewares: + - name: stripprefix + services: + - name: api + namespace: api-${CLOUD_HELM_DEPLOY_NAMESPACE} + port: 3000 + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: stripprefix + spec: + stripPrefix: + prefixes: + - /api diff --git a/idp/.gitignore b/idp/.gitignore new file mode 100644 index 0000000..9a8046d --- /dev/null +++ b/idp/.gitignore @@ -0,0 +1,14 @@ +# Logs +logs +*.log + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env + +# Mac files +.DS_Store + + diff --git a/idp/.gitlab-ci.yml b/idp/.gitlab-ci.yml new file mode 100644 index 0000000..e556d62 --- /dev/null +++ b/idp/.gitlab-ci.yml @@ -0,0 +1,36 @@ +include: + - local: 'idp/.gitlab-ci/preview.yml' + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: 'idp/.gitlab-ci/testing.yml' + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + - local: 'idp/.gitlab-ci/helm.yml' + rules: + - if: $CI_PIPELINE_SOURCE == "trigger" + +.idp-base: + variables: + MODULE_NAME: idp + MODULE_PATH: ${MODULE_NAME} + MCM_IDP_REALM: ${IDP_MCM_REALM} + MCM_GK_CLIENTID: ${MCM_GK_CLIENTID} + MCM_GK_CLIENTSECRET: ${MCM_GK_CLIENTSECRET} + MCM_CMS_ACCESS_ROLE: ${MCM_CMS_ACCESS_ROLE} + MCM_CMS_GITLAB_TOKEN: ${MCM_CMS_GITLAB_TOKEN} + IDP_DATABASE_USER_NAME: ${IDP_DEV_USER} + IDP_DATABASE_USER_PWD: ${IDP_DEV_PASSWORD} + IDP_DATABASE_ROOT_PWD: ${PGSQL_ROOT_PASSWORD} + IDP_DB_SERVICE_USER: ${PGSQL_SERVICE_USER} + IDP_DB_SERVICE_PASSWORD: ${PGSQL_SERVICE_PASSWORD} + BASE_POSTGRES_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/postgres:13.6 + NEXUS_IMAGE_KEYCLOAK: ${NEXUS_DOCKER_REPOSITORY_URL}/jboss/keycloak:16.1.1 + KEYCLOAK_IMAGE_NAME: ${REGISTRY_BASE_NAME}/${MODULE_NAME}:${IMAGE_TAG_NAME} + KEYCLOAK_MAAS_IMAGE_NAME: ${REGISTRY_BASE_NAME}/keycloak-maas:${IMAGE_TAG_NAME} + POSTGRES_IMAGE_NAME: ${REGISTRY_BASE_NAME}/postgres:${IMAGE_TAG_NAME} + only: + changes: + - '*' + - 'commons/**/*' + - 'idp/**/*' + diff --git a/idp/.gitlab-ci/helm.yml b/idp/.gitlab-ci/helm.yml new file mode 100644 index 0000000..fefbdea --- /dev/null +++ b/idp/.gitlab-ci/helm.yml @@ -0,0 +1,5 @@ + +idp_image_push: + extends: + - .helm-push-image-job + - .idp-base \ No newline at end of file diff --git a/idp/.gitlab-ci/preview.yml b/idp/.gitlab-ci/preview.yml new file mode 100644 index 0000000..24f80bd --- /dev/null +++ b/idp/.gitlab-ci/preview.yml @@ -0,0 +1,46 @@ +idp_image_build: + extends: + - .preview-image-job + - .idp-base + - .no-needs + +.idp_deploy_script: &idp_deploy_script | + # SMTP + export MAIL_HOST=mailhog.mailhog-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local + export EMAIL_FROM_KC=${MAILHOG_EMAIL_FROM_KC} + export MAIL_PORT=1025 + export SMTP_AUTH=false + + # CONCATENATE REALMS INTO ONE + jq -s '.' overlays/realms/*-realm.json > overlays/realms/all-realm.json + + ALL_REALM_PATH=overlays/realms/all-realm.json + + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < ${ALL_REALM_PATH} > ${ALL_REALM_PATH}.tmp && mv ${ALL_REALM_PATH}.tmp ${ALL_REALM_PATH} + + ### REALM STRATEGY ### + export MIGRATION_STRATEGY_REALM=IGNORE_EXISTING + if [ ${MIGRATION_STRATEGY} == "yes" ] + then + echo "Migration strategy override" + export MIGRATION_STRATEGY_REALM=OVERWRITE_EXISTING + fi + +idp_preview_deploy: + extends: + - .preview-deploy-job + - .idp-base + script: + - *idp_deploy_script + - | + deploy + wait_pod postgres-keycloak + config_volume postgres-keycloak-data + needs: ['idp_image_build', 'commons-kubetools-image'] + environment: + on_stop: idp_preview_cleanup + +idp_preview_cleanup: + extends: + - .commons_preview_cleanup + - .idp-base diff --git a/idp/.gitlab-ci/testing.yml b/idp/.gitlab-ci/testing.yml new file mode 100644 index 0000000..fc2dea2 --- /dev/null +++ b/idp/.gitlab-ci/testing.yml @@ -0,0 +1,10 @@ +idp_testing_image_build: + extends: + - .testing-image-job + - .idp-base + +idp_testing_deploy: + extends: + - .testing-deploy-job + - .idp-base + needs: ["idp_testing_image_build"] diff --git a/idp/Chart.yaml b/idp/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/idp/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/idp/README.md b/idp/README.md index 076d3a2..9317300 100644 --- a/idp/README.md +++ b/idp/README.md @@ -2,97 +2,111 @@ Le service idp se base sur la brique logicielle **[Keycloak](https://www.keycloak.org/docs/16.1/)** -Elle nous permet de gérer l'identité ainsi que de procéder au management des accès aux différentes ressources des utilisateurs. +Elle permet de gérer les identités ainsi que de procéder au management des accès aux différentes ressources des utilisateurs. -Nous avons ainsi plusieurs types d'utilisateurs +Nous avons ainsi plusieurs types d'utilisateurs : - Citoyens - Administrateur fonctionnel - Utilisateurs Financeurs - Comptes de services des clients confidentiels -(Voir relation avec les autres services) +Un template MCM a aussi été créé. # Installation en local ## Postgresql -`docker run -d --name postgres-mcm -p 5432:5432 -e POSTGRES_ROOT_PASSWORD=${ROOT_PASSWORD} -e POSTGRES_DB=idp_db -e POSTGRES_USER=${DB_USER} -e POSTGRES_PASSWORD=${DB_PASSWORD} -d postgres:13.6` - -Exectuer une commande pour modifier le schema public - -`docker exec -it postgres-mcm psql -U admin -a idp_db -c 'ALTER SCHEMA public RENAME TO idp_db;'` +```sh +docker run -d --name postgres-mcm -p 5432:5432 -e POSTGRES_ROOT_PASSWORD=${ROOT_PASSWORD} -e POSTGRES_DB=idp_db -e POSTGRES_USER=${DB_USER} -e POSTGRES_PASSWORD=${DB_PASSWORD} -d postgres:13.6 +# Exectuer une commande pour modifier le schema public +docker exec -it postgres-mcm psql -U admin -a idp_db -c 'ALTER SCHEMA public RENAME TO idp_db;' +``` ## Keycloak -`docker run -d --link postgres-mcm --name keycloak -p 9000:8080 -e KEYCLOAK_USER=${USER} -e KEYCLOAK_PASSWORD=${PASSWORD} -e DB_VENDOR=postgres -e DB_ADDR=postgres-mcm -e DB_PORT=5432 -e DB_DATABASE=idp_db -e DB_SCHEMA=idp_db -e DB_USER=${DB_USER} -e DB_PASSWORD=${DB_PASSWORD} jboss/keycloak:16.1.1` +```sh +docker run -d --link postgres-mcm --name keycloak -p 9000:8080 -e KEYCLOAK_USER=${USER} -e KEYCLOAK_PASSWORD=${PASSWORD} -e DB_VENDOR=postgres -e DB_ADDR=postgres-mcm -e DB_PORT=5432 -e DB_DATABASE=idp_db -e DB_SCHEMA=idp_db -e DB_USER=${DB_USER} -e DB_PASSWORD=${DB_PASSWORD} jboss/keycloak:16.1.1 +``` ### Configuration du container KC Ouvrir un terminal vers le répertoire parent du projet « platform » puis lister les images docker en cours -`docker ps` +```sh +docker ps +``` Entrer la commande suivante en remplaçant par le container id qui apparait dans le terminal -`docker exec -it bash` +```sh +docker exec -it bash +``` Executer ensuite la commande pour créer le dossier allant contenir la blacklist des passwords -`mkdir /opt/jboss/keycloak/standalone/data/password-blacklists` +```sh +mkdir /opt/jboss/keycloak/standalone/data/password-blacklists +``` Sortir du container (exit) et executer la commande suivante -`docker cp ./platform/idp/password-blacklists/blacklist.txt :/opt/jboss/keycloak/standalone/data/password-blacklists` +```sh +docker cp ./platform/idp/password-blacklists/blacklist.txt :/opt/jboss/keycloak/standalone/data/password-blacklists +``` Récupérer le fichier overlays/realms/mcm-realm.json -> **Note** La liste des variables à remplacer vous est fournie ci-dessous néanmoins cela n'empêche pas l'import du realm mcm. Vous pourrez modifier ces variables via l'interface si besoin. +> **Note** La liste des variables à remplacer est fournie ci-dessous néanmoins cela n'empêche pas l'import du realm **mcm**. Vous pourrez modifier ces variables via l'interface si besoin. -Importer le realm mcm via l'interface KC +Importer le realm **mcm** via l'interface Keycloak ## Variables -| Variables | Description | Obligatoire | -| ----------- | ----------- | ----------- | -| IDP_API_CLIENT_SECRET | Client secret du client confidentiel API | Non | -| IDP_SIMULATION_MAAS_CLIENT_SECRET | Client secret du client confidentiel simulation-maas-backend | Non | -| MAIL_API_KEY | Api key SMTP si auth true | Non -| SMTP_AUTH | Boolean pour savoir si nécessite un user/password | Non -| MAIL_PORT | Port SMTP | Non -| MAIL_HOST | Host SMTP | Non -| EMAIL_FROM_KC | Email from | Non -| MAIL_USER | Username SMTP si auth true | Non -| IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID | Client id identity provider Azure AD | Non | -| IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET | Client secret identity provider Azure AD | Non | -| IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET | Client secret identity provider Azure AD | Non | -| FRANCE_CONNECT_IDP_PROVIDER_CLIENT_ID | Client ID de l’identity provider de France Connect | Non | -| FRANCE_CONNECT_IDP_PROVIDER_CLIENT_SECRET | Client secret identity provider Azure AD | Non +| Variables | Description | Obligatoire | +| ----------------------------------------- | ------------------------------------------------------------ | ----------- | +| IDP_API_CLIENT_SECRET | Client secret du client confidentiel API | Non | +| IDP_SIMULATION_MAAS_CLIENT_SECRET | Client secret du client confidentiel simulation-maas-backend | Non | +| MAIL_API_KEY | Api key SMTP si auth true | Non | +| SMTP_AUTH | Boolean pour savoir si nécessite un user/password | Non | +| MAIL_PORT | Port SMTP | Non | +| MAIL_HOST | Host SMTP | Non | +| EMAIL_FROM_KC | Email from | Non | +| MAIL_USER | Username SMTP si auth true | Non | +| IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID | Client id identity provider Azure AD | Non | +| IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET | Client secret identity provider Azure AD | Non | +| IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET | Client secret identity provider Azure AD | Non | +| FRANCE_CONNECT_IDP_PROVIDER_CLIENT_ID | Client ID de l’identity provider de France Connect | Non | +| FRANCE_CONNECT_IDP_PROVIDER_CLIENT_SECRET | Client secret identity provider Azure AD | Non | ## Redirect URI -Dans les clients platform, administration et simulation-maas-client, vous trouverez des redirects URI à modifier. +Dans les clients platform, administration et simulation-maas-client, des redirects URI sont à adapter selon l'environnement. -> **Note** Comme mentionné, vous pouvez les modifier directement sur le realm avant import ou via l'interface KC. +> **Note** Comme mentionné, elles sont modifiables directement sur le realm avant import ou via l'interface Keycloak. -Ces redirect URI sont nécessaires pour pouvoir vous connectez sur Website, Administration, Simulation-maas ou l'api. +Ces redirect URI sont nécessaires pour pouvoir se connecter sur [Website](website), [Administration](administration), [Simulation-maas](simulation-maas) ou l'[api](api). -| Variables | Description | Obligatoire | -| ----------- | ----------- | ----------- | -| WEBSITE_FQDN | Url du website | Oui | -| ADMIN_FQDN | Url de l'administration | Oui | -| API_FQDN | Url de l'api | Oui -| SIMULATION_MAAS_FQDN | Url de simulation-maas | Oui +| Variables | Description | Obligatoire | +| -------------------- | ----------------------- | ----------- | +| WEBSITE_FQDN | Url du website | Oui | +| ADMIN_FQDN | Url de l'administration | Oui | +| API_FQDN | Url de l'api | Oui | +| SIMULATION_MAAS_FQDN | Url de simulation-maas | Oui | -## France connect +## FranceConnect -Un .jar est fourni afin de pouvoir utiliser France Connect en local +Le produit s'appuie sur la librairie [Keycloak-FranceConnect](https://github.com/InseeFr/Keycloak-FranceConnect) de l'INSEE pour simplifier l'intégration de FranceConnect. Executer la commande suivante -`docker cp ./platform/idp/keycloak-franceconnect-4.1.0.jar :/opt/jboss/keycloak/standalone/deployments` +```sh +docker cp ./platform/idp/keycloak-franceconnect-4.1.0.jar :/opt/jboss/keycloak/standalone/deployments +``` + +Vérifier que les informations de l'Identity Provider France connect sont bien renseignées. -Vérifier que les informations de l'identity provider France connect sont bien renseignées +Sur un environnement local ou de test, il est possible de s'appuyer sur le démonstrateur FranceConnect(https://fournisseur-de-service.dev-franceconnect.fr/). ## URL / Port @@ -111,23 +125,25 @@ Si une mise à jour du realm est nécessaire, vous pouvez lancer la pipeline ave Le deploiement en preview permet de déployer un second Keycloak sur une bdd H2 permettant de tester des connexions inter-IDP. +Un script est lancé au déploiement de la bdd pgsql permettant d'aller altérer le schema et de créer l'utilisateur de service nécessaire pour l'api. (databaseConfig/createServiceUser.sh) + ## Testing Le fichier mcm-realm.json n'est pas importé au déploiement. Il faudra l'importer manuellement comme précisé pour l'installation locale. +Sur cet environnement, la bdd pgsql est externalisée. Le paramétrage de la bdd est donc à faire en amont. + # Relation avec les autres services -Comme présenté dans le schéma global de l'architecture ci-dessus (# TODO) +Comme présenté dans le schéma global de l'architecture ci-dessous + +![technicalArchitecture](../docs/assets/MOB-CME_Archi_technique_detaillee.png) L'idp est en relation avec plusieurs services: -- Api -- Website -- Simulation-maas -- Administration -- Bus +Il est en relation avec _simulation_maas_ également. -Plusieurs utilisent la librairie keycloak-js afin de gérer les connexions, les accès des utilisateurs ainsi que les CRUD des utilisateurs. +Plusieurs de ces services utilisent la librairie [keycloak-js](https://www.npmjs.com/package/keycloak-js) afin de gérer les connexions, les accès des utilisateurs ainsi que les CRUD des utilisateurs. # Tests Unitaires diff --git a/idp/databaseConfig/createServiceUser.sh b/idp/databaseConfig/createServiceUser.sh new file mode 100644 index 0000000..7847d36 --- /dev/null +++ b/idp/databaseConfig/createServiceUser.sh @@ -0,0 +1,13 @@ + +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + ALTER SCHEMA public RENAME TO idp_db; + CREATE USER $POSTGRES_SERVICE_USER WITH PASSWORD '$POSTGRES_SERVICE_PASSWORD'; + GRANT CONNECT ON DATABASE idp_db TO $POSTGRES_SERVICE_USER; + GRANT USAGE ON SCHEMA idp_db TO $POSTGRES_SERVICE_USER; + GRANT SELECT ON ALL TABLES IN SCHEMA idp_db TO $POSTGRES_SERVICE_USER; + GRANT SELECT ON ALL SEQUENCES IN SCHEMA idp_db TO $POSTGRES_SERVICE_USER; + ALTER DEFAULT PRIVILEGES IN SCHEMA idp_db GRANT SELECT ON TABLES TO $POSTGRES_SERVICE_USER; +EOSQL + diff --git a/idp/db-export.sh b/idp/db-export.sh new file mode 100644 index 0000000..705a246 --- /dev/null +++ b/idp/db-export.sh @@ -0,0 +1,6 @@ +export OUTPUT_FNAME=$1 +cd /opt/jboss/keycloak/ +./bin/standalone.sh -Dkeycloak.migration.action=export -Dkeycloak.migration.provider=singleFile -Dkeycloak.migration.file=${OUTPUT_FNAME} -Dkeycloak.migration.usersExportStrategy=REALM_FILE -Djboss.socket.binding.port-offset=100 & +PID=$! +sleep 30 +kill -INT ${PID} \ No newline at end of file diff --git a/idp/idp-testing-values.yaml b/idp/idp-testing-values.yaml new file mode 100644 index 0000000..3442458 --- /dev/null +++ b/idp/idp-testing-values.yaml @@ -0,0 +1,178 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: master-realm-config + data: + master-realm.json: "idp/realms/master-realm.json" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: idp + name: idp + spec: + ports: + - name: "8080" + port: 8080 + targetPort: 8080 + selector: + io.kompose.service: idp + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: idp + name: idp + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: idp + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/web-nw: "true" + io.kompose.service: idp + spec: + containers: + args: + - -b + - 0.0.0.0 + - -Djboss.modules.system.pkgs=org.jboss.logmanager + - -Dkeycloak.migration.action=import + - -Dkeycloak.migration.provider=singleFile + - -Dkeycloak.migration.file=/tmp/master-realm.json + - -Dkeycloak.migration.strategy=IGNORE_EXISTING + env: + KEYCLOAK_USER: ${TESTING_PGSQL_ADMIN_USER} + KEYCLOAK_PASSWORD: ${TESTING_PGSQL_ADMIN_PASSWORD} + PROXY_ADDRESS_FORWARDING: "true" + WEBSITE_FQDN: ${WEBSITE_FQDN} + API_FQDN: ${API_FQDN} + DB_SCHEMA: ${TESTING_PGSQL_NAME} + DB_ADDR: ${TESTING_PGSQL_FLEX_ADDRESS} + DB_PORT: ${TESTING_PGSQL_PORT} + DB_VENDOR: postgres + DB_DATABASE: ${TESTING_PGSQL_NAME} + DB_USER: ${TESTING_PGSQL_DEV_USER} + DB_PASSWORD: ${TESTING_PGSQL_DEV_PASSWORD} + image: ${KEYCLOAK_IMAGE_NAME} + name: idp + ports: + - containerPort: 8080 + resources: {} + volumeMounts: + - mountPath: /tmp + name: realm-config + imagePullSecrets: + - name: ${GITLAB_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: master-realm-config + name: realm-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: idp + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${IDP_FQDN}`) + middlewares: + - name: idp-headers-middleware + # - name: idp-ratelimit-middleware + - name: idp-inflightreq-middleware + services: + - name: idp + port: 8080 + +middlewares: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: idp-headers-middleware + spec: + headers: + customRequestHeaders: + X-Forwarded-Port: "443" + X-Forwarded-Proto: https + + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: idp-inflightreq-middleware + spec: + inFlightReq: + amount: 100 + + # - metadata: + # annotations: + # app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + # app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + # kubernetes.io/ingress.class: traefik + # name: idp-ratelimit-middleware + # spec: + # rateLimit: + # average: 30 + # burst: 50 + # period: 1s + # sourceCriterion: + # ipStrategy: + # depth: 3 diff --git a/idp/keycloak-dockerfile.yml b/idp/keycloak-dockerfile.yml new file mode 100644 index 0000000..a93a6c6 --- /dev/null +++ b/idp/keycloak-dockerfile.yml @@ -0,0 +1,10 @@ +ARG BASE_IMAGE_KEYCLOAK +FROM ${BASE_IMAGE_KEYCLOAK} + +COPY db-export.sh / +COPY mcm_template /opt/jboss/keycloak/themes/mcm_template +COPY keycloak-franceconnect-4.1.0.jar /opt/jboss/keycloak/standalone/deployments/ +COPY standalone.xml /opt/jboss/keycloak/standalone/configuration/ +COPY password-blacklists/ /opt/jboss/keycloak/standalone/password-blacklists/ +VOLUME ["/opt/jboss/keycloak/standalone/data"] +ENTRYPOINT ["/opt/jboss/tools/docker-entrypoint.sh", "-Dkeycloak.password.blacklists.path=/opt/jboss/keycloak/standalone/password-blacklists/"] diff --git a/idp/keycloak-franceconnect-4.1.0.jar b/idp/keycloak-franceconnect-4.1.0.jar new file mode 100644 index 0000000..5eb2ede Binary files /dev/null and b/idp/keycloak-franceconnect-4.1.0.jar differ diff --git a/idp/keycloak-maas-dockerfile.yml b/idp/keycloak-maas-dockerfile.yml new file mode 100644 index 0000000..c6806f4 --- /dev/null +++ b/idp/keycloak-maas-dockerfile.yml @@ -0,0 +1,6 @@ +ARG BASE_IMAGE_KEYCLOAK +FROM ${BASE_IMAGE_KEYCLOAK} + +COPY db-export.sh / +COPY standalone.xml /opt/jboss/keycloak/standalone/configuration/ +VOLUME ["/opt/jboss/keycloak/standalone/data"] diff --git a/idp/kompose.yml b/idp/kompose.yml new file mode 100644 index 0000000..7f8bb6d --- /dev/null +++ b/idp/kompose.yml @@ -0,0 +1,106 @@ +version: '3' + +services: + postgres-keycloak: + image: ${POSTGRES_IMAGE_NAME} + build: + context: . + dockerfile: ./postgres-dockerfile.yml + args: + BASE_POSTGRES_IMAGE_NAME: ${BASE_POSTGRES_IMAGE_NAME} + environment: + - POSTGRES_USER=${IDP_DATABASE_USER_NAME} + - POSTGRES_PASSWORD=${IDP_DATABASE_USER_PWD} + - POSTGRES_DB=${PGSQL_NAME} + - POSTGRES_SERVICE_USER=${PGSQL_SERVICE_USER} + - POSTGRES_SERVICE_PASSWORD=${PGSQL_SERVICE_PASSWORD} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - postgres_keycloak_data:/var/lib/postgresql/data + ports: + - '5432' + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + + idp: + image: ${KEYCLOAK_IMAGE_NAME} + build: + context: . + dockerfile: ./keycloak-dockerfile.yml + args: + BASE_IMAGE_KEYCLOAK: ${NEXUS_IMAGE_KEYCLOAK} + command: + [ + '-b', + '0.0.0.0', + '-Djboss.modules.system.pkgs=org.jboss.logmanager', + '-Dkeycloak.migration.action=import', + '-Dkeycloak.migration.provider=singleFile', + '-Dkeycloak.migration.file=/tmp/all-realm.json', + '-Dkeycloak.migration.strategy=${MIGRATION_STRATEGY_REALM}', + ] + depends_on: + - postgres-keycloak + networks: + - web-nw + environment: + - KEYCLOAK_USER=${IDP_ADMIN_USER} + - KEYCLOAK_PASSWORD=${IDP_ADMIN_PASSWORD} + - PROXY_ADDRESS_FORWARDING=true + - WEBSITE_FQDN + - API_FQDN + - IDP_FQDN + - IMAGE_PULL_SECRET_NAME + - DB_ADDR=postgres-keycloak + - DB_PORT=5432 + - DB_VENDOR=postgres + - DB_DATABASE=${PGSQL_NAME} + - DB_USER=${IDP_DATABASE_USER_NAME} + - DB_PASSWORD=${IDP_DATABASE_USER_PWD} + - DB_SCHEMA=${PGSQL_NAME} + - LANDSCAPE + - BASE_DOMAIN + - MATOMO_FQDN + ports: + - '8080' + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + + idp-maas: + image: ${KEYCLOAK_MAAS_IMAGE_NAME} + build: + context: . + dockerfile: ./keycloak-maas-dockerfile.yml + args: + BASE_IMAGE_KEYCLOAK: ${NEXUS_IMAGE_KEYCLOAK} + command: + [ + '-b', + '0.0.0.0', + '-Djboss.modules.system.pkgs=org.jboss.logmanager', + '-Dkeycloak.migration.action=import', + '-Dkeycloak.migration.provider=singleFile', + '-Dkeycloak.migration.file=/tmp/maas-realm.json', + '-Dkeycloak.migration.strategy=${MIGRATION_STRATEGY_REALM}', + ] + networks: + - web-nw + environment: + - KEYCLOAK_USER + - KEYCLOAK_PASSWORD + - PROXY_ADDRESS_FORWARDING=true + - KEYCLOAK_IMPORT=/tmp/maas-realm.json + - IMAGE_PULL_SECRET_NAME + ports: + - '8087:8080' + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + +volumes: + postgres_keycloak_data: + +networks: + web-nw: diff --git a/idp/mcm_template/account/account.ftl b/idp/mcm_template/account/account.ftl new file mode 100644 index 0000000..9254b96 --- /dev/null +++ b/idp/mcm_template/account/account.ftl @@ -0,0 +1,70 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='account' bodyClass='user'; section> + +

+
+

${msg("editAccountHtmlTitle")}

+
+
+ * ${msg("requiredFields")} +
+
+ +
+ + + + <#if !realm.registrationEmailAsUsername> +
+
+ <#if realm.editUsernameAllowed>* +
+ +
+ disabled="disabled" value="${(account.username!'')}"/> +
+
+ + +
+
+ * +
+ +
+ +
+
+ +
+
+ * +
+ +
+ +
+
+ +
+
+ * +
+ +
+ +
+
+ +
+
+
+ <#if url.referrerURI??>${kcSanitize(msg("backToApplication")?no_esc)} + + +
+
+
+
+ + diff --git a/idp/mcm_template/account/applications.ftl b/idp/mcm_template/account/applications.ftl new file mode 100644 index 0000000..a8edc38 --- /dev/null +++ b/idp/mcm_template/account/applications.ftl @@ -0,0 +1,76 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='applications' bodyClass='applications'; section> + +
+
+

${msg("applicationsHtmlTitle")}

+
+
+ +
+ + + + + + + + + + + + + + + + <#list applications.applications as application> + + + + + + + + + + + + + +
${msg("application")}${msg("availableRoles")}${msg("grantedPermissions")}${msg("additionalGrants")}${msg("action")}
+ <#if application.effectiveUrl?has_content> + <#if application.client.name?has_content>${advancedMsg(application.client.name)}<#else>${application.client.clientId} + <#if application.effectiveUrl?has_content> + + <#list application.realmRolesAvailable as role> + <#if role.description??>${advancedMsg(role.description)}<#else>${advancedMsg(role.name)} + <#if role_has_next>, + + <#list application.resourceRolesAvailable?keys as resource> + <#if application.realmRolesAvailable?has_content>, + <#list application.resourceRolesAvailable[resource] as clientRole> + <#if clientRole.roleDescription??>${advancedMsg(clientRole.roleDescription)}<#else>${advancedMsg(clientRole.roleName)} + ${msg("inResource")} <#if clientRole.clientName??>${advancedMsg(clientRole.clientName)}<#else>${clientRole.clientId} + <#if clientRole_has_next>, + + + + <#if application.client.consentRequired> + <#list application.clientScopesGranted as claim> + ${advancedMsg(claim)}<#if claim_has_next>, + + <#else> + ${msg("fullAccess")} + + + <#list application.additionalGrants as grant> + ${advancedMsg(grant)}<#if grant_has_next>, + + + <#if (application.client.consentRequired && application.clientScopesGranted?has_content) || application.additionalGrants?has_content> + + +
+
+ + \ No newline at end of file diff --git a/idp/mcm_template/account/federatedIdentity.ftl b/idp/mcm_template/account/federatedIdentity.ftl new file mode 100644 index 0000000..c2eb769 --- /dev/null +++ b/idp/mcm_template/account/federatedIdentity.ftl @@ -0,0 +1,42 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='social' bodyClass='social'; section> + +
+
+

${msg("federatedIdentitiesHtmlTitle")}

+
+
+ +
+ <#list federatedIdentity.identities as identity> +
+
+ +
+
+ +
+
+ <#if identity.connected> + <#if federatedIdentity.removeLinkPossible> +
+ + + + +
+ + <#else> +
+ + + + +
+ +
+
+ +
+ + diff --git a/idp/mcm_template/account/log.ftl b/idp/mcm_template/account/log.ftl new file mode 100644 index 0000000..29046cf --- /dev/null +++ b/idp/mcm_template/account/log.ftl @@ -0,0 +1,35 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='log' bodyClass='log'; section> + +
+
+

${msg("accountLogHtmlTitle")}

+
+
+ + + + + + + + + + + + + + <#list log.events as event> + + + + + + + + + + +
${msg("date")}${msg("event")}${msg("ip")}${msg("client")}${msg("details")}
${event.date?datetime}${event.event}${event.ipAddress}${event.client!}<#list event.details as detail>${detail.key} = ${detail.value} <#if detail_has_next>,
+ + \ No newline at end of file diff --git a/idp/mcm_template/account/messages/messages_fr.properties b/idp/mcm_template/account/messages/messages_fr.properties new file mode 100644 index 0000000..fbd940b --- /dev/null +++ b/idp/mcm_template/account/messages/messages_fr.properties @@ -0,0 +1,180 @@ +# TIPS to encode UTF-8 to ISO +# native2ascii -encoding ISO8859_1 srcFile > dstFile + +doSave=Sauvegarder +doCancel=Annuler +doLogOutAllSessions=D\u00e9connexion de toutes les sessions +doRemove=Supprimer +doAdd=Ajouter +doSignOut=D\u00e9connexion + +editAccountHtmlTitle=\u00c9dition du compte +federatedIdentitiesHtmlTitle=Identit\u00e9s f\u00e9d\u00e9r\u00e9es +accountLogHtmlTitle=Acc\u00e8s au compte +changePasswordHtmlTitle=Changer de mot de passe +sessionsHtmlTitle=Sessions +accountManagementTitle=Gestion du compte Keycloak +authenticatorTitle=Authentification +applicationsHtmlTitle=Applications + +authenticatorCode=Mot de passe unique +email=Courriel +firstName=Pr\u00e9nom +givenName=Pr\u00e9nom +fullName=Nom complet +lastName=Nom +familyName=Nom de famille +password=Mot de passe +passwordConfirm=Confirmation +passwordNew=Nouveau mot de passe +username=Compte +address=Adresse +street=Rue +locality=Ville ou Localit\u00e9 +region=\u00c9tat, Province ou R\u00e9gion +postal_code=Code Postal +country=Pays +emailVerified=Courriel v\u00e9rifi\u00e9 +gssDelegationCredential=Accr\u00e9ditation de d\u00e9l\u00e9gation GSS + +role_admin=Administrateur +role_realm-admin=Administrateur du domaine +role_create-realm=Cr\u00e9er un domaine +role_view-realm=Voir un domaine +role_view-users=Voir les utilisateurs +role_view-applications=Voir les applications +role_view-clients=Voir les clients +role_view-events=Voir les \u00e9v\u00e9nements +role_view-identity-providers=Voir les fournisseurs d''identit\u00e9s +role_manage-realm=G\u00e9rer le domaine +role_manage-users=G\u00e9rer les utilisateurs +role_manage-applications=G\u00e9rer les applications +role_manage-identity-providers=G\u00e9rer les fournisseurs d''identit\u00e9s +role_manage-clients=G\u00e9rer les clients +role_manage-events=G\u00e9rer les \u00e9v\u00e9nements +role_view-profile=Voir le profil +role_manage-account=G\u00e9rer le compte +role_read-token=Lire le jeton d''authentification +role_offline-access=Acc\u00e8s hors-ligne +client_account=Compte +client_security-admin-console=Console d''administration de la s\u00e9curit\u00e9 +client_admin-cli=Admin CLI +client_realm-management=Gestion du domaine +client_broker=Broker + + +requiredFields=Champs obligatoires +allFieldsRequired=Tous les champs sont obligatoires + +backToApplication=« Revenir \u00e0 l''application +backTo=Revenir \u00e0 {0} + +date=Date +event=Ev\u00e9nement +ip=IP +client=Client +clients=Clients +details=D\u00e9tails +started=D\u00e9but +lastAccess=Dernier acc\u00e8s +expires=Expiration +applications=Applications + +account=Compte +federatedIdentity=Identit\u00e9 f\u00e9d\u00e9r\u00e9e +authenticator=Authentification +sessions=Sessions +log=Connexion + +application=Application +availablePermissions=Permissions disponibles +grantedPermissions=Permissions accord\u00e9es +grantedPersonalInfo=Informations personnelles accord\u00e9es +additionalGrants=Droits additionnels +action=Action +inResource=dans +fullAccess=Acc\u00e8s complet +offlineToken=Jeton d''authentification hors-ligne +revoke=R\u00e9voquer un droit + +configureAuthenticators=Authentifications configur\u00e9es. +mobile=T\u00e9l\u00e9phone mobile +totpStep1=Installez une des applications suivantes sur votre mobile +totpStep2=Ouvrez l''application et scannez le code-barres ou entrez la clef. +totpStep3=Entrez le code \u00e0 usage unique fourni par l''application et cliquez sur Sauvegarder pour terminer. + +totpManualStep2=Ouvrez l''application et entrez la clef +totpManualStep3=Utilisez les valeurs de configuration suivante si l''application les autorise +totpUnableToScan=Impossible de scanner ? +totpScanBarcode=Scanner le code-barres ? + +totp.totp=Bas\u00e9 sur le temps +totp.hotp=Bas\u00e9 sur un compteur + +totpType=Type +totpAlgorithm=Algorithme +totpDigits=Chiffres +totpInterval=Intervalle +totpCounter=Compteur + +missingUsernameMessage=Veuillez entrer votre nom d''utilisateur. +missingFirstNameMessage=Veuillez entrer votre pr\u00e9nom. +invalidEmailMessage=Courriel invalide. +missingLastNameMessage=Veuillez entrer votre nom. +missingEmailMessage=Veuillez entrer votre courriel. +missingPasswordMessage=Veuillez entrer votre mot de passe. +notMatchPasswordMessage=Les mots de passe ne sont pas identiques + +missingTotpMessage=Veuillez entrer le code d''authentification. +invalidPasswordExistingMessage=Mot de passe existant invalide. +invalidPasswordConfirmMessage=Le mot de passe de confirmation ne correspond pas. +invalidTotpMessage=Le code d''authentification est invalide. + +usernameExistsMessage=Le nom d''utilisateur existe d\u00e9j\u00e0. +emailExistsMessage=Le courriel existe d\u00e9j\u00e0. + +readOnlyUserMessage=Vous ne pouvez pas mettre \u00e0 jour votre compte car il est en lecture seule. +readOnlyPasswordMessage=Vous ne pouvez pas mettre \u00e0 jour votre mot de passe car votre compte est en lecture seule. + +successTotpMessage=L''authentification via t\u00e9l\u00e9phone mobile est configur\u00e9e. +successTotpRemovedMessage=L''authentification via t\u00e9l\u00e9phone mobile est supprim\u00e9e. + +successGrantRevokedMessage=Droit r\u00e9voqu\u00e9 avec succ\u00e8s. + +accountUpdatedMessage=Merci ! +accountPasswordUpdatedMessage=Votre mot de passe a \u00e9t\u00e9 mis \u00e0 jour. + +missingIdentityProviderMessage=Le fournisseur d''identit\u00e9 n''est pas sp\u00e9cifi\u00e9. +invalidFederatedIdentityActionMessage=Action manquante ou invalide. +identityProviderNotFoundMessage=Le fournisseur d''identit\u00e9 sp\u00e9cifi\u00e9 n''est pas trouv\u00e9. +federatedIdentityLinkNotActiveMessage=Cette identit\u00e9 n''est plus active dor\u00e9navant. +federatedIdentityRemovingLastProviderMessage=Vous ne pouvez pas supprimer votre derni\u00e8re f\u00e9d\u00e9ration d''identit\u00e9 sans avoir de mot de passe sp\u00e9cifi\u00e9. +identityProviderRedirectErrorMessage=Erreur de redirection vers le fournisseur d''identit\u00e9. +identityProviderRemovedMessage=Le fournisseur d''identit\u00e9 a \u00e9t\u00e9 supprim\u00e9 correctement. +identityProviderAlreadyLinkedMessage=Le fournisseur d''identit\u00e9 retourn\u00e9 par {0} est d\u00e9j\u00e0 li\u00e9 \u00e0 un autre utilisateur. + +accountDisabledMessage=Ce compte est d\u00e9sactiv\u00e9, veuillez contacter votre administrateur. + +accountTemporarilyDisabledMessage=Ce compte est temporairement d\u00e9sactiv\u00e9, veuillez contacter votre administrateur ou r\u00e9essayez plus tard. +invalidPasswordMinLengthMessage=Mot de passe invalide: longueur minimale {0}. +invalidPasswordMinLowerCaseCharsMessage=Mot de passe invalide: doit contenir au moins {0} lettre(s) en minuscule. +invalidPasswordMinDigitsMessage=Mot de passe invalide: doit contenir au moins {0} chiffre(s). +invalidPasswordMinUpperCaseCharsMessage=Mot de passe invalide: doit contenir au moins {0} lettre(s) en majuscule. +invalidPasswordMinSpecialCharsMessage=Mot de passe invalide: doit contenir au moins {0} caract\u00e8re(s) sp\u00e9ciaux. +invalidPasswordNotUsernameMessage=Mot de passe invalide: ne doit pas \u00eatre identique au nom d''utilisateur. +invalidPasswordRegexPatternMessage=Mot de passe invalide: ne valide pas l''expression rationnelle. +invalidPasswordHistoryMessage=Mot de passe invalide: ne doit pas \u00eatre \u00e9gal aux {0} derniers mots de passe. + +applicationName=Nom de l''application +update=Mettre \u00e0 jour +status=Statut +authenticatorActionSetup=Configurer +device-activity=Activit\u00e9 des Appareils +accountSecurityTitle=S\u00e9curit\u00e9 du Compte +accountManagementWelcomeMessage=Bienvenue dans la Gestion de Compte Keycloak +personalInfoHtmlTitle=Informations Personnelles +personalInfoIntroMessage=G\u00e9rez vos informations de base +personalSubMessage=G\u00e9rez ces informations de base: votre pr\u00e9nom, nom de famille et email +accountSecurityIntroMessage=G\u00e9rez votre mot de passe et l''acc\u00e8s \u00e0 votre compte +applicationsIntroMessage=Auditez et g\u00e9rez les permissions d''acc\u00e8s des applications aux donn\u00e9es de votre compte +applicationType=Type d''application diff --git a/idp/mcm_template/account/password.ftl b/idp/mcm_template/account/password.ftl new file mode 100644 index 0000000..4a043f2 --- /dev/null +++ b/idp/mcm_template/account/password.ftl @@ -0,0 +1,59 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='password' bodyClass='password'; section> + +
+
+

${msg("changePasswordHtmlTitle")}

+
+
+ ${msg("allFieldsRequired")} +
+
+ +
+ + + <#if password.passwordSet> +
+
+ +
+ +
+ +
+
+ + + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+
+ + diff --git a/idp/mcm_template/account/resource-detail.ftl b/idp/mcm_template/account/resource-detail.ftl new file mode 100644 index 0000000..2c963d7 --- /dev/null +++ b/idp/mcm_template/account/resource-detail.ftl @@ -0,0 +1,277 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='authorization' bodyClass='authorization'; section> + + + + +
+
+

+ ${msg("myResources")} <#if authorization.resource.displayName??>${authorization.resource.displayName}<#else>${authorization.resource.name} +

+
+
+ + <#if authorization.resource.iconUri??> + +
+ + +
+
+

+ ${msg("peopleAccessResource")} +

+
+
+
+
+ + + + + + + + + + + <#if authorization.resource.shares?size != 0> + <#list authorization.resource.shares as permission> + + + + + + + + + + + + + <#else> + + + + + +
${msg("user")}${msg("permission")}${msg("date")}${msg("action")}
+ <#if permission.requester.email??>${permission.requester.email}<#else>${permission.requester.username} + + <#if permission.scopes?size != 0> + <#list permission.scopes as scope> + <#if scope.granted && scope.scope??> + + <#else> + ${msg("anyPermission")} + + + <#else> + Any action + + + ${permission.createdDate?datetime} + + ${msg("doRevoke")} +
${msg("resourceIsNotBeingShared")}
+ +
+
+
+
+

+ ${msg("resourceManagedPolicies")} +

+
+
+
+
+ + + + + + + + + + <#if authorization.resource.policies?size != 0> + <#list authorization.resource.policies as permission> + + + + + + + + + + + + <#else> + + + + + +
${msg("description")}${msg("permission")}${msg("action")}
+ <#if permission.description??> + ${permission.description} + + + <#if permission.scopes?size != 0> + <#list permission.scopes as scope> + + + <#else> + ${msg("anyAction")} + + + ${msg("doRevoke")} +
+ ${msg("resourceNoPermissionsGrantingAccess")} +
+ +
+
+
+
+

+ ${msg("shareWithOthers")} +

+
+
+
+
+
+ +
+ * +
+
+
+
+ +
+
+
+ <#list authorization.resource.scopes as scope> + + +
+ +
+
+
+
+
+
+ diff --git a/idp/mcm_template/account/resources.ftl b/idp/mcm_template/account/resources.ftl new file mode 100644 index 0000000..d86e8bc --- /dev/null +++ b/idp/mcm_template/account/resources.ftl @@ -0,0 +1,403 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='authorization' bodyClass='authorization'; section> + + +
+
+

+ ${msg("myResources")} +

+
+
+ + <#if authorization.resourcesWaitingApproval?size != 0> +
+
+

+ ${msg("needMyApproval")} +

+
+
+
+
+ + + + + + + + + + + <#list authorization.resourcesWaitingApproval as resource> + <#list resource.permissions as permission> + + + + + + + + + + + + + + +
${msg("resource")}${msg("requestor")}${msg("permissionRequestion")}${msg("action")}
+ <#if resource.displayName??>${resource.displayName}<#else>${resource.name} + + <#if permission.requester.email??>${permission.requester.email}<#else>${permission.requester.username} + + <#list permission.scopes as scope> + <#if scope.scope??> + + <#else> + ${msg("anyPermission")} + + + + ${msg("doApprove")} + ${msg("doDeny")} +
+
+
+ + +
+
+

+ ${msg("myResourcesSub")} +

+
+
+
+
+ + + + + + + + + + + <#if authorization.resources?size != 0> + <#list authorization.resources as resource> + + + + + + + <#else> + + + + + +
${msg("resource")}${msg("application")}${msg("peopleSharingThisResource")}
+ + <#if resource.displayName??>${resource.displayName}<#else>${resource.name} + + + <#if resource.resourceServer.baseUri??> + ${resource.resourceServer.name} + <#else> + ${resource.resourceServer.name} + + + <#if resource.shares?size != 0> + ${resource.shares?size} + <#else> + ${msg("notBeingShared")} + +
${msg("notHaveAnyResource")}
+
+
+ +
+
+

+ ${msg("resourcesSharedWithMe")} +

+
+
+
+
+
+ + + + + + + + + + + + + + <#if authorization.sharedResources?size != 0> + <#list authorization.sharedResources as resource> + + + + + + + + + + <#else> + + + + + +
disabled="true" + ${msg("resource")}${msg("owner")}${msg("application")}${msg("permission")}${msg("date")}
+ + + <#if resource.displayName??>${resource.displayName}<#else>${resource.name} + + ${resource.ownerName} + + <#if resource.resourceServer.baseUri??> + ${resource.resourceServer.name} + <#else> + ${resource.resourceServer.name} + + + <#if resource.permissions?size != 0> +
    + <#list resource.permissions as permission> + <#list permission.scopes as scope> + <#if scope.granted && scope.scope??> +
  • + <#if scope.scope.displayName??> + ${scope.scope.displayName} + <#else> + ${scope.scope.name} + +
  • + <#else> + ${msg("anyPermission")} + + + +
+ <#else> + Any action + +
+ ${resource.permissions[0].grantedDate?datetime} +
${msg("noResourcesSharedWithYou")}
+
+
+ <#if authorization.sharedResources?size != 0> + + +
+ + <#if authorization.resourcesWaitingOthersApproval?size != 0> +
+
+
+

+ ${msg("requestsWaitingApproval")} +

+
+
+
+
+ ${msg("havePermissionRequestsWaitingForApproval",authorization.resourcesWaitingOthersApproval?size)} + ${msg("clickHereForDetails")} +
+
+
+
+
+
+
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/idp/mcm_template/account/resources/css/style.css b/idp/mcm_template/account/resources/css/style.css new file mode 100644 index 0000000..e69de29 diff --git a/idp/mcm_template/account/sessions.ftl b/idp/mcm_template/account/sessions.ftl new file mode 100644 index 0000000..89dbf65 --- /dev/null +++ b/idp/mcm_template/account/sessions.ftl @@ -0,0 +1,44 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='sessions' bodyClass='sessions'; section> + +
+
+

${msg("sessionsHtmlTitle")}

+
+
+ + + + + + + + + + + + + + <#list sessions.sessions as session> + + + + + + + + + + +
${msg("ip")}${msg("started")}${msg("lastAccess")}${msg("expires")}${msg("clients")}
${session.ipAddress}${session.started?datetime}${session.lastAccess?datetime}${session.expires?datetime} + <#list session.clients as client> + ${client}
+ +
+ +
+ + +
+ + diff --git a/idp/mcm_template/account/template.ftl b/idp/mcm_template/account/template.ftl new file mode 100644 index 0000000..6f08eef --- /dev/null +++ b/idp/mcm_template/account/template.ftl @@ -0,0 +1,88 @@ +<#macro mainLayout active bodyClass> + + + + + + + + ${msg("accountManagementTitle")} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + + + + + +
+
+ +
+ +
+ <#if message?has_content> +
+ <#if message.type=='success' > + <#if message.type=='error' > + +
+ + + <#nested "content"> +
+
+ + + + \ No newline at end of file diff --git a/idp/mcm_template/account/theme.properties b/idp/mcm_template/account/theme.properties new file mode 100644 index 0000000..863ab49 --- /dev/null +++ b/idp/mcm_template/account/theme.properties @@ -0,0 +1,5 @@ +parent=base + +locales=fr + +styles=../login/css/style.css diff --git a/idp/mcm_template/account/totp.ftl b/idp/mcm_template/account/totp.ftl new file mode 100644 index 0000000..987fe24 --- /dev/null +++ b/idp/mcm_template/account/totp.ftl @@ -0,0 +1,141 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='totp' bodyClass='totp'; section> + +
+
+

${msg("authenticatorTitle")}

+
+ <#if totp.otpCredentials?size == 0> +
+ * ${msg("requiredFields")} +
+ +
+ + <#if totp.enabled> + + + <#if totp.otpCredentials?size gt 1> + + + + <#else> + + + + + + + <#list totp.otpCredentials as credential> + + + <#if totp.otpCredentials?size gt 1> + + + + + + + +
${msg("configureAuthenticators")}
${msg("configureAuthenticators")}
${msg("mobile")}${credential.id}${credential.userLabel!} +
+ + + + +
+
+ <#else> + +
+ +
    +
  1. +

    ${msg("totpStep1")}

    + +
      + <#list totp.policy.supportedApplications as app> +
    • ${app}
    • + +
    +
  2. + + <#if mode?? && mode = "manual"> +
  3. +

    ${msg("totpManualStep2")}

    +

    ${totp.totpSecretEncoded}

    +

    ${msg("totpScanBarcode")}

    +
  4. +
  5. +

    ${msg("totpManualStep3")}

    +
      +
    • ${msg("totpType")}: ${msg("totp." + totp.policy.type)}
    • +
    • ${msg("totpAlgorithm")}: ${totp.policy.getAlgorithmKey()}
    • +
    • ${msg("totpDigits")}: ${totp.policy.digits}
    • + <#if totp.policy.type = "totp"> +
    • ${msg("totpInterval")}: ${totp.policy.period}
    • + <#elseif totp.policy.type = "hotp"> +
    • ${msg("totpCounter")}: ${totp.policy.initialCounter}
    • + +
    +
  6. + <#else> +
  7. +

    ${msg("totpStep2")}

    +

    Figure: Barcode

    +

    ${msg("totpUnableToScan")}

    +
  8. + +
  9. +

    ${msg("totpStep3")}

    +

    ${msg("totpStep3DeviceName")}

    +
  10. +
+ +
+ +
+ +
+
+ * +
+ +
+ + +
+ + +
+ +
+
+ <#if totp.otpCredentials?size gte 1>* +
+ +
+ +
+
+ +
+
+
+ + +
+
+
+
+ + + diff --git a/idp/mcm_template/email/commons/button.ftl b/idp/mcm_template/email/commons/button.ftl new file mode 100644 index 0000000..4e637d8 --- /dev/null +++ b/idp/mcm_template/email/commons/button.ftl @@ -0,0 +1,27 @@ +<#macro confirm link text> + + + + +
+ + ${text} + +
+ \ No newline at end of file diff --git a/idp/mcm_template/email/commons/footer.ftl b/idp/mcm_template/email/commons/footer.ftl new file mode 100644 index 0000000..4296227 --- /dev/null +++ b/idp/mcm_template/email/commons/footer.ftl @@ -0,0 +1,27 @@ +<#include "utils.ftl"> + +<#function if cond then else=""> + <#if cond> + <#return then> + <#else> + <#return else> + + + +<#assign footerLink = getLink("mob-footer.png")> + + + + + +
+

+ © Mon Compte Mobilité - Tous droits réservés - + Mentions légales + +

+ mob footer +
\ No newline at end of file diff --git a/idp/mcm_template/email/commons/header.ftl b/idp/mcm_template/email/commons/header.ftl new file mode 100644 index 0000000..e5860a8 --- /dev/null +++ b/idp/mcm_template/email/commons/header.ftl @@ -0,0 +1,11 @@ +<#include "utils.ftl"> + +<#assign headerLink = getLink("logo-with-baseline.png")> + + + + + +
+ mob header +
\ No newline at end of file diff --git a/idp/mcm_template/email/commons/template-mcm.ftl b/idp/mcm_template/email/commons/template-mcm.ftl new file mode 100644 index 0000000..6b2dd4e --- /dev/null +++ b/idp/mcm_template/email/commons/template-mcm.ftl @@ -0,0 +1,23 @@ +<#macro mcm> + + + <#if properties.idpFQDN == "undefined"> + + <#else> + + + + + <#include "../commons/header.ftl"> + + + + +
+ <#nested> +
+ <#include "../commons/footer.ftl"> + + + + \ No newline at end of file diff --git a/idp/mcm_template/email/commons/utils.ftl b/idp/mcm_template/email/commons/utils.ftl new file mode 100644 index 0000000..c925b50 --- /dev/null +++ b/idp/mcm_template/email/commons/utils.ftl @@ -0,0 +1,16 @@ +<#assign landscape = properties.landscape> +<#assign baseDomain = properties.baseDomain> + +<#function getLink imgName> + <#if (landscape != "null" && landscape != "undefined")> + <#if landscape == "production"> + <#return "https://static.${baseDomain}/assets/${imgName}"> + <#elseif landscape == "testing"> + <#return "https://static.preview.${baseDomain}/assets/${imgName}"> + <#else> + <#return "https://static.${landscape}.${baseDomain}/assets/${imgName}"> + + <#else> + <#return "https://static.preview.${baseDomain}/assets/${imgName}"> + + \ No newline at end of file diff --git a/idp/mcm_template/email/html/email-test.ftl b/idp/mcm_template/email/html/email-test.ftl new file mode 100644 index 0000000..3a52272 --- /dev/null +++ b/idp/mcm_template/email/html/email-test.ftl @@ -0,0 +1,5 @@ + + +${kcSanitize(msg("emailTestBodyHtml",realmName))?no_esc} + + diff --git a/idp/mcm_template/email/html/email-verification-with-code.ftl b/idp/mcm_template/email/html/email-verification-with-code.ftl new file mode 100644 index 0000000..66e8925 --- /dev/null +++ b/idp/mcm_template/email/html/email-verification-with-code.ftl @@ -0,0 +1,5 @@ + + +${kcSanitize(msg("emailVerificationBodyCodeHtml",code))?no_esc} + + diff --git a/idp/mcm_template/email/html/email-verification.ftl b/idp/mcm_template/email/html/email-verification.ftl new file mode 100644 index 0000000..a27be20 --- /dev/null +++ b/idp/mcm_template/email/html/email-verification.ftl @@ -0,0 +1,23 @@ +<#import "../commons/template-mcm.ftl" as template> +<#import "../commons/button.ftl" as button> + +<@template.mcm> + + + + +
+
+ ${kcSanitize(msg("emailVerificationBodyHtml1", user.getFirstName()))?no_esc} + ${kcSanitize(msg("emailVerificationBodyHtml2"))?no_esc} +
+ <@button.confirm link=link text=msg("emailVerificationBodyHtml3") /> +
+ ${kcSanitize(msg("emailVerificationBodyHtml4"))?no_esc} +
+
+ ${kcSanitize(msg("emailVerificationBodyHtml5"))?no_esc} +
+
+
+ diff --git a/idp/mcm_template/email/html/event-login_error.ftl b/idp/mcm_template/email/html/event-login_error.ftl new file mode 100644 index 0000000..022c024 --- /dev/null +++ b/idp/mcm_template/email/html/event-login_error.ftl @@ -0,0 +1,5 @@ + + +${kcSanitize(msg("eventLoginErrorBodyHtml",event.date,event.ipAddress))?no_esc} + + diff --git a/idp/mcm_template/email/html/event-remove_totp.ftl b/idp/mcm_template/email/html/event-remove_totp.ftl new file mode 100644 index 0000000..9a56ed3 --- /dev/null +++ b/idp/mcm_template/email/html/event-remove_totp.ftl @@ -0,0 +1,5 @@ + + +${kcSanitize(msg("eventRemoveTotpBodyHtml",event.date, event.ipAddress))?no_esc} + + diff --git a/idp/mcm_template/email/html/event-update_password.ftl b/idp/mcm_template/email/html/event-update_password.ftl new file mode 100644 index 0000000..27825c7 --- /dev/null +++ b/idp/mcm_template/email/html/event-update_password.ftl @@ -0,0 +1,5 @@ + + +${kcSanitize(msg("eventUpdatePasswordBodyHtml",event.date, event.ipAddress))?no_esc} + + diff --git a/idp/mcm_template/email/html/event-update_totp.ftl b/idp/mcm_template/email/html/event-update_totp.ftl new file mode 100644 index 0000000..3ed37c3 --- /dev/null +++ b/idp/mcm_template/email/html/event-update_totp.ftl @@ -0,0 +1,5 @@ + + +${kcSanitize(msg("eventUpdateTotpBodyHtml",event.date, event.ipAddress))?no_esc} + + diff --git a/idp/mcm_template/email/html/executeActions.ftl b/idp/mcm_template/email/html/executeActions.ftl new file mode 100644 index 0000000..3d89be4 --- /dev/null +++ b/idp/mcm_template/email/html/executeActions.ftl @@ -0,0 +1,57 @@ +<#outputformat "plainText"> + <#assign attributes = user.getAttributes()> + <#assign requiredActionsText> + <#if requiredActions??> + <#list requiredActions> + <#items as reqActionItem> + ${msg("requiredAction.${reqActionItem}")}<#sep>, + + + + + +<#import "../commons/template-mcm.ftl" as template> +<#import "../commons/button.ftl" as button> + +<@template.mcm> + <#if attributes.emailTemplate??> + <#if attributes.emailTemplate == 'financeur'> +
+
+
+ ${kcSanitize(msg("executeActionsBodyHtmlFunder1", user.getFirstName()))?no_esc} + ${kcSanitize(msg("executeActionsBodyHtmlFunder2", attributes.funderName))?no_esc} + ${kcSanitize(msg("executeActionsBodyHtmlFunder3"))?no_esc} +
+ <@button.confirm link=link text=msg("executeActionsBodyHtmlFunder4") /> +
+
+
+ ${kcSanitize(msg("executeActionsBodyHtmlFunder5"))?no_esc} +
+
+
+
+ + <#if attributes.emailTemplate == 'citoyen'> +
+
+
+ ${kcSanitize(msg("executeActionsBodyHtmlCitizen1", user.getFirstName()))?no_esc} + ${kcSanitize(msg("executeActionsBodyHtmlCitizen2", attributes.funderName))?no_esc} +
+ <@button.confirm link=link text=msg("executeActionsBodyHtmlCitizen3") /> +
+ ${kcSanitize(msg("executeActionsBodyHtmlCitizen4"))?no_esc} +
+
+ ${kcSanitize(msg("executeActionsBodyHtmlCitizen5"))?no_esc} +
+
+
+
+ + <#else> + ${kcSanitize(msg("executeActionsBodyHtml",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration)))?no_esc} + + \ No newline at end of file diff --git a/idp/mcm_template/email/html/identity-provider-link.ftl b/idp/mcm_template/email/html/identity-provider-link.ftl new file mode 100644 index 0000000..8eca412 --- /dev/null +++ b/idp/mcm_template/email/html/identity-provider-link.ftl @@ -0,0 +1,26 @@ + + + <#if properties.idpFQDN == "undefined"> + + <#else> + + + +<#include "../commons/header.ftl"> + +
+
+
+ ${kcSanitize(msg("identityProviderLinkBodyHtml1", user.getFirstName()))?no_esc} + ${kcSanitize(msg("identityProviderLinkBodyHtml2"))?no_esc} +
+ ${kcSanitize(msg("identityProviderLinkBodyHtml4"))?no_esc} +
+
+ ${kcSanitize(msg("identityProviderLinkBodyHtml5"))?no_esc} +
+ + <#include "../commons/footer.ftl"> + diff --git a/idp/mcm_template/email/html/password-reset.ftl b/idp/mcm_template/email/html/password-reset.ftl new file mode 100644 index 0000000..b076ff2 --- /dev/null +++ b/idp/mcm_template/email/html/password-reset.ftl @@ -0,0 +1,20 @@ +<#import "../commons/template-mcm.ftl" as template> +<#import "../commons/button.ftl" as button> + +<@template.mcm> + + + + +
+ ${kcSanitize(msg("passwordResetBodyHtml1", user.getFirstName()))?no_esc} + ${kcSanitize(msg("passwordResetBodyHtml2"))?no_esc} +
+ <@button.confirm link=link text=msg("passwordResetBodyHtml3") /> +
+ ${kcSanitize(msg("passwordResetBodyHtml4", linkExpirationFormatter(linkExpiration)))?no_esc} +
+ ${kcSanitize(msg("passwordResetBodyHtml5"))?no_esc} +
+
+ diff --git a/idp/mcm_template/email/messages/messages_fr.properties b/idp/mcm_template/email/messages/messages_fr.properties new file mode 100644 index 0000000..986393a --- /dev/null +++ b/idp/mcm_template/email/messages/messages_fr.properties @@ -0,0 +1,63 @@ +emailVerificationSubject=Activez votre compte moB +emailVerificationBody=Bonjour,\n\nVeuillez trouver ci-dessous le lien pour activer votre compte : \n\n{0}\n\n.\n\nAttention, ce lien sera actif 24 heures. Votre identifiant correspond \u00e0 l''adresse email personnelle que vous avez renseign\u00e9 lors de la cr\u00e9ation de votre compte.\n\nNous vous souhaitons la bienvenue dans la communaut\u00e9 de Mon Compte Mobilit\u00e9.\n\nMerci pour votre confiance,\n\nL''\u00e9quipe MOB +emailVerificationBodyHtml1=

Bonjour {0},

+emailVerificationBodyHtml2=

Derni\u00e8re \u00e9tape ! Cliquez sur le bouton ci-dessous pour activer votre compte et profiter de vos aides \u00e0 la mobilit\u00e9.

+emailVerificationBodyHtml3=J''active mon compte +emailVerificationBodyHtml4=

Attention ce lien sera actif 24 heures. Votre identifiant est l''adresse mail personnelle que vous avez renseign\u00e9e lors de la cr\u00e9ation de compte.

+emailVerificationBodyHtml5=

Merci pour votre confiance,

L''\u00e9quipe Mon Compte Mobilit\u00e9

+ +executeActionsBodyCitizen=Bonjour {5},\n\n Derni\u00e8re \u00e9tape ! Cliquez sur le bouton ci-dessous pour activer votre compte et profiter de vos aides \u00e0 la mobilit\u00e9.\n\n +executeActionsBodyHtmlCitizen1=

Bonjour {0},

+executeActionsBodyHtmlCitizen2=

Derni\u00e8re \u00e9tape ! Cliquez sur le bouton ci-dessous pour activer votre compte et profiter de vos aides \u00e0 la mobilit\u00e9.

+executeActionsBodyHtmlCitizen3=J''active mon compte +executeActionsBodyHtmlCitizen4=

Attention ce lien sera actif 24 heures. Votre identifiant est l''adresse mail personnelle que vous avez renseign\u00e9e lors de la cr\u00e9ation de compte.

+executeActionsBodyHtmlCitizen5=

Merci pour votre confiance,

L''\u00e9quipe Mon Compte Mobilit\u00e9

+ +executeActionsBodyHtmlFunder1=

Bonjour {0},

+executeActionsBodyHtmlFunder2=

Bienvenue dans la communaut\u00e9 Mon Compte Mobilit\u00e9. Votre organisation {0} a cr\u00e9\u00e9 un compte utilisateur pour administrer la plateforme MOB.

+executeActionsBodyHtmlFunder3=

Pour cela, vous devez activer votre compte et param\u00e9trer votre mot de passe depuis le lien ci-dessous :

+executeActionsBodyHtmlFunder4=Lien d''activation +executeActionsBodyHtmlFunder5=

Merci pour votre confiance,

L''\u00e9quipe Mon Compte Mobilit\u00e9

+ +executeActionsBody=Votre administrateur vient de demander une mise \u00e0 jour de votre compte {2}. Veuillez cliquer sur le lien ci-dessous afin de commencer le processus.\n\n{0}\n\nCe lien expire dans {1} minute(s).\n\nSi vous n''\u00eates pas \u00e0 l''origine de cette requ\u00eate, veuillez ignorer ce message ; aucun changement ne sera effectu\u00e9 sur votre compte. +executeActionsBodyHtml=

Votre administrateur vient de demander une mise \u00e0 jour de votre compte {2}. Veuillez cliquer sur le lien ci-dessous afin de commencer le processus.

{0}

Ce lien expire dans {1} minute(s).

Si vous n''\u00eates pas \u00e0 l''origine de cette requ\u00eate, veuillez ignorer ce message ; aucun changement ne sera effectu\u00e9 sur votre compte.

+#emailVerificationBody=Quelqu''un vient de cr\u00e9er un compte {2} avec votre courriel. Si vous \u00eates \u00e0 l''origine de cette requ\u00eate, veuillez cliquer sur le lien ci-dessous afin de v\u00e9rifier votre adresse de courriel

{0}

Ce lien expire dans {1} minute(s).

Sinon, veuillez ignorer ce message. +#emailVerificationBodyHtml=

Quelqu''un vient de cr\u00e9er un compte {2} avec votre courriel. Si vous \u00eates \u00e0 l''origine de cette requ\u00eate, veuillez cliquer sur le lien ci-dessous afin de v\u00e9rifier votre adresse de courriel

{0}

Ce lien expire dans {1} minute(s).

Sinon, veuillez ignorer ce message.

+passwordResetSubject=R\u00e9initialiser mon mot de passe +passwordResetBody=Bonjour,\n\nVous avez demand\u00e9 \u00e0 r\u00e9initialiser votre mot de passe. Cliquez sur le lien suivant pour cr\u00e9er un nouveau mot de passe. \n\n{0}\n\nAttention, ce lien sera actif {3}.\n\nSi vous n’\u00eates pas \u00e0 l''origine de cette demande, vous pouvez ignorer ce message. Aucun changement ne sera effectué.\n\nMerci pour votre confiance,\n\nL''\u00e9quipe MOB +passwordResetBodyHtml1=

Bonjour {0},

+passwordResetBodyHtml2=

Vous avez demand\u00e9 \u00e0 r\u00e9initialiser votre mot de passe. Cliquez sur le lien suivant pour cr\u00e9er votre nouveau mot de passe.

+passwordResetBodyHtml3=Je r\u00e9initialise mon mot de passe +passwordResetBodyHtml4=

Attention ce lien sera actif {0}. Si vous n''\u00eates pas \u00e0 l''origine de cette
demande, vous pouvez ignorer ce message. Aucun changement ne sera effectu\u00e9.

+passwordResetBodyHtml5=

Merci pour votre confiance,

L''\u00e9quipe Mon Compte Mobilit\u00e9

+ +executeActionsSubject=Activez votre compte moB +eventLoginErrorSubject=Erreur de connexion +eventLoginErrorBody=Une tentative de connexion a \u00e9t\u00e9 d\u00e9tect\u00e9e sur votre compte {0} depuis {1}. Si vous n''\u00eates pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur. +eventLoginErrorBodyHtml=

Une tentative de connexion a \u00e9t\u00e9 d\u00e9tect\u00e9e sur votre compte {0} depuis {1}. Si vous n''\u00eates pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur.

+eventRemoveTotpSubject=Suppression du OTP +eventRemoveTotpBody=Le OTP a \u00e9t\u00e9 supprim\u00e9 de votre compte {0} depuis {1}. Si vous n''\u00e9tiez pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur. +eventRemoveTotpBodyHtml=

Le OTP a \u00e9t\u00e9 supprim\u00e9 de votre compte {0} depuis {1}. Si vous n''\u00e9tiez pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur.

+eventUpdatePasswordSubject=Mise \u00e0 jour du mot de passe +eventUpdatePasswordBody=Votre mot de passe pour votre compte {0} a \u00e9t\u00e9 modifi\u00e9 depuis {1}. Si vous n''\u00e9tiez pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur. +eventUpdatePasswordBodyHtml=

Votre mot de passe pour votre compte {0} a \u00e9t\u00e9 modifi\u00e9 depuis {1}. Si vous n''\u00e9tiez pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur.

+eventUpdateTotpSubject=Mise \u00e0 jour du OTP +eventUpdateTotpBody=Le OTP a \u00e9t\u00e9 mis \u00e0 jour pour votre compte {0} depuis {1}. Si vous n''\u00e9tiez pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur. +eventUpdateTotpBodyHtml=

Le OTP a \u00e9t\u00e9 mis \u00e0 jour pour votre compte {0} depuis {1}. Si vous n''\u00e9tiez pas \u00e0 l''origine de cette requ\u00eate, veuillez contacter votre administrateur.

+ +identityProviderLinkSubject=Lier FranceConnect \u00e0 moB +identityProviderLinkBodyHtml1=

Bonjour {0},

+identityProviderLinkBodyHtml2=

Si vous souhaitez lier FranceConnect \u00e0 moB, veuillez cliquer sur le bouton ci-dessous pour lier FranceConnect \u00e0 moB. Attention, ce lien n''est valable que 5 mins.

Vous pourrez par la suite vous connecter \u00e0 votre compte moB avec vos identifiants FranceConnect.

+identityProviderLinkBodyHtml3=Lier FranceConnect \u00e0 moB +identityProviderLinkBodyHtml4=

Si vous ne souhaitez pas lier FranceConnect \u00e0 moB, veuillez ignorer ce message.

+identityProviderLinkBodyHtml5=

Merci pour votre confiance,

L''\u00e9quipe Mon Compte Mobilit\u00e9

+ +# units for link expiration timeout formatting +linkExpirationFormatter.timePeriodUnit.seconds=seconds +linkExpirationFormatter.timePeriodUnit.seconds.1=second +linkExpirationFormatter.timePeriodUnit.minutes=minutes +linkExpirationFormatter.timePeriodUnit.minutes.1=minute +linkExpirationFormatter.timePeriodUnit.hours=heures +linkExpirationFormatter.timePeriodUnit.hours.1=heure +linkExpirationFormatter.timePeriodUnit.days=jours +linkExpirationFormatter.timePeriodUnit.days.1=jour diff --git a/idp/mcm_template/email/resources/css/style.css b/idp/mcm_template/email/resources/css/style.css new file mode 100644 index 0000000..16ce8fb --- /dev/null +++ b/idp/mcm_template/email/resources/css/style.css @@ -0,0 +1,146 @@ +* { + font-family: 'sofia-pro', sans-serif; + font-weight: 200; + font-size: 15px; + line-height: 1.5; + overflow-wrap: break-word; + } + + .grid-footer { + display: grid; + grid-template-columns: 600px; + justify-content: center; + height: 150px; + width: 100%; + text-align: center; + padding-top: 30px; + color: white; + margin-top: 20px; + } + + .grid-header { + display: grid; + grid-template-columns: 600px; + justify-content: center; + margin-bottom: 20px; + } + + .grid-container { + display: grid; + grid-template-columns: 600px; + justify-content: center; + } + + .grid-center { + text-align: left; + } + + .header-container { + text-align: center; + } + + .confirm-btn { + color: white; + background-color: #01BF7D; + border-radius: 25px; + outline: none; + padding: 12px 20px; + border: none; + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + text-decoration: none; + } + + .btn { + margin: 35px 0; + text-align: center; + } + + .confirm-btn a { + text-decoration: none; + color: white; + } + + .footer-logo { + padding-top: 15px; + width: auto; + height: 50px; + object-fit: contain; + } + + .container { + background-color: #464CD0; + } + + .header-logo { + width: auto; + height: 50px; + } + + .italic { + font-style: italic; + font-weight: bold; + } + + .colorLink { + color: white; + } + + /* Mobile */ + + @media screen and (max-width: 600px) { + .grid-footer { + grid-template-columns: 1fr; + text-align: left !important; + height: 100px; + padding-top: 0; + margin-top: 0; + } + + .grid-header { + grid-template-columns: 1fr; + } + + .grid-container { + grid-template-columns: 1fr; + } + .confirm-btn { + padding: 10px 12px; + font-size: 8px; + } + + .container { + position: relative; + width: 100%; + height: 100%; + } + + .footer-img { + position: absolute; + left: 0; + width: 50%; + margin-left: 30px; + } + + .footer-parag { + position: absolute; + right: 0; + width: 40%; + margin-right: 80px; + } + + .footer-logo { + height: 50px; + padding-top: 20px; + } + } + + @media screen and (max-width: 438px) { + + .footer-parag { + width: 50%; + font-size: 12px; + margin-right: 60px; + } + + + } diff --git a/idp/mcm_template/email/resources/images/logo-with-baseline.svg b/idp/mcm_template/email/resources/images/logo-with-baseline.svg new file mode 100644 index 0000000..b2ada22 --- /dev/null +++ b/idp/mcm_template/email/resources/images/logo-with-baseline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/idp/mcm_template/email/resources/images/mob-footer.svg b/idp/mcm_template/email/resources/images/mob-footer.svg new file mode 100644 index 0000000..364a607 --- /dev/null +++ b/idp/mcm_template/email/resources/images/mob-footer.svg @@ -0,0 +1 @@ + diff --git a/idp/mcm_template/email/text/email-test.ftl b/idp/mcm_template/email/text/email-test.ftl new file mode 100644 index 0000000..f1becdd --- /dev/null +++ b/idp/mcm_template/email/text/email-test.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("emailTestBody", realmName)} \ No newline at end of file diff --git a/idp/mcm_template/email/text/email-verification-with-code.ftl b/idp/mcm_template/email/text/email-verification-with-code.ftl new file mode 100644 index 0000000..4ffb7d8 --- /dev/null +++ b/idp/mcm_template/email/text/email-verification-with-code.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("emailVerificationBodyCode",code)} \ No newline at end of file diff --git a/idp/mcm_template/email/text/email-verification.ftl b/idp/mcm_template/email/text/email-verification.ftl new file mode 100644 index 0000000..9e39696 --- /dev/null +++ b/idp/mcm_template/email/text/email-verification.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("emailVerificationBody",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))} \ No newline at end of file diff --git a/idp/mcm_template/email/text/event-login_error.ftl b/idp/mcm_template/email/text/event-login_error.ftl new file mode 100644 index 0000000..bfb4036 --- /dev/null +++ b/idp/mcm_template/email/text/event-login_error.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("eventLoginErrorBody",event.date,event.ipAddress)} \ No newline at end of file diff --git a/idp/mcm_template/email/text/event-remove_totp.ftl b/idp/mcm_template/email/text/event-remove_totp.ftl new file mode 100644 index 0000000..a7e3b68 --- /dev/null +++ b/idp/mcm_template/email/text/event-remove_totp.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("eventRemoveTotpBody",event.date, event.ipAddress)} \ No newline at end of file diff --git a/idp/mcm_template/email/text/event-update_password.ftl b/idp/mcm_template/email/text/event-update_password.ftl new file mode 100644 index 0000000..2ec7ea0 --- /dev/null +++ b/idp/mcm_template/email/text/event-update_password.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("eventUpdatePasswordBody",event.date, event.ipAddress)} \ No newline at end of file diff --git a/idp/mcm_template/email/text/event-update_totp.ftl b/idp/mcm_template/email/text/event-update_totp.ftl new file mode 100644 index 0000000..14778b5 --- /dev/null +++ b/idp/mcm_template/email/text/event-update_totp.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("eventUpdateTotpBody",event.date, event.ipAddress)} \ No newline at end of file diff --git a/idp/mcm_template/email/text/executeActions.ftl b/idp/mcm_template/email/text/executeActions.ftl new file mode 100644 index 0000000..0745606 --- /dev/null +++ b/idp/mcm_template/email/text/executeActions.ftl @@ -0,0 +1,23 @@ +<#ftl output_format="plainText"> +<#assign attributes = user.getAttributes()> +<#assign requiredActionsText> + <#if requiredActions??> + <#list requiredActions> + <#items as reqActionItem> + ${msg("requiredAction.${reqActionItem}")}<#sep>, + + + <#else> + + + +<#if attributes.emailTemplate??> + <#if attributes.emailTemplate == 'financeur'> + ${msg("executeActionsBodyHtmlFunder",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration), user.getFirstName(), attributes.funderName)} + + <#if attributes.emailTemplate == 'citoyen'> + ${msg("executeActionsBodyHtmlCitizen",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration), user.getFirstName())} + +<#else> + ${msg("executeActionsBodyHtml",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration))} + diff --git a/idp/mcm_template/email/text/identity-provider-link.ftl b/idp/mcm_template/email/text/identity-provider-link.ftl new file mode 100644 index 0000000..ed9d246 --- /dev/null +++ b/idp/mcm_template/email/text/identity-provider-link.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("identityProviderLinkBody", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration))} \ No newline at end of file diff --git a/idp/mcm_template/email/text/password-reset.ftl b/idp/mcm_template/email/text/password-reset.ftl new file mode 100644 index 0000000..1e79211 --- /dev/null +++ b/idp/mcm_template/email/text/password-reset.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("passwordResetBody",link, linkExpiration, realmName, user.getFirstName(), linkExpirationFormatter(linkExpiration))} diff --git a/idp/mcm_template/email/theme.properties b/idp/mcm_template/email/theme.properties new file mode 100644 index 0000000..5908873 --- /dev/null +++ b/idp/mcm_template/email/theme.properties @@ -0,0 +1,8 @@ +locales=fr + +styles=../login/css/style.css + +websiteFQDN=${env.WEBSITE_FQDN} +idpFQDN=${env.IDP_FQDN:undefined} +landscape = ${env.LANDSCAPE} +baseDomain = ${env.BASE_DOMAIN} \ No newline at end of file diff --git a/idp/mcm_template/login/code.ftl b/idp/mcm_template/login/code.ftl new file mode 100644 index 0000000..6830fc4 --- /dev/null +++ b/idp/mcm_template/login/code.ftl @@ -0,0 +1,19 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + <#if code.success> + ${msg("codeSuccessTitle")} + <#else> + ${msg("codeErrorTitle", code.error)} + + <#elseif section = "form"> +
+ <#if code.success> +

${msg("copyCodeInstruction")}

+ + <#else> +

${code.error}

+ +
+ + diff --git a/idp/mcm_template/login/error.ftl b/idp/mcm_template/login/error.ftl new file mode 100644 index 0000000..94d7d4c --- /dev/null +++ b/idp/mcm_template/login/error.ftl @@ -0,0 +1,20 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=false; section> + <#if section = "header"> + ${msg("errorTitle")} + <#elseif section = "form"> +
+

${kcSanitize(msg("messageErreur1"))?no_esc}

+

${kcSanitize(msg("messageErreur2"))?no_esc}

+ <#if client?? && client.baseUrl?has_content> + + <#else> + + +
+ + diff --git a/idp/mcm_template/login/info.ftl b/idp/mcm_template/login/info.ftl new file mode 100644 index 0000000..65f53eb --- /dev/null +++ b/idp/mcm_template/login/info.ftl @@ -0,0 +1,41 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=false; section> + <#if section = "header"> + <#if messageHeader??> + ${messageHeader} + <#else> + ${message.summary} + + <#elseif section = "form"> +
+

+ <#if requiredActions??><#list requiredActions><#items as reqActionItem>${msg("requiredAction.${reqActionItem}")}<#sep>, + <#else> + +

+ <#if skipLink??> +

${kcSanitize(msg("activeAccount"))?no_esc}

+

${kcSanitize(msg("activeAccountDescription1"))?no_esc}

+

${kcSanitize(msg("activeAccountDescription2"))?no_esc}

+
+ ${kcSanitize(msg("btnLogIn"))?no_esc} +
+ <#else> + <#if pageRedirectUri?has_content> +

${kcSanitize(msg("backToApplication"))?no_esc}

+ <#elseif actionUri?has_content> +

${kcSanitize(msg("proceedWithAction"))?no_esc}

+
+ Activer mon compte +
+ <#-- Bypass manual action from user --> + + <#elseif (client.baseUrl)?has_content> +

${kcSanitize(msg("backToApplication"))?no_esc}

+ + +
+ + \ No newline at end of file diff --git a/idp/mcm_template/login/login-config-totp-text.ftl b/idp/mcm_template/login/login-config-totp-text.ftl new file mode 100644 index 0000000..d609182 --- /dev/null +++ b/idp/mcm_template/login/login-config-totp-text.ftl @@ -0,0 +1,31 @@ +<#ftl output_format="plainText"> +${msg("loginTotpIntro")} + +${msg("loginTotpStep1")} + +<#list totp.policy.supportedApplications as app> +* ${app} + + +${msg("loginTotpManualStep2")} + + ${totp.totpSecretEncoded} + + +${msg("loginTotpManualStep3")} + +- ${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)} +- ${msg("loginTotpAlgorithm")}: ${totp.policy.getAlgorithmKey()} +- ${msg("loginTotpDigits")}: ${totp.policy.digits} +<#if totp.policy.type = "totp"> +- ${msg("loginTotpInterval")}: ${totp.policy.period} + +<#elseif totp.policy.type = "hotp"> +- ${msg("loginTotpCounter")}: ${totp.policy.initialCounter} + + + +Enter in your one time password so we can verify you have installed it correctly. + + + diff --git a/idp/mcm_template/login/login-config-totp.ftl b/idp/mcm_template/login/login-config-totp.ftl new file mode 100644 index 0000000..8a57857 --- /dev/null +++ b/idp/mcm_template/login/login-config-totp.ftl @@ -0,0 +1,93 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true displayRequiredFields=true; section> + + <#if section = "header"> + ${msg("loginTotpTitle")} + + <#elseif section = "form"> + +
    +
  1. +

    ${msg("loginTotpStep1")}

    + +
      + <#list totp.policy.supportedApplications as app> +
    • ${app}
    • + +
    +
  2. + + <#if mode?? && mode = "manual"> +
  3. +

    ${msg("loginTotpManualStep2")}

    +

    ${totp.totpSecretEncoded}

    +

    ${msg("loginTotpScanBarcode")}

    +
  4. +
  5. +

    ${msg("loginTotpManualStep3")}

    +

    +

      +
    • ${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}
    • +
    • ${msg("loginTotpAlgorithm")}: ${totp.policy.getAlgorithmKey()}
    • +
    • ${msg("loginTotpDigits")}: ${totp.policy.digits}
    • + <#if totp.policy.type = "totp"> +
    • ${msg("loginTotpInterval")}: ${totp.policy.period}
    • + <#elseif totp.policy.type = "hotp"> +
    • ${msg("loginTotpCounter")}: ${totp.policy.initialCounter}
    • + +
    +

    +
  6. + <#else> +
  7. +

    ${msg("loginTotpStep2")}

    + Figure: Barcode
    +

    ${msg("loginTotpUnableToScan")}

    +
  8. + +
  9. +

    ${msg("loginTotpStep3")}

    +

    ${msg("loginTotpStep3DeviceName")}

    +
  10. +
+ +
+
+
+ * +
+
+ +
+ + <#if mode??> +
+ +
+
+ <#if totp.otpCredentials?size gte 1>* +
+ +
+ +
+
+ + <#if isAppInitiatedAction??> + + + <#else> + + +
+ + diff --git a/idp/mcm_template/login/login-idp-link-confirm.ftl b/idp/mcm_template/login/login-idp-link-confirm.ftl new file mode 100644 index 0000000..b854265 --- /dev/null +++ b/idp/mcm_template/login/login-idp-link-confirm.ftl @@ -0,0 +1,19 @@ +<#import "template-minimal.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("confirmLinkIdpTitle", idpDisplayName, realm.displayName)} + <#elseif section = "form"> +
+
+ ${msg("confirmLinkIdpDescription", idpDisplayName, realm.displayName )} +
+
+ + +
+ +
+ + diff --git a/idp/mcm_template/login/login-idp-link-email.ftl b/idp/mcm_template/login/login-idp-link-email.ftl new file mode 100644 index 0000000..d8f1710 --- /dev/null +++ b/idp/mcm_template/login/login-idp-link-email.ftl @@ -0,0 +1,16 @@ +<#import "template-minimal.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("emailLinkIdpTitle", idpDisplayName)} + <#elseif section = "form"> +

+ ${msg("emailLinkIdp1", idpDisplayName, brokerContext.username, realm.displayName)} +

+

+ ${msg("emailLinkIdp2")} ${msg("doClickLink")} +

+

+ ${msg("emailLinkIdp4")} ${msg("doClickLink")} +

+ + \ No newline at end of file diff --git a/idp/mcm_template/login/login-oauth-grant.ftl b/idp/mcm_template/login/login-oauth-grant.ftl new file mode 100644 index 0000000..8105492 --- /dev/null +++ b/idp/mcm_template/login/login-oauth-grant.ftl @@ -0,0 +1,70 @@ +<#import "template-minimal.ftl" as layout> +<@layout.registrationLayout bodyClass="oauth"; section> + <#if section = "header"> + <#if client.name?has_content> +
+ ${msg("oauthGrantTitle", client.name)} + ${client.name} ? +
+ <#else> +
+ ${msg("oauthGrantTitle", client.clientId)} + ${client.clientId} ? +
+ + <#elseif section = "form"> +
+
${msg("oauthGrantRequest")}
+ +
    + <#if oauth.clientScopesRequested??> + <#list oauth.clientScopesRequested as clientScope> +
  • + ${advancedMsg(clientScope.consentScreenText)} +
  • + + +
+ +
+ +
+
+ ${msg("privateDataProtectionText")} +
+ ${msg("privateDataProtectionConsultation")} + + ${msg("privateDataProtectionLinkText")} + . +
+
+
+
+ + +
+
+
+
+ +
+
+ + + diff --git a/idp/mcm_template/login/login-otp.ftl b/idp/mcm_template/login/login-otp.ftl new file mode 100644 index 0000000..fc2e14d --- /dev/null +++ b/idp/mcm_template/login/login-otp.ftl @@ -0,0 +1,70 @@ +<#import "template.ftl" as layout> + <@layout.registrationLayout; section> + <#if section="header"> + ${msg("doLogIn")} + <#elseif section="form"> +
+ <#if otpLogin.userOtpCredentials?size gt 1> +
+
+ <#list otpLogin.userOtpCredentials as otpCredential> +
+ +
+ +

+ ${otpCredential.userLabel} +

+
+
+ +
+
+ + +
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+
+ + + + \ No newline at end of file diff --git a/idp/mcm_template/login/login-page-expired.ftl b/idp/mcm_template/login/login-page-expired.ftl new file mode 100644 index 0000000..2b470e0 --- /dev/null +++ b/idp/mcm_template/login/login-page-expired.ftl @@ -0,0 +1,11 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("pageExpiredTitle")} + <#elseif section = "form"> +

+ ${msg("pageExpiredMsg1")} ${msg("doClickHere")} .
+ ${msg("pageExpiredMsg2")} ${msg("doClickHere")} . +

+ + diff --git a/idp/mcm_template/login/login-password.ftl b/idp/mcm_template/login/login-password.ftl new file mode 100644 index 0000000..f9011f6 --- /dev/null +++ b/idp/mcm_template/login/login-password.ftl @@ -0,0 +1,33 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=false displayWide=false; section> + <#if section = "header"> + ${msg("doLogIn")} + <#elseif section = "form"> +
+
+
+ +
+ + +
+ +
+
+
+
+ <#if realm.resetPasswordAllowed> + ${msg("doForgotPassword")} + +
+
+ +
+ +
+
+
+
+ + + diff --git a/idp/mcm_template/login/login-reset-password.ftl b/idp/mcm_template/login/login-reset-password.ftl new file mode 100644 index 0000000..cd94b8a --- /dev/null +++ b/idp/mcm_template/login/login-reset-password.ftl @@ -0,0 +1,49 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header"> + ${msg("emailForgotTitle")} + <#elseif section = "form"> +

${msg("emailInstruction")}

+
+
+ + <#if auth?has_content && auth.showUsername()> + + <#else> + +
+

${msg("invalidEmailFormat")}

+
+ +
+ +
+ <#-- Custom invalid regex for username input as email --> + + + diff --git a/idp/mcm_template/login/login-update-password.ftl b/idp/mcm_template/login/login-update-password.ftl new file mode 100644 index 0000000..d037958 --- /dev/null +++ b/idp/mcm_template/login/login-update-password.ftl @@ -0,0 +1,59 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header"> + ${msg("updatePasswordTitle")} + <#elseif section = "form"> +
+
+

${msg("passwordPolicyText")}

+
    +
  • ${msg("passwordPolicy1")}
  • +
  • ${msg("passwordPolicy2")}
  • +
  • ${msg("passwordPolicy3")}
  • +
  • ${msg("passwordPolicy4")}
  • +
  • ${msg("passwordPolicy5")}
  • +
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ <#if isAppInitiatedAction??> + + + <#else> + + +
+
+
+ + + diff --git a/idp/mcm_template/login/login-update-profile.ftl b/idp/mcm_template/login/login-update-profile.ftl new file mode 100644 index 0000000..d87a90f --- /dev/null +++ b/idp/mcm_template/login/login-update-profile.ftl @@ -0,0 +1,61 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("loginProfileTitle")} + <#elseif section = "form"> +
+ <#if user.editUsernameAllowed> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ <#if isAppInitiatedAction??> + + + <#else> + + +
+
+
+ + diff --git a/idp/mcm_template/login/login-username.ftl b/idp/mcm_template/login/login-username.ftl new file mode 100644 index 0000000..ae2ccad --- /dev/null +++ b/idp/mcm_template/login/login-username.ftl @@ -0,0 +1,60 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=social.displayInfo displayWide=(realm.password && social.providers??); section> + <#if section = "header"> + ${msg("doLogIn")} + <#elseif section = "form"> +
class="${properties.kcContentWrapperClass!}"> +
class="${properties.kcFormSocialAccountContentClass!} ${properties.kcFormSocialAccountClass!}"> + <#if realm.password> +
+
+ + + <#if usernameEditDisabled??> + + <#else> + + +
+ +
+
+ <#if realm.rememberMe && !usernameEditDisabled??> +
+ +
+ +
+
+ +
+ +
+
+ +
+ <#if realm.password && social.providers??> +
+ +
+ +
+ <#elseif section = "info" > + <#if realm.password && realm.registrationAllowed && !registrationDisabled??> +
+ ${msg("noAccount")} ${msg("doRegister")} +
+ + + + diff --git a/idp/mcm_template/login/login-verify-email-code-text.ftl b/idp/mcm_template/login/login-verify-email-code-text.ftl new file mode 100644 index 0000000..87abcd7 --- /dev/null +++ b/idp/mcm_template/login/login-verify-email-code-text.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("console-verify-email",email, code)} \ No newline at end of file diff --git a/idp/mcm_template/login/login-verify-email.ftl b/idp/mcm_template/login/login-verify-email.ftl new file mode 100644 index 0000000..f050927 --- /dev/null +++ b/idp/mcm_template/login/login-verify-email.ftl @@ -0,0 +1,20 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("emailVerifyTitle")} + <#elseif section = "form"> +

+ ${msg("emailVerifyInstruction1")} +

+

+ ${msg("emailVerifyInstruction2")}
+ ${msg("emailVerifyInstruction3")} +

+ + + + diff --git a/idp/mcm_template/login/login-x509-info.ftl b/idp/mcm_template/login/login-x509-info.ftl new file mode 100644 index 0000000..0228b06 --- /dev/null +++ b/idp/mcm_template/login/login-x509-info.ftl @@ -0,0 +1,55 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("doLogIn")} + <#elseif section = "form"> + +
+
+ +
+ +
+ <#if x509.formData.subjectDN??> +
+ +
+ <#else> +
+ +
+ +
+ +
+ + <#if x509.formData.isUserEnabled??> +
+ +
+
+ +
+ + +
+ +
+
+
+
+
+ +
+
+ + <#if x509.formData.isUserEnabled??> + + +
+
+
+
+ + + diff --git a/idp/mcm_template/login/login.ftl b/idp/mcm_template/login/login.ftl new file mode 100644 index 0000000..9f5166c --- /dev/null +++ b/idp/mcm_template/login/login.ftl @@ -0,0 +1,112 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=social.displayInfo displayWide=(realm.password && social.providers??); section> + <#if section = "header"> + ${msg("doLogIn")} + <#elseif section = "form"> +
class="${properties.kcContentWrapperClass!}"> +
class="${properties.kcFormSocialAccountContentClass!} ${properties.kcFormSocialAccountClass!}"> +
+

${msg("headerTitle")}

+

${msg("textSignIn")}

+
+
+ <#if realm.password> +
+ +

${msg("textFC")}

+ + <#if realm.password && social.providers??> +
+ <#list social.providers as p> + <#if p.providerId == 'franceconnect-particulier'> + + ${msg("franceconnectLink")} + + +
+ +
+ +
${msg("orLabel")}
+
${msg("orLabel")}
+
+ +
+
+ +
+
+
+ + + <#elseif section = "info" > + <#if realm.password && realm.registrationAllowed && !registrationDisabled??> +
+ ${msg("noAccount")} ${msg("doRegister")} +
+ + + + \ No newline at end of file diff --git a/idp/mcm_template/login/messages/messages_fr.properties b/idp/mcm_template/login/messages/messages_fr.properties new file mode 100644 index 0000000..5a7e850 --- /dev/null +++ b/idp/mcm_template/login/messages/messages_fr.properties @@ -0,0 +1,321 @@ +doLogIn=J''ai d\u00e9j\u00e0 un compte +doSignIn=Je n''ai pas encore de compte +doLogInMob=avec vos identifiants moB +doLogInFC=avec FranceConnect +headerTitle=Connectez-vous \u00e0 votre compte moB +headerSigninMsg=Vous n''avez pas encore de compte ? +headerSigninBtn=Inscrivez-vous. +textFC=FranceConnect est la solution propos\u00e9e par l''\u00c9tat pour s\u00e9curiser et simplifier la connexion \u00e0 vos services en ligne +textSignIn=Profitez en quelques clics des dispositifs d''aides propos\u00e9s par les collectivit\u00e9s et entreprises partenaires. +btnLogIn=Me connecter +questionSignIn=Vous n''avez pas de compte ? +btnSignIn=Cr\u00e9er un compte moB +doRegister=Enregistrement +doCancel=Annuler +doSubmit=Envoyer +doYes=Oui +doNo=Non +doContinue=Continuer +doIgnore=Ignorer +doAccept=Accepter +doDecline=D\u00e9cliner +doForgotPassword=Mot de passe oubli\u00e9 ? +doClickHere=Cliquez sur le lien ci-dessous pour v\u00e9rifier votre mail et activer votre compte. +doClickLink=Cliquez sur le lien pour v\u00e9rifier votre mail et lier votre compte. +doClickGoBack=Non, je souhaite revenir en arri\u00e8re +doTryAnotherWay= +doImpersonate=Impersonate +kerberosNotConfigured=Kerberos non configur\u00e9 +kerberosNotConfiguredTitle=Kerberos non configur\u00e9 +bypassKerberosDetail=Si vous n''\u00eates pas connect\u00e9 via Kerberos ou bien que votre navigateur n''est pas configur\u00e9 pour la connexion via Kerberos. Veuillez cliquer pour vous connecter via un autre moyen. +kerberosNotSetUp=Kerberos n''est pas configur\u00e9. Connexion impossible. +registerTitle=S''enregistrer +registerWithTitle=Enregistrement avec {0} +registerWithTitleHtml={0} +loginTitle=Me connecter \u00e0 {0} +loginTitleHtml={0} +impersonateTitle={0} utilisateur impersonate +impersonateTitleHtml={0} utilisateur impersonate +realmChoice=Domaine +unknownUser=Utilisateur inconnu +loginTotpTitle=Configuration de l''authentification par mobile +loginProfileTitle=Mise \u00e0 jour du compte +loginTimeout=Le temps imparti pour la connexion est \u00e9coul\u00e9. Le processus de connexion red\u00e9marre depuis le d\u00e9but. +oauthGrantTitle=Connexion avec votre compte MOB depuis votre compte +oauthGrantTitleHtml={0} +errorTitle=Mince ! +errorTitleHtml=Mince ! +emailVerifyTitle=Mince ! +emailForgotTitle=Mot de passe oubli\u00e9 +updatePasswordTitle=D\u00e9finir votre mot de passe +codeSuccessTitle=Code succ\u00e8s +codeErrorTitle=Code d''erreur \: {0} +displayUnsupported=Type d''affichage demand\u00e9 non support\u00e9 +browserRequired=Navigateur requis pour se connecter +browserContinue=Navigateur requis pour continuer la connexion +browserContinuePrompt=Ouvrir le navigateur et continuer la connexion? [y/n]: +browserContinueAnswer=y + +termsTitle=Termes et Conditions +termsTitleHtml=Termes et Conditions +termsText=

Termes et conditions \u00e0 d\u00e9finir

+termsPlainText=Termes et conditions \u00e0 d\u00e9finir + +recaptchaFailed=Re-captcha invalide +recaptchaNotConfigured=Re-captcha est requis, mais il n''est pas configur\u00e9 +consentDenied=Consentement refus\u00e9. + +noAccount=Nouvel utilisateur ? +username=Identifiant +usernameOrEmail=Identifiant +firstName=Pr\u00e9nom +givenName=Pr\u00e9nom +fullName=Nom complet +lastName=Nom +familyName=Nom de famille +email=Email +password=Mot de passe +passwordConfirm=Confirmation du mot de passe +passwordNew=Nouveau mot de passe +passwordNewConfirm=Confirmation du nouveau mot de passe +rememberMe=Se souvenir de moi +authenticatorCode=Code \u00e0 usage unique +address=Adresse +street=Rue +locality=Ville ou Localit\u00e9 +region=\u00c9tat, Province ou R\u00e9gion +postal_code=Code postal +country=Pays +emailVerified=Mail v\u00e9rifi\u00e9 +gssDelegationCredential=Accr\u00e9ditation de d\u00e9l\u00e9gation GSS + +loginTotpIntro=Il est n\u00e9cessaire de configurer un g\u00e9n\u00e9rateur One Time Password pour acc\u00e9der \u00e0 ce compte +loginTotpStep1=Installez FreeOTP ou bien Google Authenticator sur votre mobile. Ces deux applications sont disponibles sur Google Play et Apple App Store. +loginTotpStep2=Ouvrez l''application et scannez le code-barres ou entrez la clef. +loginTotpStep3=Entrez le code \u00e0 usage unique fourni par l''application et cliquez sur Sauvegarder pour terminer. +loginTotpManualStep2=Ouvrez l''application et saisissez la cl\u00e9 +loginTotpManualStep3=Utilisez la configuration de valeur suivante si l''application permet son \u00e9dition +loginTotpUnableToScan=Impossible de scanner? +loginTotpScanBarcode=Scanner le code barre ? +loginOtpOneTime=Code \u00e0 usage unique +loginTotpType=Type +loginTotpAlgorithm=Algorithme +loginTotpDigits=Chiffres +loginTotpInterval=Intervalle +loginTotpCounter=Compteur + +loginTotp.totp=Bas\u00e9 sur le temps +loginTotp.hotp=Bas\u00e9 sur les compteurs + +oauthGrantRequest=Avec la liaison de compte, vous pourrez acc\u00e9der aux fonctionnalit\u00e9s suivantes depuis votre application : +inResource=dans + +emailVerifyInstruction1=Vous n''avez pas encore activ\u00e9 votre compte. +emailVerifyInstruction2=Un email vous a \u00e9t\u00e9 envoy\u00e9 pour activer votre compte. Si vous ne l''avez pas re\u00e7u cliquez ci-dessous. +emailVerifyInstruction3=Attention, ce lien ne sera valide que 24 heures. +emailVerifyBtn=Renvoyer le lien + +emailLinkIdpTitle=Association avec {0} +emailLinkIdp1=Un email a \u00e9t\u00e9 envoy\u00e9 \u00e0 l''adresse {1} avec des instructions pour lier {0} avec votre compte {2}. +emailLinkIdp2=Vous n''avez pas re\u00e7u de code dans le mail ? +emailLinkIdp3=pour renvoyer le mail. +emailLinkIdp4=Si vous avez d\u00e9j\u00e0 v\u00e9rifi\u00e9 votre mail dans un autre navigateur : +emailLinkIdp5=pour continuer. + +backToLogin=« Retour \u00e0 la connexion + +emailInstruction=Veuillez saisir votre adresse mail pour r\u00e9initialiser votre mot de passe. + +copyCodeInstruction=Copiez le code et recopiez le dans votre application : + +pageExpiredTitle=La page a expir\u00e9 +pageExpiredMsg1=Pour recommencer le processus d''authentification +pageExpiredMsg2=Pour continuer le processus d''authentification + +personalInfo=Information personnelle : +role_admin=Administrateur +role_realm-admin=Administrateur du domaine +role_create-realm=Cr\u00e9er un domaine +role_create-client=Cr\u00e9er un client +role_view-realm=Voir un domaine +role_view-users=Voir les utilisateurs +role_view-applications=Voir les applications +role_view-clients=Voir les clients +role_view-events=Voir les \u00e9v\u00e9nements +role_view-identity-providers=Voir les fournisseurs d''identit\u00e9 +role_manage-realm=G\u00e9rer le domaine +role_manage-users=G\u00e9rer les utilisateurs +role_manage-applications=G\u00e9rer les applications +role_manage-identity-providers=G\u00e9rer les fournisseurs d''identit\u00e9 +role_manage-clients=G\u00e9rer les clients +role_manage-events=G\u00e9rer les \u00e9v\u00e9nements +role_view-profile=Voir le profil +role_manage-account=G\u00e9rer le compte +role_manage-account-links=G\u00e9rer les liens de compte +role_read-token=Lire le jeton d''authentification +role_offline-access=Acc\u00e8s hors-ligne +client_account=Compte +client_security-admin-console=Console d''administration de la s\u00e9curit\u00e9 +client_admin-cli=Admin CLI +client_realm-management=Gestion du domaine +client_broker=Broker + +invalidUserMessage=Identifiant ou mot de passe incorrect.Vous avez jusqu''\u00e0 5 essais pour vous connecter \u00e0 MOB. Au-del\u00e0, votre compte sera temporairement bloqu\u00e9 24h. +invalidEmailMessage=Mail invalide. +accountDisabledMessage=Compte d\u00e9sactiv\u00e9, contactez votre administrateur. +accountTemporarilyDisabledMessage=Ce compte est temporairement d\u00e9sactiv\u00e9, contactez votre administrateur ou bien r\u00e9essayez plus tard. +expiredCodeMessage=Connexion expir\u00e9e. Veuillez vous reconnecter. +expiredActionMessage=Action expir\u00e9e. Merci de continuer la connexion. +expiredActionTokenNoSessionMessage=Ce lien n''est plus actif. +expiredActionTokenSessionExistsMessage=Ce lien n''est plus actif, veuillez recommencer. + +missingFirstNameMessage=Veuillez entrer votre pr\u00e9nom. +missingLastNameMessage=Veuillez entrer votre nom. +missingEmailMessage=Veuillez entrer votre mail. +missingUsernameMessage=Veuillez entrer votre nom d''utilisateur. +missingPasswordMessage=Veuillez entrer votre mot de passe. +missingTotpMessage=Veuillez entrer votre code d''authentification. +notMatchPasswordMessage=Les mots de passe ne sont pas identiques. + +invalidPasswordExistingMessage=Mot de passe existant invalide. +invalidPasswordBlacklistedMessage=Mot de passe invalide : ce mot de passe est blacklist\u00e9. +invalidPasswordConfirmMessage=Le mot de passe de confirmation ne correspond pas. +invalidTotpMessage=Le code d''authentification est invalide. + +usernameExistsMessage=Le nom d''utilisateur existe d\u00e9j\u00e0. +emailExistsMessage=Le mail existe d\u00e9j\u00e0. + +federatedIdentityExistsMessage=L''utilisateur avec {0} {1} existe d\u00e9j\u00e0. Veuillez acc\u00e9der \u00e0 au gestionnaire de compte pour lier le compte. +federatedIdentityEmailExistsMessage=Cet utilisateur avec ce mail existe d\u00e9j\u00e0. Veuillez vous connecter au gestionnaire de compte pour lier le compte. + +confirmLinkIdpTitle=Souhaitez-vous lier {0} \u00e0 {1} ? +confirmLinkIdpDescription=Vous pourrez utiliser vos identifiants {0} ou {1} pour vous connecter \u00e0 votre compte {1}. +federatedIdentityConfirmLinkMessage=Votre adresse {0} FranceConnect est identique \u00e0 celle de votre compte moB. +federatedIdentityConfirmReauthenticateMessage=Identifiez vous afin de lier votre compte avec {0} +confirmLinkIdpReviewProfile=V\u00e9rifiez vos informations de profil +confirmLinkIdpContinue=Oui, je souhaite lier {0} \u00e0 moB + +configureTotpMessage=Vous devez configurer l''authentification par mobile pour activer votre compte. +updateProfileMessage=Vous devez mettre \u00e0 jour votre profil pour activer votre compte. +updatePasswordMessage=Vous devez changer votre mot de passe pour activer votre compte. +resetPasswordMessage=Vous devez changer votre mot de passe. +linkIdpMessage=Vous devez v\u00e9rifier votre mail pour lier votre compte avec {0}. + +emailSentMessage=Un lien de r\u00e9initialisation de votre mot de passe vous a \u00e9t\u00e9 transmis sur votre email. +emailSendErrorMessage=Erreur lors de l''envoi du mail, veuillez essayer plus tard. + +accountUpdatedMessage=Votre compte a \u00e9t\u00e9 mis \u00e0 jour. +accountPasswordUpdatedMessage=Votre mot de passe a \u00e9t\u00e9 mis \u00e0 jour. + +noAccessMessage=Aucun acc\u00e8s + +invalidPasswordMinLengthMessage=Mot de passe invalide : longueur minimale requise de {0}. +invalidPasswordMinDigitsMessage=Mot de passe invalide : doit contenir au moins {0} chiffre(s). +invalidPasswordMinLowerCaseCharsMessage=Mot de passe invalide : doit contenir au moins {0} lettre(s) en minuscule. +invalidPasswordMinUpperCaseCharsMessage=Mot de passe invalide : doit contenir au moins {0} lettre(s) en majuscule. +invalidPasswordMinSpecialCharsMessage=Mot de passe invalide : doit contenir au moins {0} caract\u00e8re(s) sp\u00e9ciaux. +invalidPasswordNotUsernameMessage=Mot de passe invalide : ne doit pas \u00eatre identique au nom d''utilisateur. +invalidPasswordRegexPatternMessage=Mot de passe invalide : ne valide pas l''expression rationnelle. +invalidPasswordHistoryMessage=Mot de passe invalide : ne doit pas \u00eatre \u00e9gal aux {0} derniers mots de passe. +invalidPasswordGenericMessage=Mot de passe invalide : le nouveau mot de passe ne r\u00e9pond pas \u00e0 la politique de mot de passe. + +failedToProcessResponseMessage=Erreur lors du traitement de la r\u00e9ponse +httpsRequiredMessage=Le protocole HTTPS est requis +realmNotEnabledMessage=Le domaine n''est pas activ\u00e9 +invalidRequestMessage=Requ\u00eate invalide +failedLogout=La d\u00e9connexion a \u00e9chou\u00e9e +unknownLoginRequesterMessage=Compte inconnu du demandeur +loginRequesterNotEnabledMessage=La connexion du demandeur n''est pas active +bearerOnlyMessage=Les applications Bearer-only ne sont pas autoris\u00e9es \u00e0 initier la connexion par navigateur. +standardFlowDisabledMessage=Le client n''est pas autoris\u00e9 \u00e0 initier une connexion avec le navigateur avec ce response_type. Le flux standard est d\u00e9sactiv\u00e9 pour le client. +implicitFlowDisabledMessage=Le client n''est pas autoris\u00e9 \u00e0 initier une connexion avec le navigateur avec ce response_type. Le flux implicite est d\u00e9sactiv\u00e9 pour le client. +invalidRedirectUriMessage=L''URI de redirection est invalide +unsupportedNameIdFormatMessage=NameIDFormat non support\u00e9 +invalidRequesterMessage=Demandeur invalide +registrationNotAllowedMessage=L''enregistrement n''est pas autoris\u00e9 +resetCredentialNotAllowedMessage=La remise \u00e0 z\u00e9ro n''est pas autoris\u00e9e + +permissionNotApprovedMessage=La permission n''est pas approuv\u00e9e. +noRelayStateInResponseMessage=Aucun \u00e9tat de relais dans la r\u00e9ponse du fournisseur d''identit\u00e9. +insufficientPermissionMessage=Permissions insuffisantes pour lier les identit\u00e9s. +couldNotProceedWithAuthenticationRequestMessage=Impossible de continuer avec la requ\u00eate d''authentification vers le fournisseur d''identit\u00e9. +couldNotObtainTokenMessage=Impossible de r\u00e9cup\u00e9rer le jeton du fournisseur d''identit\u00e9. +unexpectedErrorRetrievingTokenMessage=Erreur inattendue lors de la r\u00e9cup\u00e9ration du jeton provenant du fournisseur d''identit\u00e9. +unexpectedErrorHandlingResponseMessage=Erreur inattendue lors du traitement de la r\u00e9ponse provenant du fournisseur d''identit\u00e9. +identityProviderAuthenticationFailedMessage=L''authentification a \u00e9chou\u00e9e. Impossible de s''authentifier avec le fournisseur d''identit\u00e9. +couldNotSendAuthenticationRequestMessage=Impossible d''envoyer la requ\u00eate d''authentification vers le fournisseur d''identit\u00e9. +unexpectedErrorHandlingRequestMessage=Erreur inattendue lors du traitement de la requ\u00eate vers le fournisseur d''identit\u00e9. +invalidAccessCodeMessage=Code d''acc\u00e8s invalide. +sessionNotActiveMessage=La session n''est pas active. +invalidCodeMessage=Une erreur est survenue, veuillez vous reconnecter \u00e0 votre application. +messageErreur1=Ce lien n''est plus actif. +messageErreur2=

Vous pouvez cliquer ci-dessous pour recommencer l''op\u00e9ration

+identityProviderUnexpectedErrorMessage=Erreur inattendue lors de l''authentification avec fournisseur d''identit\u00e9. +identityProviderNotFoundMessage=Impossible de trouver le fournisseur d''identit\u00e9 avec cet identifiant. +identityProviderLinkSuccess=Votre compte a \u00e9t\u00e9 correctement li\u00e9 avec {0} compte {1} . +staleCodeMessage=Cette page n''est plus valide, merci de retourner \u00e0 votre application et de vous connecter \u00e0 nouveau. +realmSupportsNoCredentialsMessage=Ce domaine ne supporte aucun type d''accr\u00e9ditation. +identityProviderNotUniqueMessage=Ce domaine autorise plusieurs fournisseurs d''identit\u00e9. Impossible de d\u00e9terminer le fournisseur d''identit\u00e9 avec lequel s''authentifier. +emailVerifiedMessage=Votre mail a \u00e9t\u00e9 v\u00e9rifi\u00e9. + +staleEmailVerificationLink=Le lien que vous avez cliqu\u00e9 est p\u00e9rim\u00e9 et n''est plus valide. Peut-\u00eatre avez vous d\u00e9j\u00e0 v\u00e9rifi\u00e9 votre mot de passe ? +identityProviderAlreadyLinkedMessage=L''identit\u00e9 f\u00e9d\u00e9r\u00e9e retourn\u00e9e par {0} est d\u00e9j\u00e0 li\u00e9e \u00e0 un autre utilisateur. +confirmAccountLinking=Confirmez la liaison du compte {0} du fournisseur d''entit\u00e9 {1} avec votre compte. +confirmEmailAddressVerification=Confirmez la validit\u00e9 de l''adresse mail {0}. +confirmExecutionOfActions=Bienvenue sur MOB ! + + +backToApplication=« Revenir \u00e0 l''application +missingParameterMessage=Param\u00e8tres manquants \: {0} +clientNotFoundMessage=Client inconnu. +clientDisabledMessage=Client d\u00e9sactiv\u00e9. +invalidParameterMessage=Param\u00e8tre invalide \: {0} +alreadyLoggedIn=Vous \u00eates d\u00e9j\u00e0 connect\u00e9. + +differentUserAuthenticated=Vous \u00eates d\u00e9j\u00e0 authentifi\u00e9 avec un autre utilisateur ''{0}'' dans cette session. Merci de vous d\u00e9connecter. +proceedWithAction=Cliquez sur le lien ci-dessous pour v\u00e9rifier votre mail et activer votre compte. + + +requiredAction.CONFIGURE_TOTP=Configurer OTP +requiredAction.terms_and_conditions=Termes et conditions +requiredAction.UPDATE_PASSWORD=Mettre \u00e0 jour votre mot de passe +requiredAction.UPDATE_PROFILE=Mettre \u00e0 jour votre profil +requiredAction.VERIFY_EMAIL=Votre compte est pr\u00eat \u00e0 \u00eatre activ\u00e9. + + +doX509Login=Vous allez \u00eatre connect\u00e9 en tant que\: +clientCertificate=X509 certificat client\: +noCertificate=[Pas de certificat] + + +pageNotFound=Page non trouv\u00e9e +internalServerError=Une erreur interne du serveur s''est produite + +#Custom MCM message key +invalidEmailFormat=Le format du mail est incorrect +passwordPolicyText=Votre mot de passe doit au moins contenir : +passwordPolicy1=12 caract\u00e8res +passwordPolicy2=une lettre majuscule +passwordPolicy3=une lettre minuscule +passwordPolicy4=un chiffre +passwordPolicy5=un caract\u00e8re sp\u00e9cial: \u0040\u0023\u0024\u0025\u005E\u0026\u002A!?.,_- + +#Custom MCM message key for requiredActions +activeAccount=Votre compte est d\u00e9sormais actif. +activeAccountDescription1=Vous pouvez d\u00e9s \u00e0 pr\u00e9sent acc\u00e9der \u00e0 l''ensemble des fonctionnalit\u00e9s propos\u00e9es par Mon Compte Mobilit\u00e9 ! +activeAccountDescription2=Connectez-vous \u00e0 l''aide du lien ci-dessous afin de g\u00e9rer votre compte et consulter les informations relatives \u00e0 votre profil et territoire. + +#Custom MCM message key for OAuth concent page +consentProfile=Acc\u00e8s aux donn\u00e9es de votre profil +consentIncentivesList=Acc\u00e8s au catalogue d''aides publiques et employeurs +consentSubscriptionsMetadata=Transmission automatique des justificatifs d''achats de votre APP de mobilit\u00e9 +consentSubscriptionsProcess=Souscription \u00e0 une demande d''aide +consentSubscriptionsList=Suivi de l''\u00e9tat de vos demandes r\u00e9alis\u00e9es + +privateDataProtectionText=La liaison de compte ne permet pas \u00e0 l''application d''acc\u00e9der \u00e0 vos donn\u00e9es personnelles MOB. +privateDataProtectionConsultation=Pour plus de d\u00e9tails, consultez +privateDataProtectionLinkText=la charte des donn\u00e9es personnelles + +#FranceConnect Login Page +franceconnectLink=Qu''est-ce que FranceConnect ? +orLabel=Ou diff --git a/idp/mcm_template/login/register.ftl b/idp/mcm_template/login/register.ftl new file mode 100644 index 0000000..7ab2be1 --- /dev/null +++ b/idp/mcm_template/login/register.ftl @@ -0,0 +1,86 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("registerTitle")} + <#elseif section = "form"> +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + <#if !realm.registrationEmailAsUsername> +
+
+ +
+
+ +
+
+ + + <#if passwordRequired??> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + <#if recaptchaRequired??> +
+
+
+
+
+ + + +
+ + diff --git a/idp/mcm_template/login/resources/css/style.css b/idp/mcm_template/login/resources/css/style.css new file mode 100644 index 0000000..89e7272 --- /dev/null +++ b/idp/mcm_template/login/resources/css/style.css @@ -0,0 +1,1102 @@ +/* GENERAL */ +.login-pf body { + margin: 0 auto; + min-height: 100vh; + max-width: 100%; + font-family: 'sofia-pro', sans-serif; + color: #363757; + font-size: 18px; + overflow-x: hidden !important; + overflow-y: auto !important; +} + +.login-pf-page { + height: 100vh; +} + +.display-none { + display: none !important; +} + +.responsive-not-display { + display: inline; +} + +.responsive-display { + display: none; +} + +.kc-feedback-text { + color: rgb(207, 29, 29); +} + +#kc-page-title { + font-size: 58px; + line-height: 60px; +} + +#kc-title-identity { + color: #464cd0; +} + +#kc-oauth-data-protection { + color: #747480; + margin-top: 24px; + margin-bottom: 40px; +} + +#kc-oauth-data-protection-consultation { + margin-top: 24px; +} + +.link-to-chart { + display: inline-flex; +} + +/* HEADER */ +#kc-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 28px 40px; +} + +#kc-header-wrapper { + display: inline; + content: url('../img/logo-with-baseline.svg'); +} + +/* NAVIGATION */ +nav .mcm-burger, +nav .mcm-burger-checkbox, +nav .mcm-mobile-nav { + display: none; +} + +nav .mcm-burger-button { + fill: var(--color-green-leaf); + width: 40px; + height: 40px; + display: block; + pointer-events: none; + padding: 0 !important; + border: none !important; + background: none; +} + +nav .mcm-nav { + display: block; + top: 0; + background: white; + left: 0; + bottom: 0; + z-index: 10; + width: 100%; + height: 100%; + overflow: hidden; +} + +nav .mcm-nav .mcm-nav-list { + display: flex; + margin: 0; +} + +nav .mcm-nav .mcm-nav-list .nav-item { + font-size: 16px; +} + +nav .mcm-nav .nav-item { + list-style: none; + font-weight: 400; +} + +nav .mcm-nav-list .nav-item:not(:last-child) { + margin-right: 40px; +} + +nav .mcm-nav .nav-item a { + color: inherit; + text-decoration: inherit; +} + +nav .mcm-nav .nav-login { + font-weight: 600; + color: #01bf7d; + cursor: pointer; +} + +/* END - NAVIGATION */ +/* END - HEADER */ + +/* INPUTS */ +label { + font-size: 14px; +} + +input[type='text'], +input[type='password'] { + margin-top: 12px; + font-size: 16px; + line-height: 22px; + font-weight: 600; + border: 1px solid #d5d6df; + padding: 18px 32px; + border-radius: 34px; + outline: 0; + max-width: 400px; +} + +input[type='text']::placeholder, +input[type='password']::placeholder { + color: #d5d6df; +} + +input[type='text']:focus, +input[type='password']:focus { + border: 1px solid #363757; +} + +/* END - INPUTS */ + +/* LINK */ +.kc-forgot-password > span > a, +#kc-reset-password-form #kc-form-options a { + font-size: 13px; + color: #363757; + text-decoration: none; +} + +.kc-forgot-password > span > a:hover, +#kc-reset-password-form #kc-form-options a:hover { + text-decoration: underline; +} + +/* END - LINK */ + +/* MCM BUTTON STYLE */ +#button-activated, +.pf-c-button, +.mcm-link { + border-radius: 27px; + background-color: #01bf7d; + color: white; + font-family: 'sofia-pro', sans-serif; + font-size: 16px; + font-weight: 600; + line-height: 1.375; + padding: 14px 36px 16px; + border: 1px solid #01bf7d; + cursor: pointer; + text-decoration: none; + width: fit-content; + max-width: 400px; +} + +#button-activated a, +.mcm-link { + text-decoration: none; + color: white; +} + +#button-activated:hover, +#button-activated:hover > a, +.pf-c-button:hover, +.mcm-link:hover { + background-color: transparent; + transition: background-color 0.2s ease-in-out; + color: #01bf7d !important; +} + +/* END - MCM BUTTON STYLE */ +/* END - GENERAL */ + +/* MCM IMAGE GLOBAL */ +#mcm-img { + position: absolute; + height: 100vh; + width: 40%; + right: 0; + display: flex; + align-items: center; + z-index: -1; +} + +#mcm-img-cover-right { + position: relative; + content: url('../img/woman-smiling.jpg'); + border-radius: 300px 0px 0px 300px; + height: 60%; +} + +#mcm-img-M-right { + position: absolute; + content: url('../img/letter-m.svg'); + height: 23%; + bottom: 16%; + left: -16%; +} + +/* END - MCM IMAGE GLOBAL */ + +/* LOGIN PAGE */ +#kc-content { + height: 100%; +} + +#kc-content-wrapper { + position: relative; + height: 100%; +} + +#mcm-login-header { + display: flex; + flex-direction: column; + align-items: center; +} + +#mcm-login-header h1 { + margin: 0; +} + +#mcm-login-header p { + text-align: center; + margin: 0; +} + +#mcm-login { + display: flex !important; + width: 100%; + justify-content: center; +} + +#mcm-login > * { + flex: 1; +} + +#mcm-login h1 { + font-size: 32px; + line-height: 40px; + margin-top: -12px; +} + +/* LOGIN FORM */ +#kc-form { + display: flex; + justify-content: center; + align-items: flex-end; + width: 100%; + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +#kc-form-wrapper { + flex-grow: 1; + margin-top: auto; + margin-bottom: auto; +} + +#kc-form-login { + max-width: 400px; + padding-right: 8%; +} + +#kc-form-login p { + display: flex; + flex: 1; + max-width: 415px; +} + +.kc-inputs-form { + display: flex; + align-items: center; + justify-content: space-between; +} + +.form-group { + display: flex; + flex-direction: column; + color: #363757; +} + +.form-section { + margin-bottom: 20px; + position: relative; +} + +.form-group label { + text-align: left; +} + +/* PASSWORD */ +.kc-forgot-password { + text-align: right; + margin-top: 18px; +} + +.mcm-eye-img { + content: url('../img/visible.svg'); + color: #363757; + opacity: 0.1; + height: 16px; + position: absolute; + right: 30px; + top: 54px; + cursor: pointer; + z-index: 1; +} + +.mcm-eye-visible { + opacity: 1 !important; +} + +/* END - PASSWORD */ +/* CHECKBOX */ + +.checkbox { + position: relative; + font-size: 14px; +} + +.checkbox * { + cursor: pointer !important; +} + +.checkbox [type='checkbox']:not(:checked), +.checkbox [type='checkbox']:checked { + height: 20px; + width: 20px; + opacity: 0.01; +} + +/* Aspect de la case */ +.checkbox label::before { + content: ''; + position: absolute; + left: -30px; + width: 20px; + height: 20px; + border: 1px solid #d1d1d1; + background: #fff; + border-radius: 5px; + transition: all 0.275s; +} + +.checkbox [type='checkbox']:not(:checked), +.checkbox [type='checkbox']:checked { + position: absolute; + opacity: 0; +} + +/* Aspect de la coche */ +.checkbox label::after { + content: ''; + background-image: url('../img/valid.svg'); + background-size: 42px 42px; + background-repeat: no-repeat; + background-position: -8px -11px; + position: absolute; + left: -29px; + top: 1px; + width: 30px; + height: 20px; + transition: all 0.2s; +} + +/* Aspect non cochée */ +.checkbox [type='checkbox']:not(:checked) + label::after { + opacity: 0; + transform: scale(0); +} + +/* Aspect cochée */ +.checkbox [type='checkbox']:checked + label::after { + opacity: 1; + transform: scale(1); +} + +/* END - CHECKBOX */ +/* END - LOGIN FORM */ + +/* START - SIGNIN LINK */ +#mcm-signin-link-container { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + margin: 24px auto 40px auto; +} + +#mcm-signin-link-container p { + font-size: 1.2em; + font-weight: bold; + text-align: center; + margin: 0; +} + +/* BUTTON SIGNIN */ + +a.mcm-basic-link { + text-decoration-line: none; + font-size: 1.2em; + font-weight: bold; + margin-inline: 5px; +} + +a.mcm-basic-link:hover { + text-decoration-line: underline; +} + +/* END - BUTTON SIGNIN */ +/* END - SIGNIN LINK */ + +.separ-sections { + position: relative; + flex: 0 0 1px !important; + margin: 40px 0 5px 0; + background-color: #d5d6df; +} + +.separ-sections .label { + background-color: white; + position: absolute; + left: -50%; + top: 50%; + transform: translate(-50%, -50%); + width: 37px; + height: 25px; + line-height: 23px; + text-align: center; +} + +/* SIGNIN FORM */ +#signin-section { + display: flex; + justify-content: space-between; + padding-left: 8%; + max-width: 400px; +} + +#signin-section .signin-section-left { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +#signin-section .signin-text > * { + max-width: 400px; +} + +#kc-form #mcm-img-login { + background-image: url('../img/girl-bike.jpg'); + border-radius: 300px 0px 0px 300px; + height: 400px; + margin-right: -40px; + margin-bottom: 40px; + width: 224px; +} + +/* END - SIGNIN FORM */ +/* END - LOGIN PAGE */ + +/* INFOS PAGE */ +.card-pf { + margin: auto 40px auto 40px; + height: 75%; +} + +.card-pf-minimal { + margin: 100px; +} + +.mcm-subtitle { + color: #464cd0; + font-size: 24px; + line-height: 28px; + font-family: 'sofia-pro', sans-serif; +} + +.retour-ligne { + width: 500px; +} + +.button-redirect { + margin-top: 40px; + width: 100%; +} + +/* END - INFOS PAGE */ + +/* RESET PASSWORD PAGE */ +#kc-reset-password-form { + margin-top: 20px; +} +#kc-reset-password-form input[type='text'] { + max-width: 326px; +} + +#kc-form-options { + margin: 20px 0px; +} + +#kc-reset-password-form #kc-form-buttons { + margin-top: 20px; +} + +#kc-reset-password-form .mcm-regex-error { + color: #e35447; +} + +/* END - RESET PASSWORD PAGE */ + +/* BANNER ERROR & SUCCESS */ +.alert-error, +.alert-success { + display: flex; + justify-content: center; + position: absolute; + top: 0; + width: 100%; + right: 0px; + font-size: 14px; + z-index: 100; +} + +.alert-error .kc-feedback-text { + position: absolute; + background-color: #e35447; + padding: 10px; + border-radius: 0px 0px 6px 6px; + padding: 10px 24px 10px 16px; +} + +.alert-success .kc-feedback-text { + position: absolute; + background-color: #01bf7d; + padding: 10px; + border-radius: 0px 0px 6px 6px; + padding: 10px 24px 10px 16px; +} + +.alert-error span, +.alert-success span { + color: white !important; +} + +.alert-error .span-line-retour, +.alert-success .span-line-retour { + display: block; +} + +.alert-error #close-error, +.alert-success #close-error { + position: absolute; + top: 0px; + right: 10px; + cursor: pointer; + display: block !important; +} + +#close-error { + display: none; + font-family: sans-serif; + font-size: 20px; +} + +/* END - BANNER ERROR */ + +/* VERIFICATION EMAIL PAGE */ +.instruction { + max-width: 50%; +} + +/* END - VERIFICATION EMAIL PAGE */ + +/* LOGIN IDP LINK PAGE */ +.instruction-link { + display: block; +} +/* END - LOGIN IDP LINK PAGE */ + +.kc-screen-list-group li::marker { + color: #464cd0; + font-size: 20px; +} + +/* FRANCECONNECT BUTTON */ + +a.zocial.franceconnect-particulier { + background: url('../img/franceconnect-btn.svg') no-repeat left top; + height: 60px; + width: 216px; + margin-bottom: 8px; + display: block; +} + +a.zocial.franceconnect-particulier:hover { + background: url('../img/franceconnect-btn-hover.svg') no-repeat left top; + height: 60px; + width: 216px; + margin-bottom: 8px; + display: block; +} + +.franceconnect-container { + display: flex; + flex-direction: column; + width: 230px; + margin-top: 50px; +} + +.franceconnect-link { + font-size: 15px; + text-decoration-line: none !important; +} + +.franceconnect-link:hover { + text-decoration-line: underline !important; +} + +/*END FRANCECONNECT BUTTON */ + +/** BEGIN LINK IDP **/ + +.submit-btn { + margin: 30px 0; +} + +.go-back-btn { + margin: 25px 0 0 95px; + width: fit-content; +} + +/** END LINK IDP **/ + +/* RESPONSIVE */ +@media screen and (max-width: 1600px) { + .kc-forgot-password { + margin-top: 5px; + } + + .form-section { + margin-bottom: 10px; + } + + input[type='text'], + input[type='password'] { + margin-top: 5px; + } + + #kc-form #mcm-img-login { + margin-bottom: 10px; + } + + .mcm-eye-img { + top: 47px; + } +} + +@media screen and (max-width: 1250px) { + #mcm-img { + align-items: flex-end; + } + + #mcm-img-cover-right { + height: 40%; + margin-bottom: 20%; + } + + #mcm-img-M-right { + height: 15%; + bottom: 6.5%; + left: -17%; + } + + #kc-form #mcm-img-login { + display: none; + } + + .instruction { + max-width: 100%; + } +} + +@media screen and (max-width: 1070px) { + #kc-content-wrapper { + position: relative; + } + + #kc-form { + display: flex; + justify-content: center; + align-items: flex-end; + width: 100%; + position: static; + transform: initial; + } + + #mcm-login-header { + max-width: 350px; + text-align: center; + margin: 20px auto; + } + + #mcm-login-header h1 { + margin-bottom: 20px; + } + + #mcm-login-header p { + width: auto; + } + + /* START - SIGNIN LINK RESPONSIVE */ + #mcm-signin-link-container { + flex-direction: column; + max-width: 355px; + text-align: center; + margin: 0 auto 40px auto; + } + + #mcm-signin-link-container p { + width: auto; + } + /* END - SIGNIN LINK RESPONSIVE */ + + #kc-header { + padding: 28px 24px !important; + } + + #kc-header-wrapper { + content: url('../img/logo-mobile.svg'); + width: 76px; + } + + #kc-oauth { + margin-top: 40px; + } + + #kc-oauth-form #kc-form-buttons-group { + background-color: #ffffff; + border-top: 1px solid #dddddd; + box-sizing: border-box; + bottom: 0; + left: 0; + padding: 32px 32px 32px 32px; + position: fixed; + width: 100%; + } + + #kc-oauth-data-protection { + font-size: 16px; + margin-bottom: 0; + } + + #kc-form-buttons-content { + display: flex; + flex-direction: column; + align-items: center; + } + + #kc-oauth-form #kc-form-buttons-group #kc-form-buttons-content #kc-login { + margin-bottom: 24px; + } + + #kc-oauth-form #kc-form-buttons-group #kc-form-buttons-content #kc-cancel { + background-color: #ffffff; + color: #363757; + } + + .kc-forgot-password { + margin-top: 18px; + } + + input[type='submit'] { + width: 100%; + } + + input[type='text'], + input[type='password'] { + margin-top: 12px; + } + + .retour-ligne { + width: auto; + } + + #mcm-img { + display: none; + } + + #mcm-login { + max-height: max-content; + flex-direction: column; + text-align: center; + align-items: center; + margin: 20px auto 20px auto; + } + + .form-group { + width: 100%; + } + + .form-section { + margin-bottom: 20px; + } + + #kc-form-login { + margin-top: 10px; + padding-right: 0; + } + + .kc-inputs-form { + flex-direction: column-reverse; + align-items: flex-start; + width: 100%; + } + + .mcm-eye-img { + top: 50px; + } + + .kc-inputs-form .checkbox { + margin-bottom: 30px; + margin-left: 30px; + } + + .franceconnect-container { + margin-top: 40px; + margin-left: auto; + margin-right: auto; + } + + .separ-sections { + margin-bottom: 0px; + margin-top: 60px; + } + + .separ-sections .label { + left: 50%; + transform: translate(-50%, -50%); + line-height: 21px; + } + + #signin-section { + flex-direction: column; + border-left: none; + align-items: center; + padding-left: 0; + width: 100%; + } + + #signin-section .signin-section-left { + flex-direction: row; + margin-top: 30px; + } + + #signin-section > a { + width: 75%; + max-width: 326px; + display: inline; + margin-left: auto; + margin-right: auto; + margin-bottom: 40px; + } + + #signin-section .signin-text > * { + max-width: auto; + } + + .form-group > input[type='text'], + .form-group > input[type='password'] { + margin-top: 8px; + font-size: 16px; + line-height: 22px; + font-weight: 600; + border: 1px solid #d5d6df; + padding: 18px 32px; + border-radius: 34px; + outline: 0; + } + + .responsive-not-display { + display: none !important; + } + + .responsive-display { + display: flex; + width: inherit; + max-width: 400px; + align-self: center; + } + + .mcm-link { + width: 100%; + max-width: 400px; + padding: 14px 0px 16px; + text-align: center; + } + + nav .mcm-burger-checkbox, + nav .mcm-mobile-nav, + nav .mcm-burger { + display: block !important; + } + + nav .mcm-burger { + margin-left: auto; + width: 40px; + position: relative; + cursor: pointer; + z-index: 20; + } + + nav .mcm-nav .nav-item { + line-height: 32px; + font-size: 26px; + font-weight: 600; + margin-bottom: 40px; + } + + nav .mcm-burger-checkbox { + opacity: 0; + position: absolute; + } + + nav .mcm-burger-checkbox:checked + .mcm-burger { + position: fixed; + top: 28px; + right: 28px; + } + + nav .mcm-burger-checkbox:checked + .mcm-burger-button { + margin-left: auto; + } + + nav .mcm-burger-checkbox:checked ~ .mcm-burger .mcm-burger-button > i { + background-image: none; + justify-content: center; + background-position-x: center; + z-index: 1000; + } + + nav + .mcm-burger-checkbox:checked + ~ .mcm-burger + .mcm-burger-button + > i::before { + transform: translateY(50%) translateX(12%) rotate3d(0, 0, 1, 45deg); + width: 24px; + } + + nav .mcm-burger-checkbox:checked ~ .mcm-burger .mcm-burger-button > i::after { + transform: translateY(-50%) translateX(12%) rotate3d(0, 0, 1, -45deg); + width: 24px; + } + + nav .mcm-burger-checkbox:checked ~ .mcm-nav { + display: block !important; + position: fixed; + width: 100%; + height: 100vh; + } + + nav .mcm-burger-checkbox:checked ~ .mcm-nav .mcm-nav-list .nav-item { + font-size: 26px; + } + + nav .mcm-burger-checkbox:not(:checked) ~ .mcm-nav { + display: none; + } + + nav .mcm-burger-button > i { + display: inline-flex; + vertical-align: top; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + padding: 9px 5px; + background-color: transparent; + background-image: linear-gradient(#01bf7d, #01bf7d); + background-position: left center; + background-repeat: no-repeat; + background-origin: content-box; + background-size: 24px 2px; + transition: 0.25s; + transition-property: transform; + will-change: transform; + width: 22px; + height: 22px; + } + + nav .mcm-burger-button > i::before, + nav .mcm-burger-button > i::after { + content: ''; + height: 2px; + background: #01bf7d; + transition: 0.25s; + transition-property: transform, top; + will-change: transform, top; + } + + nav .mcm-burger-button > i::before { + width: 30px; + } + + nav .mcm-burger-button > i::after { + width: 16px; + } + + nav .mcm-nav { + padding: 28px 24px; + } + + nav .mcm-nav .mcm-nav-list { + flex-direction: column-reverse; + } + + nav .mcm-nav ul { + padding: 51px 0px; + } + + nav .mcm-mobile-nav { + margin: 0px 50px 0px 0px; + border-top: 1px solid #d5d6df; + } + + nav .mcm-mobile-nav .nav-item { + font-size: 20px; + line-height: 28px; + margin-bottom: 26px; + } + + nav .mcm-nav-list .nav-item { + font-weight: bold !important; + } + + nav .mcm-nav-list .nav-item:first-child { + margin-bottom: 0px; + } +} + +@media screen and (max-width: 620px) { + #kc-page-title { + font-size: 24px; + line-height: 28px; + } + + .card-pf, + .card-pf-minimal { + margin: 10px 30px; + } + + .go-back-btn { + margin: 0; + align-items: center; + } + + .submit-btn { + margin: 30px 0; + } +} + +/* END - RESPONSIVE */ diff --git a/idp/mcm_template/login/resources/img/franceconnect-btn-hover.svg b/idp/mcm_template/login/resources/img/franceconnect-btn-hover.svg new file mode 100644 index 0000000..3383a49 --- /dev/null +++ b/idp/mcm_template/login/resources/img/franceconnect-btn-hover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/idp/mcm_template/login/resources/img/franceconnect-btn.svg b/idp/mcm_template/login/resources/img/franceconnect-btn.svg new file mode 100644 index 0000000..2e46e60 --- /dev/null +++ b/idp/mcm_template/login/resources/img/franceconnect-btn.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/idp/mcm_template/login/resources/img/girl-bike.jpg b/idp/mcm_template/login/resources/img/girl-bike.jpg new file mode 100644 index 0000000..9cb2ba0 Binary files /dev/null and b/idp/mcm_template/login/resources/img/girl-bike.jpg differ diff --git a/idp/mcm_template/login/resources/img/letter-m.svg b/idp/mcm_template/login/resources/img/letter-m.svg new file mode 100644 index 0000000..8113e40 --- /dev/null +++ b/idp/mcm_template/login/resources/img/letter-m.svg @@ -0,0 +1 @@ + diff --git a/idp/mcm_template/login/resources/img/logo-mobile.svg b/idp/mcm_template/login/resources/img/logo-mobile.svg new file mode 100644 index 0000000..dbd406d --- /dev/null +++ b/idp/mcm_template/login/resources/img/logo-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/idp/mcm_template/login/resources/img/logo-with-baseline.svg b/idp/mcm_template/login/resources/img/logo-with-baseline.svg new file mode 100644 index 0000000..b2ada22 --- /dev/null +++ b/idp/mcm_template/login/resources/img/logo-with-baseline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/idp/mcm_template/login/resources/img/mob-favicon.svg b/idp/mcm_template/login/resources/img/mob-favicon.svg new file mode 100644 index 0000000..ce42712 --- /dev/null +++ b/idp/mcm_template/login/resources/img/mob-favicon.svg @@ -0,0 +1 @@ + diff --git a/idp/mcm_template/login/resources/img/mob-footer.svg b/idp/mcm_template/login/resources/img/mob-footer.svg new file mode 100644 index 0000000..364a607 --- /dev/null +++ b/idp/mcm_template/login/resources/img/mob-footer.svg @@ -0,0 +1 @@ + diff --git a/idp/mcm_template/login/resources/img/valid.svg b/idp/mcm_template/login/resources/img/valid.svg new file mode 100644 index 0000000..d04e2c1 --- /dev/null +++ b/idp/mcm_template/login/resources/img/valid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/idp/mcm_template/login/resources/img/visible.svg b/idp/mcm_template/login/resources/img/visible.svg new file mode 100644 index 0000000..60e8c9d --- /dev/null +++ b/idp/mcm_template/login/resources/img/visible.svg @@ -0,0 +1,3 @@ + + + diff --git a/idp/mcm_template/login/resources/img/woman-smiling.jpg b/idp/mcm_template/login/resources/img/woman-smiling.jpg new file mode 100644 index 0000000..0c92a36 Binary files /dev/null and b/idp/mcm_template/login/resources/img/woman-smiling.jpg differ diff --git a/idp/mcm_template/login/saml-post-form.ftl b/idp/mcm_template/login/saml-post-form.ftl new file mode 100644 index 0000000..631d9dd --- /dev/null +++ b/idp/mcm_template/login/saml-post-form.ftl @@ -0,0 +1,25 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${kcSanitize(msg("saml.post-form.title"))} + <#elseif section = "form"> + +

${kcSanitize(msg("saml.post-form.message"))}

+
+ <#if samlPost.SAMLRequest??> + + + <#if samlPost.SAMLResponse??> + + + <#if samlPost.relayState??> + + + + +
+ + diff --git a/idp/mcm_template/login/select-authenticator.ftl b/idp/mcm_template/login/select-authenticator.ftl new file mode 100644 index 0000000..0225cf9 --- /dev/null +++ b/idp/mcm_template/login/select-authenticator.ftl @@ -0,0 +1,42 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header" || section = "show-username"> + + <#if section = "header"> + ${msg("loginChooseAuthenticator")} + + <#elseif section = "form"> + +
+
+ <#list auth.authenticationSelections as authenticationSelection> +
+
+
+ +
+
+
+
+ ${msg('${authenticationSelection.displayName}')} +
+
+ ${msg('${authenticationSelection.helpText}')} +
+
+
+
+
+ + +
+
+ + + + diff --git a/idp/mcm_template/login/template-minimal.ftl b/idp/mcm_template/login/template-minimal.ftl new file mode 100644 index 0000000..a17b71b --- /dev/null +++ b/idp/mcm_template/login/template-minimal.ftl @@ -0,0 +1,158 @@ +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false displayWide=false showAnotherWayIfPresent=true> + + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if scripts??> + <#list scripts as script> + + + + + + +
+
+
+
+ +
+ +
+ +
+
+
+
+
+ <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+

<#nested "header">

+
+
+ <#else> + <#if skipLink??> +

Merci !

+ <#else> +

<#nested "header">

+ + + <#else> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+ <#nested "show-username"> +
+
+ + + + +
+
+
+
+ <#else> + <#nested "show-username"> +
+
+ + + + +
+
+ + +
+
+
+ <#-- App-initiated actions should not see warning messages about the need to complete the action --> + <#-- during login. --> + <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)> +
+ <#if message.type = 'success'> + <#if message.type = 'warning'> + <#if message.type = 'error'> + <#if message.type = 'info'> + +
+ + + <#nested "form"> + + <#if auth?has_content && auth.showTryAnotherWayLink() && showAnotherWayIfPresent> +
class="${properties.kcContentWrapperClass!}"> +
class="${properties.kcFormSocialAccountContentClass!} ${properties.kcFormSocialAccountClass!}"> + +
+
+ + + <#if displayInfo> +
+
+ <#nested "info"> +
+
+ +
+
+ +
+
+ + + + diff --git a/idp/mcm_template/login/template.ftl b/idp/mcm_template/login/template.ftl new file mode 100644 index 0000000..d8676ab --- /dev/null +++ b/idp/mcm_template/login/template.ftl @@ -0,0 +1,201 @@ +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false displayWide=false showAnotherWayIfPresent=true> + + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if scripts??> + <#list scripts as script> + + + + + + +
+
+
+
+ +
+ <#-- App-initiated actions should not see warning messages about the need to complete the action --> + <#-- during login. --> + <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)> +
+ <#if message.type = 'success'> + <#if message.type = 'warning'> + <#if message.type = 'error'> + <#if message.type = 'info'> + +
+ + +
+
+ <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+

<#nested "header">

+
+
+ <#else> + <#if skipLink??> +

Merci !

+ <#else> +

<#nested "header">

+ + + <#else> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+ <#nested "show-username"> +
+
+ + + + +
+
+
+
+ <#else> + <#nested "show-username"> +
+
+ + + + +
+
+ + +
+
+
+ + <#nested "form"> + + <#if auth?has_content && auth.showTryAnotherWayLink() && showAnotherWayIfPresent> +
class="${properties.kcContentWrapperClass!}"> +
class="${properties.kcFormSocialAccountContentClass!} ${properties.kcFormSocialAccountClass!}"> + +
+
+ + + <#if displayInfo> +
+
+ <#nested "info"> +
+
+ +
+
+ +
+
+ + + + diff --git a/idp/mcm_template/login/terms.ftl b/idp/mcm_template/login/terms.ftl new file mode 100644 index 0000000..687b192 --- /dev/null +++ b/idp/mcm_template/login/terms.ftl @@ -0,0 +1,15 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=false; section> + <#if section = "header"> + ${msg("termsTitle")} + <#elseif section = "form"> +
+ ${kcSanitize(msg("termsText"))?no_esc} +
+
+ + +
+
+ + diff --git a/idp/mcm_template/login/theme.properties b/idp/mcm_template/login/theme.properties new file mode 100644 index 0000000..36bb412 --- /dev/null +++ b/idp/mcm_template/login/theme.properties @@ -0,0 +1,136 @@ +parent=base + +locales=fr + +styles=css/style.css + +meta=viewport==width=device-width,initial-scale=1 + +kcHtmlClass=login-pf +kcLoginClass=login-pf-page + +kcLogoLink=http://www.keycloak.org + +kcLogoClass=login-pf-brand + +kcContainerClass=container-fluid +kcContentClass=col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3 + +kcHeaderClass=login-pf-page-header +kcFeedbackAreaClass=col-md-12 +kcLocaleClass=col-xs-12 col-sm-1 + +## Alert +kcAlertClass=pf-c-alert pf-m-inline +kcAlertTitleClass=pf-c-alert__title kc-feedback-text + +kcFormAreaClass=col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2 +kcFormCardClass=card-pf +kcFormCardMinimalClass=card-pf-minimal + +### Social providers +kcFormSocialAccountListClass=pf-c-login__main-footer-links kc-social-links +kcFormSocialAccountListGridClass=pf-l-grid kc-social-grid +kcFormSocialAccountListButtonClass=pf-c-button pf-m-control pf-m-block kc-social-item kc-social-gray +kcFormSocialAccountGridItem=pf-l-grid__item + +kcFormSocialAccountNameClass=kc-social-provider-name +kcFormSocialAccountLinkClass=pf-c-login__main-footer-links-item-link +kcFormSocialAccountSectionClass=kc-social-section kc-social-gray +kcFormHeaderClass=login-pf-header + +kcFeedbackErrorIcon=fa fa-fw fa-exclamation-circle +kcFeedbackWarningIcon=fa fa-fw fa-exclamation-triangle +kcFeedbackSuccessIcon=fa fa-fw fa-check-circle +kcFeedbackInfoIcon=fa fa-fw fa-info-circle + +kcResetFlowIcon=pficon pficon-arrow fa +kcWebAuthnKeyIcon=pficon pficon-key + +kcFormClass=form-horizontal +kcFormGroupClass=form-group +kcFormGroupErrorClass=has-error +kcLabelClass=pf-c-form__label pf-c-form__label-text +kcLabelWrapperClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcInputClass=pf-c-form-control +kcInputErrorMessageClass=pf-c-form__helper-text pf-m-error required kc-feedback-text +kcInputWrapperClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcFormOptionsClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcFormButtonsClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcFormSettingClass=login-pf-settings +kcTextareaClass=form-control +kcSignUpClass=login-pf-signup + + +kcInfoAreaClass=col-xs-12 col-sm-4 col-md-4 col-lg-5 details + +##### css classes for form buttons +# main class used for all buttons +kcButtonClass=pf-c-button +# classes defining priority of the button - primary or default (there is typically only one priority button for the form) +kcButtonPrimaryClass=pf-m-primary +kcButtonDefaultClass=btn-default +# classes defining size of the button +kcButtonLargeClass=btn-lg +kcButtonBlockClass=pf-m-block + +##### css classes for input +kcInputLargeClass=input-lg + +##### css classes for form accessability +kcSrOnlyClass=sr-only + +##### css classes for select-authenticator form +kcSelectAuthListClass=pf-l-stack select-auth-container +kcSelectAuthListItemClass=pf-l-stack__item select-auth-box-parent pf-l-split +kcSelectAuthListItemIconClass=pf-l-split__item select-auth-box-icon +kcSelectAuthListItemBodyClass=pf-l-split__item pf-l-stack +kcSelectAuthListItemHeadingClass=pf-l-stack__item select-auth-box-headline pf-c-title +kcSelectAuthListItemDescriptionClass=pf-l-stack__item select-auth-box-desc +kcSelectAuthListItemFillClass=pf-l-split__item pf-m-fill +kcSelectAuthListItemArrowClass=pf-l-split__item select-auth-box-arrow +kcSelectAuthListItemArrowIconClass=fa fa-angle-right fa-lg + +##### css classes for the authenticators +kcAuthenticatorDefaultClass=fa list-view-pf-icon-lg +kcAuthenticatorPasswordClass=fa fa-unlock list-view-pf-icon-lg +kcAuthenticatorOTPClass=fa fa-mobile list-view-pf-icon-lg +kcAuthenticatorWebAuthnClass=fa fa-key list-view-pf-icon-lg +kcAuthenticatorWebAuthnPasswordlessClass=fa fa-key list-view-pf-icon-lg + +##### css classes for the OTP Login Form +kcLoginOTPListClass=pf-c-tile otp-tile +kcLoginOTPListItemHeaderClass=pf-c-tile__header +kcLoginOTPListItemIconBodyClass=pf-c-tile__icon otp-tile-icon +kcLoginOTPListItemIconClass=fa fa-mobile +kcLoginOTPListItemTitleClass=pf-c-tile__title + +##### css classes for identity providers logos +kcCommonLogoIdP=kc-social-provider-logo kc-social-gray + +## Social +kcLogoIdP-facebook=fa fa-facebook +kcLogoIdP-google=fa fa-google +kcLogoIdP-github=fa fa-github +kcLogoIdP-linkedin=fa fa-linkedin +kcLogoIdP-instagram=fa fa-instagram +## windows instead of microsoft - not included in PF4 +kcLogoIdP-microsoft=fa fa-windows +kcLogoIdP-bitbucket=fa fa-bitbucket +kcLogoIdP-gitlab=fa fa-gitlab +kcLogoIdP-paypal=fa fa-paypal +kcLogoIdP-stackoverflow=fa fa-stack-overflow +kcLogoIdP-twitter=fa fa-twitter +kcLogoIdP-openshift-v4=pf-icon pf-icon-openshift +kcLogoIdP-openshift-v3=pf-icon pf-icon-openshift + +## custom URL +websiteFQDN=${env.WEBSITE_FQDN} +matomoFQDN=https://${env.MATOMO_FQDN}/ +siteID=1 +redirectToLoginPage=/auth/realms/mcm/protocol/openid-connect/auth?client_id=platform&response_mode=fragment&response_type=code&login=true&redirect_uri=https://${env.WEBSITE_FQDN}/redirection + +## custom css +kcFormButtonsGroupClass=kc-form-buttons +kcFormButtonsWrapperClass=kc-form-buttons-content +kcScreenListGroup=kc-screen-list-group diff --git a/idp/mcm_template/login/webauthn-authenticate.ftl b/idp/mcm_template/login/webauthn-authenticate.ftl new file mode 100644 index 0000000..e3adcc2 --- /dev/null +++ b/idp/mcm_template/login/webauthn-authenticate.ftl @@ -0,0 +1,107 @@ + <#import "template.ftl" as layout> + <@layout.registrationLayout showAnotherWayIfPresent=false; section> + <#if section = "title"> + title + <#elseif section = "header"> + ${kcSanitize(msg("webauthn-login-title"))?no_esc} + <#elseif section = "form"> + +
+
+ + + + + + +
+
+ + <#if authenticators??> +
+ <#list authenticators.authenticators as authenticator> + + +
+ + + + + + <#elseif section = "info"> + + + diff --git a/idp/mcm_template/login/webauthn-error.ftl b/idp/mcm_template/login/webauthn-error.ftl new file mode 100644 index 0000000..ed904f9 --- /dev/null +++ b/idp/mcm_template/login/webauthn-error.ftl @@ -0,0 +1,55 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=true; section> + <#if section = "header"> + ${kcSanitize(msg("webauthn-error-title"))?no_esc} + <#elseif section = "form"> + + + +
+ + +
+ + <#if authenticators??> + + + + + + + + <#list authenticators.authenticators as authenticator> + + + + + +
${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}
+ ${kcSanitize(authenticator.label)?no_esc} +
+ + + + + <#if isAppInitiatedAction??> +
+ +
+ + + + \ No newline at end of file diff --git a/idp/mcm_template/login/webauthn-register.ftl b/idp/mcm_template/login/webauthn-register.ftl new file mode 100644 index 0000000..58ba709 --- /dev/null +++ b/idp/mcm_template/login/webauthn-register.ftl @@ -0,0 +1,166 @@ + <#import "template.ftl" as layout> + <@layout.registrationLayout; section> + <#if section = "title"> + title + <#elseif section = "header"> + + ${kcSanitize(msg("webauthn-registration-title"))?no_esc} + <#elseif section = "form"> + +
+
+ + + + + +
+
+ + + + + + <#if !isSetRetry?has_content && isAppInitiatedAction?has_content> + +
+ +
+ <#else> + + + + + \ No newline at end of file diff --git a/idp/overlays/idp-certificate.yml b/idp/overlays/idp-certificate.yml new file mode 100644 index 0000000..2d97bc6 --- /dev/null +++ b/idp/overlays/idp-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: idp-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/idp/overlays/idp-headers-middleware.yml b/idp/overlays/idp-headers-middleware.yml new file mode 100644 index 0000000..d381dff --- /dev/null +++ b/idp/overlays/idp-headers-middleware.yml @@ -0,0 +1,9 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: idp-headers-middleware +spec: + headers: + customRequestHeaders: + X-Forwarded-Proto: "https" + X-Forwarded-Port: "443" diff --git a/idp/overlays/idp-inflightreq-middleware.yml b/idp/overlays/idp-inflightreq-middleware.yml new file mode 100644 index 0000000..58e124b --- /dev/null +++ b/idp/overlays/idp-inflightreq-middleware.yml @@ -0,0 +1,7 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: idp-inflightreq-middleware +spec: + inFlightReq: + amount: 100 diff --git a/idp/overlays/idp-ingressroute.yml b/idp/overlays/idp-ingressroute.yml new file mode 100644 index 0000000..cf83499 --- /dev/null +++ b/idp/overlays/idp-ingressroute.yml @@ -0,0 +1,27 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: idp + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${IDP_FQDN}`) + middlewares: + - name: idp-headers-middleware + # - name: idp-ratelimit-middleware + - name: idp-inflightreq-middleware + kind: Rule + services: + - name: idp + port: 8080 + # tls: + # secretName: ${SECRET_NAME} # idp-tls + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - "*.preview.${BASE_DOMAIN}" + # - "*.testing.${BASE_DOMAIN}" diff --git a/idp/overlays/idp-maas-ingressroute.yml b/idp/overlays/idp-maas-ingressroute.yml new file mode 100644 index 0000000..a13ac3b --- /dev/null +++ b/idp/overlays/idp-maas-ingressroute.yml @@ -0,0 +1,17 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: idp-maas + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - web + routes: + - match: Host(`${IDP_MAAS_FQDN}`) + middlewares: + - name: idp-headers-middleware + kind: Rule + services: + - name: idp-maas + port: 8087 diff --git a/idp/overlays/idp-ratelimit-middleware.yml b/idp/overlays/idp-ratelimit-middleware.yml new file mode 100644 index 0000000..693dfb3 --- /dev/null +++ b/idp/overlays/idp-ratelimit-middleware.yml @@ -0,0 +1,12 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: idp-ratelimit-middleware +spec: + rateLimit: + period: 1s + average: 30 + burst: 50 + sourceCriterion: + ipStrategy: + depth: 3 diff --git a/idp/overlays/idp_deployment_set_fsgroup.yml b/idp/overlays/idp_deployment_set_fsgroup.yml new file mode 100644 index 0000000..4c6c4f1 --- /dev/null +++ b/idp/overlays/idp_deployment_set_fsgroup.yml @@ -0,0 +1,9 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: idp +spec: + template: + spec: + securityContext: + fsGroup: 1000 diff --git a/idp/overlays/idp_deployment_set_maas_fsgroup.yml b/idp/overlays/idp_deployment_set_maas_fsgroup.yml new file mode 100644 index 0000000..7859271 --- /dev/null +++ b/idp/overlays/idp_deployment_set_maas_fsgroup.yml @@ -0,0 +1,9 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: idp-maas +spec: + template: + spec: + securityContext: + fsGroup: 1000 diff --git a/idp/overlays/idp_deployment_set_maas_realm_config.yml b/idp/overlays/idp_deployment_set_maas_realm_config.yml new file mode 100644 index 0000000..29261fa --- /dev/null +++ b/idp/overlays/idp_deployment_set_maas_realm_config.yml @@ -0,0 +1,18 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: idp-maas +spec: + template: + spec: + securityContext: + fsGroup: 1000 + containers: + - name: idp-maas + volumeMounts: + - name: maas-realm-config + mountPath: /tmp + volumes: + - name: maas-realm-config + configMap: + name: maas-realm-config diff --git a/idp/overlays/idp_deployment_set_realm_config.yml b/idp/overlays/idp_deployment_set_realm_config.yml new file mode 100644 index 0000000..cf64016 --- /dev/null +++ b/idp/overlays/idp_deployment_set_realm_config.yml @@ -0,0 +1,18 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: idp +spec: + template: + spec: + securityContext: + fsGroup: 1000 + containers: + - name: idp + volumeMounts: + - name: realm-config + mountPath: /tmp + volumes: + - name: realm-config + configMap: + name: mcm-realm-config diff --git a/idp/overlays/kustomization.yaml b/idp/overlays/kustomization.yaml new file mode 100644 index 0000000..fce1d73 --- /dev/null +++ b/idp/overlays/kustomization.yaml @@ -0,0 +1,27 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - idp-ingressroute.yml + - idp-maas-ingressroute.yml + - idp-headers-middleware.yml + # - idp-ratelimit-middleware.yml + - idp-inflightreq-middleware.yml + # - idp-certificate.yml + +patchesStrategicMerge: + - web_nw_networkpolicy_namespaceselector.yml + - idp_deployment_set_fsgroup.yml + - idp_deployment_set_realm_config.yml + - idp_deployment_set_maas_fsgroup.yml + - idp_deployment_set_maas_realm_config.yml + +configMapGenerator: + - name: mcm-realm-config + files: + - realms/all-realm.json + - name: maas-realm-config + files: + - maas-realm.json diff --git a/idp/overlays/maas-realm.json b/idp/overlays/maas-realm.json new file mode 100644 index 0000000..f6f7e9e --- /dev/null +++ b/idp/overlays/maas-realm.json @@ -0,0 +1,1994 @@ +{ + "id": "maas_test", + "realm": "maas_test", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "55f993f6-1d5a-4a52-95be-247f34b709ff", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "maas_test", + "attributes": {} + }, + { + "id": "d64dc7e2-5ae8-4c7f-909a-e3d0926a9b6c", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "maas_test", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "77b3bc63-8ad7-44e1-9598-458b61c0ffb1", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "dd9681a7-6d4a-41b4-b2c4-da27834da890", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "fe44a4d3-c1e1-4c90-9881-62ccf3c7932a", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "802fd683-1d8b-4ab4-9ae4-6de64463be0e", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "a7bdea8c-6c49-49c8-b668-63cd116d4b26", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "3eca1df7-72cb-4b77-9cbe-d5f5c48297af", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "3063433d-c473-4b41-bb99-9d58ef34d2ec", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "fc117ae5-fb96-477d-b34f-9ec171d15cdb", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "f3d140dd-6d31-4e0a-b679-75b7abc0f349", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "6d0d590e-1393-4548-af79-2021463819ca", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "8b53e4d9-bd70-47a3-bcf3-e5bccf2ff1b5", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "24710721-f7ab-4471-afee-bc69d4d58489", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "356ce6b3-ef9f-4322-bcda-fa3b96cd3b68", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "cdc143e1-e0fd-4285-b88a-877088df58d7", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "29500b44-e54d-4eca-9ca3-fb555ce25895", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "6709ebd8-b215-4ce7-87ca-b6a0eaa288ea", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-realms", + "impersonation", + "view-users", + "manage-authorization", + "view-events", + "create-client", + "manage-clients", + "manage-realm", + "query-groups", + "view-identity-providers", + "view-realm", + "query-clients", + "manage-users", + "manage-identity-providers", + "view-clients", + "view-authorization", + "manage-events" + ] + } + }, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "20577231-c0f9-4a4a-9903-833c8b349da6", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "a234df2d-e069-40eb-a83c-1d719fcf5baf", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + }, + { + "id": "36bcea11-149c-4441-8e10-79fcc22968c6", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "c2cf9f5a-b978-437a-baa8-5181d57419dd", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "c86598de-991c-4dfe-b9ea-2d8e5c3e8616", + "attributes": {} + } + ], + "account": [ + { + "id": "455ade5b-5f37-48f4-bfe8-2f318e303c34", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "attributes": {} + }, + { + "id": "af332bc4-a9a3-46f8-a885-20872ff8b67d", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "attributes": {} + }, + { + "id": "9e2d42ae-6bf1-4d27-871a-9c0bf5114f8e", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "attributes": {} + }, + { + "id": "12fa047f-06a2-47d2-b678-d0976e7b7c47", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "attributes": {} + }, + { + "id": "310f9522-7414-4f61-b92c-47b51a17a541", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "attributes": {} + }, + { + "id": "ef5953e4-e426-46e3-bfda-6f11d2c2d66d", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRoles": ["offline_access", "uma_authorization"], + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account"] + } + ] + }, + "clients": [ + { + "id": "0fa1e4c3-f8db-4cf0-b68c-42fa625e4020", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/maas_test/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "defaultRoles": ["manage-account", "view-profile"], + "redirectUris": ["/realms/maas_test/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "b8c57f8e-318e-4b83-88de-d4b917b4b21a", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/maas_test/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["/realms/maas_test/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "cfbf7487-4ba0-45a2-999d-406151d0fac3", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "a6852331-e8c4-47cf-8156-7a4063ca8796", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "c86598de-991c-4dfe-b9ea-2d8e5c3e8616", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "7bfa1e8c-54b1-455d-92da-c0387e565e26", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "7c2d0c30-a7ef-48c8-8cb1-18afdebbc811", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/maas_test/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["/admin/maas_test/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "0eb15807-171e-468b-9913-414c0c4c7c27", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "aa50a1dd-9a8f-40fa-ba7b-31375f0f23d5", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "7e168b38-9bcb-49f4-9322-932e6a399cc4", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "42b57806-0e03-4220-b370-08b553fee653", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "43737935-7df2-438c-b3d6-bf1ab6d52eed", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "f81d1093-cc0d-4b10-9fd0-c5e1a25ae5d3", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "ec3edcec-1d74-442b-9379-8679399b7aa5", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "f39b4459-4463-4d92-8620-99c9736a9010", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "e1ad1a49-c687-4c47-9c63-92df45eb4bad", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "db02ecaa-7049-46a1-952d-48523d9a3527", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "5ed4f039-abdf-4fae-a376-880a358339e9", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "616a0073-23b6-4b10-b94e-eaf68db18d7b", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "d95e7d6b-36c2-4822-a871-1f79bd097e0d", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "cf8e4c1a-9953-4756-952f-08a0f3b56ebf", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "4108fe2c-40cf-4a22-b477-6e59967f5d4a", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "05145b25-8663-4188-8a79-3f12b6c9f303", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "4ddcad8e-1ef5-4be7-b0bd-fdf08b3880e0", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "6a037b52-edf4-49d7-938c-e1363d22b64a", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "6b5ecb92-8efa-4010-a7df-d50f3e0d1415", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "11ca0e57-5da6-4232-8c85-a7f68d183a22", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "b29ca5c1-db51-48ca-86e5-13b7fba62b55", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "aa07837d-9d3c-482a-b777-6e5322758220", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "931d53d6-a8f4-4858-a0df-f4a45e05125a", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "4fa89d78-1287-4b28-a598-772da498e574", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "f9239bb6-fe5e-4e13-82c9-dbaeef7d2a64", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "a4d0326e-de47-49af-b88a-5257be9e0f55", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "24b3074e-f36b-44fe-9808-f0de82393e07", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "22f3ef7c-e46f-454e-a5d0-c4e954704a43", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "3b4bf743-85de-4847-8437-0fee79fdf566", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "07cc957e-3fa1-432a-8346-cace7040858c", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "613e72fb-67cb-44fb-a27e-8d5c941d8217", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "271f15a6-6795-4fab-aa44-0996431312b6", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "4d60f907-e70e-460c-b903-a4f3db8943f6", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "4883738a-47c6-4078-b999-e9ee0d8acc45", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "cbea7a69-f0e8-4859-ab29-12b3ac30ce2e", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "727b5d4a-a671-4d1f-95f4-a8195f4f474e", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "email", + "roles", + "web-origins", + "profile" + ], + "defaultOptionalClientScopes": [ + "phone", + "address", + "offline_access", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [ + { + "alias": "keycloak-oidc", + "displayName": "MOB Connect", + "internalId": "fd9fa20d-390c-4950-ab1b-1feef36e6b3f", + "providerId": "keycloak-oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "userInfoUrl": "https://${IDP_FQDN}/auth/realms/${MCM_IDP_REALM}/protocol/openid-connect/userinfo", + "validateSignature": "true", + "clientId": "simulation-test-maas", + "tokenUrl": "https://${IDP_FQDN}/auth/realms/${MCM_IDP_REALM}/protocol/openid-connect/token", + "jwksUrl": "https://${IDP_FQDN}/auth/realms/${MCM_IDP_REALM}/protocol/openid-connect/certs", + "issuer": "https://${IDP_FQDN}/auth/realms/${MCM_IDP_REALM}", + "useJwksUrl": "true", + "authorizationUrl": "https://${IDP_FQDN}/auth/realms/${MCM_IDP_REALM}/protocol/openid-connect/auth", + "clientAuthMethod": "client_secret_basic", + "logoutUrl": "https://${IDP_FQDN}/auth/realms/${MCM_IDP_REALM}/protocol/openid-connect/logout", + "syncMode": "IMPORT", + "clientSecret": "${IDP_MAAS_IDENTITY_PROVIDER_SECRET}" + } + } + ], + "identityProviderMappers": [ + { + "id": "20cb6f7a-3b93-4055-9269-16299bc135a2", + "name": "birthdate", + "identityProviderAlias": "keycloak-oidc", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "birthdate", + "user.attribute": "birthdate" + } + } + ], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "fe2b7f13-67d2-4938-b25e-bfe5e9c3bc5c", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "2c4cc01d-2c64-4020-b567-530729553280", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "16d7af90-e5ce-4cb4-bcc6-c98f8d0d98a4", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "bf24b740-b394-44df-83f8-9874b76e7cd2", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "19ee50b4-6cf6-48b8-bda6-3410f5c61b1e", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "f37fd0a8-4c5c-48e3-a557-6919c2f3a1bb", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-address-mapper", + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "0f63c797-ceb9-4030-b377-843664c5dc8d", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-full-name-mapper", + "oidc-address-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "15d593d7-7770-40b8-9977-281fc8de5bef", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "96893b34-1f7f-4b67-b9a6-5ad07a94d605", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + }, + { + "id": "42f74be4-225b-43b3-a4b5-bb4a981981ea", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": ["100"], + "algorithm": ["HS256"] + } + }, + { + "id": "5376d5fb-185a-4274-8450-9ab2ec45b13f", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "043616fc-1239-49bb-bfaf-e6d569d9506c", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "5d6174fc-0298-4880-b316-492436433448", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "26259450-933d-4518-a26d-0a7c35e9c63e", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "06aa1c7e-f848-4a42-8669-10034f7c10e3", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f9618afe-b449-40af-9d0e-e4e584c5b3d2", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b3c80e1d-fe47-4a32-9862-a6bb73dad2ec", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "e107d2c8-3564-4c8e-9b9a-0e09d11db00a", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "79e2194c-7273-4d73-b055-9d1f9c01453a", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "c5a3babf-28c8-4088-9bec-d907af4d970f", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "8537daee-2479-4515-a3ab-05f875e9dd2b", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "7bf8aec9-803d-40e7-9360-a9940cafb4da", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "ca0abbd0-95ac-49ed-9028-2c754e1be67f", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9acca9ee-de26-4316-86dd-a33d62ceaf70", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b6b975e7-ed64-4859-b87b-d93cb1b233f5", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "fe0ac962-5934-486e-bca4-7a54dd678c42", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "d8dd1c90-edb2-417b-9425-3c8bb2e93c38", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "7e104e6f-0f68-4d37-9c30-08929cb777de", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "2d898882-8901-4fde-8268-27656a543a8f", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f355de9b-1cad-456d-89d7-caf84d3230a1", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "88f52c4f-eae7-42f4-8350-6db9c7923566", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "bebc9999-d377-4f97-bbaf-da0c08fd9c8b", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "a1bfaa06-e94b-4429-a88a-3e6f4b8e7dff", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0" + }, + "keycloakVersion": "11.0.2", + "userManagedAccessAllowed": false +} diff --git a/idp/overlays/realms/master-realm.json b/idp/overlays/realms/master-realm.json new file mode 100644 index 0000000..deed180 --- /dev/null +++ b/idp/overlays/realms/master-realm.json @@ -0,0 +1,1447 @@ +{ + "id": "master", + "realm": "master", + "displayName": "Keycloak", + "displayNameHtml": "
Keycloak
", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 60, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": true, + "maxFailureWaitSeconds": 86400, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 86400, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 86400, + "failureFactor": 5, + "defaultRoles": [ + "offline_access", + "uma_authorization" + ], + "requiredCredentials": [ + "password" + ], + "passwordPolicy": "hashIterations(27500) and forceExpiredPasswordChange(90) and upperCase(1) and passwordHistory(10) and passwordBlacklist(blacklist.txt) and hashAlgorithm(pbkdf2-sha512) and length(16) and lowerCase(1) and specialChars(1) and notUsername(undefined) and digits(1)", + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopes": [ + { + "id": "cbf28520-7e2a-4ede-97ca-f0845614aea1", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "773f868a-8c8e-4727-b2d0-d204ac95b3c9", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "5ed05554-76f2-4099-a9e8-96d72e8ca6e5", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "0967acf4-3d92-4235-b1e9-6f1d84e8e347", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "deb4acac-4d50-4214-8126-15ba8a976746", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "7ef25fd1-7033-4e7c-b772-9ba1023eb0e0", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "439ebeea-de3e-402e-9296-6de0e2eef580", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "b3820c47-eebc-4512-97fc-9bb998d3d766", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "8c185973-7be0-4fdd-af9e-4c43b79c5d7f", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "b6853304-7006-41c3-8b7b-ab97cfdc739b", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "7a7b88eb-0940-419e-828a-9a384774cfb9", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "5ca85ff1-68e3-45f2-acb3-36c686289ff6", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "2cfc81b7-6498-49d8-81ee-21d0f86f3ccd", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "43917998-1c1a-4daf-96c7-8151c93272e6", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "f4acf3c8-cc5c-4438-937b-a3b529c48255", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "3150f2f8-2f7d-467c-bf21-cd99b9b6a19a", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "e2a7c7d7-50ce-4fcb-b83d-bb2d3097965e", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "be59414b-ae26-47f7-9864-e5e2d98ca169", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "ef16cf79-5c58-4101-80cc-dd0f5289901f", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "630d2bbd-fed1-4dc5-95ed-27678f58ce21", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "02fa07c3-efc7-46ef-8f71-4827e34a7608", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "f601a655-6618-436e-beb5-7b592b1ec0cc", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "32ed2745-3cb5-4b1d-b8c2-2f9705195e1a", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "ac9e5d0e-b7d5-475d-93fb-cf6218886942", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "2f5c3db7-5282-4f5e-9705-d8c61d9c02dd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "1879f5bd-e54b-4844-b386-59eecf29c8a9", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "821a2165-e378-4254-8176-cd480016a5b9", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "7ba71185-06e7-4eed-ad75-2e0e3a46312d", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "aa5c0e74-bd99-4f17-a519-3864a369585e", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "978f7928-864c-4bf2-98ea-439544ddf1a1", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "77b856e2-adac-49d4-91d5-bf47079c750e", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "4932eb64-2b5b-4f5c-a9bc-eca0be7a3c39", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "966864ed-0aa8-4ca0-9fdd-b202da1342eb", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "aea5ef6a-548a-4e3a-9c04-0d0c789bd93c", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "9019c9d3-6f28-499b-a57e-7df6db48283e", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "xXSSProtection": "1; mode=block", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [ + { + "alias": "oidc", + "displayName": "Azure AD", + "internalId": "f05ab3a9-8911-4c08-9ee0-ea3b3ebe742c", + "providerId": "oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "validateSignature": "true", + "userInfoUrl": "https://graph.microsoft.com/oidc/userinfo", + "tokenUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/oauth2/v2.0/token", + "clientId": "${IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID}", + "jwksUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/discovery/v2.0/keys", + "issuer": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/v2.0", + "useJwksUrl": "true", + "authorizationUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/oauth2/v2.0/authorize", + "clientAuthMethod": "client_secret_basic", + "logoutUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/oauth2/v2.0/logout", + "syncMode": "IMPORT", + "clientSecret": "${IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET}", + "defaultScope": "openid profile" + } + } + ], + "identityProviderMappers": [ + { + "id": "aab419bc-af18-465a-88d1-b549599efc12", + "name": "view-realm", + "identityProviderAlias": "oidc", + "identityProviderMapper": "oidc-role-idp-mapper", + "config": { + "syncMode": "FORCE", + "claim": "roles", + "role": "mcm-realm.view-realm", + "claim.value": "Task.Read" + } + }, + { + "id": "5fc00b76-3f62-4a11-a1cb-593222c23d2f", + "name": "view-clients", + "identityProviderAlias": "oidc", + "identityProviderMapper": "oidc-role-idp-mapper", + "config": { + "syncMode": "FORCE", + "claim": "roles", + "role": "mcm-realm.view-clients", + "claim.value": "Task.Read" + } + }, + { + "id": "0f75a597-54d5-4a47-8b97-81da1120d892", + "name": "view-users", + "identityProviderAlias": "oidc", + "identityProviderMapper": "oidc-role-idp-mapper", + "config": { + "syncMode": "FORCE", + "claim": "roles", + "role": "mcm-realm.view-users", + "claim.value": "Task.Read" + } + }, + { + "id": "14bcede6-29a0-40e1-af5b-8308e6dd6047", + "name": "admin_role", + "identityProviderAlias": "oidc", + "identityProviderMapper": "oidc-role-idp-mapper", + "config": { + "syncMode": "FORCE", + "claim": "roles", + "role": "admin", + "claim.value": "Task.Write" + } + } + ], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "31c7d299-4ae1-4695-aaaf-31687a6b7bd8", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "1d89ec38-49a6-4c94-9fea-b1ac37d06491", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "45f8a7e3-93b0-4bc8-896e-5960540de39b", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "ed54cd0c-55c5-4f21-8f71-2ab095d5d9ca", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "28abce94-bcf5-4c5b-b2a5-b41464a44804", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "25f7f9af-7bba-498c-b08a-236cac8abfac", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "ea512dcc-a30a-4452-a301-b68da8590a5d", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "saml-role-list-mapper" + ] + } + }, + { + "id": "242f222e-7ed0-4cf9-a0f0-288965f47b11", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "c9b2d716-17a1-4339-a143-9ed30dc40c99", + "name": "fallback-HS256", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "-100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "a98a5cde-5b0c-4831-a2bd-037e36fabd6e", + "name": "fallback-RS256", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "-100" + ], + "algorithm": [ + "RS256" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "eb4d9282-034a-4fd6-9c85-459988d68f8c", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "28ffd6d3-bc61-48f2-9060-3ed0072bc58c", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "3c0dab0d-6eb2-46b0-b30f-8836290f248b", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "77948dc3-f9e0-4f26-a9c4-cc51973ecb03", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f2531880-f999-40a2-b8a9-fe0a01f6b8d9", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "e3f90620-d7b3-46cd-a3e0-58c4586b2399", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "d57be86a-700a-4f8a-b209-145a42dacd31", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "3e78099e-3bc3-4e87-9ce8-ee5df33e1827", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9ba42fa9-ccc1-4e3f-8139-e454d36c2b28", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "dfc1d95d-9fde-4cd4-a620-0e48f37420fc", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "7b588cc5-b0fe-4d1b-b0c9-4d772577a410", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "959c1344-c8aa-4f47-a0b8-f30197a55c1b", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4827a626-f1e6-47c6-b073-ad6624cfd572", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "deaae361-a7c3-4f9c-826d-8a6fe04d771b", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9a22a482-e2c2-4680-880a-5decd7aeabcf", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "a61d2462-3cdd-4f66-adce-65dd89e8f819", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "fdc32a72-37ed-47bc-af5d-468a8d0d1db7", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "01912588-5e34-4461-b960-095022c0ff1f", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "516087b7-3d3c-4204-93b3-052a58fbe6c0", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "2e4d804a-2239-40fb-85fe-d6b5f4711f99", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "459c8e2c-c756-4d96-b755-50c14ba19c4a", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "73d55acf-9b80-4f69-85fe-45c79a14f844", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": {}, + "keycloakVersion": "16.1.1", + "userManagedAccessAllowed": false +} diff --git a/idp/overlays/realms/mcm-realm.json b/idp/overlays/realms/mcm-realm.json new file mode 100644 index 0000000..d4c3cc2 --- /dev/null +++ b/idp/overlays/realms/mcm-realm.json @@ -0,0 +1,3931 @@ +{ + "id": "mcm", + "realm": "mcm", + "displayName": "moB", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 31104000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 86400, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "all", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": true, + "loginWithEmailAllowed": false, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 86400, + "minimumQuickLoginWaitSeconds": 86400, + "waitIncrementSeconds": 86400, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 86400, + "failureFactor": 5, + "roles": { + "realm": [ + { + "id": "192de4f8-a0f9-45f2-9d14-dfb423ab74f2", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "7ffae649-3a1a-4e49-b2e3-467f2268b14c", + "name": "financeurs", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "62290509-e46d-45d5-9392-b3e8612aaf7c", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "7caffe31-e934-42bd-83b0-9ac26294b014", + "name": "content_editor", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "e31292d6-5fb4-4892-9ca1-5d3fb77f0b2c", + "name": "gestionnaires", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "690a8044-aad3-4048-b957-88e59feff0b9", + "name": "superviseurs", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "2f12d2cb-ec53-4f22-b5b0-03d77b7fb922", + "name": "citoyens", + "composite": false, + "clientRole": false, + "containerId": "mcm", + "attributes": {} + }, + { + "id": "cf40a414-2388-4b12-a078-e899814767df", + "name": "citoyens_fc", + "clientRole": false, + "composite": false, + "containerId": "mcm", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "1e15089c-fc94-4113-8799-9ca81327fc05", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "b31d033b-28f6-4d2a-ac9d-739149c7f1fa", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "c3e9e98e-ab47-42c7-a71e-f9fc2a3e9e4c", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "378685bf-69cd-4507-b32e-ed8332e813ec", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "32005694-ba1b-49f1-b4db-ee403149f801", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-realms", + "impersonation", + "view-identity-providers", + "manage-identity-providers", + "query-users", + "manage-clients", + "manage-events", + "create-client", + "manage-realm", + "manage-authorization", + "query-groups", + "view-realm", + "manage-users", + "view-users", + "view-events", + "view-authorization", + "view-clients", + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "24bd8fa9-f2d6-4fdc-998f-07b9304042d8", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "6e59f8e4-e04f-41cd-9cbc-9743f533c1f8", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "ea5c05b9-d2dc-4039-b8ff-21e22fd5366f", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "3044fe1e-fdb3-45ce-91a3-0ae154cb8dae", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "a2cbf44e-8025-4b66-899b-ace11b598d93", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "5c1d8ca2-915b-4049-b18d-d4bfbb1e8534", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "2a5588f6-944d-4ce6-bd69-b8aa365a7bbc", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "252bc002-49af-4d72-9951-d7a4f08d31f6", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "165b7600-99ad-4f68-9adb-5b74d071e122", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "1c901f34-5ee8-448b-8869-6a85b0d71195", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-groups", "query-users"] + } + }, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "fd487d67-9eec-4a1b-9f19-1aabe21e17ec", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "6bd8829d-f068-406b-b2ea-942d349f13bf", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "73c0a5cc-b913-450a-9cd7-50a255eb361a", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + }, + { + "id": "85cffe96-a808-4e41-80e7-cbbda6f845c4", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "simulation-maas-client": [ + { + "id": "208ae5cd-c9b7-4734-b95e-2a8f56523b93", + "name": "maas", + "composite": false, + "clientRole": true, + "containerId": "a7220241-4afa-4554-8b4f-865cdef2c8a7", + "attributes": {} + } + ], + "simulation-maas-backend": [ + { + "id": "3f9c8ea6-799f-4520-a1d3-7027dede2053", + "name": "service_maas", + "composite": false, + "clientRole": true, + "containerId": "c1d90a9c-e1eb-40d4-a48d-aa307154c29d", + "attributes": {} + } + ], + "simulation-maas-client-cme": [ + { + "id": "758a2a7d-b0a0-45ec-a4d3-660f0f028732", + "name": "maas", + "composite": false, + "clientRole": true, + "containerId": "8247f628-9839-48ad-a03e-dbffe1f1f1d6", + "attributes": {} + } + ], + "simulation-test-maas": [ + { + "id": "19121d99-2bb4-4c0d-a8d8-b79e0a74ba9b", + "name": "maas", + "composite": false, + "clientRole": true, + "containerId": "d08aa8bc-1db8-4ca7-bab4-3a2e6e61e2b1", + "attributes": {} + } + ], + "account-console": [], + "api": [ + { + "id": "e45b49e8-b114-40ce-ae10-e35bfcd1f1ff", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "15b5a657-5056-4086-8e28-05ffeb0a62a7", + "attributes": {} + } + ], + "broker": [ + { + "id": "fe62f351-d4e0-453c-a0a4-bba962b648df", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "39c508e2-15c7-420b-b3cf-2b55050f28bb", + "attributes": {} + } + ], + "account": [ + { + "id": "8250a254-14a7-4cd5-bd46-e82f8541722b", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "attributes": {} + }, + { + "id": "882d39eb-eaf2-4a37-a6fc-e31a50143a4c", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "attributes": {} + }, + { + "id": "625695be-cf6a-4961-95b4-d870b81c9a10", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "attributes": {} + }, + { + "id": "41f42da7-717c-4aeb-8bb6-03f093bb937d", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "attributes": {} + }, + { + "id": "2ff04be2-9f50-4c99-ac08-d6a4219e13c8", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "attributes": {} + }, + { + "id": "bf44c0b0-5957-43f0-9ba1-231effb2e239", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "attributes": {} + } + ], + "platform": [ + { + "id": "55142ccf-fa77-46dd-b02f-21ee99f03289", + "name": "platform", + "composite": false, + "clientRole": true, + "containerId": "101f25ec-cf03-4ae7-b955-48b4b8cc5adc", + "attributes": {} + } + ], + "administration": [] + } + }, + "groups": [ + { + "id": "b2ea69c6-5a9b-46ee-b986-1c48f8a3319e", + "name": "admins", + "path": "/admins", + "attributes": {}, + "realmRoles": ["content_editor"], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "ec26af4e-e597-4526-a5b0-e0f9d83ae141", + "name": "citoyens", + "path": "/citoyens", + "attributes": {}, + "realmRoles": ["citoyens"], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "6b1e8f98-20a4-47bd-8fbb-338ddd60ef13", + "name": "financeurs", + "path": "/financeurs", + "attributes": {}, + "realmRoles": ["financeurs"], + "clientRoles": {}, + "subGroups": [ + { + "id": "51c8f034-b59c-474a-8662-7a55929c62a3", + "name": "superviseurs", + "path": "/financeurs/superviseurs", + "attributes": {}, + "realmRoles": ["superviseurs"], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "6cdfda15-0e3d-4ca8-9cd7-538b9d7f8031", + "name": "gestionnaires", + "path": "/financeurs/gestionnaires", + "attributes": {}, + "realmRoles": ["gestionnaires"], + "clientRoles": {}, + "subGroups": [] + } + ] + }, + { + "id": "ecae930d-29b9-4884-800f-8af1b5d0dc51", + "name": "collectivités", + "path": "/collectivités", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "ecae930d-29b9-4884-800f-8af1b5d0dc52", + "name": "entreprises", + "path": "/entreprises", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ], + "defaultRoles": ["offline_access", "uma_authorization"], + "requiredCredentials": ["password"], + "passwordPolicy": "upperCase(1) and lowerCase(1) and specialChars(1) and length(12) and hashAlgorithm(pbkdf2-sha512) and passwordHistory(6) and passwordBlacklist(blacklist.txt) and notUsername(undefined) and digits(1)", + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "0e0e8b9e-3534-4dde-88ee-4acf6ede479e", + "createdTimestamp": 1621937920935, + "username": "service-account-api", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "api", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["offline_access", "uma_authorization"], + "clientRoles": { + "realm-management": ["manage-users"], + "api": ["uma_protection"], + "account": ["view-profile", "manage-account"] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "9293588e-fa24-4ccb-b0d6-bee9f9779f37", + "createdTimestamp": 1632994225304, + "username": "service-account-simulation-maas-backend", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "simulation-maas-backend", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["offline_access"], + "clientRoles": { + "simulation-maas-backend": ["maas"], + "account": ["view-profile", "manage-account"] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account"] + } + ] + }, + "clients": [ + { + "id": "261d2a2b-e531-4a0b-9906-04977c1433d4", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/mcm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "defaultRoles": ["manage-account", "view-profile"], + "redirectUris": ["/realms/mcm/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "beac8a75-ae5a-4c5a-be75-afdfb958e298", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/mcm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["/realms/mcm/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "075f2708-e830-413f-b198-39f47cad3c8a", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "98627c84-3da6-4a87-95ef-98eae683068f", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "15b5a657-5056-4086-8e28-05ffeb0a62a7", + "clientId": "api", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "${IDP_API_CLIENT_SECRET}", + "redirectUris": ["*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "6ceb820c-2f89-4aa2-b66e-c8fec99a1c73", + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "false", + "access.token.claim": "true", + "included.custom.audience": "rabbitmq" + } + }, + { + "id": "78d9163d-3847-4a66-9ab1-8fe2998621ce", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "2d13cd2c-7a6b-44ad-9a7a-4d74bceab9fd", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "d12e1e9b-00d1-4a26-bedf-6ba63137a75a", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "rabbitmq.tag:management", + "rabbitmq.write:%2F/mob.*", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "39c508e2-15c7-420b-b3cf-2b55050f28bb", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "101f25ec-cf03-4ae7-b955-48b4b8cc5adc", + "clientId": "platform", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["https://${WEBSITE_FQDN}/*", "https://${API_FQDN}/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "login_theme": "mcm_template", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "35a7b1e0-5318-49f2-af23-b8a6a4e63399", + "name": "membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "membership", + "userinfo.token.claim": "false" + } + }, + { + "id": "baf67583-9f8b-4d80-a6e9-9144c71cfa60", + "name": "platform_role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-role-mapper", + "consentRequired": false, + "config": { + "role": "platform.platform" + } + }, + { + "id": "5a0c0549-ce7b-46ba-8805-472484395f9c", + "name": "Identity Provider", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "identity_provider", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "identity_provider", + "jsonType.label": "String" + } + }, + { + "id": "7243e74c-f2c2-4642-aed0-ba1d4132a082", + "name": "FEDERATED ID TOKEN", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "FEDERATED_ID_TOKEN", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "FEDERATED_ID_TOKEN", + "access.tokenResponse.claim": "false" + } + }, + { + "id": "9a2e6bca-a53c-4726-ae5c-53203d74ffc6", + "name": "FEDERATED ACCESS TOKEN", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "FEDERATED_ACCESS_TOKEN", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "FEDERATED_ACCESS_TOKEN", + "access.tokenResponse.claim": "false" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email", + "birth" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "7ea41d29-48eb-445f-b853-37c7ad022206", + "clientId": "administration", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["https://${ADMIN_FQDN}/*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "login_theme": "keycloak", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ceee9bba-f270-4fa1-b232-0ac610ed394a", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e4fd6bdc-803b-431d-aee0-f0d4d94cd7fe", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/mcm/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["/admin/mcm/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "39a026cf-ca8d-4ae0-85a3-f342e84bc4e0", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "c1d90a9c-e1eb-40d4-a48d-aa307154c29d", + "clientId": "simulation-maas-backend", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "${IDP_SIMULATION_MAAS_CLIENT_SECRET}", + "redirectUris": ["*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "login_theme": "mcm_template", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "46ebff1d-8dc8-4db8-92f0-89d6e748d3ec", + "name": "maas_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.value": "simulation-maas-backend", + "userinfo.token.claim": "false", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "maas_name", + "jsonType.label": "String" + } + }, + { + "id": "32552201-54a5-46b0-9489-2b0e14c335e2", + "name": "maas_role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-role-mapper", + "consentRequired": false, + "config": { + "role": "simulation-maas-backend.service_maas" + } + }, + { + "id": "bcd48135-c5b2-43d4-9f26-cfd8051d538a", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "7c032b53-7f6e-4264-bd0a-2bd91f266cbc", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "8b069dba-40fb-4303-b45c-eca61f8532ba", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": ["role_list", "roles", "funders-clients"], + "optionalClientScopes": ["offline_access"] + }, + { + "id": "a7220241-4afa-4554-8b4f-865cdef2c8a7", + "clientId": "simulation-maas-client", + "name": "simulation maas client", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["https://${SIMULATION_MAAS_FQDN}/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "consent.screen.text": "${consentProfile}", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "cd4313b5-f7e1-4280-9a7f-d18e191413d4", + "name": "maas_role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-role-mapper", + "consentRequired": false, + "config": { + "role": "simulation-maas-client.maas" + } + }, + { + "id": "b185afb1-d7da-41eb-8131-97a6dd16e4b9", + "name": "maas_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.value": "simulation-maas-client", + "userinfo.token.claim": "false", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "maas_name", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "role_list", + "roles", + "profile", + "subscriptions_list", + "subscriptions_metadata", + "subscriptions_process", + "incentives_list" + ], + "optionalClientScopes": ["offline_access"] + }, + { + "id": "8247f628-9839-48ad-a03e-dbffe1f1f1d6", + "clientId": "simulation-maas-client-cme", + "name": "simulation maas client cme", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": ["https://${SIMULATION_MAAS_FQDN}/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "consent.screen.text": "${consentProfile}", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "66eac4e3-0d82-4933-8f71-39d54625aca3", + "name": "maas_role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-role-mapper", + "consentRequired": false, + "config": { + "role": "simulation-maas-client-cme.maas" + } + }, + { + "id": "931dd262-8ec7-4247-9763-206718b0e144", + "name": "maas_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.value": "simulation-maas-client-cme", + "userinfo.token.claim": "false", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "maas_name", + "jsonType.label": "String" + } + }, + { + "id": "5bd3b5ba-df03-41d5-af24-c8388ff08de7", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "false", + "claim.name": "birthdate", + "id.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "birthdate", + "userinfo.token.claim": "false" + } + } + ], + "defaultClientScopes": [ + "role_list", + "roles", + "profile", + "subscriptions_list", + "subscriptions_metadata", + "subscriptions_process", + "incentives_list" + ], + "optionalClientScopes": ["offline_access"] + }, + { + "id": "d08aa8bc-1db8-4ca7-bab4-3a2e6e61e2b1", + "clientId": "simulation-test-maas", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "${IDP_MAAS_IDENTITY_PROVIDER_SECRET}", + "redirectUris": ["*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "4a9effe9-b6ef-4b12-9dd3-728e736e34ad", + "name": "maas_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.value": "simulation-test-maas", + "userinfo.token.claim": "false", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "maas_name", + "jsonType.label": "String" + } + }, + { + "id": "34d3bc07-d065-4e86-82ea-c4f8fc1d458b", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "false", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "18aa25bc-3ccc-4708-a9be-a8e9c7e3567e", + "name": "maas_role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-role-mapper", + "consentRequired": false, + "config": { + "role": "simulation-test-maas.maas" + } + } + ], + "defaultClientScopes": [ + "incentives_list", + "profile", + "roles", + "subscriptions_list", + "subscriptions_metadata", + "subscriptions_process" + ], + "optionalClientScopes": ["offline_access"] + } + ], + "clientScopes": [ + { + "id": "fc062ce5-ac49-4aec-b7ce-de714d5faac9", + "name": "rabbitmq.tag:monitoring", + "description": "RabbitMQ Monitoring", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "462e420b-4167-4541-b542-1b6724051429", + "name": "rabbitmq.read:*/*", + "description": "RabbitMQ full read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + }, + { + "id": "2d40fdc4-9530-4d41-843f-c5258933aadf", + "name": "rabbitmq.write:%2F/mob.*", + "description": "RabbitMQ write access for mob exchange", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "1670850d-3248-443d-b33c-e526fc2d0428", + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "false", + "access.token.claim": "true", + "included.custom.audience": "rabbitmq" + } + } + ] + }, + { + "id": "ac19ce78-5341-444f-9aba-e78e663d865e", + "name": "rabbitmq.read:%2F/mob.subscriptions.status.*", + "description": "RabbitMQ read access for all MOB subscription status messages", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + }, + { + "id": "8e62850b-d6e6-45ca-adaa-acb69a0444a1", + "name": "rabbitmq.write:*/*", + "description": "lecture", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "481ac14e-ee5f-43c4-b0c5-ca14efc38bd3", + "name": "rabbitmq.configure:*/*", + "description": "Conf", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + }, + { + "id": "9dcb056f-f54f-4380-836f-28f9a44c520a", + "name": "rabbitmq.tag:administrator", + "description": "RabbitMQ Administrator", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "58447a76-7ec5-4083-80b3-5206dd6c8104", + "name": "funders-clients", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + } + }, + { + "id": "a5096f8d-b51a-4868-ba50-81510bbd7e7a", + "name": "rabbitmq.tag:management", + "description": "RabbitMQ Management (UI and REST API)", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "e00da82b-9d31-40b3-9660-10901e9ddcab", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "1", + "consent.screen.text": "${consentProfile}" + }, + "protocolMappers": [ + { + "id": "4892897b-5773-4582-891b-075042f5c732", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "64e02cd5-984e-4971-9aec-07565984f212", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "1ffb9fa7-876d-4d7a-8806-8b1061a221b2", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "65c41022-1a5a-4d4d-93e2-a46c87dbe1d5", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "7d6e9d50-3361-41f5-a9e8-c321b0291a28", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "24e9142e-8afb-437d-9075-9040b97a134e", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "ad459c4e-ee1c-4e11-bc12-69db791dac04", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "f303443c-8e30-4430-8587-9d1f80d08b79", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "0bc9cefd-85e9-4393-b683-082a26ec6849", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "f665e037-d087-429d-8bfa-5e8342a98f5a", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "9e318e07-70a0-4150-897f-3d6cf76dd035", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "6816bb10-c991-4174-ba64-1624373d3f71", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "331c3a5b-b115-4f27-8522-ab3ffeada530", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "f5342490-38ca-4ee3-b6b5-ac108d4ff237", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "9ac03521-0541-4af8-b120-1fa0d3a2291a", + "name": "incentives_list", + "description": "Accès au catalogue d'aides publiques et employeurs", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "2", + "consent.screen.text": "${consentIncentivesList}" + } + }, + { + "id": "4093b9fb-2e76-431a-81a4-7b0ba9e2ae0c", + "name": "subscriptions_metadata", + "description": "Transmission automatique des justificatifs d'achats de votre APP de mobilité", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "3", + "consent.screen.text": "${consentSubscriptionsMetadata}" + } + }, + { + "id": "921df8af-6eff-4aef-8b29-69b69bab7874", + "name": "subscriptions_process", + "description": "Souscription à une demande d'aide", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "4", + "consent.screen.text": "${consentSubscriptionsProcess}" + } + }, + { + "id": "08a9042c-6902-4685-ba6b-ec322cdea167", + "name": "subscriptions_list", + "description": "Suivi de l'état de vos demandes réalisées", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "5", + "consent.screen.text": "${consentSubscriptionsList}" + } + }, + { + "id": "cf49b274-1741-45f4-98d4-a1e9d50ef4fd", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "66bbfb37-2986-435d-b153-650063002fa1", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "77c2b883-0688-46b0-98ee-75f97868ddf2", + "name": "api", + "description": "GitLab API", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + }, + { + "id": "a42616c2-7b18-4f5e-94db-dd7df3424a5c", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "d72f20a3-eb51-4e7f-aef6-ddef6dec5eba", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "48a3d9a5-836f-4344-97d2-ffd996a0d92b", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "4cc17cfc-5ca9-4daf-b5da-e036f60af14f", + "name": "good-service", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "ab81c234-b6b3-44bd-97d6-e8ed636cccfe", + "name": "my-app-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false + } + ] + }, + { + "id": "24c4bb89-a260-4393-a433-de0ad7bbcb67", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "f4a4a47c-0054-4739-8ca5-4096c6a24e2b", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "8eebb4c0-3880-4d79-805f-1d7c46ad1f31", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "eda81a24-992d-4fa4-a62a-348dfb6aa8fc", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false", + "consent.screen.text": "${offlineAccessScopeConsentText}" + } + }, + { + "id": "1aed2959-e0ba-4f88-865b-e7a825a8d493", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "e48b388a-1b5f-4a08-8f72-050c4b65a17e", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "c422d0b7-cfad-4b2e-8fa5-54e7689d6c9f", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "8034a88d-ec16-44bb-be98-6692df7a536d", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "9e12ab6d-8f4d-4063-b878-319a6f19f156", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "602f477f-48b4-44d8-971d-abb09eff7787", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "4dc9d43f-a3ec-4136-9576-71f532d98354", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "3679c279-cd2d-4788-86b5-b242ecce666f", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "91860550-7b6f-4890-ada8-606d3f727e95", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "902e6e7f-8969-425e-b298-d87e55d36b62", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "160e05cd-f30a-4282-bae7-01c060cc53b8", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "6d6b6d5b-c19b-4f99-ac96-d98b7795072a", + "name": "urn:cms:identity:read", + "description": "OpenID Connect built-in scope: identity", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "227295c7-025f-4243-886d-bf6c97729054", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.gender", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "gender", + "jsonType.label": "JSON" + } + }, + { + "id": "ae664043-bd8b-4733-a639-9f96f1b8df8e", + "name": "birthDate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.birthDate", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "birthDate", + "jsonType.label": "JSON" + } + }, + { + "id": "9d091b84-028d-4fa7-b687-0d44b4f20ce3", + "name": "firstName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.firstName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "firstName", + "jsonType.label": "JSON" + } + }, + { + "id": "62e3b799-cb8b-4c8f-9be4-5352028f6215", + "name": "middleNames", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.middleNames", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "middleNames", + "jsonType.label": "JSON" + } + }, + { + "id": "cf6c95a9-5c67-47d7-8a81-3531fed5208a", + "name": "birthCountry", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.birthCountry", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "birthCountry", + "jsonType.label": "JSON" + } + }, + { + "id": "f77fe6d9-e635-4de1-b474-fd4ac39ed617", + "name": "birthPlace", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.birthPlace", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "birthPlace", + "jsonType.label": "JSON" + } + }, + { + "id": "2a1dcb2d-d9f7-48d3-aefe-a722a9a090a1", + "name": "lastName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "identity.lastName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "lastName", + "jsonType.label": "JSON" + } + } + ] + }, + { + "id": "e46741a6-8c25-4a86-9037-ad92467d9fdf", + "name": "urn:cms:personal-information:read", + "description": "OpenID Connect built-in scope: personal-information", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "36174c1d-50a9-4c82-800a-8f20d7ce93ca", + "name": "secondaryPostalAddress", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "personalInformation.secondaryPostalAddress", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "secondaryPostalAddress", + "jsonType.label": "JSON" + } + }, + { + "id": "5f82f4ee-638e-41fe-bda0-5af1e659400d", + "name": "secondaryPhoneNumber", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "personalInformation.secondaryPhoneNumber", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "secondaryPhoneNumber", + "jsonType.label": "JSON" + } + }, + { + "id": "6854d718-5654-49fa-8d5a-a24cb739db1a", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "personalInformation.email", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "email", + "jsonType.label": "JSON" + } + }, + { + "id": "e27e02d0-79f8-4c06-bc40-4aae0e94413d", + "name": "primaryPhoneNumber", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "personalInformation.primaryPhoneNumber", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "primaryPhoneNumber", + "jsonType.label": "JSON" + } + }, + { + "id": "0bf8f225-7638-4e2f-a079-c9151e525e2f", + "name": "primaryPostalAddress", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "personalInformation.primaryPostalAddress", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "primaryPostalAddress", + "jsonType.label": "JSON" + } + } + ] + }, + { + "id": "0376b3ac-f440-49cb-bec4-a3f108e16dd5", + "name": "urn:cms:fr-dgfip-information:read", + "description": "OpenID Connect built-in scope: dgfip-information", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "41534fdc-5192-4744-a3cc-906e021aa32f", + "name": "declarant2.birthDate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.birthDate", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.birthDate", + "jsonType.label": "JSON" + } + }, + { + "id": "58d6bfab-37b5-4eed-9812-0da4676e40ac", + "name": "declarant1.lastName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.lastName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.lastName", + "jsonType.label": "JSON" + } + }, + { + "id": "14a1eeca-2eed-4629-bc68-44aa63adb871", + "name": "declarant2.birthName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.birthName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.birthName", + "jsonType.label": "JSON" + } + }, + { + "id": "f9367d17-bd48-482c-be46-3d93c3c28608", + "name": "declarant2.birthCountry", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.birthCountry", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.birthCountry", + "jsonType.label": "JSON" + } + }, + { + "id": "856a4605-ace1-4e34-8c43-cc39565f1b8d", + "name": "taxNotices.declarationYear", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices.declarationYear", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices.declarationYear", + "jsonType.label": "JSON" + } + }, + { + "id": "16386ae2-cb10-49a6-9b76-cdc9caaed010", + "name": "declarant1.primaryPostalAddress", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.primaryPostalAddress", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.primaryPostalAddress", + "jsonType.label": "JSON" + } + }, + { + "id": "a6b95399-764f-451d-befe-9ba1c485f174", + "name": "taxNotices", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices", + "jsonType.label": "JSON" + } + }, + { + "id": "4d28bf84-1fb2-49bc-926c-407a4317cdde", + "name": "declarant2.primaryPhoneNumber", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.primaryPhoneNumber", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.primaryPhoneNumber", + "jsonType.label": "JSON" + } + }, + { + "id": "30142c24-d68d-4ff1-b38f-91c2f4241e7f", + "name": "declarant1.firstName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.firstName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.firstName", + "jsonType.label": "JSON" + } + }, + { + "id": "64a6244c-85be-4005-9ceb-5d43b1c1e5d1", + "name": "declarant2", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "declarant2", + "jsonType.label": "JSON" + } + }, + { + "id": "a4f7fbb7-bdfd-4e81-b3e7-d0b7ac0f091d", + "name": "declarant1.middleNames", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.middleNames", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.middleNames", + "jsonType.label": "JSON" + } + }, + { + "id": "dc0e1ede-89f1-446f-b670-ceac36d1647b", + "name": "taxNotices.numberOfShares", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices.numberOfShares", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices.numberOfShares", + "jsonType.label": "JSON" + } + }, + { + "id": "66e34e80-5117-4c3f-a9be-da13b35a7503", + "name": "declarant1.primaryPhoneNumber", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.primaryPhoneNumber", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.primaryPhoneNumber", + "jsonType.label": "JSON" + } + }, + { + "id": "04a24f32-b6fb-47ae-b598-b6ef698dfb0e", + "name": "declarant2.middleNames", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.middleNames", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.middleNames", + "jsonType.label": "JSON" + } + }, + { + "id": "dcebe4ff-5587-4200-984b-a21a3ec41775", + "name": "declarant1.email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.email", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.email", + "jsonType.label": "JSON" + } + }, + { + "id": "1d8df2a8-0c94-4701-b478-d09fde387d18", + "name": "taxNotices.referenceTaxIncome", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices.referenceTaxIncome", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices.referenceTaxIncome", + "jsonType.label": "JSON" + } + }, + { + "id": "e6bda0bc-5e30-4bef-8222-3556d26516d4", + "name": "declarant2.primaryPostalAddress", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.primaryPostalAddress", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.primaryPostalAddress", + "jsonType.label": "JSON" + } + }, + { + "id": "568a6a4b-096c-48ac-ab80-f8271d7d873e", + "name": "declarant2.email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.email", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.email", + "jsonType.label": "JSON" + } + }, + { + "id": "e6c01827-f2c7-4596-89f4-3dd3aa3eef30", + "name": "declarant1.birthPlace", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.birthPlace", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.birthPlace", + "jsonType.label": "JSON" + } + }, + { + "id": "9ae60674-f481-4a32-b3bb-ebbef622a306", + "name": "declarant1.secondaryPostalAddress", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.secondaryPostalAddress", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.secondaryPostalAddress", + "jsonType.label": "JSON" + } + }, + { + "id": "f0e9ddb5-ed1f-4566-82c5-d60007a3efda", + "name": "taxNotices.taxAmount", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices.taxAmount", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices.taxAmount", + "jsonType.label": "JSON" + } + }, + { + "id": "9f6a7ca6-4515-4436-988e-45b5fb3e948b", + "name": "declarant2.lastName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.lastName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.lastName", + "jsonType.label": "JSON" + } + }, + { + "id": "bbb3d647-8c14-4d01-80ca-74759f12cb3b", + "name": "declarant2.firstName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.firstName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.firstName", + "jsonType.label": "JSON" + } + }, + { + "id": "bf22ab71-ca69-4647-8896-a8384dc47641", + "name": "declarant1.birthCountry", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.birthCountry", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.birthCountry", + "jsonType.label": "JSON" + } + }, + { + "id": "de50765b-f082-49db-b1af-e4b52f724b0e", + "name": "declarant1.birthName", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.birthName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.birthName", + "jsonType.label": "JSON" + } + }, + { + "id": "69b423b5-ebc3-4b5b-84ae-645df772a64b", + "name": "taxNotices.taxableIncome", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices.taxableIncome", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices.taxableIncome", + "jsonType.label": "JSON" + } + }, + { + "id": "de3f388c-bf5b-4bac-877b-5fcf660ff042", + "name": "declarant1.birthDate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1.birthDate", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1.birthDate", + "jsonType.label": "JSON" + } + }, + { + "id": "18f50dfb-c0d2-4697-91f1-5abf1c102db6", + "name": "taxNotices.grossIncome", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.taxNotices.grossIncome", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "taxNotices.grossIncome", + "jsonType.label": "JSON" + } + }, + { + "id": "fe137504-2012-49d6-8b29-8a4f8e8bce37", + "name": "declarant2.birthPlace", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.birthPlace", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.birthPlace", + "jsonType.label": "JSON" + } + }, + { + "id": "2765c9ed-1db2-4797-9a8d-6088cb5a2a75", + "name": "declarant1", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant1", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant1", + "jsonType.label": "JSON" + } + }, + { + "id": "33eb812d-ad8a-4d8c-b5a3-46f4e829314b", + "name": "declarant2.secondaryPostalAddress", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "frenchDgfipInformation.declarant2.secondaryPostalAddress", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "declarant2.secondaryPostalAddress", + "jsonType.label": "JSON" + } + } + ] + }, + { + "id": "cb5b1719-756f-4360-9e51-31488c7a7c31", + "name": "urn:cms:driving-licence:read", + "description": "A person's driving license information", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "1e2b4ba4-a736-4f1e-ba43-cadbcbd69196", + "name": "dateOfIssue", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "drivingLicence.dateOfIssue", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "dateOfIssue", + "jsonType.label": "JSON" + } + }, + { + "id": "db34644a-8d0a-4277-b0cf-ba832cae6011", + "name": "dateOfValidity", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "drivingLicence.dateOfValidity", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "dateOfValidity", + "jsonType.label": "JSON" + } + }, + { + "id": "41f2c200-5605-4769-bf03-665c13d96550", + "name": "issuingCountry", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "drivingLicence.issuingCountry", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "issuingCountry", + "jsonType.label": "JSON" + } + }, + { + "id": "0fa03ce2-c9d1-4911-860e-b73d69e5ffe3", + "name": "number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "drivingLicence.number", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "number", + "jsonType.label": "JSON" + } + } + ] + }, + { + "id": "6a7e716d-c31a-4920-a197-4f436eff82fc", + "name": "birth", + "description": "OpenID Connect built-in scope: birth", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${birthScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a4d4c74e-bf4c-4fdf-a838-207448125403", + "name": "birthcountry", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthcountry", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthcountry", + "jsonType.label": "String" + } + }, + { + "id": "fdcce1af-5552-4162-abb6-66f9d1d75199", + "name": "birthplace", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthplace", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthplace", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": ["roles", "profile"], + "defaultOptionalClientScopes": ["offline_access"], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": { + "password": "${MAIL_API_KEY}", + "starttls": "false", + "auth": "${SMTP_AUTH}", + "port": "${MAIL_PORT}", + "host": "${MAIL_HOST}", + "from": "${EMAIL_FROM_KC}", + "fromDisplayName": "Mon Compte Mobilité", + "ssl": "false", + "user": "${MAIL_USER}" + }, + "loginTheme": "mcm_template", + "emailTheme": "mcm_template", + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [ + { + "alias": "oidc", + "displayName": "Administrateurs fonctionnels", + "internalId": "e9d9448d-71e3-43e5-93f7-5c4e217425a2", + "providerId": "oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "userInfoUrl": "https://graph.microsoft.com/oidc/userinfo", + "validateSignature": "true", + "clientId": "${IDP_MCM_IDENTITY_PROVIDER_CLIENT_ID}", + "tokenUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/oauth2/v2.0/token", + "jwksUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/discovery/v2.0/keys", + "issuer": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/v2.0", + "useJwksUrl": "true", + "authorizationUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/oauth2/v2.0/authorize", + "clientAuthMethod": "client_secret_basic", + "logoutUrl": "https://login.microsoftonline.com/52707392-fe79-46d6-ae10-cd8e506f41f9/oauth2/v2.0/logout", + "syncMode": "IMPORT", + "clientSecret": "${IDP_MCM_IDENTITY_PROVIDER_CLIENT_SECRET}" + } + }, + { + "alias": "franceconnect-particulier", + "displayName": "FranceConnect", + "internalId": "ad7784d8-24c1-47a2-a7a7-c5fa6e189ab8", + "providerId": "franceconnect-particulier", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "hideOnLoginPage": "false", + "loginHint": "false", + "eidas_values": "eidas1", + "clientId": "${FRANCE_CONNECT_IDP_PROVIDER_CLIENT_ID}", + "uiLocales": "false", + "fc_environment": "integration", + "syncMode": "IMPORT", + "clientSecret": "${FRANCE_CONNECT_IDP_PROVIDER_CLIENT_SECRET}", + "defaultScope": "email openid identite_pivot", + "useJwksUrl": "true" + } + } + ], + "identityProviderMappers": [ + { + "id": "5911de54-b270-4213-b409-66760932579a", + "name": "birthplace", + "identityProviderAlias": "franceconnect-particulier", + "identityProviderMapper": "franceconnect-user-attribute-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "birthplace", + "user.attribute": "birthplace" + } + }, + { + "id": "cd2d5481-c06b-4048-85a1-1cee72322837", + "name": "birthcountry", + "identityProviderAlias": "franceconnect-particulier", + "identityProviderMapper": "franceconnect-user-attribute-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "birthcountry", + "user.attribute": "birthcountry" + } + }, + { + "id": "be831ac2-e8ee-4808-966e-e68ca99cd99d", + "name": "birthdate", + "identityProviderAlias": "franceconnect-particulier", + "identityProviderMapper": "franceconnect-user-attribute-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "birthdate", + "user.attribute": "birthdate" + } + }, + { + "id": "57127d4b-31fb-4ae7-912d-cb6e3dcb0734", + "name": "emailTemplate", + "identityProviderAlias": "franceconnect-particulier", + "identityProviderMapper": "hardcoded-attribute-idp-mapper", + "config": { + "attribute.value": "citoyen", + "syncMode": "INHERIT", + "attribute": "emailTemplate" + } + }, + { + "id": "00e7e9a4-5132-4242-b059-23ed1c6b5f60", + "name": "gender", + "identityProviderAlias": "franceconnect-particulier", + "identityProviderMapper": "franceconnect-user-attribute-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "gender", + "user.attribute": "gender" + } + }, + { + "id": "f1daba55-fbc9-45c9-bc97-ed68fbdc835e", + "name": "Citizens FC", + "identityProviderAlias": "franceconnect-particulier", + "identityProviderMapper": "oidc-hardcoded-role-idp-mapper", + "config": { + "role": "citoyens_fc", + "syncMode": "INHERIT" + } + }, + { + "id": "91b90add-53cd-4ba4-bd92-22639cbb5722", + "name": "Content editor role mapping", + "identityProviderAlias": "oidc", + "identityProviderMapper": "oidc-hardcoded-role-idp-mapper", + "config": { + "syncMode": "INHERIT", + "role": "content_editor" + } + } + ], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "f85f882e-3609-422e-bee6-1a2e7c83323a", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "95ea8bbb-6fec-4fdf-8418-e3ffbf431a9f", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "42d6e6cb-93df-4a69-a413-06c1d467f8af", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "5c36f123-6555-4269-879e-7770c38222d1", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "a56be214-22a7-4702-9268-2f57d3bc57df", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "6d9ea905-45b9-4022-907c-3e97a3f42622", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "08f9db6a-1d77-4ac8-a043-f24b48fb3c73", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "03e9cab5-5852-429c-a479-5795df620279", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "68699fe5-5429-447b-bfb6-b2e5acce2e23", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + }, + { + "id": "bb7b80c8-10cb-4eb1-aa87-409fec511e90", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": ["100"] + } + }, + { + "id": "d213c77e-2afb-44e8-80bb-a58778a627b2", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": ["100"], + "algorithm": ["HS256"] + } + } + ] + }, + "internationalizationEnabled": true, + "supportedLocales": ["fr"], + "defaultLocale": "fr", + "authenticationFlows": [ + { + "id": "b5cb0d48-636b-430f-8794-7b4b0e564b7d", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "cb13699d-acc0-4072-901b-17414a685724", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "d9f6b244-b278-47cf-8a48-cc2555a4f9bb", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "45223199-5c13-4a75-9fdb-d2f9c9f59691", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "1c80dd97-61e8-4d5a-a5b7-0806c1c1972b", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "9d8ce806-d56d-4017-8321-2d44dc77f311", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "DISABLED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "39ba7fab-5409-480a-91b1-b92f93e12c00", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "482f20a8-d158-4be9-b785-e77d5f89b534", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "507e0fd3-d030-4507-a5ac-e4848caf6d2a", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9db1a01d-346f-4187-8527-422855cd8022", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "1b73d4fe-88ab-430b-a3ec-9802786a40bb", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "0542be52-27ca-4c77-89c5-d59b3c2a668d", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "308ef0a1-c375-4f40-b0f6-a21432807321", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b5584791-51bf-48b8-be85-2ae0957a071e", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "56e9260a-ef3b-49a6-b414-0ebb6d2f54d4", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9afc56c7-e202-4b5b-bf58-77ba326dcea3", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "b2b1f7e3-4c7b-4374-8c25-6b5d418ac23c", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "7f8c4526-d79a-4159-a350-924e54488be3", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "e2b55d64-94b8-4be1-859e-6a17277a166d", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4a30a8eb-e9a4-46a9-9856-1d81e8acec59", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "39280002-85d8-4c94-9e1c-e32b91374189", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "067b37ca-67a8-415e-8a86-a0dc514789df", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "actionTokenGeneratedByUserLifespan.reset-credentials": "3600" + }, + "keycloakVersion": "16.1.1", + "userManagedAccessAllowed": false +} diff --git a/idp/overlays/web_nw_networkpolicy_namespaceselector.yml b/idp/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..53e6193 --- /dev/null +++ b/idp/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" diff --git a/idp/password-blacklists/blacklist.txt b/idp/password-blacklists/blacklist.txt new file mode 100644 index 0000000..d6b4641 --- /dev/null +++ b/idp/password-blacklists/blacklist.txt @@ -0,0 +1,370 @@ +111111 +11111111 +112233 +121212 +123123 +123456 +1234567 +12345678 +131313 +232323 +654321 +666666 +696969 +777777 +7777777 +8675309 +987654 +aaaaaa +abc123 +abc123 +abcdef +abgrtyu +access +access14 +action +albert +alexis +amanda +amateur +andrea +andrew +angela +angels +animal +anthony +apollo +apples +arsenal +arthur +asdfgh +asdfgh +ashley +august +austin +badboy +bailey +banana +barney +baseball +batman +beaver +beavis +bigdaddy +bigdog +birdie +bitches +biteme +blazer +blonde +blondes +bond007 +bonnie +booboo +booger +boomer +boston +brandon +brandy +braves +brazil +bronco +broncos +bulldog +buster +butter +butthead +calvin +camaro +cameron +canada +captain +carlos +carter +casper +charles +charlie +cheese +chelsea +chester +chicago +chicken +cocacola +coffee +college +compaq +computer +cookie +cooper +corvette +cowboy +cowboys +crystal +dakota +dallas +daniel +danielle +debbie +dennis +diablo +diamond +doctor +doggie +dolphin +dolphins +donald +dragon +dreams +driver +eagle1 +eagles +edward +einstein +erotic +extreme +falcon +fender +ferrari +firebird +fishing +florida +flower +flyers +football +forever +freddy +freedom +gandalf +gateway +gators +gemini +george +giants +ginger +golden +golfer +gordon +gregory +guitar +gunner +hammer +hannah +hardcore +harley +heather +helpme +hockey +hooters +horney +hotdog +hunter +hunting +iceman +iloveyou +internet +iwantu +jackie +jackson +jaguar +jasmine +jasper +jennifer +jeremy +jessica +johnny +johnson +jordan +joseph +joshua +junior +justin +killer +knight +ladies +lakers +lauren +leather +legend +letmein +little +london +lovers +maddog +madison +maggie +magnum +marine +marlboro +martin +marvin +master +matrix +matthew +maverick +maxwell +melissa +member +mercedes +merlin +michael +michelle +mickey +midnight +miller +mistress +monica +monkey +monkey +monster +morgan +mother +mountain +muffin +murphy +mustang +naked +nascar +nathan +naughty +ncc1701 +newyork +nicholas +nicole +nipple +nipples +oliver +orange +packers +panther +panties +parker +password +password +password1 +password12 +password123 +patrick +peaches +peanut +pepper +phantom +phoenix +player +please +pookie +porsche +prince +princess +private +purple +pussies +qazwsx +qwerty +qwertyui +rabbit +rachel +racing +raiders +rainbow +ranger +rangers +rebecca +redskins +redsox +redwings +richard +robert +rocket +rosebud +runner +rush2112 +russia +samantha +sammy +samson +sandra +saturn +scooby +scooter +scorpio +scorpion +secret +sexsex +shadow +shannon +shaved +sierra +silver +skippy +slayer +smokey +snoopy +soccer +sophie +spanky +sparky +spider +squirt +srinivas +startrek +starwars +steelers +steven +sticky +stupid +success +summer +sunshine +superman +surfer +swimming +sydney +taylor +tennis +teresa +tester +testing +theman +thomas +thunder +thx1138 +tiffany +tigers +tigger +tomcat +topgun +toyota +travis +trouble +trustno1 +tucker +turtle +twitter +united +vagina +victor +victoria +viking +voodoo +voyager +walter +warrior +welcome +whatever +william +willie +wilson +winner +winston +winter +wizard +xavier +xxxxxx +xxxxxxxx +yamaha +yankee +yankees +yellow +zxcvbn +zxcvbnm +zzzzzz diff --git a/idp/postgres-dockerfile.yml b/idp/postgres-dockerfile.yml new file mode 100644 index 0000000..189e0f9 --- /dev/null +++ b/idp/postgres-dockerfile.yml @@ -0,0 +1,4 @@ +ARG BASE_POSTGRES_IMAGE_NAME +FROM ${BASE_POSTGRES_IMAGE_NAME} + +COPY ./databaseConfig/*.sh /docker-entrypoint-initdb.d/ diff --git a/idp/standalone.xml b/idp/standalone.xml new file mode 100644 index 0000000..5b66f4c --- /dev/null +++ b/idp/standalone.xml @@ -0,0 +1,599 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + 100 + + + sa + sa + + + + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + -1 + false + false + ${env.KEYCLOAK_WELCOME_THEME:keycloak} + ${env.KEYCLOAK_DEFAULT_THEME:keycloak} + ${jboss.home.dir}/themes + + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + ${keycloak.hostname.provider:default} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mailhog/.gitlab-ci.yml b/mailhog/.gitlab-ci.yml new file mode 100644 index 0000000..242b985 --- /dev/null +++ b/mailhog/.gitlab-ci.yml @@ -0,0 +1,19 @@ +include: + - local: "mailhog/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "mailhog/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + +.mailhog-base: + variables: + MODULE_NAME: mailhog + MODULE_PATH: ${MODULE_NAME} + MAILHOG_IMAGE_NAME: ${NEXUS_DOCKER_REGISTRY_URL}/mailhog/mailhog:v1.0.1 + only: + changes: + - "*" + - "commons/**/*" + - "mailhog/**/*" + diff --git a/mailhog/.gitlab-ci/preview.yml b/mailhog/.gitlab-ci/preview.yml new file mode 100644 index 0000000..8daaaf7 --- /dev/null +++ b/mailhog/.gitlab-ci/preview.yml @@ -0,0 +1,12 @@ +mailhog_preview_deploy: + extends: + - .preview-deploy-job + - .mailhog-base + needs: ["commons-kubetools-image"] + environment: + on_stop: mailhog_preview_cleanup + +mailhog_preview_cleanup: + extends: + - .commons_preview_cleanup + - .mailhog-base diff --git a/mailhog/.gitlab-ci/testing.yml b/mailhog/.gitlab-ci/testing.yml new file mode 100644 index 0000000..d578b32 --- /dev/null +++ b/mailhog/.gitlab-ci/testing.yml @@ -0,0 +1,5 @@ +mailhog_testing_deploy: + extends: + - .testing-deploy-job + - .mailhog-base + needs: ["commons-kubetools-image"] diff --git a/mailhog/Chart.yaml b/mailhog/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/mailhog/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/mailhog/README.md b/mailhog/README.md index 6617e00..e85eb01 100644 --- a/mailhog/README.md +++ b/mailhog/README.md @@ -2,15 +2,15 @@ Le service mailhog se base sur la brique logicielle **[Mailhog](https://github.com/mailhog/MailHog/)** -Elle nous permet de centraliser et de tester l'envoie des mails en local, preview & testing. +Elle nous permet de centraliser et de tester l'envoi des mails en local, et sur des environnements de test ne faisant pas appel à un service externe d'envoi de mails. C'est pourquoi ce service n'est pas mentionné dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png). -Son installation en local n'est pas requise mais permet de faciliter le parcours fonctionnel pour quelqu'un ne connaissant pas toutes les fonctionnalités de notre produit. - -(Voir relation avec les autres services) +Son installation en local n'est pas requise mais permet de faciliter le parcours fonctionnel pour quelqu'un ne connaissant pas toutes les fonctionnalités du produit. # Installation en local -`docker run -d --name mailhog -p 8025:8025 -p 1025:1025 mailhog/mailhog` +```sh +docker run -d --name mailhog -p 8025:8025 -p 1025:1025 mailhog/mailhog +``` **Si vous souhaitez intégrer mailhog en local, il est nécessaire de créer un network local docker entre toutes les briques et mailhog.** @@ -24,7 +24,6 @@ SMTP : - URL : localhost - Port : 1025 - # Précisions pipelines ## Preview @@ -38,14 +37,12 @@ Pas de précisions nécéssaires pour ce service # Relation avec les autres services -Comme présenté dans le schéma global de l'architecture ci-dessus (# TODO) - -L'api et l'idp sont les deux services pouvant envoyer des mails aux utilisateurs. +L'[api](api) et l'[idp](idp) sont les deux services pouvant envoyer des mails aux utilisateurs. **Bilan des relations:** -- Requête SMTP de l'api vers mailhog -- Requête SMTP de l'IDP vers mailhog +- Requête SMTP de _api_ vers _mailhog_ +- Requête SMTP de _idp_ vers _mailhog_ # Tests Unitaires diff --git a/mailhog/kompose.yml b/mailhog/kompose.yml new file mode 100644 index 0000000..afe23b9 --- /dev/null +++ b/mailhog/kompose.yml @@ -0,0 +1,11 @@ +version: "3" + +services: + mailhog: + image: ${MAILHOG_IMAGE_NAME} + ports: + - "1025" # smtp server + - "8025" # web ui + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" diff --git a/mailhog/mailhog-testing-values.yaml b/mailhog/mailhog-testing-values.yaml new file mode 100644 index 0000000..421a8de --- /dev/null +++ b/mailhog/mailhog-testing-values.yaml @@ -0,0 +1,85 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: mailhog + name: mailhog + spec: + ports: + - name: "1025" + port: 1025 + targetPort: 1025 + - name: "8025" + port: 8025 + targetPort: 8025 + selector: + io.kompose.service: mailhog + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: mailhog + name: mailhog + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: mailhog + strategy: + type: Recreate + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: mailhog + spec: + containers: + image: ${MAILHOG_IMAGE_NAME} + name: mailhog + ports: + - containerPort: 1025 + - containerPort: 8025 + resources: {} + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + status: {} + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: mailhog + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${MAILHOG_FQDN}`) + services: + - name: mailhog + port: 8025 diff --git a/mailhog/overlays/kustomization.yaml b/mailhog/overlays/kustomization.yaml new file mode 100644 index 0000000..d71a166 --- /dev/null +++ b/mailhog/overlays/kustomization.yaml @@ -0,0 +1,10 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - mailhog-ingressroute.yml + # - mailhog-certificate.yml +# patchesStrategicMerge: +# - web_nw_networkpolicy_namespaceselector.yml diff --git a/mailhog/overlays/mailhog-certificate.yml b/mailhog/overlays/mailhog-certificate.yml new file mode 100644 index 0000000..ed557d7 --- /dev/null +++ b/mailhog/overlays/mailhog-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: mailhog-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/mailhog/overlays/mailhog-ingressroute.yml b/mailhog/overlays/mailhog-ingressroute.yml new file mode 100644 index 0000000..240fe02 --- /dev/null +++ b/mailhog/overlays/mailhog-ingressroute.yml @@ -0,0 +1,24 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: mailhog + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${MAILHOG_FQDN}`) + kind: Rule + services: + - kind: Service + name: mailhog + port: 8025 + # tls: + # secretName: ${SECRET_NAME} # mailhog-tls # cert-dev + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - "*.preview.${BASE_DOMAIN}" + # - "*.testing.${BASE_DOMAIN}" diff --git a/mailhog/overlays/web_nw_networkpolicy_namespaceselector.yml b/mailhog/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..32da467 --- /dev/null +++ b/mailhog/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: 'true' diff --git a/publication/.gitlab-ci.yml b/publication/.gitlab-ci.yml new file mode 100644 index 0000000..d0d9009 --- /dev/null +++ b/publication/.gitlab-ci.yml @@ -0,0 +1,80 @@ +.publication-base: + variables: + MODULE_NAME: publication + MODULE_PATH: ${MODULE_NAME} + GITHUB_REPO: ${PUBLICATION_GITHUB_REPO} + GITHUB_TOKEN: ${PUBLICATION_GITHUB_TOKEN} + GITHUB_USER_EMAIL: ${PUBLICATION_GITHUB_USER_EMAIL} + GITHUB_USER_NAME: ${PUBLICATION_GITHUB_USER_NAME} + PACKAGE_VERSION: ${PACKAGE_VERSION} + +.prepare_to_publish: &prepare_to_publish | + echo -e '>>> STEP 1 : Prepare to publish' + ./clean_project_to_commit.sh + +.publish_code: &publish_code | + echo -e '>>> STEP 2 : Publish Code' + cd /builds/mcm + # Install github-cli + apk add github-cli + + # Clone a repository into distant-repo + git clone https://${GITHUB_TOKEN}@github.com/${GITHUB_REPO} distant-repo; + + echo -e "Create a new branch ${PACKAGE_VERSION}" + cd distant-repo + git checkout -b ${PACKAGE_VERSION} + + SHOULD_CREATE_PR=1 + git config --global user.email ${GITHUB_USER_EMAIL} + git config --global user.name ${GITHUB_USER_NAME} + + #push the upstram branch if branch does not exist already in remote repo + if [ `git branch -r | egrep "${PACKAGE_VERSION}"` ] + then + echo "branch exist already" + git branch --set-upstream-to=origin/${PACKAGE_VERSION} ${PACKAGE_VERSION} + git pull --rebase + SHOULD_CREATE_PR=0 + else + echo "new branch" + git push --set-upstream origin ${PACKAGE_VERSION} + git branch --set-upstream-to=origin/${PACKAGE_VERSION} ${PACKAGE_VERSION} + fi + + echo -e "Stage changes" + cp -r ../platform/. . + git add . + + # Commit and push if there are changes + echo "Commiting branch to github..." + git commit -m "MCM v${PACKAGE_VERSION} ${PUBLICATION_COMMIT_MESSAGE}" || true + + echo "Push branch to github..." + git push origin ${PACKAGE_VERSION} + + echo "Pull request Creation..." + + # create a pr if it not exist already in remote repo + if [ $SHOULD_CREATE_PR -eq 1 ] + then + echo "Creating PR" + gh pr create --title "Programme Mon Compte Mobilité - Version ${PACKAGE_VERSION}" --body "" -H ${PACKAGE_VERSION} + echo "PR Created" + else + echo "No PR created as branch already existed." + fi + +publish: + extends: + - .commons + - .publication-base + - .manual + - .no-needs + stage: publication + image: + name: ${NEXUS_DOCKER_REGISTRY_URL}/alpine/git:v2.32.0 + entrypoint: [""] + script: + - *prepare_to_publish + - *publish_code diff --git a/publication/clean_project_to_commit.sh b/publication/clean_project_to_commit.sh new file mode 100755 index 0000000..186efa9 --- /dev/null +++ b/publication/clean_project_to_commit.sh @@ -0,0 +1,31 @@ +echo " > cd out of module publicaion" + +cd .. + +echo -e " > Remove all files contain unpublishable document pattern" +find . -name "package-lock.json" -type f -delete +find . -name "yarn.lock" -type f -delete +find . -name ".npmrc" -type f -delete +find . -name ".env*" -type f -delete +find . -name "*.pem" -type f -delete +find . -name "*.pfx" -type f -delete + +echo -e " > Remove git config (.git)" +rm -rf ./.git + +echo -e " > Add good version to package.json" +apk add jq + +for d in */ ; +do + cd $d + echo "\n*** $d ***\n" + if test -f "package.json"; + then + echo "Set package version : $PACKAGE_VERSION" + tmp=$(mktemp) + jq --arg a "$PACKAGE_VERSION" '.version = $a' package.json > "$tmp" && mv "$tmp" package.json + fi + cd .. +done + diff --git a/s3/.gitlab-ci.yml b/s3/.gitlab-ci.yml new file mode 100644 index 0000000..aee1d2a --- /dev/null +++ b/s3/.gitlab-ci.yml @@ -0,0 +1,20 @@ +include: + - local: "s3/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "s3/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + +# Base s3 variables +.s3-base: + variables: + MODULE_NAME: s3 + MODULE_PATH: ${MODULE_NAME} + MINIO_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/minio/minio:RELEASE.2022-04-30T22-23-53Z + MINIO_MC_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/minio/mc:RELEASE.2022-04-16T21-11-21Z + only: + changes: + - "*" + - "commons/**/*" + - "s3/**/*" diff --git a/s3/.gitlab-ci/preview.yml b/s3/.gitlab-ci/preview.yml new file mode 100644 index 0000000..631f3f2 --- /dev/null +++ b/s3/.gitlab-ci/preview.yml @@ -0,0 +1,33 @@ +.s3_remove_mc_service: &s3_remove_mc_service | + # After_script do not have access to function declared in script or before_script, that's why we do not use them here + # https://docs.gitlab.com/ee/ci/variables/where_variables_can_be_used.html#execution-shell-environment + + kubectl wait --for=condition=ContainersReady pod $(kubectl get pods -A | grep -- -${BRANCH_NAME}- | grep -w s3 | awk '{ print $2 }') --timeout=-1s + while [[ $(kubectl get pods -A | grep -- -${BRANCH_NAME}- | grep s3mc | grep Completed | awk '{ print $1 }') == "" ]] + do + echo "Wait the end of s3mc script" + sleep 5 + done + kubectl delete -n $(kubectl get pods -A | grep ${BRANCH_NAME} | grep -w s3mc | awk '{ print $1 }') pods $(kubectl get pods -A | grep ${BRANCH_NAME} | grep -w s3mc | awk '{ print $2 }') + +s3_preview_deploy: + extends: + - .preview-deploy-job + - .s3-base + - .no-needs + - .only-master + - .manual + script: + - | + deploy + config_volume s3-claim + after_script: + - *s3_remove_mc_service + environment: + on_stop: s3_preview_cleanup + +s3_preview_cleanup: + extends: + - .commons_preview_cleanup + - .s3-base + - .only-master diff --git a/s3/.gitlab-ci/testing.yml b/s3/.gitlab-ci/testing.yml new file mode 100644 index 0000000..9bea929 --- /dev/null +++ b/s3/.gitlab-ci/testing.yml @@ -0,0 +1,4 @@ +s3_testing_deploy: + extends: + - .testing-deploy-job + - .s3-base diff --git a/s3/Chart.yaml b/s3/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/s3/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/s3/README.md b/s3/README.md index fa3ffea..da07d3f 100644 --- a/s3/README.md +++ b/s3/README.md @@ -2,21 +2,20 @@ Le service s3 se base sur la brique logicielle **[MinIO](https://min.io/)** -Elle est compliant s3 et nous permet de stocker les fichiers que nous pouvons recevoir. +Elle est s3-compliant et permet de stocker les fichiers que moB peut être amené à recevoir de la part des citoyens dans les souscriptions déposées. +Ainsi, ce sont les fonctions AWS S3 pour créer des buckets, uploader, downloader des fichiers ... -Nous utilisons les fontions S3 AWS pour créer des buckets, uploader, downloader des fichiers ... +Les documents stockés sont tous encryptés avant écriture par le service [api](api), au moyen de la clé publique RSA paramétrée active sur le financeur de l'aide (incentive). -Les documents stockés sont encryptés en amont grâce au service vault. +L'architecture des buckets choisie est la suivante : -L'architecture des buckets est la suivante : - -![s3BucketArchitecture](docs/assets/s3BucketArchitecture.png) - -(Voir relation avec les autres services) +![s3BucketArchitecture](docs/assets/s3bucketArchitecture.png) # Installation en local -`docker run --name minio -p 9001:9000 minio/minio:RELEASE.2021-06-17T00-10-46Z server /data` +```sh +docker run --name minio -p 9001:9000 minio/minio:RELEASE.2021-06-17T00-10-46Z server /data +``` ⚠ la version minio de l'image de minio n'est pas iso environnement preview & testing @@ -30,18 +29,18 @@ En local, c'est le compte d'admin qui peut être utilisé par l'api. ## Variables -| Variables | Description | Obligatoire | -| ----------- | ----------- | ----------- | -| S3_SERVICE_USER | Username pour le compte de service | Oui | -| S3_SERVICE_PASSWORD | Password pour le compte de service | Oui | -| S3_SUPPORT_USER | Username pour le compte de support | Non | -| S3_SUPPORT_PASSWORD | Password pour le compte de support | Non | +| Variables | Description | Obligatoire | +| ------------------- | ---------------------------------- | ----------- | +| S3_SERVICE_USER | Username pour le compte de service | Oui | +| S3_SERVICE_PASSWORD | Password pour le compte de service | Oui | +| S3_SUPPORT_USER | Username pour le compte de support | Non | +| S3_SUPPORT_PASSWORD | Password pour le compte de support | Non | - Compte de service : ReadWrite - Compte de support : Diagnostics - ## URL / Port + - URL : localhost - Port : 9001 @@ -49,35 +48,34 @@ En local, c'est le compte d'admin qui peut être utilisé par l'api. ## Preview -Un job s3mc est lancé au déploiement pour créer les comptes de service et de support. +Un job K8S _s3mc_ est lancé au déploiement pour créer les comptes de service et de support. ## Testing L'ajout des comptes de service et de support est à faire manuellement via l'interface avec les droits mentionnés dans l'installation locale. -Le deploiement de s3 est de type statefulSet. - +Le déploiement de s3 dans K8S est de type statefulSet. # Relation avec les autres services -Comme présenté dans le schéma global de l'architecture ci-dessus (# TODO) +Comme présenté dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png) : -L'api effectue une requête HEAD HTTP vers s3 pour vérifier l'existence du bucket (voir achitecture bucket mentionnée au début du fichier). - -L'api effectue une requête POST HTTP vers s3 pour créer le bucket s'il n'est pas déjà existant. +![technicalArchitecture](../docs/assets/MOB-CME_Archi_technique_detaillee.png) -L'api effectue une requête POST HTTP vers s3 pour uploader les fichiers dans le bucket associé. +L'api effectue une requête HEAD HTTP vers s3 pour vérifier l'existence du bucket (voir achitecture bucket mentionnée au début du fichier). -L'api effectue une requête GET HTTP vers s3 pour récupérer les fichiers afin qu'ils soient visualisable via Website. +- api effectue une requête HEAD HTTP vers s3 pour vérifier l'existence du bucket (voir architecture des buckets mentionnée plus haut). +- api effectue une requête POST HTTP vers s3 pour créer le bucket s'il n'est pas déjà existant. +- api effectue une requête POST HTTP vers s3 pour uploader les fichiers dans le bucket associé. +- api effectue une requête GET HTTP vers s3 pour récupérer les fichiers afin qu'ils soient visualisables via [website](website). **Bilan des relations:** -- Requête HTTP de l'api vers s3 pour vérifier existence du bucket -- Requête HTTP de l'api vers s3 pour créer le bucket associé s'il n'existe pas -- Requête HTTP de l'api vers s3 pour enregistrer le fichier -- Requête HTTP de l'api vers s3 pour télécharger le fichier +- Requête HTTP de _api_ vers _s3_ pour vérifier existence du bucket +- Requête HTTP de _api_ vers _s3_ pour créer le bucket associé s'il n'existe pas +- Requête HTTP de _api_ vers _s3_ pour enregistrer le fichier +- Requête HTTP de _api_ vers _s3_ pour télécharger le fichier # Tests Unitaires Pas de tests unitaires nécéssaires pour ce service - diff --git a/s3/docs/assets/s3bucketArchitecture.png b/s3/docs/assets/s3bucketArchitecture.png new file mode 100644 index 0000000..336389d Binary files /dev/null and b/s3/docs/assets/s3bucketArchitecture.png differ diff --git a/s3/kompose.yml b/s3/kompose.yml new file mode 100644 index 0000000..f18b4ff --- /dev/null +++ b/s3/kompose.yml @@ -0,0 +1,41 @@ +version: "3" + +services: + s3: + image: ${MINIO_IMAGE_NAME} + volumes: + - /dataminio/:/data/ + environment: + MINIO_ROOT_USER: ${S3_ROOT_USER} + MINIO_ROOT_PASSWORD: ${S3_ROOT_PASSWORD} + command: server /data --console-address ":9001" + ports: + - "9001" + - "9000" + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" + + s3mc: + image: ${MINIO_MC_IMAGE_NAME} + depends_on: + - s3 + entrypoint: > + /bin/sh -c " + chmod +x /usr/bin/mc; + bash +o history; + until (mc alias set s3alias https://serveur-${S3_FQDN} ${S3_ROOT_USER} ${S3_ROOT_PASSWORD} --api S3v4) do echo '...waiting...' && sleep 1; done; + until (mc admin info s3alias) do echo '...waiting...' && sleep 1; done; + mc admin user add s3alias ${S3_SUPPORT_USER} ${S3_SUPPORT_PASSWORD}; + mc admin policy set s3alias diagnostics user=${S3_SUPPORT_USER}; + mc admin user add s3alias ${S3_SERVICE_USER} ${S3_SERVICE_PASSWORD}; + mc admin policy set s3alias readwrite user=${S3_SERVICE_USER}; + bash -o history; + " + restart: "no" + labels: + - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" + +volumes: + minio-data: diff --git a/s3/overlays/kustomization.yaml b/s3/overlays/kustomization.yaml new file mode 100644 index 0000000..ea1f14e --- /dev/null +++ b/s3/overlays/kustomization.yaml @@ -0,0 +1,13 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - s3-ingressroute.yml + # - s3-certificate.yml + # - s3-kes-ingressroute.yml + # - s3-kes-certificate.yml + +patchesStrategicMerge: + # - web_nw_networkpolicy_namespaceselector.yml diff --git a/s3/overlays/s3-certificate.yml b/s3/overlays/s3-certificate.yml new file mode 100644 index 0000000..580c245 --- /dev/null +++ b/s3/overlays/s3-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: s3-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/s3/overlays/s3-ingressroute.yml b/s3/overlays/s3-ingressroute.yml new file mode 100644 index 0000000..b3db49f --- /dev/null +++ b/s3/overlays/s3-ingressroute.yml @@ -0,0 +1,23 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: s3 + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${S3_FQDN}`) + kind: Rule + services: + - name: s3 + port: 9001 + # tls: + # secretName: ${SECRET_NAME} #cert-dev + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - "*.preview.${BASE_DOMAIN}" + # - "*.testing.${BASE_DOMAIN}" diff --git a/s3/overlays/web_nw_networkpolicy_namespaceselector.yml b/s3/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..53e6193 --- /dev/null +++ b/s3/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" diff --git a/s3/s3-testing-values.yaml b/s3/s3-testing-values.yaml new file mode 100644 index 0000000..d97768a --- /dev/null +++ b/s3/s3-testing-values.yaml @@ -0,0 +1,117 @@ +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + creationTimestamp: null + labels: + io.kompose.service: s3 + name: s3 + spec: + ports: + - name: "9001" + port: 9001 + targetPort: 9001 + - name: "9000" + port: 9000 + targetPort: 9000 + selector: + io.kompose.service: s3 + type: ClusterIP + status: + loadBalancer: {} + +headlessService: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + creationTimestamp: null + labels: + io.kompose.service: s3 + name: s3-headless + spec: + clusterIP: None + ports: + - name: "9001" + protocol: TCP + port: 9001 + targetPort: 9001 + - name: "9000" + protocol: TCP + port: 9000 + targetPort: 9000 + publishNotReadyAddresses: true + selector: + io.kompose.service: s3 + sessionAffinity: None + type: ClusterIP + +statefulSet: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + labels: + io.kompose.service: s3 + name: s3 + spec: + podManagementPolicy: OrderedReady + selector: + matchLabels: + io.kompose.service: s3 + updateStrategy: + type: RollingUpdate + replicas: 4 + serviceName: s3 + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + labels: + io.kompose.service: s3 + spec: + containers: + name: s3 + env: + MINIO_ROOT_PASSWORD: ${TESTING_S3_ROOT_PASSWORD} + MINIO_ROOT_USER: ${TESTING_S3_ROOT_USER} + MINIO_DISTRIBUTED_MODE_ENABLED: "yes" + MINIO_DISTRIBUTED_NODES: s3-{0...3} + image: ${MINIO_IMAGE_NAME} + args: + - server + - http://s3-{0...3}.s3.$(MY_POD_NAMESPACE).svc.cluster.local/data1 + - --console-address + - :9001 + ports: + - containerPort: 9001 + - containerPort: 9000 + # These volume mounts are persistent. Each pod in the StatefulSet + # gets a volume mounted based on this field. + volumeMounts: + - name: data1 + mountPath: /data1 + imagePullSecrets: + - name: ${PROXY_IMAGE_PULL_SECRET_NAME} + # These are converted to volume claims by the controller + # and mounted at the paths mentioned above. + volumeClaimTemplates: + - metadata: + name: data1 + spec: + accessModes: + - ReadWriteOnce + storageClassName: "azurefile-${LANDSCAPE}-s3" + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/simulation-maas/.babelrc b/simulation-maas/.babelrc new file mode 100644 index 0000000..3f10c37 --- /dev/null +++ b/simulation-maas/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "useBuiltIns": "usage", + "debug": true, + "corejs": 3 + } + ] + ], + "plugins": ["@babel/plugin-syntax-dynamic-import"] +} diff --git a/simulation-maas/.gitignore b/simulation-maas/.gitignore new file mode 100644 index 0000000..e598c25 --- /dev/null +++ b/simulation-maas/.gitignore @@ -0,0 +1,52 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.development + +# Transpiled JavaScript files from Typescript +/dist +build + +# Cache used by TypeScript's incremental build +*.tsbuildinfo + +# Env conf +static/ + diff --git a/simulation-maas/.gitlab-ci.yml b/simulation-maas/.gitlab-ci.yml new file mode 100644 index 0000000..3a3845d --- /dev/null +++ b/simulation-maas/.gitlab-ci.yml @@ -0,0 +1,41 @@ +include: + - local: "simulation-maas/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "simulation-maas/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + - local: "simulation-maas/.gitlab-ci/helm.yml" + rules: + - if: $CI_PIPELINE_SOURCE == "trigger" + +.simulation-maas-base: + variables: + MODULE_NAME: simulation-maas + MODULE_PATH: ${MODULE_NAME} + COMMON_NAME: common + NEXUS_IMAGE_NGINX: ${NEXUS_DOCKER_REPOSITORY_URL}/nginx:1.21 + SIMULATION_MAAS_IMAGE_NAME: ${REGISTRY_BASE_NAME}/simulation-maas:${IMAGE_TAG_NAME} + only: + changes: + - "*" + - "commons/**/*" + - "simulation-maas/**/*" + +.simulation_maas_build_script: &simulation_maas_build_script | + sed -i "s/%IDP_FQDN%/${IDP_FQDN}/g" .env.production + sed -i "s/%API_FQDN%/${API_FQDN}/g" .env.production + npm install + npm version ${PACKAGE_VERSION} + npm run build + +simulation_maas_build: + extends: + - .build-job + - .simulation-maas-base + script: + - *simulation_maas_build_script + artifacts: + paths: + - ${MODULE_PATH}/build/ + expire_in: 5 days diff --git a/simulation-maas/.gitlab-ci/helm.yml b/simulation-maas/.gitlab-ci/helm.yml new file mode 100644 index 0000000..b357f31 --- /dev/null +++ b/simulation-maas/.gitlab-ci/helm.yml @@ -0,0 +1,5 @@ + +simulation_maas_image_push: + extends: + - .helm-push-image-job + - .simulation-maas-base \ No newline at end of file diff --git a/simulation-maas/.gitlab-ci/preview.yml b/simulation-maas/.gitlab-ci/preview.yml new file mode 100644 index 0000000..c4d3a52 --- /dev/null +++ b/simulation-maas/.gitlab-ci/preview.yml @@ -0,0 +1,18 @@ +simulation_maas_image_build: + extends: + - .preview-image-job + - .simulation-maas-base + needs: ["simulation_maas_build"] + +simulation_maas_preview_deploy: + extends: + - .preview-deploy-job + - .simulation-maas-base + needs: ["simulation_maas_image_build"] + environment: + on_stop: simulation_maas_preview_cleanup + +simulation_maas_preview_cleanup: + extends: + - .commons_preview_cleanup + - .simulation-maas-base diff --git a/simulation-maas/.gitlab-ci/testing.yml b/simulation-maas/.gitlab-ci/testing.yml new file mode 100644 index 0000000..4d37971 --- /dev/null +++ b/simulation-maas/.gitlab-ci/testing.yml @@ -0,0 +1,12 @@ + +simulation_maas_testing_image_build: + extends: + - .testing-image-job + - .simulation-maas-base + needs: ["simulation_maas_build"] + +simulation_maas_testing_deploy: + extends: + - .testing-deploy-job + - .simulation-maas-base + needs: ["simulation_maas_testing_image_build"] \ No newline at end of file diff --git a/simulation-maas/Chart.yaml b/simulation-maas/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/simulation-maas/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/simulation-maas/Dockerfile b/simulation-maas/Dockerfile new file mode 100644 index 0000000..47af89c --- /dev/null +++ b/simulation-maas/Dockerfile @@ -0,0 +1,5 @@ +ARG BASE_IMAGE_NGINX +FROM ${BASE_IMAGE_NGINX} + +COPY ./nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY /build /usr/share/nginx/html diff --git a/simulation-maas/README.md b/simulation-maas/README.md index d93fbeb..8f17442 100644 --- a/simulation-maas/README.md +++ b/simulation-maas/README.md @@ -1,28 +1,23 @@ # Description -Le service simulation-mass se base sur du vanilla js +Le service simulation-mass se base sur du [vanilla-js](http://vanilla-js.com/) -Ce service nous permet de tester les liaisons de comptes des MaaS avec notre système. - -Son interface est très pauvre et basique. - -(Voir relation avec les autres services) +Ce service permet de tester les liaisons de comptes des MaaS avec le système moB. +Ainsi, son interface est basique et simpliste en terme d'UX. # Installation en local Modifier les fichiers présents dans le dossier static avec les variables mentionnées ci-dessous -`npm install && npm run start` - ## Variables -| Variables | Description | Obligatoire | -| ----------- | ----------- | ----------- | -| IDP_FQDN | Url de l'IDP | Oui | -| API_FQDN | Url de l'api | Oui | -| IDP_MCM_REALM | Nom du realm mcm | Oui | -| IDP_MCM_SIMULATION_MAAS_CLIENT_ID | Client id du client public simulation-maas | Oui -| MCM_IDP_CLIENTID_MAAS_CME | Client id du client public simulation-maas | Oui (si test France Connect) +| Variables | Description | Obligatoire | +| --------------------------------- | ------------------------------------------ | ---------------------------- | +| IDP_FQDN | Url de l'IDP | Oui | +| API_FQDN | Url de l'api | Oui | +| IDP_MCM_REALM | Nom du realm mcm | Oui | +| IDP_MCM_SIMULATION_MAAS_CLIENT_ID | Client id du client public simulation-maas | Oui | +| MCM_IDP_CLIENTID_MAAS_CME | Client id du client public simulation-maas | Oui (si test France Connect) | ## URL / Port @@ -31,6 +26,12 @@ Portail d'admin : - URL : localhost - Port : 8080 +## Démarrage + +```sh +npm install && npm run start +``` + # Précisions pipelines ## Preview @@ -43,11 +44,8 @@ Pas de précisions nécéssaires pour ce service # Relation avec les autres services -Comme présenté dans le schéma global de l'architecture ci-dessus (# TODO) - -Via simulation-maas, un citoyen peut effectuer une liaison de compte sur un client public. Ceci est fait pour simuler la liaison de compte d'un MaaS avec notre système. - -Via simulation-maas, un citoyen peut être redigirer sur le début d'une souscription à une aide en envoyant des metadata. +Via simulation-maas, un citoyen peut effectuer une liaison de compte sur un client public. Ceci est fait pour simuler la liaison de compte d'un MaaS avec l'[idp](idp) moB. +Via simulation-maas, un citoyen peut être redirigé sur le début d'une souscription à une aide en envoyant des metadata concernant un justificatif d'achat issu d'une plateforme tierce. # Tests Unitaires diff --git a/simulation-maas/config/webpack.dev.js b/simulation-maas/config/webpack.dev.js new file mode 100644 index 0000000..b743197 --- /dev/null +++ b/simulation-maas/config/webpack.dev.js @@ -0,0 +1,46 @@ +const path = require("path"); +const Dotenv = require("dotenv-webpack"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); + +var webpack = require("webpack"); + +module.exports = { + entry: { + main: "./src/index.js", + }, + output: { + path: path.join(__dirname, "../build"), + filename: "[name].bundle.js", + }, + mode: "development", + devServer: { + static: path.join(__dirname, "../build"), + }, + devtool: "source-map", + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: "babel-loader", + }, + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + filename: "index.html", + }), + new Dotenv({ + path: "./.env.mcm", + }), + new CopyWebpackPlugin({ + patterns: [ + { from: "static", to: "static" }, + ], + }), + ].filter(Boolean), +}; diff --git a/simulation-maas/config/webpack.prod.js b/simulation-maas/config/webpack.prod.js new file mode 100644 index 0000000..54fe022 --- /dev/null +++ b/simulation-maas/config/webpack.prod.js @@ -0,0 +1,41 @@ +const path = require("path"); +const Dotenv = require("dotenv-webpack"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); + +module.exports = { + entry: { + main: "./src/index.js", + }, + output: { + path: path.join(__dirname, "../build"), + filename: "[name].bundle.js" + }, + mode: "production", + devtool: "source-map", + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: "babel-loader", + }, + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + filename: "index.html", + }), + new Dotenv({ + path: "./.env.production", + }), + new CopyWebpackPlugin({ + patterns: [ + { from: "static", to: "static" }, + ], + }), + ] +}; diff --git a/simulation-maas/kompose.yml b/simulation-maas/kompose.yml new file mode 100644 index 0000000..8a6db4d --- /dev/null +++ b/simulation-maas/kompose.yml @@ -0,0 +1,25 @@ +version: "3" + +services: + simulation-maas: + image: ${SIMULATION_MAAS_IMAGE_NAME} + build: + context: . + args: + BASE_IMAGE_NGINX: ${NEXUS_IMAGE_NGINX} + networks: + - web-nw + environment: + - IDP_FQDN + - API_FQDN + - IDP_MCM_REALM + - IDP_MCM_SIMULATION_MAAS_CLIENT_ID + - MCM_IDP_CLIENTID_MAAS_CME + ports: + - "8888" + labels: + - "kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}" + - "kompose.service.type=clusterip" + +networks: + web-nw: diff --git a/simulation-maas/nginx/nginx.conf b/simulation-maas/nginx/nginx.conf new file mode 100644 index 0000000..d2af157 --- /dev/null +++ b/simulation-maas/nginx/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 8888; + server_name maas; + location / { + # This would be the directory where your React app's static files are stored at + root /usr/share/nginx/html; + try_files $uri /index.html; + } + + location /services/m { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-NginX-Proxy true; + proxy_ssl_session_reuse off; + proxy_set_header Host $http_host; + proxy_cache_bypass $http_upgrade; + proxy_redirect off; + } +} diff --git a/simulation-maas/overlays/config/config.json b/simulation-maas/overlays/config/config.json new file mode 100644 index 0000000..e812e1d --- /dev/null +++ b/simulation-maas/overlays/config/config.json @@ -0,0 +1,17 @@ +{ + "idp": { + "url": "https://${IDP_FQDN}/auth", + "realm": "${IDP_MCM_REALM}", + "clientId": "${IDP_MCM_SIMULATION_MAAS_CLIENT_ID}", + "enable-cors": true + }, + "idpCme": { + "url": "https://${IDP_FQDN}/auth", + "realm": "${MCM_IDP_REALM}", + "clientId": "${MCM_IDP_CLIENTID_MAAS_CME}", + "enable-cors": true + }, + "api": { + "url": "https://${API_FQDN}" + } +} diff --git a/simulation-maas/overlays/kustomization.yaml b/simulation-maas/overlays/kustomization.yaml new file mode 100644 index 0000000..fb4cfec --- /dev/null +++ b/simulation-maas/overlays/kustomization.yaml @@ -0,0 +1,17 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - maas-ingressroute.yml + # - maas-certificate.yml + +patchesStrategicMerge: + - web_nw_networkpolicy_namespaceselector.yml + - maas_configmap_volumes.yml + +configMapGenerator: + - name: simulation-maas-config + files: + - config/config.json diff --git a/simulation-maas/overlays/maas-certificate.yml b/simulation-maas/overlays/maas-certificate.yml new file mode 100644 index 0000000..0570c69 --- /dev/null +++ b/simulation-maas/overlays/maas-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: maas-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/simulation-maas/overlays/maas-ingressroute.yml b/simulation-maas/overlays/maas-ingressroute.yml new file mode 100644 index 0000000..d3a933f --- /dev/null +++ b/simulation-maas/overlays/maas-ingressroute.yml @@ -0,0 +1,21 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: simulation-maas +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${SIMULATION_MAAS_FQDN}`) + kind: Rule + services: + - name: simulation-maas + port: 8888 + # tls: + # secretName: ${SECRET_NAME} # maas-tls #cert-dev + # domains: + # - main: ${BASE_DOMAIN} + # sans: + # - "*.preview.${BASE_DOMAIN}" + # - "*.testing.${BASE_DOMAIN}" diff --git a/simulation-maas/overlays/maas_configmap_volumes.yml b/simulation-maas/overlays/maas_configmap_volumes.yml new file mode 100644 index 0000000..d0c59e1 --- /dev/null +++ b/simulation-maas/overlays/maas_configmap_volumes.yml @@ -0,0 +1,19 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simulation-maas +spec: + template: + spec: + containers: + - name: simulation-maas + volumeMounts: + - name: maas-config + mountPath: /usr/share/nginx/html/static/config.json + subPath: config.json + securityContext: + fsGroup: 1000 + volumes: + - name: maas-config + configMap: + name: simulation-maas-config diff --git a/simulation-maas/overlays/web_nw_networkpolicy_namespaceselector.yml b/simulation-maas/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..53e6193 --- /dev/null +++ b/simulation-maas/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" diff --git a/simulation-maas/package.json b/simulation-maas/package.json new file mode 100644 index 0000000..6bc17b1 --- /dev/null +++ b/simulation-maas/package.json @@ -0,0 +1,36 @@ +{ + "name": "simulation-maas", + "version": "1.10.1", + "description": "here is a maas simulator", + "main": "index.js", + "scripts": { + "start": "webpack-dev-server --open --config=config/webpack.dev.js", + "build": "webpack --config=config/webpack.prod.js" + }, + "author": "Mon Compte Mobilité", + "license": "ISC", + "devDependencies": { + "@babel/core": "^7.15.5", + "@babel/preset-env": "^7.15.6", + "babel-loader": "^8.2.2", + "dotenv": "^10.0.0", + "html-webpack-plugin": "^5.3.2", + "webpack": "^5.58.1", + "webpack-cli": "^4.8.0", + "webpack-dev-server": "^4.3.1", + "write-file-webpack-plugin": "^4.5.1" + }, + "dependencies": { + "@babel/polyfill": "^7.12.1", + "copy-webpack-plugin": "^9.0.0", + "core-js": "^3.18.2", + "dotenv-webpack": "^7.0.3", + "file-loader": "^6.2.0", + "keycloak-js": "^15.0.2" + }, + "browserslist": [ + "> 1%", + "not ie <= 9", + "last 2 versions" + ] +} diff --git a/simulation-maas/simulation-maas-testing-values.yaml b/simulation-maas/simulation-maas-testing-values.yaml new file mode 100644 index 0000000..b4ba5a6 --- /dev/null +++ b/simulation-maas/simulation-maas-testing-values.yaml @@ -0,0 +1,121 @@ +configMaps: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: simulation-maas-config + data: + config.json: "simulation-maas/config.json" + +services: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + creationTimestamp: null + labels: + io.kompose.service: simulation-maas + name: simulation-maas + spec: + ports: + - name: "8888" + port: 8888 + targetPort: 8888 + selector: + io.kompose.service: simulation-maas + type: ClusterIP + status: + loadBalancer: {} + +deployments: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.service: simulation-maas + name: simulation-maas + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: simulation-maas + strategy: {} + template: + metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME} + kompose.service.type: clusterip + kubernetes.io/ingress.class: traefik + labels: + io.kompose.network/web-nw: "true" + io.kompose.service: simulation-maas + spec: + containers: + env: + API_FQDN: ${API_FQDN} + IDP_FQDN: ${IDP_FQDN} + MCM_IDP_CLIENTID_MAAS: simulation-maas + MCM_IDP_REALM: ${IDP_MCM_REALM} + image: ${SIMULATION_MAAS_IMAGE_NAME} + name: simulation-maas + ports: + - containerPort: 8888 + resources: {} + volumeMounts: + - mountPath: /usr/share/nginx/html/static/config.json + name: maas-config + subPath: config.json + imagePullSecrets: + - name: ${GITLAB_IMAGE_PULL_SECRET_NAME} + restartPolicy: Always + securityContext: + fsGroup: 1000 + volumes: + - configMap: + name: simulation-maas-config + name: maas-config + status: {} + +networkPolicies: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: web-nw + spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" + podSelector: + matchLabels: + io.kompose.network/web-nw: "true" + +ingressRoutes: + - metadata: + annotations: + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + kubernetes.io/ingress.class: traefik + name: simulation-maas + spec: + entryPoints: + - web + routes: + - kind: Rule + match: Host(`${SIMULATION_MAAS_FQDN}`) + services: + - name: simulation-maas + port: 8888 diff --git a/simulation-maas/src/index.html b/simulation-maas/src/index.html new file mode 100644 index 0000000..12d5e92 --- /dev/null +++ b/simulation-maas/src/index.html @@ -0,0 +1,83 @@ + + + + + + + + Simulateur MAAS + + +

Vous êtes chez le MAAS : simulation-maas

+ +
+

Liaison de compte

+

+

Le Token d'accès est :

+

+

+ + + + +
+
+

Demandes souscription

+
+

incentiveId

+ +
+
+

Token

+ +
+
+

Métadonnées

+ +
+ + +
+
+

MOB Connect

+

+

Le Token id est :

+

+

+ + + + +
+ + diff --git a/simulation-maas/src/index.js b/simulation-maas/src/index.js new file mode 100644 index 0000000..cc03434 --- /dev/null +++ b/simulation-maas/src/index.js @@ -0,0 +1,249 @@ +import Keycloak from "keycloak-js"; +import metadata from "./metadata.json"; + +let token, + keycloak, + incentiveId, + refreshToken, + getMetadata, + idpConfig, + apiConfig, + idpCmeConfig, + keycloakCme; + +// Init the keycloak connection +const initConfig = async () => { + const response = await (await fetch("/static/config.json")).json(); + apiConfig = response.api; + idpConfig = response.idp; + keycloak = Keycloak(idpConfig); + + // Simulation maas client cme + idpCmeConfig = response.idpCme; + keycloakCme = Keycloak(idpCmeConfig); + const mobConnectClic = localStorage.getItem("mobConnect"); + if (!mobConnectClic) { + await initKeycloak(); + } + + await initKeycloakCme(); +}; + +const initKeycloak = async () => { + keycloak + .init({}) + .then(() => { + if (keycloak.refreshToken) { + localStorage.setItem("refresh_token", keycloak.refreshToken); + document.getElementById( + "messageName" + ).innerHTML = `Bonjour ${keycloak?.idTokenParsed?.name}, votre id citoyen est : ${keycloak.idTokenParsed.sub} et votre adresse email est : ${keycloak.idTokenParsed.preferred_username}`; + document.getElementById("accessToken").innerHTML = keycloak.token; + } else { + document.getElementById("messageName").innerHTML = `Bonjour incognito`; + localStorage.removeItem("refresh_token"); + localStorage.removeItem("mobConnect"); + } + }) + .catch( + (err) => + (document.getElementById( + "errors" + ).innerHTML = `erreur nouveau token d'accès: ${err}`) + ); +}; + +// Keycloak simulation maas client cme +const initKeycloakCme = async () => { + keycloakCme + .init({}) + .then(async () => { + if (keycloakCme.refreshToken) { + localStorage.setItem("refresh_token_cme", keycloakCme.refreshToken); + document.getElementById( + "messageNameMobConnect" + ).innerHTML = `Bonjour ${keycloakCme?.idTokenParsed?.name}, votre id citoyen est : ${keycloakCme.idTokenParsed.sub} et votre adresse email est : ${keycloakCme.idTokenParsed.preferred_username}`; + document.getElementById("idTokenMobConnect").innerHTML = + keycloakCme.idToken; + await initKeycloak(); + } else { + document.getElementById( + "messageNameMobConnect" + ).innerHTML = `Bonjour incognito`; + localStorage.removeItem("refresh_token_cme"); + localStorage.removeItem("mobConnect"); + } + }) + .catch(async (err) => { + await initKeycloak(); + document.getElementById( + "errorsMobConnect" + ).innerHTML = `erreur nouveau token: ${err}`; + }); +}; + +const linkMyAccount = () => { + localStorage.removeItem("mobConnect"); + return keycloak.login({ + redirectUri: window.location.origin, + scope: "offline_access", + }); +}; + +// Clic button Mob connect +const mobConnect = () => { + localStorage.setItem("mobConnect", true); + return keycloakCme.login({ + redirectUri: window.location.origin, + scope: "offline_access", + }); +}; + +const disconnect = async () => { + await keycloak.logout({ redirectUri: window.location.origin }); +}; + +// Clic button disconnect Mob connect +const disconnectMobConnect = async () => { + await keycloakCme.logout({ redirectUri: window.location.origin }); +}; + +const copyClipboard = () => { + token = document.getElementById("accessToken").innerHTML; + token ? navigator.clipboard.writeText(token) : alert("Demander un token"); +}; + +// Clic button copy Mob connect +const copyClipboardMobConnect = () => { + token = document.getElementById("idTokenMobConnect").innerHTML; + token ? navigator.clipboard.writeText(token) : alert("Demander un token"); +}; + +const subscriptions = () => { + // Get the value of form + incentiveId = document.getElementById("incentiveId").value; + refreshToken = document.getElementById("token").value; + getMetadata = document.getElementById("metadata").value; + if (!verifyInput(incentiveId, refreshToken, getMetadata)) return false; + const body = { + incentiveId: incentiveId, + attachmentMetadata: JSON.parse(getMetadata), + }; + return fetch(`${apiConfig.url}/v1/subscriptions/metadata`, { + method: "post", + headers: { + accept: "*/*", + "Content-Type": "application/json", + Authorization: `Bearer ${refreshToken}`, + }, + body: JSON.stringify(body), + }) + .then((response) => { + if (response.ok) { + return response.json(); + } else { + throw new Error(`erreur ${response.status}`); + } + }) + .then((res) => { + //redirection to the website + if (res.subscriptionURL) + window.open(res.subscriptionURL, "_blank", "noopener"); + }) + .catch((err) => alert(`erreur lors de l'appel à l'api ${err}`)); +}; + +function verifyInput(incentiveId, token, metadata) { + // Regex to securize the request + const regexToken = /[^\s\.,!?]+/g, + regexincentiveId = /[a-z0-9]{8,20}$/gm, + regexJson = /[!$%^&]/; + let check = true; + !token.match(regexToken) && + (alert("Erreur dans le Token !"), (check = false)); + !incentiveId.match(regexincentiveId) && + (alert("Erreur dans id de l'aide !"), (check = false)); + metadata.match(regexJson) && + (alert("Erreur dans les metadata !"), (check = false)); + return check; +} + +const askForNewToken = () => { + const body = new URLSearchParams({ + client_id: "simulation-maas-client", + grant_type: "refresh_token", + refresh_token: localStorage.getItem("refresh_token"), + }); + + return fetch( + `${idpConfig.url}/realms/${idpConfig.realm}/protocol/openid-connect/token`, + { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + } + ) + .then(async (result) => await result.json()) + .then( + ({ access_token }) => + (document.getElementById("accessToken").innerHTML = access_token) + ) + .catch( + (err) => + (document.getElementById( + "errors" + ).innerHTML = `erreur nouveau token d'accès: ${err}`) + ); +}; + +// Clic button asky for new token Mob connect +const askForNewTokenMobConnect = () => { + const body = new URLSearchParams({ + client_id: "simulation-maas-client-cme", + grant_type: "refresh_token", + refresh_token: localStorage.getItem("refresh_token_cme"), + }); + + return fetch( + `${idpCmeConfig.url}/realms/${idpCmeConfig.realm}/protocol/openid-connect/token`, + { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + } + ) + .then(async (result) => await result.json()) + .then( + ({ id_token }) => + (document.getElementById("idTokenMobConnect").innerHTML = id_token) + ) + .catch( + (err) => + (document.getElementById( + "errorsMobConnect" + ).innerHTML = `erreur nouveau token d'accès: ${err}`) + ); +}; + +const getMeta = () => { + document.getElementById("metadata").value = JSON.stringify(metadata); +}; + +window.onload = initConfig; + +document.getElementById("linkMyAccount").onclick = linkMyAccount; +document.getElementById("askForNewToken").onclick = askForNewToken; +document.getElementById("disconnect").onclick = disconnect; +document.getElementById("copyToken").onclick = copyClipboard; +document.getElementById("subscription").onclick = subscriptions; +document.getElementById("getMeta").onclick = getMeta; +document.getElementById("mobConnect").onclick = mobConnect; +document.getElementById("askForNewTokenMobConnect").onclick = + askForNewTokenMobConnect; +document.getElementById("disconnectMobConnect").onclick = disconnectMobConnect; +document.getElementById("copyTokenMobConnect").onclick = + copyClipboardMobConnect; diff --git a/simulation-maas/src/metadata.json b/simulation-maas/src/metadata.json new file mode 100644 index 0000000..4944fa9 --- /dev/null +++ b/simulation-maas/src/metadata.json @@ -0,0 +1,50 @@ +{ + "invoices": [ + { + "enterprise": { + "enterpriseName": "IDFM", + "sirenNumber": "362521879", + "siretNumber": "36252187900034", + "apeCode": "4711D", + "enterpriseAddress": { + "zipCode": 75018, + "city": "Paris", + "street": "6 rue Lepic" + } + }, + "customer": { + "customerId": "123789", + "customerName": "Toto", + "customerSurname": "Samy", + "customerAddress": { + "zipCode": 75018, + "city": "Paris", + "street": "15 rue Veron" + } + }, + "transaction": { + "orderId": "30723", + "purchaseDate": "2021-03-03T14:54:18+01:00", + "amountInclTaxes": 7520, + "amountExclTaxes": 7520 + }, + "products": [ + { + "productName": "Forfait Navigo Mois", + "quantity": 1, + "amountInclTaxes": 7520, + "amountExclTaxes": 7520, + "percentTaxes": 10, + "productDetails": { + "periodicity": "Mensuel", + "zoneMin": 1, + "zoneMax": 5, + "validityStart": "2021-03-01T00:00:00+01:00", + "validityEnd": "2021-03-31T00:00:00+01:00" + } + } + ] + } + ], + "totalElements": 1 +} diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..2a5538f --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,5 @@ +# Dependency directories +node_modules/ + +**/videos +**/screenshots diff --git a/test/.gitlab-ci.yml b/test/.gitlab-ci.yml new file mode 100644 index 0000000..0775916 --- /dev/null +++ b/test/.gitlab-ci.yml @@ -0,0 +1,54 @@ +include: + - local: "test/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: "test/.gitlab-ci/testing.yml" + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + +.test-base: + variables: + MODULE_NAME: test + MODULE_PATH: ${MODULE_NAME} + POSTMAN_IMAGE_NAME: ${NEXUS_DOCKER_REGISTRY_URL}/postman/newman:5.3 + CYPRESS_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/cypress/browsers:node16.14.0-chrome99-ff97 + only: + changes: + - "*" + - "commons/**/*" + - "test/**/*" + +.replace_variables: + script: + - | + apk add gettext + ENV_FILE_PATH=api-tests/mcm-${LANDSCAPE}.postman_environment.json + envsubst "$(env | cut -d= -f1 | sed -e 's/^/$/')" < ${ENV_FILE_PATH} > ${ENV_FILE_PATH}.tmp && mv ${ENV_FILE_PATH}.tmp ${ENV_FILE_PATH} + cat ${ENV_FILE_PATH} + +.integration_tests_script: + script: + - | + npm install -g newman-reporter-htmlextra + newman run api-tests/MCM-${LANDSCAPE}.postman_collection.json -e api-tests/mcm-${LANDSCAPE}.postman_environment.json -r cli,htmlextra --reporter-htmlextra-export api-tests/integration-tests-report.html; + +.cypress_env_setup: + script: + - | + export CYPRESS_API_FQDN=${API_FQDN} + export CYPRESS_IDP_FQDN=${IDP_FQDN} + export CYPRESS_WEBSITE_FQDN=${WEBSITE_FQDN} + export CYPRESS_ADMIN_FQDN=${ADMIN_FQDN} + export CYPRESS_STUDENT_PASSWORD=${CITOYEN_PASSWORD} + export CYPRESS_API_KEY=${API_KEY} + +.smoke_tests_script: + script: + - | + cd ${MODULE_PATH} + npm i + npx cypress run \ + --spec "cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js" \ + --config-file cypress-smoke.json \ + --browser chrome | tee cypress-smoke-tests.log + cat cypress-smoke-tests.log diff --git a/test/.gitlab-ci/preview.yml b/test/.gitlab-ci/preview.yml new file mode 100644 index 0000000..41428a7 --- /dev/null +++ b/test/.gitlab-ci/preview.yml @@ -0,0 +1,51 @@ + +generate_data: + extends: + - .commons + - .preview-env-vars + - .preview-deploy-tags + - .test-base + - .only-branches + - .except-release + - .manual + - .no-dependencies + image: ${POSTMAN_IMAGE_NAME} + stage: utils + script: + - !reference [.replace_variables, script] + - !reference [.integration_tests_script, script] + artifacts: + expire_in: 2 days + when: always + paths: + - ${MODULE_PATH}/api-tests/integration-tests-report.html + +smoke_tests: + extends: + - .test-base + - .preview-env-vars + - .only-branches + - .manual + - .except-clean-or-release + image: ${CYPRESS_IMAGE_NAME} + stage: smoke-test + script: + - !reference [.cypress_env_setup, script] + - !reference [.smoke_tests_script, script] + artifacts: + when: always + paths: + - ${MODULE_PATH}/cypress-smoke-tests.log + expire_in: 5 days + +functional_tests: + extends: + - .test-base + - .preview-env-vars + - .only-branches + - .manual + - .except-clean-or-release + stage: functional-test + trigger: + include: ${MODULE_PATH}/cypress/integration/functional-tests/.gitlab-ci.yml + strategy: depend diff --git a/test/.gitlab-ci/testing.yml b/test/.gitlab-ci/testing.yml new file mode 100644 index 0000000..5ccf09a --- /dev/null +++ b/test/.gitlab-ci/testing.yml @@ -0,0 +1,47 @@ +smoke_tests_testing: + extends: + - .test-base + - .testing-env-vars + - .manual + - .no-dependencies + image: ${CYPRESS_IMAGE_NAME} + stage: smoke-test + script: + - !reference [.cypress_env_setup, script] + - !reference [.smoke_tests_script, script] + artifacts: + when: always + paths: + - ${MODULE_PATH}/cypress-smoke-tests.log + expire_in: 5 days + +integration_tests_testing: + extends: + - .commons + - .test-base + - .testing-env-vars + - .manual + - .no-dependencies + image: ${POSTMAN_IMAGE_NAME} + stage: integration-test + script: + - !reference [.replace_variables, script] + - !reference [.integration_tests_script, script] + needs: ['smoke_tests_testing'] + artifacts: + expire_in: 5 days + when: always + paths: + - ${MODULE_PATH}/api-tests/integration-tests-report.html + + +functional_tests_testing: + extends: + - .test-base + - .testing-env-vars + - .manual + stage: functional-test + trigger: + include: ${MODULE_PATH}/cypress/integration/functional-tests/.gitlab-ci.yml + strategy: depend + needs: ['integration_tests_testing'] diff --git a/test/README.md b/test/README.md index 47f65c5..78aa09b 100644 --- a/test/README.md +++ b/test/README.md @@ -1,33 +1,32 @@ # Description -Ce dossier contient les test API et d'intégration que nous pouvons réaliser sur nos pipelines. +Ce dossier contient les tests API et d'intégration que nous pouvons réaliser sur nos pipelines. +Il est destiné également aux tests fonctionnels UI qui sont à construire à l'aide du framework [Cypress.io](https://www.cypress.io/) +Les navigateurs basés sur les moteurs Chrome et Firefox sont ceux ciblés pour ces tests. # Installation en local Pas d'installation en local - ## URL / Port Pas d'installation en local - # Précisions pipelines ## Preview -Les tests API servent aussi de JDD aux environnements afin d'avoir des data minimales. +Les tests API servent aussi de jeu de données (JDD) aux environnements afin d'avoir des données suffisantes pour initier une environnement fonctionnel. ## Testing -Pas de précisions nécéssaires pour ce service - +Pas de précisions nécessaires pour ce service # Relation avec les autres services -Nécéssité d'avoir l'ensemble des services déployés pour pouvoir être exécutés. +Nécessité d'avoir l'ensemble des services déployés pour pouvoir être exécutés. # Tests Unitaires -Pas de tests unitaires nécéssaires pour ce service +N/A diff --git a/test/api-tests/MCM-preview.postman_collection.json b/test/api-tests/MCM-preview.postman_collection.json new file mode 100644 index 0000000..da37535 --- /dev/null +++ b/test/api-tests/MCM-preview.postman_collection.json @@ -0,0 +1,3425 @@ +{ + "info": { + "_postman_id": "20d2c89e-06c1-4591-b4f0-2018e9685e9b", + "name": "MCM", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "KC - Create Admin fonctionnel", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{apiClientSecret}}", + "type": "text" + }, + { + "key": "client_id", + "value": "{{apiClientId}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Create admin fonctionnel", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create Admin fonctionnel test: OK\", function () {\r", + " pm.response.to.have.status(201);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const locationHeader = pm.response.headers.find(header => header.key === 'Location');\r", + " const idAdminFonctionnel = locationHeader.value.split('/').slice(-1);\r", + " pm.environment.set(\"idAdminFonctionnel\", idAdminFonctionnel);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\": \"admin_fonctionnel\",\r\n \"enabled\": true,\r\n \"totp\": false,\r\n \"emailVerified\": true,\r\n \"email\": \"\",\r\n \"disableableCredentialTypes\": [],\r\n \"requiredActions\": [],\r\n \"notBefore\": 0,\r\n \"credentials\": [\r\n {\r\n \"type\": \"password\",\r\n \"value\":\"{{adminFonctionnelPassword}}\",\r\n \"temporary\": false\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "users"] + } + }, + "response": [] + }, + { + "name": "Get KC groups", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Get KC groups test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " const adminGroup = response.find(group => group.name === 'admins');\r", + " pm.environment.set(\"idAdminGroup\", adminGroup.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/groups", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "groups"] + } + }, + "response": [] + }, + { + "name": "Add to admin group", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Add admin fonctionnel to admin group: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users/{{idAdminFonctionnel}}/groups/{{idAdminGroup}}", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "admin", + "realms", + "mcm", + "users", + "{{idAdminFonctionnel}}", + "groups", + "{{idAdminGroup}}" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "React Admin", + "item": [ + { + "name": "Login", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + " pm.environment.set(\"refreshToken\", response.refresh_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "password", + "type": "text" + }, + { + "key": "username", + "value": "admin_fonctionnel", + "type": "text" + }, + { + "key": "password", + "value": "{{adminFonctionnelPassword}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Territoires", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}" + } + ] + }, + "item": [ + { + "name": "Create Territoire Mulhouse", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create territoire Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"TerritoryId\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Mulhouse\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/territories", + "host": ["{{api_base_url}}"], + "path": ["v1", "territories"] + } + }, + "response": [] + }, + { + "name": "Create Territoire Simulation maas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create territoire SM test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"SMTerritoryId\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Simulation maas\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/territories", + "host": ["{{api_base_url}}"], + "path": ["v1", "territories"] + } + }, + "response": [] + } + ] + }, + { + "name": "Aides Nationales", + "item": [ + { + "name": "Création Aide Nationale 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide Nationale 1\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Prime à la casse de votre véhicule pollueur\",\r\n\"incentiveType\": \"AideNationale\",\r\n\"funderName\": \"Etat français\",\r\n\"territory\": {\r\n \"name\": \"France métropolitaine\"\r\n },\r\n\"minAmount\": \"A partir de 550€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 206 308\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"voiture\", \"libreService\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\", \"Certificat immatriculation\"],\r\n\"validityDuration\": \"12 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Get aide id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET aide test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/incentives/{{ID Aide Nationale 1}}", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives", "{{ID Aide Nationale 1}}"] + } + }, + "response": [] + }, + { + "name": "Création Aide Nationale 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide Nationale 2\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Aide à la réparation de vélos immatriculés\",\r\n\"incentiveType\": \"AideNationale\",\r\n\"funderName\": \"Etat français\",\r\n\"territory\": {\r\n \"name\": \"DOMTOM\"\r\n },\r\n\"minAmount\": \"A partir de 10€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 206 308\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"velo\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\", \"Certificat immatriculation Vélo\"],\r\n\"validityDuration\": \"6 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Get aide id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET aide test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/incentives/{{ID Aide Nationale 2}}", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives", "{{ID Aide Nationale 2}}"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Collectivite Mulhouse", + "item": [ + { + "name": "Create collectivité", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idCollectivite\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Mulhouse\",\r\n \"encryptionKey\": {\r\n \"id\": \"Mulhouse-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"privateKeyAccess\": {\r\n \"loginURL\": \"https://keyvault/auth/cert/login\",\r\n \"getKeyURL\": \"https://keyvault/keyname\"\r\n},\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/collectivities", + "host": ["{{api_base_url}}"], + "path": ["v1", "collectivities"] + } + }, + "response": [] + }, + { + "name": "Create communauté A", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMulhouseA\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"Mulhouse-Communauté A\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Get communaute id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET communaute test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/funders/{{idCollectivite}}/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "{{idCollectivite}}", "communities"] + } + }, + "response": [] + }, + { + "name": "Create communauté B", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMulhouseB\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"Mulhouse-Communauté B\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Get communaute id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET communaute test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/funders/{{idCollectivite}}/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "{{idCollectivite}}", "communities"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (A+B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Ochon-SG-AB\",\r\n \"firstName\": \"Paul\",\r\n \"email\": \"superviseur-gestionnaire-ab.mulhouse@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMulhouseA}}\", \"{{communityIdMulhouseB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Ruetsch-SG-B\",\r\n \"firstName\": \"Alexis\",\r\n \"email\": \"superviseur-gestionnaire-b.mulhouse@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMulhouseB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite superviseur test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Tache-S\",\r\n \"firstName\": \"Mouss\",\r\n \"email\": \"superviseur.mulhouse@yopmail.com\",\r\n \"roles\":[\"superviseurs\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Térieur-G-B\",\r\n \"firstName\": \"Alex\",\r\n \"email\": \"gestionnaire-b.mulhouse@yopmail.com\",\r\n \"roles\":[\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMulhouseB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Création Aide 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 collectivité Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 1 Collectivité Mulhouse\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Un réseau de transport en commun régional plus performant\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"Mulhouse\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"A partir de 5€ par mois sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 603 603\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"transportsCommun\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 collectivité Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 2 Collectivité Mulhouse\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Le vélo électrique arrive à Mulhouse !\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"Mulhouse\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"A partir de 55€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 08 90 83\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"velo\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\"],\r\n\"validityDuration\": \"12 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 3 collectivité Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 3 Collectivité Mulhouse\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Covoiturez à Mulhouse\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"Mulhouse\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"Inscription gratuite\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez-nous directement depuis le site WWW.Covoiturage.com\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"covoiturage\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Collectivite simulation-maas", + "item": [ + { + "name": "Create collectivité simulation-maas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idCollectivite\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"simulation-maas\",\r\n \"encryptionKey\": {\r\n \"id\": \"simulation-maas-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"privateKeyAccess\": {\r\n \"loginURL\": \"https://keyvault/auth/cert/login\",\r\n \"getKeyURL\": \"https://keyvault/keyname\"\r\n},\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/collectivities", + "host": ["{{api_base_url}}"], + "path": ["v1", "collectivities"] + } + }, + "response": [] + }, + { + "name": "Create communauté A", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMAASA\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"SM-Communauté A\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create communauté B", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMAASB\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"SM-Communauté B\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (A+B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Mansoif-SG-AB\",\r\n \"firstName\": \"Gérard\",\r\n \"email\": \"{{emailSuperviseurGestionnaireSM}}\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMAASA}}\", \"{{communityIdMAASB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Fort-SG-B\",\r\n \"firstName\": \"Franck\",\r\n \"email\": \"superviseur-gestionnaire-b.sm@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMAASB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite superviseur test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Tome-S\",\r\n \"firstName\": \"Emma\",\r\n \"email\": \"superviseur.sm@yopmail.com\",\r\n \"roles\":[\"superviseurs\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Golo-G-B\",\r\n \"firstName\": \"Terry\",\r\n \"email\": \"gestionnaire-b.sm@yopmail.com\",\r\n \"roles\":[\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMAASB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Création Aide 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 collectivité simulation-maas test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 1 Collectivité simulation-maas\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Un réseau de transport en commun régional plus performant\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"simulation-maas\",\r\n\"territory\": {\r\n \"id\": \"{{SMTerritoryId}}\",\r\n \"name\": \"Simulation maas\"\r\n },\r\n\"minAmount\": \"A partir de 5€ par mois sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 603 603\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"transportsCommun\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 collectivité simulation-maas test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 2 Collectivité simulation-maas\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Le vélo électrique arrive à Simulation maas !\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"simulation-maas\",\r\n\"territory\": {\r\n \"id\": \"{{SMTerritoryId}}\",\r\n \"name\": \"Simulation maas\"\r\n },\r\n\"minAmount\": \"A partir de 55€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 08 90 83\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"velo\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\"],\r\n\"validityDuration\": \"12 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 3 collectivité simulation-maas test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 3 Collectivité simulation-maas\", response.id);\r", + "\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Covoiturez à Simulation maas\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"simulation-maas\",\r\n\"territory\": {\r\n \"id\": \"{{SMTerritoryId}}\",\r\n \"name\": \"Simulation maas\"\r\n },\r\n\"minAmount\": \"Inscription gratuite\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez-nous directement depuis le site WWW.Covoiturage.com\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"covoiturage\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Entreprise Capgemini", + "item": [ + { + "name": "Create entreprise Capgemini", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idEntreprise\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Capgemini\",\r\n \"emailFormat\": [\r\n \"@yopmail.com\",\r\n \"@capgemini.fr\",\r\n \"@capgemini.com\",\r\n \"@sogeti.com\"\r\n ],\r\n \"isHris\": false,\r\n \"hasManualAffiliation\": false,\r\n \"encryptionKey\": {\r\n \"id\": \"Capgemini-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"privateKeyAccess\": {\r\n \"loginURL\": \"https://keyvault/auth/cert/login\",\r\n \"getKeyURL\": \"https://keyvault/keyname\"\r\n},\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/enterprises", + "host": ["{{api_base_url}}"], + "path": ["v1", "enterprises"] + } + }, + "response": [] + }, + { + "name": "Create communauté 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdCapgemini1\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"name\": \"C-Communauté 1\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create communauté 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdCapgemini2\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"name\": \"C-Communauté 2\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (1+2)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Zona-SG-12\",\r\n \"firstName\": \"Harry\",\r\n \"email\": \"superviseur-gestionnaire-12.capgemini@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdCapgemini1}}\",\"{{communityIdCapgemini2}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (2)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Fornie-SG-2\",\r\n \"firstName\": \"Callie\",\r\n \"email\": \"superviseur-gestionnaire-2.capgemini@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdCapgemini2}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise superviseur test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Nateur-S\",\r\n \"firstName\": \"Jordy\",\r\n \"email\": \"superviseur.capgemini@yopmail.com\",\r\n \"roles\":[\"superviseurs\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur gestionnaire (2)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Bonno-G-2\",\r\n \"firstName\": \"Jean\",\r\n \"email\": \"gestionnaire-2.capgemini@yopmail.com\",\r\n \"roles\":[\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdCapgemini2}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Création Aide 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 Capgemini test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 1 Capgemini\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Aide à l'achat d'une trotinette électrique\",\r\n\"incentiveType\": \"AideEmployeur\",\r\n\"funderName\": \"Capgemini\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"A partir de 10€\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 603 603\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"electrique\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 entreprise 1 test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 2 Entreprise 1\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Covoiturez à Mulhouse\",\r\n\"incentiveType\": \"AideEmployeur\",\r\n\"funderName\": \"Capgemini\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"Inscription gratuite\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez-nous directement depuis le site WWW.Covoiturage.com\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"covoiturage\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Entreprise SIRH", + "item": [ + { + "name": "Create entreprise SIRH", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idEntrepriseSIRH\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"SIRH\",\r\n \"emailFormat\": [\r\n \"@yopmail.com\"\r\n ],\r\n \"isHris\": true,\r\n \"hasManualAffiliation\": false,\r\n \"encryptionKey\": {\r\n \"id\": \"SIRH-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/enterprises", + "host": ["{{api_base_url}}"], + "path": ["v1", "enterprises"] + } + }, + "response": [] + } + ] + }, + { + "name": "Logout", + "item": [ + { + "name": "Logout", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Logout to KC test: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " pm.environment.unset(\"accessToken\");\r", + " pm.environment.unset(\"refreshToken\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/logout", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "logout" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Website - Create citoyen", + "item": [ + { + "name": "Create utilisateur étudiant", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Rasovsky\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Bob\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\":\"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailEtudiantCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"Paris\",\r\n \"postcode\": \"75000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"etudiant\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur indépendant", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Aconda\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Anne\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailIndependantCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"Ouashing-tone\",\r\n \"postcode\": \"99000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"independantLiberal\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur retraite", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Lognaise\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Thibault\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailRetraiteCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"Paris\",\r\n \"postcode\": \"75000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"retraite\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur sans emploi", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Javel\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Aude\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailSansEmploiCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"L'eau Sangèles\",\r\n \"postcode\": \"99000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"sansEmploi\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur salarié Capgemini", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Térieur\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Alain\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailSalarieCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"L'eau Sangèles\",\r\n \"postcode\": \"99000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"salarie\",\r\n \"affiliation\": {\r\n \"enterpriseEmail\": \"salarie.mcm.pro@yopmail.com\",\r\n \"enterpriseId\": \"{{idEntreprise}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + } + ] + }, + { + "name": "KC - Validate accounts", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{apiClientSecret}}", + "type": "text" + }, + { + "key": "client_id", + "value": "{{apiClientId}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get all users", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Get all accounts : OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " const idUserList = [];\r", + " const idUserListToAddPassword = [];\r", + " for (const user of response) {\r", + " if(!user.emailVerified) {\r", + " idUserList.push(user.id);\r", + " }\r", + " if(user.requiredActions.includes('UPDATE_PASSWORD')) {\r", + " idUserListToAddPassword.push(user.id)\r", + " }\r", + " }\r", + " pm.environment.set(\"idUserList\", idUserList);\r", + " pm.environment.set(\"idUserListToAddPassword\", idUserListToAddPassword);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "users"] + } + }, + "response": [] + }, + { + "name": "Set Password for funders", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const idUserListToAddPassword = pm.environment.get(\"idUserListToAddPassword\");\r", + "if (idUserListToAddPassword && idUserListToAddPassword.length > 0){\r", + " pm.environment.set(\"idUserToAddPassword\", idUserListToAddPassword[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate user : OK\", function () {\r", + " const idUserListToAddPassword = pm.environment.get(\"idUserListToAddPassword\");\r", + " const idUserToAddPassword = idUserListToAddPassword.shift();\r", + " pm.environment.set(\"idUserToAddPassword\", idUserToAddPassword);\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if (idUserListToAddPassword && idUserListToAddPassword.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " pm.environment.set(\"idUserListToAddPassword\", idUserListToAddPassword);\r", + " } else {\r", + " pm.environment.unset(\"idUserToAddPassword\");\r", + " pm.environment.unset(\"idUserListToAddPassword\");\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"type\": \"password\",\r\n \"value\": \"{{utilisateurFinanceurPassword}}\",\r\n \"temporary\": false\r\n}" + }, + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users/{{idUserToAddPassword}}/reset-password", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "admin", + "realms", + "mcm", + "users", + "{{idUserToAddPassword}}", + "reset-password" + ] + }, + "description": "Set up a new password for the user.\n" + }, + "response": [] + }, + { + "name": "Validate user email", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate user : OK\", function () {\r", + " const idUserList = pm.environment.get(\"idUserList\");\r", + " const idUser = idUserList.shift();\r", + " pm.environment.set(\"idUser\", idUser);\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if (idUserList && idUserList.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " pm.environment.set(\"idUserList\", idUserList);\r", + " } else {\r", + " pm.environment.set(\"idUserList\", []);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idUserList = pm.environment.get(\"idUserList\");\r", + "if (idUserList && idUserList.length > 0){\r", + " pm.environment.set(\"idUser\", idUserList[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"emailVerified\": true,\r\n \"requiredActions\": []\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users/{{idUser}}", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "users", "{{idUser}}"] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "MAAS", + "item": [ + { + "name": "Login MAAS", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{maasClientSecret}}", + "type": "text" + }, + { + "key": "client_id", + "value": "simulation-maas-backend", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get Maas aides", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET Maas aides test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Website - Utilisateur", + "item": [ + { + "name": "Login Citizen Website", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + " pm.environment.set(\"refreshToken\", response.refresh_token);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let emailCitoyenList = pm.environment.get(\"emailCitoyenList\");\r", + "\r", + "// Init emailCitoyenList\r", + "if (!emailCitoyenList || emailCitoyenList.length == 0) {\r", + " emailCitoyenList = [\r", + " pm.environment.get(\"emailEtudiantCitoyen\"),\r", + " pm.environment.get(\"emailIndependantCitoyen\"),\r", + " pm.environment.get(\"emailRetraiteCitoyen\"),\r", + " pm.environment.get(\"emailSansEmploiCitoyen\"),\r", + " pm.environment.get(\"emailSalarieCitoyen\")\r", + " ];\r", + "}\r", + "\r", + "const emailCitoyen = emailCitoyenList.shift();\r", + "console.log(\"emailCitoyen\", emailCitoyen)\r", + "pm.environment.set(\"emailCitoyen\", emailCitoyen);\r", + "pm.environment.set(\"emailCitoyenList\", emailCitoyenList);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "password", + "type": "text" + }, + { + "key": "username", + "value": "{{emailCitoyen}}", + "type": "text" + }, + { + "key": "password", + "value": "{{citoyenPassword}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Create multiple demandes brouillon", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create demande MAAS test: OK\", function () {\r", + "\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "\r", + " const response = pm.response.json();\r", + " const incentiveIdListMAAS = pm.environment.get(\"incentiveIdListMAAS\");\r", + " let idDemandeMAASListJustifs = pm.environment.get(\"idDemandeMAASListJustifs\");\r", + " let idDemandeMAASListFinalize = pm.environment.get(\"idDemandeMAASListFinalize\");\r", + "\r", + " idDemandeMAASListJustifs.push(response.id);\r", + " idDemandeMAASListFinalize.push(response.id);\r", + "\r", + " pm.environment.set(\"idDemandeMAASListJustifs\", idDemandeMAASListJustifs);\r", + " pm.environment.set(\"idDemandeMAASListFinalize\", idDemandeMAASListFinalize);\r", + "\r", + "\r", + " if (incentiveIdListMAAS && incentiveIdListMAAS.length > 0) {\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.unset(\"incentiveIdMAAS\");\r", + " pm.environment.unset(\"incentiveIdListMAAS\");\r", + " postman.setNextRequest();\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let incentiveIdListMAAS = pm.environment.get(\"incentiveIdListMAAS\");\r", + "\r", + "// Init incentiveIdListMAAS\r", + "if (!incentiveIdListMAAS || incentiveIdListMAAS.length == 0) {\r", + " incentiveIdListMAAS = [\r", + " pm.environment.get(\"ID Aide 1 Collectivité simulation-maas\"),\r", + " pm.environment.get(\"ID Aide 2 Collectivité simulation-maas\"),\r", + " pm.environment.get(\"ID Aide 3 Collectivité simulation-maas\")\r", + " ];\r", + " pm.environment.set(\"idDemandeMAASListJustifs\", []);\r", + " pm.environment.set(\"idDemandeMAASListFinalize\", []);\r", + "}\r", + "\r", + "const incentiveIdMAAS = incentiveIdListMAAS.shift();\r", + "pm.environment.set(\"incentiveIdListMAAS\", incentiveIdListMAAS);\r", + "pm.environment.set(\"incentiveIdMAAS\", incentiveIdMAAS);\r", + "\r", + "// Init communaute according to condition\r", + "// Communaute MAAS A if etudiant, sans emploi or (retraite and first aide) \r", + "if ((pm.environment.get(\"emailCitoyen\") === pm.environment.get(\"emailEtudiantCitoyen\")) ||\r", + " (pm.environment.get(\"emailCitoyen\") === pm.environment.get(\"emailSansEmploiCitoyen\")) ||\r", + " (pm.environment.get(\"emailCitoyen\") === pm.environment.get(\"emailRetraiteCitoyen\")) && (pm.environment.get(\"incentiveIdMAAS\") === pm.environment.get(\"ID Aide 1 Collectivité simulation-maas\"))) {\r", + " pm.environment.set(\"communityIdMAAS\", pm.environment.get(\"communityIdMAASA\"));\r", + "} else {\r", + " pm.environment.set(\"communityIdMAAS\", pm.environment.get(\"communityIdMAASB\"));\r", + "}\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"incentiveId\": \"{{incentiveIdMAAS}}\",\r\n \"consent\": true,\r\n \"communityId\": \"{{communityIdMAAS}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/maas/subscriptions", + "host": ["{{api_base_url}}"], + "path": ["v1", "maas", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "Upload justificatifs multiple subscriptions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create subscription MAAS justificatif test: OK\", function () {\r", + " const idDemandeMAASListJustifs = pm.environment.get(\"idDemandeMAASListJustifs\");\r", + "\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "\r", + " if (idDemandeMAASListJustifs && idDemandeMAASListJustifs.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.unset(\"idDemandeMAAS\");\r", + " pm.environment.unset(\"idDemandeMAASListJustifs\");\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idDemandeMAASListJustifs = pm.environment.get(\"idDemandeMAASListJustifs\");\r", + "\r", + "if (idDemandeMAASListJustifs && idDemandeMAASListJustifs.length > 0) {\r", + " const idDemandeMAAS = idDemandeMAASListJustifs.shift();\r", + " pm.environment.set(\"idDemandeMAAS\", idDemandeMAAS);\r", + " pm.environment.set(\"idDemandeMAASListJustifs\", idDemandeMAASListJustifs);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "CI", + "type": "file", + "src": "api-tests/mob.PNG" + }, + { + "key": "Passport", + "type": "file", + "src": "api-tests/mob.PNG" + } + ] + }, + "url": { + "raw": "{{api_base_url}}/v1/maas/subscriptions/{{idDemandeMAAS}}/attachments", + "host": ["{{api_base_url}}"], + "path": [ + "v1", + "maas", + "subscriptions", + "{{idDemandeMAAS}}", + "attachments" + ] + } + }, + "response": [] + }, + { + "name": "Finalize multiple subscriptions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Finaliser une demande MAAS test: OK\", function () {\r", + " const idDemandeMAASListFinalize = pm.environment.get(\"idDemandeMAASListFinalize\");\r", + "\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "\r", + " if (idDemandeMAASListFinalize && idDemandeMAASListFinalize.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.unset(\"idDemandeMAASListFinalize\");\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idDemandeMAASListFinalize = pm.environment.get(\"idDemandeMAASListFinalize\");\r", + "\r", + "if (idDemandeMAASListFinalize && idDemandeMAASListFinalize.length > 0) {\r", + " const idDemandeMAAS = idDemandeMAASListFinalize.shift();\r", + " pm.environment.set(\"idDemandeMAAS\", idDemandeMAAS);\r", + " pm.environment.set(\"idDemandeMAASListFinalize\", idDemandeMAASListFinalize);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/maas/subscriptions/{{idDemandeMAAS}}/verify", + "host": ["{{api_base_url}}"], + "path": [ + "v1", + "maas", + "subscriptions", + "{{idDemandeMAAS}}", + "verify" + ] + } + }, + "response": [] + }, + { + "name": "Logout Citizen Website", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Logout to KC test: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " pm.environment.unset(\"accessToken\");\r", + " pm.environment.unset(\"refreshToken\");\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const emailCitoyenList = pm.environment.get(\"emailCitoyenList\");\r", + "\r", + "if (emailCitoyenList && emailCitoyenList.length > 0){\r", + " postman.setNextRequest(\"Login Citizen MAAS\");\r", + "} else {\r", + " pm.environment.unset(\"emailCitoyenList\");\r", + " pm.environment.unset(\"emailCitoyen\");\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/logout", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "logout" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Website - Validate/Reject", + "item": [ + { + "name": "Login Superviseur Gestionnaire simulation-maas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + " pm.environment.set(\"refreshToken\", response.refresh_token);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "password", + "type": "text" + }, + { + "key": "username", + "value": "{{emailSuperviseurGestionnaireSM}}", + "type": "text" + }, + { + "key": "password", + "value": "{{utilisateurFinanceurPassword}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get subscriptions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Get subscriptions id : OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " const idSubscriptionsList = [];\r", + " for (const subscription of response) {\r", + " idSubscriptionsList.push(subscription.id);\r", + " }\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/subscriptions", + "host": ["{{api_base_url}}"], + "path": ["v1", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "Validate subscription", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate subscription : OK\", function () {\r", + " const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + " const idSubscription = idSubscriptionsList.shift();\r", + " pm.environment.set(\"idSubscription\", idSubscription);\r", + " var counter = pm.environment.get(\"subscriptionCounter\");\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if ((idSubscriptionsList && idSubscriptionsList.length > 0) && counter < 2){\r", + " counter++;\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + " pm.environment.set(\"subscriptionCounter\", counter);\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + " pm.environment.set(\"subscriptionCounter\", 0);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + "if (idSubscriptionsList && idSubscriptionsList.length > 0){\r", + " pm.environment.set(\"idSubscription\", idSubscriptionsList[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"mode\": \"multiple\",\r\n \"frequency\": \"mensuelle\",\r\n \"amount\": 50,\r\n \"lastPayment\": \"2023-01-01\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/subscriptions/{{idSubscription}}/validate", + "host": ["{{api_base_url}}"], + "path": ["v1", "subscriptions", "{{idSubscription}}", "validate"] + } + }, + "response": [] + }, + { + "name": "Reject subscription", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Reject subscription : OK\", function () {\r", + " const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + " const idSubscription = idSubscriptionsList.shift();\r", + " pm.environment.set(\"idSubscription\", idSubscription);\r", + " var counter = pm.environment.get(\"subscriptionCounter\");\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if ((idSubscriptionsList && idSubscriptionsList.length > 0) && counter < 2){\r", + " counter++;\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + " pm.environment.set(\"subscriptionCounter\", counter);\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.set(\"idSubscriptionsList\", []);\r", + " pm.environment.set(\"subscriptionCounter\", 0);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + "if (idSubscriptionsList && idSubscriptionsList.length > 0){\r", + " pm.environment.set(\"idSubscription\", idSubscriptionsList[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"type\": \"Autre\",\r\n \"other\": \"Ne remplit pas les conditions d'éligibilité à cette aide\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/subscriptions/{{idSubscription}}/reject", + "host": ["{{api_base_url}}"], + "path": ["v1", "subscriptions", "{{idSubscription}}", "reject"] + } + }, + "response": [] + }, + { + "name": "Logout Superviseur Gestionnaire", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Logout to KC test: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " pm.environment.unset(\"accessToken\");\r", + " pm.environment.unset(\"refreshToken\");\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/logout", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "logout" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] +} diff --git a/test/api-tests/MCM-testing.postman_collection.json b/test/api-tests/MCM-testing.postman_collection.json new file mode 100644 index 0000000..99e64a5 --- /dev/null +++ b/test/api-tests/MCM-testing.postman_collection.json @@ -0,0 +1,3373 @@ +{ + "info": { + "_postman_id": "619b2d76-022c-4234-92ac-9c16f0f0d35f", + "name": "MCM", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "5583834" + }, + "item": [ + { + "name": "KC - Create Admin fonctionnel", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{apiClientSecret}}", + "type": "text" + }, + { + "key": "client_id", + "value": "{{apiClientId}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Create admin fonctionnel", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create Admin fonctionnel test: OK\", function () {\r", + " pm.response.to.have.status(201);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const locationHeader = pm.response.headers.find(header => header.key === 'Location');\r", + " const idAdminFonctionnel = locationHeader.value.split('/').slice(-1);\r", + " pm.environment.set(\"idAdminFonctionnel\", idAdminFonctionnel);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\": \"admin_fonctionnel-{{rcVersionSlug}}\",\r\n \"enabled\": true,\r\n \"totp\": false,\r\n \"emailVerified\": true,\r\n \"email\": \"\",\r\n \"disableableCredentialTypes\": [],\r\n \"requiredActions\": [],\r\n \"notBefore\": 0,\r\n \"credentials\": [\r\n {\r\n \"type\": \"password\",\r\n \"value\":\"{{adminFonctionnelPassword}}\",\r\n \"temporary\": false\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "users"] + } + }, + "response": [] + }, + { + "name": "Get KC groups", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Get KC groups test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " const adminGroup = response.find(group => group.name === 'admins');\r", + " pm.environment.set(\"idAdminGroup\", adminGroup.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/groups", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "groups"] + } + }, + "response": [] + }, + { + "name": "Add to admin group", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Add admin fonctionnel to admin group: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users/{{idAdminFonctionnel}}/groups/{{idAdminGroup}}", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "admin", + "realms", + "mcm", + "users", + "{{idAdminFonctionnel}}", + "groups", + "{{idAdminGroup}}" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "React Admin", + "item": [ + { + "name": "Login", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + " pm.environment.set(\"refreshToken\", response.refresh_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "password", + "type": "text" + }, + { + "key": "username", + "value": "admin_fonctionnel-{{rcVersionSlug}}", + "type": "text" + }, + { + "key": "password", + "value": "{{adminFonctionnelPassword}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Territoires", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}" + } + ] + }, + "item": [ + { + "name": "Create Territoire Mulhouse", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create territoire Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"TerritoryId\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Mulhouse\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/territories", + "host": ["{{api_base_url}}"], + "path": ["v1", "territories"] + } + }, + "response": [] + }, + { + "name": "Create Territoire Simulation maas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create territoire SM test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"SMTerritoryId\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Simulation maas\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/territories", + "host": ["{{api_base_url}}"], + "path": ["v1", "territories"] + } + }, + "response": [] + } + ] + }, + { + "name": "Aides Nationales", + "item": [ + { + "name": "Création Aide Nationale 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide Nationale 1\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Prime à la casse de votre véhicule pollueur {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideNationale\",\r\n\"funderName\": \"Etat français\",\r\n\"territory\": {\r\n \"name\": \"France métropolitaine\"\r\n },\r\n\"minAmount\": \"A partir de 550€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 206 308\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"voiture\", \"libreService\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\", \"Certificat immatriculation\"],\r\n\"validityDuration\": \"12 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Get aide id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET aide test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/incentives/{{ID Aide Nationale 1}}", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives", "{{ID Aide Nationale 1}}"] + } + }, + "response": [] + }, + { + "name": "Création Aide Nationale 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide Nationale 2\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Aide à la réparation de vélos immatriculés {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideNationale\",\r\n\"funderName\": \"Etat français\",\r\n\"territory\": {\r\n \"name\": \"DOMTOM\"\r\n },\r\n\"minAmount\": \"A partir de 10€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 206 308\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"velo\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\", \"Certificat immatriculation Vélo\"],\r\n\"validityDuration\": \"6 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Get aide id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET aide test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/incentives/{{ID Aide Nationale 2}}", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives", "{{ID Aide Nationale 2}}"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Collectivite Mulhouse", + "item": [ + { + "name": "Create collectivité", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idCollectivite\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Mulhouse-{{rcVersionSlug}}\",\r\n \"encryptionKey\": {\r\n \"id\": \"Mulhouse-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"privateKeyAccess\": {\r\n \"loginURL\": \"https://keyvault/auth/cert/login\",\r\n \"getKeyURL\": \"https://keyvault/keyname\"\r\n},\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/collectivities", + "host": ["{{api_base_url}}"], + "path": ["v1", "collectivities"] + } + }, + "response": [] + }, + { + "name": "Create communauté A", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMulhouseA\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"Mulhouse-Communauté A {{rcVersionSlug}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Get communaute id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET communaute test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/funders/{{idCollectivite}}/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "{{idCollectivite}}", "communities"] + } + }, + "response": [] + }, + { + "name": "Create communauté B", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMulhouseB\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"Mulhouse-Communauté B {{rcVersionSlug}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Get communaute id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET communaute test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/funders/{{idCollectivite}}/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "{{idCollectivite}}", "communities"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (A+B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Ochon-SG-AB\",\r\n \"firstName\": \"Paul\",\r\n \"email\": \"superviseur-gestionnaire-ab.mulhouse-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMulhouseA}}\", \"{{communityIdMulhouseB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Ruetsch-SG-B\",\r\n \"firstName\": \"Alexis\",\r\n \"email\": \"superviseur-gestionnaire-b.mulhouse-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMulhouseB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite superviseur test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Tache-S\",\r\n \"firstName\": \"Mouss\",\r\n \"email\": \"superviseur.mulhouse-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Térieur-G-B\",\r\n \"firstName\": \"Alex\",\r\n \"email\": \"gestionnaire-b.mulhouse-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMulhouseB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Création Aide 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 collectivité Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 1 Collectivité Mulhouse\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Un réseau de transport en commun régional plus performant {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"Mulhouse-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"A partir de 5€ par mois sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 603 603\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"transportsCommun\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 collectivité Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 2 Collectivité Mulhouse\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Le vélo électrique arrive à Mulhouse ! {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"Mulhouse-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"A partir de 55€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 08 90 83\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"velo\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\"],\r\n\"validityDuration\": \"12 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 3 collectivité Mulhouse test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 3 Collectivité Mulhouse\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Covoiturez à Mulhouse {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"Mulhouse-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"Inscription gratuite\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez-nous directement depuis le site WWW.Covoiturage.com\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"covoiturage\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Collectivite simulation-maas", + "item": [ + { + "name": "Create collectivité simulation-maas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idCollectivite\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"simulation-maas-{{rcVersionSlug}}\",\r\n \"encryptionKey\": {\r\n \"id\": \"simulation-maas-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"privateKeyAccess\": {\r\n \"loginURL\": \"https://keyvault/auth/cert/login\",\r\n \"getKeyURL\": \"https://keyvault/keyname\"\r\n},\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/collectivities", + "host": ["{{api_base_url}}"], + "path": ["v1", "collectivities"] + } + }, + "response": [] + }, + { + "name": "Create communauté A", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMAASA\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"SM-Communauté A {{rcVersionSlug}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create communauté B", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdMAASB\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"name\": \"SM-Communauté B {{rcVersionSlug}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (A+B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Mansoif-SG-AB\",\r\n \"firstName\": \"Gérard\",\r\n \"email\": \"{{emailSuperviseurGestionnaireSM}}\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMAASA}}\", \"{{communityIdMAASB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Fort-SG-B\",\r\n \"firstName\": \"Franck\",\r\n \"email\": \"superviseur-gestionnaire-b.sm-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMAASB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite superviseur test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Tome-S\",\r\n \"firstName\": \"Emma\",\r\n \"email\": \"superviseur.sm-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur gestionnaire (B)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create collectivite gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idCollectivite}}\",\r\n \"lastName\": \"Golo-G-B\",\r\n \"firstName\": \"Terry\",\r\n \"email\": \"gestionnaire-b.sm-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdMAASB}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Création Aide 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 collectivité simulation-maas test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 1 Collectivité simulation-maas\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Un réseau de transport en commun régional plus performant {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"simulation-maas-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{SMTerritoryId}}\",\r\n \"name\": \"Simulation maas\"\r\n },\r\n\"minAmount\": \"A partir de 5€ par mois sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 603 603\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"transportsCommun\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 collectivité simulation-maas test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 2 Collectivité simulation-maas\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Le vélo électrique arrive à Simulation maas ! {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"simulation-maas-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{SMTerritoryId}}\",\r\n \"name\": \"Simulation maas\"\r\n },\r\n\"minAmount\": \"A partir de 55€ sous conditions\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 08 90 83\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"velo\"],\r\n\"attachments\": [\"rib\", \"justificatifDomicile\"],\r\n\"validityDuration\": \"12 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 3 collectivité simulation-maas test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 3 Collectivité simulation-maas\", response.id);\r", + "\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Covoiturez à Simulation maas {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideTerritoire\",\r\n\"funderName\": \"simulation-maas-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{SMTerritoryId}}\",\r\n \"name\": \"Simulation maas\"\r\n },\r\n\"minAmount\": \"Inscription gratuite\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez-nous directement depuis le site WWW.Covoiturage.com\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"covoiturage\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Entreprise Capgemini", + "item": [ + { + "name": "Create entreprise Capgemini", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"idEntreprise\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Capgemini-{{rcVersionSlug}}\",\r\n \"emailFormat\": [\r\n \"@yopmail.com\",\r\n \"@capgemini.fr\",\r\n \"@capgemini.com\",\r\n \"@sogeti.com\"\r\n ],\r\n \"isHris\": false,\r\n \"hasManualAffiliation\": false,\r\n \"encryptionKey\": {\r\n \"id\": \"Capgemini-backend\",\r\n \"version\": 1,\r\n \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq\\nn76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf\\nn8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq\\nsokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85\\nOOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG\\nX6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n\\nowIDAQAB\\n-----END PUBLIC KEY-----\",\r\n \"expirationDate\": \"2099-12-28T19:01:00Z\",\r\n \"privateKeyAccess\": {\r\n \"loginURL\": \"https://keyvault/auth/cert/login\",\r\n \"getKeyURL\": \"https://keyvault/keyname\"\r\n},\r\n \"lastUpdateDate\": \"2022-06-28T19:28:00Z\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/enterprises", + "host": ["{{api_base_url}}"], + "path": ["v1", "enterprises"] + } + }, + "response": [] + }, + { + "name": "Create communauté 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdCapgemini1\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"name\": \"C-Communauté 1 {{rcVersionSlug}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create communauté 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise communauté test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"communityIdCapgemini2\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"name\": \"C-Communauté 2 {{rcVersionSlug}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/funders/communities", + "host": ["{{api_base_url}}"], + "path": ["v1", "funders", "communities"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (1+2)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Zona-SG-12\",\r\n \"firstName\": \"Harry\",\r\n \"email\": \"superviseur-gestionnaire-12.capgemini-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdCapgemini1}}\",\"{{communityIdCapgemini2}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur-gestionnaire (2)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise super gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Fornie-SG-2\",\r\n \"firstName\": \"Callie\",\r\n \"email\": \"superviseur-gestionnaire-2.capgemini-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\",\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdCapgemini2}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur superviseur", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise superviseur test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Nateur-S\",\r\n \"firstName\": \"Jordy\",\r\n \"email\": \"superviseur.capgemini-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"superviseurs\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur gestionnaire (2)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create entreprise gestionnaire test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"funderId\": \"{{idEntreprise}}\",\r\n \"lastName\": \"Bonno-G-2\",\r\n \"firstName\": \"Jean\",\r\n \"email\": \"gestionnaire-2.capgemini-{{rcVersionSlug}}@yopmail.com\",\r\n \"roles\":[\"gestionnaires\"],\r\n \"communityIds\": [\"{{communityIdCapgemini2}}\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/users", + "host": ["{{api_base_url}}"], + "path": ["v1", "users"] + } + }, + "response": [] + }, + { + "name": "Création Aide 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 1 Capgemini test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 1 Capgemini\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Aide à l'achat d'une trotinette électrique {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideEmployeur\",\r\n\"funderName\": \"Capgemini-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"A partir de 10€\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez le numéro vert au 05 603 603\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"electrique\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + }, + { + "name": "Création Aide 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create aide 2 entreprise 1 test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"ID Aide 2 Entreprise 1\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\"title\": \"Covoiturez à Capgemini {{rcVersionSlug}}\",\r\n\"incentiveType\": \"AideEmployeur\",\r\n\"funderName\": \"Capgemini-{{rcVersionSlug}}\",\r\n\"territory\": {\r\n \"id\": \"{{TerritoryId}}\",\r\n \"name\": \"Mulhouse\"\r\n },\r\n\"minAmount\": \"Inscription gratuite\",\r\n\"additionalInfos\": \"Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis. Sed tempor condimentum nulla vel convallis. Vivamus finibus tincidunt leo, ullamcorper semper libero malesuada id.\",\r\n\"allocatedAmount\": \"Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"paymentMethod\": \"Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"conditions\": \"Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas lacus dui, vulputate tempus purus bibendum nec. \\n- Fusce bibendum ipsum at risus venenatis, et luctus metus facilisis. \\n- Maecenas consectetur neque sit amet enim porta fringilla. \\n- Cras fringilla odio nisl, a finibus diam volutpat in. \\n- Nullam vestibulum odio purus, blandit maximus lectus malesuada a. \\n- Nunc rutrum velit quis tincidunt pretium. \\n- Suspendisse elementum quis ante eu sollicitudin. \\nDonec eu risus urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean eu dignissim sem. Integer placerat suscipit ex vel ullamcorper. Praesent tempus dictum odio in congue. Praesent tincidunt lectus id ligula consequat dictum. Morbi varius sollicitudin malesuada.\\n\\nInteger sodales in nisi id gravida. Maecenas dui sapien, interdum ut mi dapibus, fermentum faucibus orci. Duis interdum, ex nec congue maximus, est nulla viverra odio, non interdum augue nisl nec sapien. Morbi ante augue, convallis nec massa at, efficitur euismod mi. Integer a magna elementum, rhoncus purus sed, ultricies turpis. Aenean in dolor lobortis, posuere magna sed, consectetur urna. Duis sit amet mattis magna. Nunc non tempus lacus. Donec a lacus vel mauris condimentum feugiat. In non diam porttitor, semper elit in, placerat metus. Pellentesque neque velit, tincidunt eget finibus in, faucibus id tortor. Duis ut gravida nisl, eu dignissim magna. Donec in magna sed arcu tempor fermentum id et sapien. Nam ut semper justo, id dictum augue. Aenean suscipit sem non tempor vehicula.\\n\\nSed venenatis erat quis neque vehicula laoreet. Curabitur ac vehicula massa. In eget risus at purus eleifend finibus. Quisque ultricies orci id lorem sodales eleifend. Nulla eu condimentum tortor, nec mattis orci. Nam et orci tincidunt, ultricies sem at, congue risus. Vestibulum tincidunt, massa ac sollicitudin volutpat, odio dui laoreet diam, non facilisis nulla velit nec nisl. Vivamus quis tristique massa. Ut tincidunt metus vehicula velit lacinia viverra. Donec consequat dapibus tortor, nec dapibus nunc rhoncus at. Sed volutpat leo sed augue ultricies, in ultricies lorem porttitor.\",\r\n\"contact\": \"Contactez-nous directement depuis le site WWW.Covoiturage.com\",\r\n\"description\": \"Description dolor sit amet, consectetur adipiscing elit. Praesent congue dolor diam, vel aliquet tortor malesuada in. Lorem ipsum dolor sit amet, consectetur adipiscing elit.\\nCette aide est valable jusqu'au 31/03/2021\",\r\n\"transportList\": [\"covoiturage\"],\r\n\"attachments\": [\"justificatifDomicile\"],\r\n\"validityDuration\": \"24 mois\",\r\n\"validityDate\": \"2024-07-31\",\r\n\"isMCMStaff\": true\r\n}" + }, + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Logout", + "item": [ + { + "name": "Logout", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Logout to KC test: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " pm.environment.unset(\"accessToken\");\r", + " pm.environment.unset(\"refreshToken\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/logout", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "logout" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Website - Create citoyen", + "item": [ + { + "name": "Create utilisateur étudiant", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Rasovsky\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Bob\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\":\"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailEtudiantCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"Paris\",\r\n \"postcode\": \"75000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"etudiant\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur indépendant", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Aconda\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Anne\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailIndependantCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"Ouashing-tone\",\r\n \"postcode\": \"99000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"independantLiberal\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur retraite", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Lognaise\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Thibault\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailRetraiteCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"Paris\",\r\n \"postcode\": \"75000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"retraite\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur sans emploi", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Javel\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Aude\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailSansEmploiCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"L'eau Sangèles\",\r\n \"postcode\": \"99000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"sansEmploi\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + }, + { + "name": "Create utilisateur salarié Capgemini", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create citizen test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "});\r", + "\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"identity\":{ \r\n \"gender\":{\r\n \"value\":1,\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"lastName\":{\r\n \"value\":\"Térieur\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"firstName\":{\r\n \"value\":\"Alain\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n },\r\n \"birthDate\":{\r\n \"value\": \"1970-01-01\",\r\n \"source\": \"moncomptemobilite.fr\",\r\n \"certificationDate\": \"2022-10-18T17:13:37.432Z\"\r\n }\r\n },\r\n \"email\": \"{{emailSalarieCitoyen}}\",\r\n \"password\": \"{{citoyenPassword}}\",\r\n \"city\": \"L'eau Sangèles\",\r\n \"postcode\": \"99000\",\r\n \"tos1\": true,\r\n \"tos2\": true,\r\n \"status\": \"salarie\",\r\n \"affiliation\": {\r\n \"enterpriseEmail\": \"salarie.mcm.pro@yopmail.com\",\r\n \"enterpriseId\": \"{{idEntreprise}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/citizens", + "host": ["{{api_base_url}}"], + "path": ["v1", "citizens"] + } + }, + "response": [] + } + ] + }, + { + "name": "KC - Validate accounts", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{apiClientSecret}}", + "type": "text" + }, + { + "key": "client_id", + "value": "{{apiClientId}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get all users", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Get all accounts : OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " const idUserList = [];\r", + " const idUserListToAddPassword = [];\r", + " for (const user of response) {\r", + " if(!user.emailVerified) {\r", + " idUserList.push(user.id);\r", + " }\r", + " if(user.requiredActions.includes('UPDATE_PASSWORD')) {\r", + " idUserListToAddPassword.push(user.id)\r", + " }\r", + " }\r", + " pm.environment.set(\"idUserList\", idUserList);\r", + " pm.environment.set(\"idUserListToAddPassword\", idUserListToAddPassword);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "users"] + } + }, + "response": [] + }, + { + "name": "Set Password for funders", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const idUserListToAddPassword = pm.environment.get(\"idUserListToAddPassword\");\r", + "if (idUserListToAddPassword && idUserListToAddPassword.length > 0){\r", + " pm.environment.set(\"idUserToAddPassword\", idUserListToAddPassword[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate user : OK\", function () {\r", + " const idUserListToAddPassword = pm.environment.get(\"idUserListToAddPassword\");\r", + " const idUserToAddPassword = idUserListToAddPassword.shift();\r", + " pm.environment.set(\"idUserToAddPassword\", idUserToAddPassword);\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if (idUserListToAddPassword && idUserListToAddPassword.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " pm.environment.set(\"idUserListToAddPassword\", idUserListToAddPassword);\r", + " } else {\r", + " pm.environment.unset(\"idUserToAddPassword\");\r", + " pm.environment.unset(\"idUserListToAddPassword\");\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"type\": \"password\",\r\n \"value\": \"{{utilisateurFinanceurPassword}}\",\r\n \"temporary\": false\r\n}" + }, + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users/{{idUserToAddPassword}}/reset-password", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "admin", + "realms", + "mcm", + "users", + "{{idUserToAddPassword}}", + "reset-password" + ] + }, + "description": "Set up a new password for the user.\n" + }, + "response": [] + }, + { + "name": "Validate user email", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate user : OK\", function () {\r", + " const idUserList = pm.environment.get(\"idUserList\");\r", + " const idUser = idUserList.shift();\r", + " pm.environment.set(\"idUser\", idUser);\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if (idUserList && idUserList.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " pm.environment.set(\"idUserList\", idUserList);\r", + " } else {\r", + " pm.environment.set(\"idUserList\", []);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idUserList = pm.environment.get(\"idUserList\");\r", + "if (idUserList && idUserList.length > 0){\r", + " pm.environment.set(\"idUser\", idUserList[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"emailVerified\": true,\r\n \"requiredActions\": []\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{idp_base_url}}/auth/admin/realms/mcm/users/{{idUser}}", + "host": ["{{idp_base_url}}"], + "path": ["auth", "admin", "realms", "mcm", "users", "{{idUser}}"] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "MAAS", + "item": [ + { + "name": "Login MAAS", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{maasClientSecret}}", + "type": "text" + }, + { + "key": "client_id", + "value": "simulation-maas-backend", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get Maas aides", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"GET Maas aides test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/incentives", + "host": ["{{api_base_url}}"], + "path": ["v1", "incentives"] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Website - Utilisateur", + "item": [ + { + "name": "Login Citizen Website", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + " pm.environment.set(\"refreshToken\", response.refresh_token);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let emailCitoyenList = pm.environment.get(\"emailCitoyenList\");\r", + "\r", + "// Init emailCitoyenList\r", + "if (!emailCitoyenList || emailCitoyenList.length == 0) {\r", + " emailCitoyenList = [\r", + " pm.environment.get(\"emailEtudiantCitoyen\"),\r", + " pm.environment.get(\"emailIndependantCitoyen\"),\r", + " pm.environment.get(\"emailRetraiteCitoyen\"),\r", + " pm.environment.get(\"emailSansEmploiCitoyen\"),\r", + " pm.environment.get(\"emailSalarieCitoyen\")\r", + " ];\r", + "}\r", + "\r", + "const emailCitoyen = emailCitoyenList.shift();\r", + "console.log(\"emailCitoyen\", emailCitoyen)\r", + "pm.environment.set(\"emailCitoyen\", emailCitoyen);\r", + "pm.environment.set(\"emailCitoyenList\", emailCitoyenList);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "password", + "type": "text" + }, + { + "key": "username", + "value": "{{emailCitoyen}}", + "type": "text" + }, + { + "key": "password", + "value": "{{citoyenPassword}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Create multiple demandes brouillon", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create demande MAAS test: OK\", function () {\r", + "\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "\r", + " const response = pm.response.json();\r", + " const incentiveIdListMAAS = pm.environment.get(\"incentiveIdListMAAS\");\r", + " let idDemandeMAASListJustifs = pm.environment.get(\"idDemandeMAASListJustifs\");\r", + " let idDemandeMAASListFinalize = pm.environment.get(\"idDemandeMAASListFinalize\");\r", + "\r", + " idDemandeMAASListJustifs.push(response.id);\r", + " idDemandeMAASListFinalize.push(response.id);\r", + "\r", + " pm.environment.set(\"idDemandeMAASListJustifs\", idDemandeMAASListJustifs);\r", + " pm.environment.set(\"idDemandeMAASListFinalize\", idDemandeMAASListFinalize);\r", + "\r", + "\r", + " if (incentiveIdListMAAS && incentiveIdListMAAS.length > 0) {\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.unset(\"incentiveIdMAAS\");\r", + " pm.environment.unset(\"incentiveIdListMAAS\");\r", + " postman.setNextRequest();\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let incentiveIdListMAAS = pm.environment.get(\"incentiveIdListMAAS\");\r", + "\r", + "// Init incentiveIdListMAAS\r", + "if (!incentiveIdListMAAS || incentiveIdListMAAS.length == 0) {\r", + " incentiveIdListMAAS = [\r", + " pm.environment.get(\"ID Aide 1 Collectivité simulation-maas\"),\r", + " pm.environment.get(\"ID Aide 2 Collectivité simulation-maas\"),\r", + " pm.environment.get(\"ID Aide 3 Collectivité simulation-maas\")\r", + " ];\r", + " pm.environment.set(\"idDemandeMAASListJustifs\", []);\r", + " pm.environment.set(\"idDemandeMAASListFinalize\", []);\r", + "}\r", + "\r", + "const incentiveIdMAAS = incentiveIdListMAAS.shift();\r", + "pm.environment.set(\"incentiveIdListMAAS\", incentiveIdListMAAS);\r", + "pm.environment.set(\"incentiveIdMAAS\", incentiveIdMAAS);\r", + "\r", + "// Init communaute according to condition\r", + "// Communaute MAAS A if etudiant, sans emploi or (retraite and first aide) \r", + "if ((pm.environment.get(\"emailCitoyen\") === pm.environment.get(\"emailEtudiantCitoyen\")) ||\r", + " (pm.environment.get(\"emailCitoyen\") === pm.environment.get(\"emailSansEmploiCitoyen\")) ||\r", + " (pm.environment.get(\"emailCitoyen\") === pm.environment.get(\"emailRetraiteCitoyen\")) && (pm.environment.get(\"incentiveIdMAAS\") === pm.environment.get(\"ID Aide 1 Collectivité simulation-maas\"))) {\r", + " pm.environment.set(\"communityIdMAAS\", pm.environment.get(\"communityIdMAASA\"));\r", + "} else {\r", + " pm.environment.set(\"communityIdMAAS\", pm.environment.get(\"communityIdMAASB\"));\r", + "}\r", + "\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"incentiveId\": \"{{incentiveIdMAAS}}\",\r\n \"consent\": true,\r\n \"communityId\": \"{{communityIdMAAS}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/maas/subscriptions", + "host": ["{{api_base_url}}"], + "path": ["v1", "maas", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "Upload justificatifs multiple subscriptions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Create subscription MAAS justificatif test: OK\", function () {\r", + " const idDemandeMAASListJustifs = pm.environment.get(\"idDemandeMAASListJustifs\");\r", + "\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "\r", + " if (idDemandeMAASListJustifs && idDemandeMAASListJustifs.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.unset(\"idDemandeMAAS\");\r", + " pm.environment.unset(\"idDemandeMAASListJustifs\");\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idDemandeMAASListJustifs = pm.environment.get(\"idDemandeMAASListJustifs\");\r", + "\r", + "if (idDemandeMAASListJustifs && idDemandeMAASListJustifs.length > 0) {\r", + " const idDemandeMAAS = idDemandeMAASListJustifs.shift();\r", + " pm.environment.set(\"idDemandeMAAS\", idDemandeMAAS);\r", + " pm.environment.set(\"idDemandeMAASListJustifs\", idDemandeMAASListJustifs);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "CI", + "type": "file", + "src": "api-tests/mob.PNG" + }, + { + "key": "Passport", + "type": "file", + "src": "api-tests/mob.PNG" + } + ] + }, + "url": { + "raw": "{{api_base_url}}/v1/maas/subscriptions/{{idDemandeMAAS}}/attachments", + "host": ["{{api_base_url}}"], + "path": [ + "v1", + "maas", + "subscriptions", + "{{idDemandeMAAS}}", + "attachments" + ] + } + }, + "response": [] + }, + { + "name": "Finalize multiple subscriptions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Finaliser une demande MAAS test: OK\", function () {\r", + " const idDemandeMAASListFinalize = pm.environment.get(\"idDemandeMAASListFinalize\");\r", + "\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + "\r", + " if (idDemandeMAASListFinalize && idDemandeMAASListFinalize.length > 0){\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.unset(\"idDemandeMAASListFinalize\");\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idDemandeMAASListFinalize = pm.environment.get(\"idDemandeMAASListFinalize\");\r", + "\r", + "if (idDemandeMAASListFinalize && idDemandeMAASListFinalize.length > 0) {\r", + " const idDemandeMAAS = idDemandeMAASListFinalize.shift();\r", + " pm.environment.set(\"idDemandeMAAS\", idDemandeMAAS);\r", + " pm.environment.set(\"idDemandeMAASListFinalize\", idDemandeMAASListFinalize);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/maas/subscriptions/{{idDemandeMAAS}}/verify", + "host": ["{{api_base_url}}"], + "path": [ + "v1", + "maas", + "subscriptions", + "{{idDemandeMAAS}}", + "verify" + ] + } + }, + "response": [] + }, + { + "name": "Logout Citizen Website", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Logout to KC test: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " pm.environment.unset(\"accessToken\");\r", + " pm.environment.unset(\"refreshToken\");\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const emailCitoyenList = pm.environment.get(\"emailCitoyenList\");\r", + "\r", + "if (emailCitoyenList && emailCitoyenList.length > 0){\r", + " postman.setNextRequest(\"Login Citizen MAAS\");\r", + "} else {\r", + " pm.environment.unset(\"emailCitoyenList\");\r", + " pm.environment.unset(\"emailCitoyen\");\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/logout", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "logout" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] + }, + { + "name": "Website - Validate/Reject", + "item": [ + { + "name": "Login Superviseur Gestionnaire simulation-maas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Login to KC test: OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " pm.environment.set(\"accessToken\", response.access_token);\r", + " pm.environment.set(\"refreshToken\", response.refresh_token);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "password", + "type": "text" + }, + { + "key": "username", + "value": "{{emailSuperviseurGestionnaireSM}}", + "type": "text" + }, + { + "key": "password", + "value": "{{utilisateurFinanceurPassword}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/token", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get subscriptions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Get subscriptions id : OK\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " const response = pm.response.json();\r", + " const idSubscriptionsList = [];\r", + " for (const subscription of response) {\r", + " idSubscriptionsList.push(subscription.id);\r", + " }\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api_base_url}}/v1/subscriptions", + "host": ["{{api_base_url}}"], + "path": ["v1", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "Validate subscription", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate subscription : OK\", function () {\r", + " const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + " const idSubscription = idSubscriptionsList.shift();\r", + " pm.environment.set(\"idSubscription\", idSubscription);\r", + " var counter = pm.environment.get(\"subscriptionCounter\");\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if ((idSubscriptionsList && idSubscriptionsList.length > 0) && counter < 2){\r", + " counter++;\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + " pm.environment.set(\"subscriptionCounter\", counter);\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + " pm.environment.set(\"subscriptionCounter\", 0);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + "if (idSubscriptionsList && idSubscriptionsList.length > 0){\r", + " pm.environment.set(\"idSubscription\", idSubscriptionsList[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"mode\": \"multiple\",\r\n \"frequency\": \"mensuelle\",\r\n \"amount\": 50,\r\n \"lastPayment\": \"2023-01-01\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/subscriptions/{{idSubscription}}/validate", + "host": ["{{api_base_url}}"], + "path": ["v1", "subscriptions", "{{idSubscription}}", "validate"] + } + }, + "response": [] + }, + { + "name": "Reject subscription", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Reject subscription : OK\", function () {\r", + " const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + " const idSubscription = idSubscriptionsList.shift();\r", + " pm.environment.set(\"idSubscription\", idSubscription);\r", + " var counter = pm.environment.get(\"subscriptionCounter\");\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " if ((idSubscriptionsList && idSubscriptionsList.length > 0) && counter < 2){\r", + " counter++;\r", + " pm.environment.set(\"idSubscriptionsList\", idSubscriptionsList);\r", + " pm.environment.set(\"subscriptionCounter\", counter);\r", + " postman.setNextRequest(pm.info.requestName);\r", + " } else {\r", + " pm.environment.set(\"idSubscriptionsList\", []);\r", + " pm.environment.set(\"subscriptionCounter\", 0);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const idSubscriptionsList = pm.environment.get(\"idSubscriptionsList\");\r", + "if (idSubscriptionsList && idSubscriptionsList.length > 0){\r", + " pm.environment.set(\"idSubscription\", idSubscriptionsList[0]);\r", + "} else {\r", + " postman.setNextRequest();\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"type\": \"Autre\",\r\n \"other\": \"Ne remplit pas les conditions d'éligibilité à cette aide\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api_base_url}}/v1/subscriptions/{{idSubscription}}/reject", + "host": ["{{api_base_url}}"], + "path": ["v1", "subscriptions", "{{idSubscription}}", "reject"] + } + }, + "response": [] + }, + { + "name": "Logout Superviseur Gestionnaire", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Logout to KC test: OK\", function () {\r", + " pm.response.to.have.status(204);\r", + " pm.response.to.not.have.jsonBody(\"error\");\r", + " pm.environment.unset(\"accessToken\");\r", + " pm.environment.unset(\"refreshToken\");\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [""], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "platform", + "type": "text" + } + ] + }, + "url": { + "raw": "{{idp_base_url}}/auth/realms/mcm/protocol/openid-connect/logout", + "host": ["{{idp_base_url}}"], + "path": [ + "auth", + "realms", + "mcm", + "protocol", + "openid-connect", + "logout" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ] +} diff --git a/test/api-tests/mcm-preview.postman_environment.json b/test/api-tests/mcm-preview.postman_environment.json new file mode 100644 index 0000000..5b5b31a --- /dev/null +++ b/test/api-tests/mcm-preview.postman_environment.json @@ -0,0 +1,96 @@ +{ + "id": "b672b73c-3eb6-4b7f-9615-566d4a2c95b9", + "name": "mcm", + "values": [ + { + "key": "env", + "value": "master", + "enabled": true + }, + { + "key": "api_base_url", + "value": "https://${API_FQDN}", + "enabled": true + }, + { + "key": "idp_base_url", + "value": "https://${IDP_FQDN}", + "enabled": true + }, + { + "key": "maasClientSecret", + "value": "${IDP_SIMULATION_MAAS_CLIENT_SECRET}", + "enabled": true + }, + { + "key": "apiClientId", + "value": "api", + "enabled": true + }, + { + "key": "apiClientSecret", + "value": "${IDP_API_CLIENT_SECRET}", + "enabled": true + }, + { + "key": "adminFonctionnelPassword", + "value": "${ADMIN_FONCTIONNEL_PASSWORD}", + "enabled": true + }, + { + "key": "utilisateurFinanceurPassword", + "value": "${DEFAULT_FUNDER_PASSWORD}", + "enabled": true + }, + { + "key": "citoyenPassword", + "value": "${CITOYEN_PASSWORD}", + "enabled": true + }, + { + "key": "api_key", + "value": "${API_KEY}", + "enabled": true + }, + { + "key": "emailEtudiantCitoyen", + "value": "etudiant.mcm@yopmail.com", + "enabled": true + }, + { + "key": "emailIndependantCitoyen", + "value": "independant.mcm@yopmail.com", + "enabled": true + }, + { + "key": "emailRetraiteCitoyen", + "value": "retraite.mcm@yopmail.com", + "enabled": true + }, + { + "key": "emailSansEmploiCitoyen", + "value": "sans-emploi.mcm@yopmail.com", + "enabled": true + }, + { + "key": "emailSalarieCitoyen", + "value": "salarie.mcm@yopmail.com", + "enabled": true + }, + { + "key": "emailSuperviseurGestionnaireSM", + "value": "superviseur-gestionnaire-ab.sm@yopmail.com", + "type": "default", + "enabled": true + }, + { + "key": "subscriptionCounter", + "value": "0", + "type": "any", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2021-12-13T15:09:38.013Z", + "_postman_exported_using": "Postman/9.2.0" +} diff --git a/test/api-tests/mcm-testing.postman_environment.json b/test/api-tests/mcm-testing.postman_environment.json new file mode 100644 index 0000000..66f795b --- /dev/null +++ b/test/api-tests/mcm-testing.postman_environment.json @@ -0,0 +1,97 @@ +{ + "id": "8b827dcc-2786-4fb5-8e68-2a4dc6d5c816", + "name": "mcm-testing", + "values": [ + { + "key": "rcVersionSlug", + "value": "${CI_COMMIT_BRANCH}", + "type": "default", + "enabled": true + }, + { + "key": "api_base_url", + "value": "https://${API_FQDN}", + "enabled": true + }, + { + "key": "idp_base_url", + "value": "https://${IDP_FQDN}", + "enabled": true + }, + { + "key": "maasClientSecret", + "value": "${TESTING_IDP_SIMULATION_MAAS_CLIENT_SECRET}", + "enabled": true + }, + { + "key": "apiClientId", + "value": "api", + "enabled": true + }, + { + "key": "apiClientSecret", + "value": "${TESTING_IDP_API_CLIENT_SECRET}", + "enabled": true + }, + { + "key": "adminFonctionnelPassword", + "value": "${ADMIN_FONCTIONNEL_PASSWORD}", + "enabled": true + }, + { + "key": "utilisateurFinanceurPassword", + "value": "${DEFAULT_FUNDER_PASSWORD}", + "enabled": true + }, + { + "key": "citoyenPassword", + "value": "${CITOYEN_PASSWORD}", + "enabled": true + }, + { + "key": "api_key", + "value": "${TESTING_API_KEY}", + "enabled": true + }, + { + "key": "emailEtudiantCitoyen", + "value": "etudiant.mcm-{{rcVersionSlug}}@yopmail.com", + "enabled": true + }, + { + "key": "emailIndependantCitoyen", + "value": "independant.mcm-{{rcVersionSlug}}@yopmail.com", + "enabled": true + }, + { + "key": "emailRetraiteCitoyen", + "value": "retraite.mcm-{{rcVersionSlug}}@yopmail.com", + "enabled": true + }, + { + "key": "emailSansEmploiCitoyen", + "value": "sans-emploi.mcm-{{rcVersionSlug}}@yopmail.com", + "enabled": true + }, + { + "key": "emailSalarieCitoyen", + "value": "salarie.mcm-{{rcVersionSlug}}@yopmail.com", + "enabled": true + }, + { + "key": "emailSuperviseurGestionnaireSM", + "value": "superviseur-gestionnaire-ab.sm-{{rcVersionSlug}}@yopmail.com", + "type": "default", + "enabled": true + }, + { + "key": "subscriptionCounter", + "value": "0", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2022-09-16T07:25:12.170Z", + "_postman_exported_using": "Postman/9.31.6" +} \ No newline at end of file diff --git a/test/api-tests/mob.PNG b/test/api-tests/mob.PNG new file mode 100644 index 0000000..5a89b7c Binary files /dev/null and b/test/api-tests/mob.PNG differ diff --git a/test/cypress-firefox.json b/test/cypress-firefox.json new file mode 100644 index 0000000..ab1f261 --- /dev/null +++ b/test/cypress-firefox.json @@ -0,0 +1,15 @@ +{ + "failOnStatusCode": false, + "redirectionLimit": 20, + "integrationFolder": "./cypress/integration/functional-tests/test-mcm/", + "screenshotsFolder": "./cypress/integration/functional-tests/screenshots/", + "videosFolder": "./cypress/integration/functional-tests/videos", + "pluginsFile": "./cypress/plugins/index.js", + "screenshotOnRunFailure": true, + "supportFile": "./cypress/support/index.js", + "retries": { + "runMode": 2, + "openMode": 1 + }, + "reporter": "mochawesome" + } \ No newline at end of file diff --git a/test/cypress-functional.json b/test/cypress-functional.json new file mode 100644 index 0000000..20a3d31 --- /dev/null +++ b/test/cypress-functional.json @@ -0,0 +1,16 @@ +{ + "failOnStatusCode": false, + "chromeWebSecurity": false, + "redirectionLimit": 20, + "integrationFolder": "./cypress/integration/functional-tests/test-mcm/", + "screenshotsFolder": "./cypress/integration/functional-tests/screenshots/", + "videosFolder": "./cypress/integration/functional-tests/videos", + "pluginsFile": "./cypress/plugins/index.js", + "screenshotOnRunFailure": true, + "supportFile": "./cypress/support/index.js", + "retries": { + "runMode": 2, + "openMode": 1 + }, + "reporter": "mochawesome" +} diff --git a/test/cypress-smoke.json b/test/cypress-smoke.json new file mode 100644 index 0000000..b43f4df --- /dev/null +++ b/test/cypress-smoke.json @@ -0,0 +1,16 @@ +{ + "integrationFolder": "./cypress/integration/smoke-tests/test-mcm/", + "screenshotsFolder": "./cypress/integration/smoke-tests/screenshots/", + "screenshotOnRunFailure": true, + "videosFolder": "./cypress/integration/smoke-tests/videos", + "supportFile": "./cypress/support/index.js", + "pluginsFile": "./cypress/plugins/index.js", + "failOnStatusCode": false, + "chromeWebSecurity": false, + "redirectionLimit": 20, + "retries": { + "runMode": 5, + "openMode": 2 + }, + "reporter": "mochawesome" +} \ No newline at end of file diff --git a/test/cypress.json b/test/cypress.json new file mode 100644 index 0000000..bc5b56d --- /dev/null +++ b/test/cypress.json @@ -0,0 +1,11 @@ +{ + "failOnStatusCode": false, + "chromeWebSecurity": false, + "redirectionLimit": 20, + "retries": { + "runMode": 1, + "openMode": 1 + }, + "experimentalStudio": true, + "reporter": "mochawesome" +} diff --git a/test/cypress/fixtures/example.json b/test/cypress/fixtures/example.json new file mode 100644 index 0000000..02e4254 --- /dev/null +++ b/test/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/test/cypress/fixtures/mock_user.json b/test/cypress/fixtures/mock_user.json new file mode 100644 index 0000000..91a2a78 --- /dev/null +++ b/test/cypress/fixtures/mock_user.json @@ -0,0 +1,11 @@ +{ + "lastName": "Lafripouille", + "firstName": "Jacqouille", + "birthdate": "05/08/1900", + "email": "", + "password": "Jacq00illes!", + "passwordConfirmation": "Jacq00illes!", + "city": "MontMirail", + "postcode": "31000", + "status": "Étudiant" +} \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/.gitlab-ci.yml b/test/cypress/integration/functional-tests/.gitlab-ci.yml new file mode 100644 index 0000000..918e68f --- /dev/null +++ b/test/cypress/integration/functional-tests/.gitlab-ci.yml @@ -0,0 +1,98 @@ +.declare-functional-tests-functions: &declare-functional-tests-functions | + function launch_functional_test_job { + cd ${MODULE_PATH} + npm i + npx cypress run \ + --spec "cypress/integration/functional-tests/test-mcm/$1" \ + --config-file $2.json \ + --browser $3 | tee cypress-functional-tests.log + cat cypress-functional-tests.log + } + +.cypress_env_setup: &cypress_env_setup | + export CYPRESS_API_FQDN=${API_FQDN} + export CYPRESS_IDP_FQDN=${IDP_FQDN} + export CYPRESS_WEBSITE_FQDN=${WEBSITE_FQDN} + export CYPRESS_ADMIN_FQDN=${ADMIN_FQDN} + export CYPRESS_MAILHOG_FQDN=${MAILHOG_FQDN} + export CYPRESS_STUDENT_PASSWORD=${CITOYEN_PASSWORD} + +chrome_homepage_tests: + image: ${CYPRESS_IMAGE_NAME} + before_script: + - *declare-functional-tests-functions + script: + - *cypress_env_setup + - | + launch_functional_test_job homepage-tests/* cypress-functional chrome + artifacts: + expire_in: 5 days + when: always + paths: + - ${MODULE_PATH}/cypress-functional-tests.log + - ${MODULE_PATH}/cypress/integration/functional-tests/screenshots/**/*.png + - ${MODULE_PATH}/mochawesome-report + +chrome_incentive_tests: + image: ${CYPRESS_IMAGE_NAME} + before_script: + - *declare-functional-tests-functions + script: + - *cypress_env_setup + - | + launch_functional_test_job incentive-tests/* cypress-functional chrome + artifacts: + expire_in: 5 days + when: always + paths: + - ${MODULE_PATH}/cypress-functional-tests.log + - ${MODULE_PATH}/cypress/integration/functional-tests/screenshots/**/*.png + - ${MODULE_PATH}/mochawesome-report + +email_reception_tests: + image: ${CYPRESS_IMAGE_NAME} + before_script: + - *declare-functional-tests-functions + script: + - *cypress_env_setup + - | + launch_functional_test_job email-reception-tests/* cypress-functional chrome + artifacts: + expire_in: 5 days + when: always + paths: + - ${MODULE_PATH}/cypress-functional-tests.log + - ${MODULE_PATH}/cypress/integration/functional-tests/screenshots/**/*.png + - ${MODULE_PATH}/mochawesome-report + +firefox_homepage_tests: + image: ${CYPRESS_IMAGE_NAME} + before_script: + - *declare-functional-tests-functions + script: + - *cypress_env_setup + - | + launch_functional_test_job homepage-tests/* cypress-firefox firefox + artifacts: + expire_in: 5 days + when: always + paths: + - ${MODULE_PATH}/cypress-functional-tests.log + - ${MODULE_PATH}/cypress/integration/functional-tests/screenshots/**/*.png + - ${MODULE_PATH}/mochawesome-report + +firefox_incentive_tests: + image: ${CYPRESS_IMAGE_NAME} + before_script: + - *declare-functional-tests-functions + script: + - *cypress_env_setup + - | + launch_functional_test_job incentive-tests/* cypress-firefox firefox + artifacts: + expire_in: 5 days + when: always + paths: + - ${MODULE_PATH}/cypress-functional-tests.log + - ${MODULE_PATH}/cypress/integration/functional-tests/screenshots/**/*.png + - ${MODULE_PATH}/mochawesome-report \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/email-reception-tests/mass_email_reception.spec.js b/test/cypress/integration/functional-tests/test-mcm/email-reception-tests/mass_email_reception.spec.js new file mode 100644 index 0000000..abca52c --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/email-reception-tests/mass_email_reception.spec.js @@ -0,0 +1,29 @@ +import faker from '@faker-js/faker'; + +describe("Mass User Creation Test", () => { + context("On souhaite repérer l'anomalie de non réception d'e-mail de bienvenue en effectuant une injection de masse d'utilisateurs", () => { + it("Création d'une centaine d'utilisateurs à la chaîne", () => { + const mockUserList = []; + const i = 20; + Cypress._.times(i, () => { + const randomUserEmail = faker.internet.email('', '', 'capgemini.com'); + const user = { + email: randomUserEmail, + affiliation: { + enterpriseEmail: randomUserEmail + } + } + cy.injectUser(user); + mockUserList.push(user); + }); + cy.visit(`https://${Cypress.env("MAILHOG_FQDN")}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }).then(() => { + cy.get(mockUserList).each((user) => { + cy.assertEmailReceptionMailHog(user.email.toLowerCase()); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/email-reception-tests/user_creation_test.spec.js b/test/cypress/integration/functional-tests/test-mcm/email-reception-tests/user_creation_test.spec.js new file mode 100644 index 0000000..fac78f4 --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/email-reception-tests/user_creation_test.spec.js @@ -0,0 +1,26 @@ +import mockUser from '../../../../fixtures/mock_user.json' +import faker from '@faker-js/faker'; +const randomUserEmail = faker.internet.email('','', 'capgemini.com'); + + +describe("Nominal User Creation Test", () => { + context("Test de création d'un utilisateur via website", () => { + it("Remplissage du formulaire pour la création d'un utilisateur", () => { + cy.visit(`https://${Cypress.env("WEBSITE_FQDN")}/inscription/formulaire`, { + failOnStatusCode: false, + redirectionLimit: 20, + }).then(() => { + cy.createUser(mockUser, randomUserEmail); + }); + }); + + it("Test de la réception de l'email de bienvenue", () => { + cy.visit(`https://${Cypress.env("MAILHOG_FQDN")}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }).then(() => { + cy.assertEmailReceptionMailHog(randomUserEmail.toLowerCase()); + }); + }); + }); +}) \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/homepage-tests/citizen_homepage_test.spec.js b/test/cypress/integration/functional-tests/test-mcm/homepage-tests/citizen_homepage_test.spec.js new file mode 100644 index 0000000..d60a807 --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/homepage-tests/citizen_homepage_test.spec.js @@ -0,0 +1,143 @@ +// https://gitlab-dev.cicd.moncomptemobilite.fr/mcm/platform/-/issues/287 +describe("Citizen homepage", function () { + it("Test of the citizen homepage", function () { + cy.viewport(1440, 900); // Desktop mode + cy.justVisit("WEBSITE_FQDN"); // Open website homepage + + /* ==== Tabs Banner */ + cy.get('.nav-links__item--active > a').should('have.text', 'Citoyen.ne'); // Citizen tab + cy.get('.nav-links > :nth-child(2) > a').should('have.text', 'Employeur'); // Enterprise tab + cy.get('.nav-links > :nth-child(3) > a').should('have.text', 'Collectivité'); // Collectivity tab + cy.get('.nav-links > :nth-child(4) > a').should('have.text', 'Opérateur de mobilité'); // MSP tab + // TODO default citizen tab & mobile : tabs slide + + /* ==== Video Section ==== */ + cy.get('.display__item').should('have.text', 'Découvrir moB'); // Button text TODO video plays onClick + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('.display__item').should('have.text', 'Découvrir moB'); // Button text TODO mobile : video plays onClick & adapts to screen width + cy.viewport(1440, 900); // Switch back to desktop mode for next step + + /* ==== Steps Section ==== */ + cy.get('.mcm-steps') + .should('have.class', 'mcm-steps') // Gray card + .should('have.text', 'Mon Compte Mobilité, comment ça marche ?Je crée Mon Compte Mobilité en quelques clicsJe souscris aux aides qui me correspondentJe finance tous mes trajets grâce à mes aides'); // Title & subs + cy.get(':nth-child(2) > .step-img > img').should( // Image 1/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODUiIGhlaWdodD0iODUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNNDIuODczIDYwLjkxMWMtNC43OTcgMC04Ljc1Ny0yLjYzNi05LjQ1LTYuMDdDMTguOTI3IDU3Ljk3MiA4LjIyNCA2OC40MTYgOCA4MC44OGg2OC45NzVjLS4yMi0xMi4yNTMtMTAuNTY1LTIyLjU1Ni0yNC42OS0yNS44NzQtLjc4MyAzLjM1NC00LjY5MiA1LjkwNi05LjQxMiA1LjkwNnoiIGZpbGw9IiNGRkQzMTQiLz48cGF0aCBkPSJNMjguNDg3IDE4LjM3OXMyLjkxNS04LjI5NSA1LjI4OC0xMC44NTFjMS44OTItMi4wNCA1LjA1MS0yLjM3NSA3Ljc2OC0xLjc4IDIuNzE3LjU5NiA1LjE5OCAxLjk1OSA3LjgyNyAyLjg2NyA0Ljc2MyAxLjY0NiA3LjYxNyAzLjI2NCAxMS42MTcgNS4yNjQtMiA2LTIuMzE1IDUuMTktNy41MzUgNi45NjgtMy41MDMgMS4xOTQtNy4zMTkuNjctMTEuMDIuNzM1LS44NDkuMDE0LTEuNzkzLjEwMy0yLjM2NS43My0uODUyLjkzNC0uNzMgMi4xMy0xLjU4IDMuMDY3LS41NTQuNjEtMS4yMDUuNzc5LTIgMS0xMy45OSAzLjg5OS04LTgtOC04IiBmaWxsPSIjMzYzNzU3Ii8+PHBhdGggZD0iTTYxLjc0NCAxMi41ODRjLTMuMTA0LTEuNTU3LTYuMTcyLTMuMTcyLTkuNDAyLTQuNDYtMS44MDUtLjcyLTMuNjQzLTEuMzM0LTUuNDM4LTIuMDgzLTEuNzI1LS43Mi0zLjQ3LTEuNDY3LTUuMzE4LTEuODExLTMuMDI3LS41NjMtNi40ODItLjE3NC04Ljc0NSAyLjEwOS0xLjY4NSAxLjctMi43MSA0LjExMi0zLjY1OCA2LjI2OWE3OC4xNzMgNzguMTczIDAgMDAtMi4wNyA1LjE4Yy0xLjQ4MyAzLjA1Ny0yLjU2OCA3Ljk1Ljk0MSAxMC4wMjIgMS4zNTUuNzk5IDMuMDI2LjkzNyA0LjU2My44MTUgMi4wOTEtLjE2NyA1LjA3MS0uNDkyIDYuNjg3LTEuOTUuNDc2LS40MjguODE1LS45NjQgMS4wNzYtMS41NDMuMjI1LS40OTguMzI1LTEuMzgxLjc0Ny0xLjc1OC40ODQtLjQzMyAxLjQwOC0uMjk3IDIuMDAxLS4yOTguOTMxLS4wMDEgMS44NjMuMDI4IDIuNzk0LjA0NyAzLjIwMy4wNjQgNi4wODMtLjI1NCA5LjEzNS0xLjIyNiAxLjYtLjUwOCAzLjQxOC0uODk0IDQuNjY4LTIuMDk0IDEuNDc4LTEuNDIgMi4wOC0zLjY1IDIuNzA4LTUuNTI1LjItLjU5MS0uMTI0LTEuNDExLS42ODktMS42OTRNNDEuOTM4IDcwLjQyMUw0MS45NiA3N2MuMDAyLjc4NC42ODggMS41MzYgMS41IDEuNS44MS0uMDM3IDEuNTAzLS42NiAxLjUtMS41LS4wMDctMi4xOTMtLjAxNS05LjM4Ni0uMDIyLTExLjU3OC0uMDAyLS43ODUtLjY4OC0xLjUzNy0xLjUtMS41LS44MS4wMzYtMS41MDIuNjYtMS41IDEuNSIgZmlsbD0iIzM2Mzc1NyIvPjxwYXRoIGQ9Ik00Ny44NzggMjcuNTcyYzEuOTMgMCAxLjkzNC0zIDAtMy0xLjkzMSAwLTEuOTM0IDMgMCAzIiBmaWxsPSIjMzYzNzU3Ii8+PHBhdGggZD0iTTU3Ljg0NSAxOS45MjVjLTMuNTQtMS41ODEtNi44OTctMy4wMy05LjkzMy01LjQ5NC0zLjE2LTIuNTY2LTYuNzQ2LTQuMzI4LTEwLjkzMi0zLjg0LTEuNzc1LjIwNy0zLjUzMS44MzctNC43NzIgMi4xNzgtMS4yMTEgMS4zMDktMS44MzUgMy4wMTgtMi4yODUgNC43MTMtLjQwMSAxLjUwNi0uNTk3IDMuMTMtMS4xOTYgNC41NzUtLjYzMiAxLjUyNC0yLjAwNyAxLjcwOS0zLjE3OCAyLjcwOS0yLjMxNiAxLjk3Ni0yLjAxNSA1LjA4NC0uMzkgNy4zOTUgMS4zNzUgMS45NTcgMy4zNzkgMy40MjggNS43NSAzLjY2OC4wMTkgNS42MjQuMDM4IDEzLjI0OC4wNTYgMTguODcuMDA1IDEuNDQ5IDIuMjU1IDEuNDUgMi4yNSAwLS4wMi02LjA2Mi0uMDQtMTQuMTI2LS4wNi0yMC4xOWExLjAyNyAxLjAyNyAwIDAwLS4zODQtLjgzMmMtLjIxNS0uNDQ3LS42NC0uNzktMS4yODMtLjc5OC0xLjA3MS0uMDE0LTEuMzQ1LS4yMi0yLjM1NC0uODU4LS41MDEtLjMxNy0xLjEwMy0uOTU0LTEuNTE3LTEuNTY2LS40MzItLjY0LS45LTEuNDgxLS43ODgtMi4yODcuMTQ2LTEuMDU3IDEuMTk3LTEuNDg3IDIuMDMyLTEuOTI2IDIuNTc4LTEuMzU0IDMuMDU1LTQuMjcgMy42NzYtNi44NTMuMzItMS4zMzEuNjItMi43NDggMS4zNjMtMy45Mi45NDUtMS40OTMgMi42MDMtMS45MzggNC4yODMtMS45NThhMTAuMjkgMTAuMjkgMCAwMTUuMTEyIDEuMjc3YzEuNjM4LjkwOCAyLjk4NiAyLjIzMiA0LjQ4MiAzLjM0MiAyLjU5NyAxLjkyOSA1LjYyNCAzLjA3NiA4LjU1NCA0LjM4NSAyLjU1IDEuMTQgNi40MyAyLjcxNyA3LjU2OSA1LjUyNC4yMjMuNTUuMzAzIDEuMjM1LjAzIDEuNzg3LS4zNS43MDctMS4xMS43ODMtMS44MTUuOS0xLjg1NC4zMS0zLjgyNS4zMTUtNS42NDUuNzgyLTEuNjQzLjQyMy0yLjQ5NyAxLjY2NS0yLjc0MyAzLjI4NC0uMzI4IDIuMTYyLS40MiA0LjM4Ni0uNTU2IDYuNTY3YTE3My4yMyAxNzMuMjMgMCAwMC0uMzA3IDEzLjYzNmMuMDMgMS45MjggMy4wMyAxLjkzNCAzIDBhMTczLjIzIDE3My4yMyAwIDAxLjMwNy0xMy42MzZjLjA3LTEuMTM1LjE1My0yLjI3LjI0Ny0zLjQwMi4wNzktLjk0Ny0uMDA4LTIuMTUyLjMyMy0zLjA0Ni4yOC0uNzU4IDEuNDc3LS42NiAyLjE4NC0uNzU1IDEuMDYzLS4xNDIgMi4xMjctLjI4MyAzLjE5LS40MjcgMS43NzItLjI0IDMuNDk0LS42OTMgNC40MDYtMi4zOS44MDktMS41MDIuNjk0LTMuMzExLS4wMy00LjgxNy0xLjU4LTMuMjktNS40ODQtNS4xODUtOC42NDYtNi41OTd6IiBmaWxsPSIjMzYzNzU3Ii8+PC9nPjwvc3ZnPgo=' + ); + cy.get(':nth-child(3) > .step-img > img').should( // Image 2/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODUiIGhlaWdodD0iODUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNNTIuMDE1IDkuMzc3YzcuNDQ1LTUuMTcyIDEzLjkxIDMuMDQ3IDguNTg2IDEyLjQzOCA5LjE2LTEuMDM1IDE0LjY5NSAxMC45NTMgNS4wNCAxOC4wNDcgOS4xNjcgMi41NjIgOC4zMDggMTAuNjQxLjI1NyAxNS4zMjQgMS45NyAxMC43ODUtMTUuMDEyIDEwLjM2Ny0yMS41NyAzLjg1Mi0uODQ0LS44MzYtMy4zMjgtMS4xODgtNC40MjItLjU5LTQuOTMgMi42OTUtMTAuNTY2IDEuNDI2LTEzLjgwNS0zLjAyLTE0LjAwOC0xLjI0Ni04LjU5LTEwLjI3Ny00LjEwNi0xMi4yOTdDOS45MSA0MS42NzggOC4zMiAzMC40OTQgMTkuNzc3IDI3LjEwMWMtNC44MTctNS40ODUuNTQzLTExLjY2NSAxMy40OTEtMTEuNDMtMS4yODUtNC45NDIgOC43NjItMTcuMTc3IDE4Ljc0Ny02LjI5NHoiIGZpbGw9IiM0NjRDRDAiLz48cGF0aCBkPSJNNDcuMTA3IDQ5LjkzN2MzLjEzNi0zLjQxNyA3LjgyOC00Ljc3IDExLjI0OC03Ljg1NyAzLjQ4OC0zLjE1IDUuNTI2LTcuNjY2IDUuODA0LTEyLjMzMS4xMTUtMS45MjgtMi44ODYtMS45MjEtMyAwLS4yNSA0LjE5LTIuMzIzIDguMTg0LTUuNjE0IDEwLjc5Ni0yLjQ4NSAxLjk3Mi01LjQzMiAzLjIzNy03Ljk5NyA1LjA1OC4wNTgtLjU3OC4xMjgtMS4xNTMuMTY1LTEuNzM2LjIyNi0zLjYwNy4yNDYtNy4yNDMuMDczLTEwLjg1NiAzLjEzMi0yLjI1MSA1LjY2OC01LjI4NyA3LjE3My04Ljg1Mi4zMDQtLjcyMS4yNDEtMS42NDEtLjUzOS0yLjA1Mi0uNjQtLjM0LTEuNzI2LS4yMzQtMi4wNTIuNTM4YTE5LjcyNiAxOS43MjYgMCAwMS0xLjE4MiAyLjM1NyAxOS4xMTYgMTkuMTE2IDAgMDEtMS4wOCAxLjYzOGwtLjIwNC4yNy0uMDAzLjAwNWEyMC42MiAyMC42MiAwIDAxLTEuNzg4IDEuOTYzYy0uMTg2LjE4LS4zODQuMzQ3LS41NzguNTE4LS4zMy0zLjUzNC0uOTEzLTcuMDQzLTEuODgtMTAuNDYxLS41MjQtMS44NTUtMy40MTktMS4wNjYtMi44OTIuNzk3IDEuMzcyIDQuODU2IDEuOTUzIDkuODUzIDIuMDk0IDE0Ljg4OC4xMzggNC45ODguMDg2IDEwLjA5OS0uODE2IDE1LjAxOS0uNDAyIDIuMTk1LS44NjYgNC4zNS0xLjI3IDYuNTEzLTEuODk2LTIuNDU0LTQuNS00LjMyNi03LjEzLTUuOTUyLTMuMzYtMi4wNzctNy4xMi0zLjk1OC05LjU0My03LjE5Mi0uNDg1LS42NDctMS4yOTYtLjk4LTIuMDUyLS41MzgtLjYzOC4zNzMtMS4wMjcgMS40LS41MzggMi4wNTIgMi41MjcgMy4zNzQgNi4wNyA1LjUxOSA5LjYyNSA3LjY2MyAzLjMzMyAyLjAxIDYuNzQ1IDQuMjI1IDguNTMgNy44MTYuMTEyLjIyNi4yNTQuMzkzLjQxMy41MTVhMzkuOTQ3IDM5Ljk0NyAwIDAwLS4yODMgMy42NjljLS4xNDQgNS4wNjkuMTkxIDEwLjE2LjM3NCAxNS4yMjYuMDcgMS45MjUgMy4wNyAxLjkzNCAzIDAtLjI3NS03LjYzNC0uOTM1LTE1LjM2LjUxLTIyLjkwNS40NDEuMjQzLjk3MS4yNjcgMS40NzQuMDE4YTMyLjIgMzIuMiAwIDAxMS41MzYtLjcwMyAzMS44NSAzMS44NSAwIDAxMi44MjgtMS4wMTdjLjc1LS4yMjggMS4zMDItMS4wNiAxLjA0OC0xLjg0NS0uMjQ1LS43NTYtMS4wNC0xLjI5My0xLjg0NS0xLjA0N2EzMy4zOCAzMy4zOCAwIDAwLTQuNDcyIDEuNzM2Yy4yNDUtMS4xODUuNDgtMi4zNy42ODgtMy41NTUuMDYtLjA0OC4xMTgtLjA5NC4xNzUtLjE1NiIgZmlsbD0iIzM2Mzc1NyIvPjxwYXRoIGQ9Ik00Mi4xMzYgNDMuMzkyYy43NDMuMzIxIDEuNjEuMjIgMi4wNTItLjUzOC4zNzEtLjYzNC4yMS0xLjcyOS0uNTM4LTIuMDUyLTUuNjY0LTIuNDUyLTEwLjQ2OC02LjU3Ny0xMy42Ni0xMS44NjgtLjk5Ni0xLjY1MS0zLjU5MS0uMTQ1LTIuNTkgMS41MTQgMy40MzYgNS42OTYgOC42MjUgMTAuMyAxNC43MzYgMTIuOTQ0IiBmaWxsPSIjMzYzNzU3Ii8+PC9nPjwvc3ZnPg==' + ); + cy.get(':nth-child(4) > .step-img > img').should( // Image 3/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODUiIGhlaWdodD0iODUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGlkPSJhIiBkPSJNMCAuMjM4aDY4LjJWNjVIMHoiLz48cGF0aCBpZD0iYyIgZD0iTS4xNDIuMjY3aDguMDcydjUuMDcySC4xNDJ6Ii8+PC9kZWZzPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNyAxMS4xMzQpIj48bWFzayBpZD0iYiIgZmlsbD0iI2ZmZiI+PHVzZSB4bGluazpocmVmPSIjYSIvPjwvbWFzaz48cGF0aCBkPSJNNjcuMTE3IDM5LjA5NUM2Mi4xNTcgNTYuOTUyIDQ4LjI3NyA2NSAyOS40MzcgNjUgOC4yNDMgNjUgMCA1My41MDggMCAzNC45NzRTMTMuNTQuMjM4IDM1LjMyNS4yMzhjMTguNTMzIDAgMzcuNjc5IDE3LjY2MiAzMS43OTIgMzguODU3IiBmaWxsPSIjMDFCRjdEIiBtYXNrPSJ1cmwoI2IpIi8+PC9nPjxwYXRoIGQ9Ik02MC4zMTggNDkuNjkxYy0uMDg2IDEuODk5LS40ODIgMy40OS0yLjI2NSA0LjQyNC0xLjY5NC44ODgtMy43ODcgMS4xMDEtNS42NjIgMS4yNjYtNy42ODcuNjc3LTE1LjUzNy41NDEtMjMuMjQyLjMxNi0yLjk5Mi0uMDg3LTguMDQuNTA2LTguMzc4LTMuNjA4LS4yMjQtMi43MTkuNDItNS4xNjkuNzE2LTcuOTM4LjItMi4xNDcuODMtNC4yMyAxLjYzMy02LjIyMy43MjUtMS44IDEuNTYzLTMuNjUyIDIuODM2LTUuMTM2IDEuMS0xLjI4MiAyLjQ3Mi0yLjM0IDQuMTI0LTIuNzgxIDIuMDg2LS41NTcgNC40MTktLjM0OSA2LjU1OC0uMzQ3IDQuNTg2LjAwNCA5LjE3Ni4xMjEgMTMuNzU0LjM4NyAxLjkwMy4xMTEgMy44NjUuMjkgNS4yMzQgMS43NzMgMS4yODUgMS4zOTIgMS44NCAzLjMwMyAyLjQyNSA1LjA1OSAxLjM3NyA0LjEzOSAyLjQ2NyA4LjQxMyAyLjI2NyAxMi44MDhtMi43NC0zLjg5OGMtLjI2Mi0yLjQ5NS0uODA4LTQuOTQ1LTEuNTE4LTcuMzQ5LTEuMjAyLTQuMDY5LTIuNTA3LTkuMDYxLTYuOTcyLTEwLjYxMy0yLjI1Ni0uNzg1LTQuODUxLS42ODYtNy4yMDUtLjc4OGEyNTIuNDMzIDI1Mi40MzMgMCAwMC03LjkyNi0uMjJjLTIuODc4LS4wMzQtNS44NjUtLjIxNS04LjcyMy4xNjktMi4zMTguMzEyLTQuMzY4IDEuMzY3LTYuMDQyIDIuOTk1LTEuNzUyIDEuNzA0LTIuODc4IDMuODMzLTMuODM3IDYuMDUyLTEuMTI4IDIuNjA5LTEuOTQyIDUuMjc0LTIuMjA2IDguMTEyLS4yOSAyLjY5Ni0uOTY1IDUuNTM1LS42OSA4LjI1OS4xNjcgMS42NTkuODU1IDMuMjQyIDIuMTQyIDQuMzQ1IDEuNTMgMS4zMTIgMy41NDIgMS41NjQgNS40ODQgMS42NjUgNC4zNTUuMjI4IDguNzI0LjI2NSAxMy4wODMuMjQ4IDQuNS0uMDE4IDkuMDM2LS4wMzggMTMuNTIzLS40MSA0LS4zMzEgOS4xNTYtMS4wNTggMTAuNTc2LTUuNDk2LjY5LTIuMTYyLjU0OC00Ljc0Ni4zMTItNi45NjkiIGZpbGw9IiMzNjM3NTciLz48cGF0aCBkPSJNNDIuNzgxIDI2Ljc3NGEzLjE1OSAzLjE1OSAwIDAxLS4zOTctLjFjLS4wMTItLjAwNS0uMDI0LS4wMTQtLjAzNy0uMDJ2LS4wMDJhMS45NDMgMS45NDMgMCAwMS0uMDUzLS4xOTkgNC41NCA0LjU0IDAgMDEuMDA1LS42OGwuMDE2LS4wODRjLjAxNi0uMDY2LjA0LS4xMjguMDYtLjE5Mi4wMi0uMDM1LjA0LS4wNzIuMDYyLS4xMDUuMDAyLS4wMDIuMDAzLS4wMDIuMDA1LS4wMDIuMDUyLS4wMTcuMTA0LS4wMzIuMTU3LS4wNDRsLjA2Ni4wMDJjLjEyLjAzMy4yMzguMDcuMzU0LjExNi4xNzEuMDc3LjMzNC4xNjYuNDkxLjI2Ny4wNDguMDQzLjA5NS4wOS4xNDEuMTM3bC4wMzcuMDZjMCAuMDMxIDAgLjA2My0uMDAyLjA5My0uMDM1LjE1LS4wOC4yOTYtLjEzNC40NGEzLjM4NyAzLjM4NyAwIDAxLS4xOC4zMyA0LjE1OCA0LjE1OCAwIDAxLS41OS0uMDE3bTMuNjMzLTEuNzRjLS4yNzUtLjc1OC0uNzgyLTEuMzM4LTEuNDUyLTEuNzc3LS4zMS0uMjA0LS42NjUtLjM0NS0xLjAwNy0uNDgtLjM3Mi0uMTQ4LS43NDUtLjI4NC0xLjE1My0uMjk0LS42NDMtLjAxNS0xLjM2Ni4xMjItMS45MDcuNDg2LS42ODcuNDYtMS4xNDUgMS4xNi0xLjMzOSAxLjk2LS4zMzcgMS41MTctLjEyIDMuMzI4IDEuMzI1IDQuMi42MS4zNjcgMS4zMjEuNDY2IDIuMDE2LjUxOC43OC4wNTkgMS41NTctLjA2OSAyLjE4OC0uNTU2LjYxNS0uNDc2LjkzNi0xLjEyNSAxLjIxNC0xLjgzMi4yNzEtLjY5LjM3Mi0xLjUxNS4xMTUtMi4yMjYiIGZpbGw9IiMzNjM3NTciLz48cGF0aCBkPSJNMzcuODM0IDI1LjQxM2wuMDYtLjAzN2guMDkzYy4xNS4wMzcuMjk2LjA4Mi40NC4xMzYuMTEzLjA1NC4yMjMuMTE0LjMyOS4xOC4wMDguMTk4LjAwNC4zOTQtLjAxNi41OWEzLjAzNSAzLjAzNSAwIDAxLS4xLjM5OGwtLjAyMS4wMzZoLS4wMDFhMi4yNCAyLjI0IDAgMDEtLjIuMDU0IDQuNTQgNC41NCAwIDAxLS42OC0uMDA1bC0uMDg0LS4wMTZjLS4wNjUtLjAxNi0uMTI4LS4wNC0uMTkyLS4wNmExLjk2OCAxLjk2OCAwIDAxLS4xMDUtLjA2M2wtLjAwMi0uMDA1YTEuMzggMS4zOCAwIDAxLS4wNDItLjE1NmMtLjAwMi0uMDIyIDAtLjA0NSAwLS4wNjdhMy44MyAzLjgzIDAgMDEuMTE2LS4zNTNjLjA3Ny0uMTcxLjE2Ni0uMzM0LjI2Ny0uNDkxLjA0NC0uMDUuMDktLjA5Ni4xMzgtLjE0MW0zLjIyMi0xLjQzNWMtLjQ3NS0uNjE1LTEuMTIzLS45MzYtMS44MzEtMS4yMTQtLjY5LS4yNzEtMS41MTYtLjM3Mi0yLjIyNi0uMTE1LS43NTcuMjc0LTEuMzM4Ljc4Mi0xLjc3NyAxLjQ1Mi0uMjAyLjMxLS4zNDQuNjY1LS40OCAxLjAwNi0uMTQ4LjM3My0uMjg0Ljc0Ni0uMjkzIDEuMTUzLS4wMTUuNjQ0LjEyIDEuMzY2LjQ4NSAxLjkwOC40Ni42ODcgMS4xNiAxLjE0NCAxLjk2IDEuMzM4IDEuNTE3LjMzNyAzLjMyOC4xMiA0LjItMS4zMjQuMzY4LS42MS40NjgtMS4zMjEuNTE5LTIuMDE3LjA1OC0uNzgtLjA3LTEuNTU2LS41NTctMi4xODdNNjAuMjg2IDUxLjUyOGMtLjM5NSAzLjg2NC01LjgzOSAzLjgxOC04Ljc3NiAzLjk4Ni03LjExOC40MS0xNC4yOS4zNzEtMjEuNDE2LjIyNC0xLjg1Mi0uMDM4LTMuNzIzLS4wMzgtNS41Ny0uMTktMS4zNTMtLjExMS0zLjAxNC0uNDQ3LTMuNTk4LTEuODU1LS4yNTYtLjYxNy0uMTk3LTEuMzczLS4xNjItMi4wMjQuMDIyLS40Mi4wNjUtLjc1Mi4wOS0uOTA3LjIyMy0xLjQyMi40NDctMi44MzkuNjMzLTQuMjY2LjM4Mi0zLjI4OCAyLjItNy4wODkgNC42NjgtOS4zMjYgMi45MjMtMi42NSA2LjgwNC0yLjMxIDEwLjQ4My0yLjMwNyA0LjE5Ny4wMDMgOC4zOTUuMDkyIDEyLjU4OC4yNjggMS44NDUuMDc2IDMuOTU0LS4wMSA1LjYyNC44OTkgMS42MTUuODggMi4zOTUgMi42MTEgMy4wNjMgNC4yMzMgMS40NTYgMy41MzggMi43NyA3LjM4MyAyLjM3MyAxMS4yNjVtMi43ODktMy41NDdjLS4yNzItMi4yODgtLjg5LTQuNS0xLjY5LTYuNjU3LTEuNDU5LTMuOTMzLTMuMTY0LTcuODMtNy43MjUtOC42OC0yLjI4OC0uNDI2LTQuNjc4LS4zOTItNi45OTctLjQ3YTMwNC4xMTIgMzA0LjExMiAwIDAwLTcuNDYtLjE1N2MtNC44MjItLjA0NC0xMC4xODctLjY3NS0xNC4yNDIgMi40NS0zLjU2MyAyLjc0Ni01LjgyMyA3LjYzNi02LjMzMyAxMi4wMy0uMzE4IDIuNDM4LTEuMjAzIDUuMjY5LS41MzMgNy43MTYgMS4wNDIgMy44MDggNS4yMTYgNC4xNzggOC41MDcgNC4yOSA4LjIwOS4yODMgMTYuNDg0LjMyNyAyNC42ODgtLjExNyAzLjU5Ny0uMTk1IDkuMTYzLS4zMzQgMTEuMTI1LTQuMDEgMS0xLjg3Mi45MDQtNC4zNDguNjYtNi4zOTRNNjUuNDY2IDE0LjE5OGMuNTEzLTEuOTggMS4wMjUtMi45NiAxLjUzOC00Ljk0LjEzLS41MDYtLjE2Ni0xLjExLS43LTEuMjMtLjUyLS4xMi0xLjA4OC4xNTYtMS4yMy42OTgtLjUxMSAxLjk4LTEuMDI0IDIuOTYtMS41MzcgNC45NC0uMTMxLjUwNi4xNjcgMS4xMDkuNjk5IDEuMjMuNTIyLjExOSAxLjA5LS4xNTYgMS4yMy0uNjk4TTY5Ljg4NCAxNi42MDdhMTMuMzkgMTMuMzkgMCAwMTEuNDY2LTEuNDhjLjEzNy0uMTIxLjI3Ni0uMjQuNDE3LS4zNTZsLjIyNS0uMTgzLjExMy0uMDlhMTIuNjYgMTIuNjYgMCAwMS44NDQtLjYwMmMuNDM2LS4yODguNjY0LS44OTUuMzU5LTEuMzY3LS4yOC0uNDM0LS45MDItLjY2OC0xLjM2OC0uMzYtMS4yNy44NDMtMi40OTMgMS44NS0zLjQ3IDMuMDI0LS4xNzQuMjA5LS4yOTMuNDI2LS4yOTMuNzA3IDAgLjI0NS4xMDkuNTM4LjI5My43MDcuMzY5LjMzOSAxLjA1Ny40MjkgMS40MTQgMCIgZmlsbD0iIzM2Mzc1NyIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDcwIDE2LjEzNCkiPjxtYXNrIGlkPSJkIiBmaWxsPSIjZmZmIj48dXNlIHhsaW5rOmhyZWY9IiNjIi8+PC9tYXNrPjxwYXRoIGQ9Ik0xLjY0IDUuMjE3YzIuOS0xLjQwMiAyLjgwNi0xLjkyNyA1Ljg4NC0yLjk2NCAxLjIxOC0uNDEuNjg5LTIuMzQ0LS41MzItMS45MjhDMy43MTIgMS40NCAzLjY4NiAyLjAxNC42MyAzLjQ5Yy0uNDg3LjIzNS0uNjE2LjkyOC0uMzU4IDEuMzY4LjI5LjQ5Ny44OC41OTUgMS4zNjcuMzU5IiBmaWxsPSIjMzYzNzU3IiBtYXNrPSJ1cmwoI2QpIi8+PC9nPjwvZz48L3N2Zz4=' + ); + + /* ==== Search for an incentive Section ==== */ + cy.get('.mcm-section-with-image--image-left > .mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should( // Image + 'have.attr', + 'srcset', + '/static/a121f1570b7b10709bf14f6a60b3b971/fd013/woman-yellow-coat.jpg 200w,\n/static/a121f1570b7b10709bf14f6a60b3b971/25252/woman-yellow-coat.jpg 400w,\n/static/a121f1570b7b10709bf14f6a60b3b971/2f1b1/woman-yellow-coat.jpg 800w,\n/static/a121f1570b7b10709bf14f6a60b3b971/0ff54/woman-yellow-coat.jpg 1200w,\n/static/a121f1570b7b10709bf14f6a60b3b971/06655/woman-yellow-coat.jpg 1600w,\n/static/a121f1570b7b10709bf14f6a60b3b971/7731d/woman-yellow-coat.jpg 1808w' + ); + cy.get('.mcm-section-with-image--image-left > .mcm-section-with-image__body > h2.mb-s').should('have.text', 'Rechercher facilement des aides éco-responsables pour financer vos déplacements'); // Title + cy.get('.mcm-section-with-image--image-left > .mcm-section-with-image__body > p.mb-s').should('have.text', 'Profitez de tous les avantages mis à disposition par votre collectivité ou votre entreprise pour des déplacements plus simples et plus durables au quotidien. MOB, c’est un compte unique et personnel qui rassemble tout ce dont vous avez besoin pour mieux vous déplacer et profiter de vos aides.'); // Text + cy.get('.mcm-section-with-image--image-left > .mcm-section-with-image__body > a > .button') + .should('have.text', 'Rechercher une aide ') // Button text + .click(); // Redirect + cy.url().should("match", /recherche/); // Search page + cy.go(-1); // Go back to homepage + cy.get('.mcm-section-with-image--image-left > .mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO elements (image, title, text, button) left-aligned + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('.mcm-section-with-image--image-left > .mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO elements (image, title, text, button) left-aligned & ordered + + /* ==== moB banner Section ==== */ + cy.get('.mob-pattern__svg').should('be.visible'); // Logo image (mobile) + cy.viewport(1440, 900); // Switch back to desktop mode for next step + cy.get('.mob-pattern__svg').should('be.visible'); // Logo image (desktop) + + /* ==== Collaborative project Section ==== */ + cy.get(':nth-child(4) > .mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should( // Image + 'have.attr', + 'srcset', + '/static/608102772e219ed9d765051143a1f289/fd013/trees.jpg 200w,\n/static/608102772e219ed9d765051143a1f289/25252/trees.jpg 400w,\n/static/608102772e219ed9d765051143a1f289/2f1b1/trees.jpg 800w,\n/static/608102772e219ed9d765051143a1f289/0ff54/trees.jpg 1200w,\n/static/608102772e219ed9d765051143a1f289/06655/trees.jpg 1600w,\n/static/608102772e219ed9d765051143a1f289/7731d/trees.jpg 1808w' + ); + cy.get(':nth-child(4) > .mcm-section-with-image__body > h2.mb-s').should('have.text', 'Un projet collaboratif'); // Title + cy.get('.mb-xs').should('have.text', 'Ce projet d’intérêt général ouvert et collaboratif, financé dans le cadre de l’appel à programmes des certificats d’économies d’énergie lancé par le ministère de la Transition écologique et solidaire.'); // Text 1/2 + cy.get(':nth-child(4) > .mcm-section-with-image__body > p.mb-s').should('have.text', 'Son développement sera incrémental et expérimenté sur 3 territoires pilotes en 2021 et 2022, en partenariat avec plusieurs collectivités, employeurs et acteurs de la mobilité. Il sera ensuite porté par un acteur neutre, tiers de confiance.'); // Text 2/2 + cy.get('[href="/contact"] > .button') + .should('have.text', 'Nous contacter') // Button text + .click(); // Redirect + cy.url().should("match", /contact/); // Contact page + cy.go(-1); // Go back to homepage + cy.get('[href="/decouvrir-le-projet"] > .button') + .should('have.text', 'Découvrir le projet') // Button text + .should('have.class', 'button--secondary') // Different button style + .click(); // Redirect + cy.url().should("match", /decouvrir-le-projet/); // Search page + cy.go(-1); // Go back to homepage + cy.get(':nth-child(4) > .mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO image right-aligned & other elements (title, text, button) left-aligned + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get(':nth-child(4) > .mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO image right-aligned & other elements (title, text, button) left-aligned & ordered + cy.viewport(1440, 900); // Switch back to desktop mode for next step + + /* ==== Partners Section ==== */ + cy.get('main.mcm-container__main > .mb-m').should('have.text', 'Tous nos partenaires'); // Title + cy.get(':nth-child(1) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/a23bb028afc39085b9153e5f10b77489/8ac63/logo-ministere.png 200w,\n/static/a23bb028afc39085b9153e5f10b77489/37d5a/logo-ministere.png 300w'); // Image 1/7 + cy.get('.partner-list > :nth-child(1) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', ' https://www.gouvernement.fr/ministere-de-la-transition-ecologique-charge-des-transports') // TODO DEV remove blank ^^ + .then((href) => { + cy.request(href.trim()).its('status').should('eq', 200); // TODO remove trim + }); + cy.get(':nth-child(2) > a > .mcm-image').should('have.attr', 'src', '/static/0a1183844844c732e6d2f1f748f5ddd8/logo-francemob.svg'); // Image 2/7 + cy.get('.partner-list > :nth-child(2) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.francemobilites.fr') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(3) > a > .mcm-image').should('have.attr', 'src', '/static/66e7dd5e7118c2530d603c629276e063/logo-fabmob.png'); // Image 3/7 + cy.get('.partner-list > :nth-child(3) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'http://lafabriquedesmobilites.fr/communs/mon-compte-mobilite/') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(4) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/86b6820a622b2cd351c5631eaac00851/8ac63/logo-igart.png 200w,\n/static/86b6820a622b2cd351c5631eaac00851/8bf6f/logo-igart.png 352w'); // Image 4/7 + cy.get('.partner-list > :nth-child(4) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.gart.org') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(5) > a > .mcm-image').should('have.attr', 'src', '/static/23ad41fdd697ba45ef1dfa568671ae55/logo-ademe.svg'); // Image 5/7 + cy.get('.partner-list > :nth-child(5) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.ademe.fr') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(6) > a > .mcm-image').should('have.attr', 'src', '/static/089f1ed33fee54b05556d02698f72f4e/logo-capgemini.svg'); // Image 6/7 + cy.get('.partner-list > :nth-child(6) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.capgemini.com/fr-fr/mon-compte-mobilite/') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(7) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/f906326c6cfd9c28f37ba0ed5f66fff0/8ac63/logo-certificats-%C3%A9conomies-%C3%A9nergie.png 200w,\n/static/f906326c6cfd9c28f37ba0ed5f66fff0/37d5a/logo-certificats-%C3%A9conomies-%C3%A9nergie.png 300w'); // Image 7/7 + cy.get('.partner-list > :nth-child(7) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.ecologie.gouv.fr/dispositif-des-certificats-deconomies-denergie') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + }); +}); \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/homepage-tests/collectivity_homepage_test.spec.js b/test/cypress/integration/functional-tests/test-mcm/homepage-tests/collectivity_homepage_test.spec.js new file mode 100644 index 0000000..a615603 --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/homepage-tests/collectivity_homepage_test.spec.js @@ -0,0 +1,152 @@ +// https://gitlab-dev.cicd.moncomptemobilite.fr/mcm/platform/-/issues/418 +describe("Collectivity homepage", function () { + it("Test of the collectivity homepage", function () { + cy.viewport(1440, 900); // Desktop mode + cy.justVisit("WEBSITE_FQDN"); // Open website homepage + + /* ==== Tabs Banner */ + cy.get('.nav-links__item--active > a').should('have.text', 'Citoyen.ne'); // Citizen tab + cy.get('.nav-links > :nth-child(2) > a').should('have.text', 'Employeur'); // Enterprise tab + cy.get('.nav-links > :nth-child(3) > a').should('have.text', 'Collectivité'); // Collectivity tab + cy.get('.nav-links > :nth-child(4) > a').should('have.text', 'Opérateur de mobilité'); // MSP tab + cy.get('.nav-links > :nth-child(3) > a').click(); // GOTO Collectivity tab + cy.url().should("match", /collectivite/); // New page, new URL + // TODO default citizen tab & mobile : tabs slide + + /* ==== Page title ==== */ + cy.get('.page-container > .mt-m').should('have.text', 'MOB vous accompagne dans le déploiement des offres de mobilité sur votre territoire, en calibrant au mieux votre politique d’aides.'); // Text + cy.get('#collectivite-contact > .button').should('have.text', 'Nous contacter'); + + /* ==== Video Section ==== */ + cy.get('[data-testid="button"] > img').should('have.attr', 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxjaXJjbGUgZmlsbD0iI0ZGRkZGRiIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+PHBhdGggZD0iTTQ3IDQxLjZsMTIgOC4zODdMNDcgNTguNHoiIGZpbGw9IiMwMGE3NmUiLz48L2c+PC9zdmc+'); // Play icon TODO video plays onClick + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('[data-testid="button"] > img').should('have.attr', 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxjaXJjbGUgZmlsbD0iI0ZGRkZGRiIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+PHBhdGggZD0iTTQ3IDQxLjZsMTIgOC4zODdMNDcgNTguNHoiIGZpbGw9IiMwMGE3NmUiLz48L2c+PC9zdmc+'); // Play icon TODO mobile : video plays onClick & adapts to screen width + cy.viewport(1440, 900); // Switch back to desktop mode for next step + + /* ==== Steps Section ==== */ + cy.get('.mcm-steps') + .should('have.class', 'mcm-steps') // Gray card + .should('have.text', 'Mon Compte Mobilité, comment ça marche ?Je me connecte à moB Mon Compte MobilitéJe gère les demandes d’aides de mes citoyensJe pilote ma politique de mobilité sur mon tableau de bord'); // Title & subs + cy.get(':nth-child(2) > .step-img > img').should( // Image 1/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODUiIGhlaWdodD0iODUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNNDIuODczIDYwLjkxMWMtNC43OTcgMC04Ljc1Ny0yLjYzNi05LjQ1LTYuMDdDMTguOTI3IDU3Ljk3MiA4LjIyNCA2OC40MTYgOCA4MC44OGg2OC45NzVjLS4yMi0xMi4yNTMtMTAuNTY1LTIyLjU1Ni0yNC42OS0yNS44NzQtLjc4MyAzLjM1NC00LjY5MiA1LjkwNi05LjQxMiA1LjkwNnoiIGZpbGw9IiM0NjRDRDAiLz48cGF0aCBkPSJNMjguNDg3IDE4LjM3OXMyLjkxNS04LjI5NSA1LjI4OC0xMC44NTFjMS44OTItMi4wNCA1LjA1MS0yLjM3NSA3Ljc2OC0xLjc4IDIuNzE3LjU5NiA1LjE5OCAxLjk1OSA3LjgyNyAyLjg2NyA0Ljc2MyAxLjY0NiA3LjYxNyAzLjI2NCAxMS42MTcgNS4yNjQtMiA2LTIuMzE1IDUuMTktNy41MzUgNi45NjgtMy41MDMgMS4xOTQtNy4zMTkuNjctMTEuMDIuNzM1LS44NDkuMDE0LTEuNzkzLjEwMy0yLjM2NS43My0uODUyLjkzNC0uNzMgMi4xMy0xLjU4IDMuMDY3LS41NTQuNjEtMS4yMDUuNzc5LTIgMS0xMy45OSAzLjg5OS04LTgtOC04IiBmaWxsPSIjMzYzNzU3Ii8+PHBhdGggZD0iTTYxLjc0NCAxMi41ODRjLTMuMTA0LTEuNTU3LTYuMTcyLTMuMTcyLTkuNDAyLTQuNDYtMS44MDUtLjcyLTMuNjQzLTEuMzM0LTUuNDM4LTIuMDgzLTEuNzI1LS43Mi0zLjQ3LTEuNDY3LTUuMzE4LTEuODExLTMuMDI3LS41NjMtNi40ODItLjE3NC04Ljc0NSAyLjEwOS0xLjY4NSAxLjctMi43MSA0LjExMi0zLjY1OCA2LjI2OWE3OC4xNzMgNzguMTczIDAgMDAtMi4wNyA1LjE4Yy0xLjQ4MyAzLjA1Ny0yLjU2OCA3Ljk1Ljk0MSAxMC4wMjIgMS4zNTUuNzk5IDMuMDI2LjkzNyA0LjU2My44MTUgMi4wOTEtLjE2NyA1LjA3MS0uNDkyIDYuNjg3LTEuOTUuNDc2LS40MjguODE1LS45NjQgMS4wNzYtMS41NDMuMjI1LS40OTguMzI1LTEuMzgxLjc0Ny0xLjc1OC40ODQtLjQzMyAxLjQwOC0uMjk3IDIuMDAxLS4yOTguOTMxLS4wMDEgMS44NjMuMDI4IDIuNzk0LjA0NyAzLjIwMy4wNjQgNi4wODMtLjI1NCA5LjEzNS0xLjIyNiAxLjYtLjUwOCAzLjQxOC0uODk0IDQuNjY4LTIuMDk0IDEuNDc4LTEuNDIgMi4wOC0zLjY1IDIuNzA4LTUuNTI1LjItLjU5MS0uMTI0LTEuNDExLS42ODktMS42OTRNNDEuOTM4IDcwLjQyMUw0MS45NiA3N2MuMDAyLjc4NC42ODggMS41MzYgMS41IDEuNS44MS0uMDM3IDEuNTAzLS42NiAxLjUtMS41LS4wMDctMi4xOTMtLjAxNS05LjM4Ni0uMDIyLTExLjU3OC0uMDAyLS43ODUtLjY4OC0xLjUzNy0xLjUtMS41LS44MS4wMzYtMS41MDIuNjYtMS41IDEuNSIgZmlsbD0iIzM2Mzc1NyIvPjxwYXRoIGQ9Ik00Ny44NzggMjcuNTcyYzEuOTMgMCAxLjkzNC0zIDAtMy0xLjkzMSAwLTEuOTM0IDMgMCAzIiBmaWxsPSIjMzYzNzU3Ii8+PHBhdGggZD0iTTU3Ljg0NSAxOS45MjVjLTMuNTQtMS41ODEtNi44OTctMy4wMy05LjkzMy01LjQ5NC0zLjE2LTIuNTY2LTYuNzQ2LTQuMzI4LTEwLjkzMi0zLjg0LTEuNzc1LjIwNy0zLjUzMS44MzctNC43NzIgMi4xNzgtMS4yMTEgMS4zMDktMS44MzUgMy4wMTgtMi4yODUgNC43MTMtLjQwMSAxLjUwNi0uNTk3IDMuMTMtMS4xOTYgNC41NzUtLjYzMiAxLjUyNC0yLjAwNyAxLjcwOS0zLjE3OCAyLjcwOS0yLjMxNiAxLjk3Ni0yLjAxNSA1LjA4NC0uMzkgNy4zOTUgMS4zNzUgMS45NTcgMy4zNzkgMy40MjggNS43NSAzLjY2OC4wMTkgNS42MjQuMDM4IDEzLjI0OC4wNTYgMTguODcuMDA1IDEuNDQ5IDIuMjU1IDEuNDUgMi4yNSAwLS4wMi02LjA2Mi0uMDQtMTQuMTI2LS4wNi0yMC4xOWExLjAyNyAxLjAyNyAwIDAwLS4zODQtLjgzMmMtLjIxNS0uNDQ3LS42NC0uNzktMS4yODMtLjc5OC0xLjA3MS0uMDE0LTEuMzQ1LS4yMi0yLjM1NC0uODU4LS41MDEtLjMxNy0xLjEwMy0uOTU0LTEuNTE3LTEuNTY2LS40MzItLjY0LS45LTEuNDgxLS43ODgtMi4yODcuMTQ2LTEuMDU3IDEuMTk3LTEuNDg3IDIuMDMyLTEuOTI2IDIuNTc4LTEuMzU0IDMuMDU1LTQuMjcgMy42NzYtNi44NTMuMzItMS4zMzEuNjItMi43NDggMS4zNjMtMy45Mi45NDUtMS40OTMgMi42MDMtMS45MzggNC4yODMtMS45NThhMTAuMjkgMTAuMjkgMCAwMTUuMTEyIDEuMjc3YzEuNjM4LjkwOCAyLjk4NiAyLjIzMiA0LjQ4MiAzLjM0MiAyLjU5NyAxLjkyOSA1LjYyNCAzLjA3NiA4LjU1NCA0LjM4NSAyLjU1IDEuMTQgNi40MyAyLjcxNyA3LjU2OSA1LjUyNC4yMjMuNTUuMzAzIDEuMjM1LjAzIDEuNzg3LS4zNS43MDctMS4xMS43ODMtMS44MTUuOS0xLjg1NC4zMS0zLjgyNS4zMTUtNS42NDUuNzgyLTEuNjQzLjQyMy0yLjQ5NyAxLjY2NS0yLjc0MyAzLjI4NC0uMzI4IDIuMTYyLS40MiA0LjM4Ni0uNTU2IDYuNTY3YTE3My4yMyAxNzMuMjMgMCAwMC0uMzA3IDEzLjYzNmMuMDMgMS45MjggMy4wMyAxLjkzNCAzIDBhMTczLjIzIDE3My4yMyAwIDAxLjMwNy0xMy42MzZjLjA3LTEuMTM1LjE1My0yLjI3LjI0Ny0zLjQwMi4wNzktLjk0Ny0uMDA4LTIuMTUyLjMyMy0zLjA0Ni4yOC0uNzU4IDEuNDc3LS42NiAyLjE4NC0uNzU1IDEuMDYzLS4xNDIgMi4xMjctLjI4MyAzLjE5LS40MjcgMS43NzItLjI0IDMuNDk0LS42OTMgNC40MDYtMi4zOS44MDktMS41MDIuNjk0LTMuMzExLS4wMy00LjgxNy0xLjU4LTMuMjktNS40ODQtNS4xODUtOC42NDYtNi41OTd6IiBmaWxsPSIjMzYzNzU3Ii8+PC9nPjwvc3ZnPgo=' + ); + cy.get(':nth-child(3) > .step-img > img').should( // Image 2/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iODVweCIgaGVpZ2h0PSI4NXB4IiB2aWV3Qm94PSIwIDAgODUgODUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+SWxsdXMvQmlnL2lwYWQ8L3RpdGxlPgogICAgPGcgaWQ9IklsbHVzL0JpZy9pcGFkIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8cGF0aCBkPSJNMTguNjMxOSwxNS40ODc4IEMzNy4zMjQ5LDE1LjA5OTggNzQuNzYyOSwxNC4xMzM4IDc3LjI1ODksMTYuODUxOCBDNzkuMDQxOSwxOC43OTI4IDc4Ljg0NDksMjMuMjc5OCA3OS4wNDE5LDI1LjQ5NjggQzgwLjA2ODksMzcuMDA1OCA3OS4zNzc5LDQ5LjI5MjggNzkuNDAwOSw2MC45NDQ4IEM3OS40MDM5LDYyLjg4NjggNzkuMzA5OSw2NS42Mzg4IDc4LjQxNjksNjYuNjg3OCBDNzYuNzIyOSw2OC42Nzk4IDczLjc4NDksNjguNDQxOCA3MC4yMDU5LDY4LjU5ODggQzUwLjc0MzksNjkuNDU3OCAzMi4yMDU5LDY5LjY1NzggMTEuMDk5OSw2OS43OTY4IEM5LjE3MDksNjkuODA4OCA3LjY4NTksNjkuNzY1OCA2LjcwOTksNjcuODUyOCBDNi4xNDY5LDY2Ljc0NzggNi4wNjc5LDU2LjUwODggNi4wNTA5LDUxLjcwNjggQzYuMDE1OSw0Mi4wNTQ4IDUuODUxOSwzMy41Mzc4IDYuMzg3OSwyMy45MDA4IEM2LjQ5ODksMjEuOTA1OCA2LjQyMjksMTguNjI5OCA3Ljg5NTksMTcuMjc5OCBDMTAuMDI1OSwxNS4zMjU4IDEyLjE3NTksMTUuOTI0OCAxOC42MzE5LDE1LjQ4NzgiIGlkPSJGaWxsLTEiIGZpbGw9IiNGRkQzMTQiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNDIuNTM0MywxOS41Mzk1IEM0MS44ODQzLDE4Ljk4NTUgNDEuNTA1MywxOS4xMjQ1IDQxLjEyOTMsMTkuMTEzNSBDNDAuMzc4MywxOS4wOTA1IDM5Ljg5MzMsMTkuOTQ0NSAzOS45NDAzLDIwLjY5NDUgQzM5Ljk2OTMsMjEuMTUwNSA0MC4xNTUzLDIxLjYxMTUgNDAuNTEzMywyMS44OTY1IEM0MS4wOTczLDIyLjM2MDUgNDIuMDQyMywyMi4yMDU1IDQyLjUxMDMsMjEuNjI0NSBDNDIuOTc2MywyMS4wNDQ1IDQyLjk1ODMsMjAuMTUyNSA0Mi41MzQzLDE5LjUzOTUiIGlkPSJGaWxsLTQiIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNMTUuMDEwOCwzNS40NjczIEMxOS44NTg4LDM1LjU3MzMgMjQuNzI4OCwzNS4zNjQzIDI5LjU3NDgsMzUuMjg2MyBDMzYuNjIxOCwzNS4xNzIzIDQzLjY2NzgsMzUuMDQ2MyA1MC43MTM4LDM0Ljk0ODMgQzUyLjY5NDgsMzQuOTIxMyA1NC42ODI4LDM0Ljg1NjMgNTYuNjYyOCwzNC45MDAzIEM1OC4xMTE4LDM0LjkzMTMgNTguMTExOCwzMi42ODEzIDU2LjY2MjgsMzIuNjUwMyBDNTEuODE1OCwzMi41NDMzIDQ2Ljk0NTgsMzIuNzUyMyA0Mi4wOTc4LDMyLjgzMDMgQzM1LjA1MTgsMzIuOTQ1MyAyOC4wMDU4LDMzLjA3MTMgMjAuOTU5OCwzMy4xNjgzIEMxOC45Nzg4LDMzLjE5NjMgMTYuOTkwOCwzMy4yNjAzIDE1LjAxMDgsMzMuMjE3MyBDMTMuNTYxOCwzMy4xODUzIDEzLjU2MjgsMzUuNDM1MyAxNS4wMTA4LDM1LjQ2NzMiIGlkPSJGaWxsLTYiIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNMTUuMDEwOCw0Ny4xMTcyIEMxOS44NTg4LDQ3LjIyMzIgMjQuNzI4OCw0Ny4wMTQyIDI5LjU3NDgsNDYuOTM2MiBDMzYuNjIxOCw0Ni44MjIyIDQzLjY2NzgsNDYuNjk2MiA1MC43MTM4LDQ2LjU5ODIgQzUyLjY5NDgsNDYuNTcxMiA1NC42ODI4LDQ2LjUwNjIgNTYuNjYyOCw0Ni41NDkyIEM1OC4xMTE4LDQ2LjU4MTIgNTguMTExOCw0NC4zMzEyIDU2LjY2MjgsNDQuMjk5MiBDNTEuODE1OCw0NC4xOTMyIDQ2Ljk0NTgsNDQuNDAyMiA0Mi4wOTc4LDQ0LjQ4MDIgQzM1LjA1MTgsNDQuNTk1MiAyOC4wMDU4LDQ0LjcyMTIgMjAuOTU5OCw0NC44MTgyIEMxOC45Nzg4LDQ0Ljg0NjIgMTYuOTkwOCw0NC45MTAyIDE1LjAxMDgsNDQuODY3MiBDMTMuNTYxOCw0NC44MzUyIDEzLjU2MjgsNDcuMDg1MiAxNS4wMTA4LDQ3LjExNzIiIGlkPSJGaWxsLTgiIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNMTUuMDEwOCw1OC43NjY2IEMxOS44NTg4LDU4Ljg3MzYgMjQuNzI4OCw1OC42NjM2IDI5LjU3NDgsNTguNTg1NiBDMzYuNjIxOCw1OC40NzE2IDQzLjY2NzgsNTguMzQ1NiA1MC43MTM4LDU4LjI0NzYgQzUyLjY5NDgsNTguMjIwNiA1NC42ODI4LDU4LjE1NTYgNTYuNjYyOCw1OC4xOTk2IEM1OC4xMTE4LDU4LjIzMDYgNTguMTExOCw1NS45ODA2IDU2LjY2MjgsNTUuOTQ5NiBDNTEuODE1OCw1NS44NDI2IDQ2Ljk0NTgsNTYuMDUxNiA0Mi4wOTc4LDU2LjEzMDYgQzM1LjA1MTgsNTYuMjQ0NiAyOC4wMDU4LDU2LjM3MDYgMjAuOTU5OCw1Ni40Njc2IEMxOC45Nzg4LDU2LjQ5NTYgMTYuOTkwOCw1Ni41NjA2IDE1LjAxMDgsNTYuNTE2NiBDMTMuNTYxOCw1Ni40ODQ2IDEzLjU2MjgsNTguNzM0NiAxNS4wMTA4LDU4Ljc2NjYiIGlkPSJGaWxsLTEwIiBmaWxsPSIjMzYzNzU3Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTYzLjkxMjIsMzUuMTQyNiBDNjQuMzIwMiwzNS41MTM2IDY1LjExNDIsMzUuNjQwNiA2NS41MDMyLDM1LjE0MjYgQzY3LjE4MDIsMzIuOTk2NiA2OC44NTUyLDMwLjg1MDYgNzAuNTMyMiwyOC43MDQ2IEM3MC44OTMyLDI4LjI0MTYgNzEuMDE2MiwyNy41NTg2IDcwLjUzMjIsMjcuMTEyNiBDNzAuMTI4MiwyNi43NDI2IDY5LjMyNzIsMjYuNjE3NiA2OC45NDAyLDI3LjExMjYgQzY3LjUxMDIsMjguOTQ1NiA2Ni4wNzgyLDMwLjc3ODYgNjQuNjQ3MiwzMi42MTA2IEM2NC41NzUyLDMyLjUwNjYgNjQuNTAzMiwzMi40MDI2IDY0LjQzNjIsMzIuMjk0NiBDNjQuMjgyMiwzMi4wNDQ2IDY0LjE0NDIsMzEuNzg0NiA2NC4wMjQyLDMxLjUxNjYgQzY0LjAwOTIsMzEuNDcyNiA2My45MjEyLDMxLjI1MzYgNjMuOTExMiwzMS4yMjU2IEM2My44NjEyLDMxLjA4MDYgNjMuODE3MiwzMC45MzU2IDYzLjc3NTIsMzAuNzg4NiBDNjMuNjE2MiwzMC4yMjI2IDYyLjk3MzIsMjkuODE0NiA2Mi4zOTEyLDMwLjAwMjYgQzYxLjgxNDIsMzAuMTg5NiA2MS40MzQyLDMwLjc4MDYgNjEuNjA2MiwzMS4zODY2IEM2Mi4wMTIyLDMyLjgyMTYgNjIuODA4MiwzNC4xMzg2IDYzLjkxMjIsMzUuMTQyNiIgaWQ9IkZpbGwtMTIiIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNjguOTQwNSwzOC43NjI3IEM2Ny41MDk1LDQwLjU5NTcgNjYuMDc4NSw0Mi40Mjg3IDY0LjY0NzUsNDQuMjYwNyBDNjQuNTc1NSw0NC4xNTY3IDY0LjUwMzUsNDQuMDUyNyA2NC40MzY1LDQzLjk0NDcgQzY0LjI4MjUsNDMuNjk0NyA2NC4xNDQ1LDQzLjQzMzcgNjQuMDI0NSw0My4xNjY3IEM2NC4wMDg1LDQzLjEyMTcgNjMuOTIwNSw0Mi45MDM3IDYzLjkxMTUsNDIuODc0NyBDNjMuODYxNSw0Mi43MzA3IDYzLjgxNzUsNDIuNTg1NyA2My43NzU1LDQyLjQzODcgQzYzLjYxNjUsNDEuODcyNyA2Mi45NzI1LDQxLjQ2NDcgNjIuMzkxNSw0MS42NTI3IEM2MS44MTQ1LDQxLjgzOTcgNjEuNDM0NSw0Mi40Mjk3IDYxLjYwNjUsNDMuMDM2NyBDNjIuMDExNSw0NC40NzE3IDYyLjgwNzUsNDUuNzg3NyA2My45MTI1LDQ2Ljc5MjcgQzY0LjMyMDUsNDcuMTYyNyA2NS4xMTQ1LDQ3LjI4OTcgNjUuNTAyNSw0Ni43OTI3IEM2Ny4xNzk1LDQ0LjY0NjcgNjguODU1NSw0Mi40OTk3IDcwLjUzMjUsNDAuMzUzNyBDNzAuODkzNSwzOS44OTA3IDcxLjAxNjUsMzkuMjA3NyA3MC41MzI1LDM4Ljc2MjcgQzcwLjEyNzUsMzguMzkxNyA2OS4zMjc1LDM4LjI2NzcgNjguOTQwNSwzOC43NjI3IiBpZD0iRmlsbC0xNCIgZmlsbD0iIzM2Mzc1NyI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik02OC45NDA1LDUwLjQxMjYgQzY3LjUwOTUsNTIuMjQ1NiA2Ni4wNzg1LDU0LjA3ODYgNjQuNjQ3NSw1NS45MTA2IEM2NC41NzU1LDU1LjgwNjYgNjQuNTAzNSw1NS43MDI2IDY0LjQzNjUsNTUuNTk0NiBDNjQuMjgyNSw1NS4zNDQ2IDY0LjE0NDUsNTUuMDgzNiA2NC4wMjQ1LDU0LjgxNjYgQzY0LjAwODUsNTQuNzcxNiA2My45MjA1LDU0LjU1MzYgNjMuOTExNSw1NC41MjQ2IEM2My44NjE1LDU0LjM4MDYgNjMuODE3NSw1NC4yMzQ2IDYzLjc3NTUsNTQuMDg4NiBDNjMuNjE2NSw1My41MjI2IDYyLjk3MjUsNTMuMTE0NiA2Mi4zOTE1LDUzLjMwMjYgQzYxLjgxNDUsNTMuNDg5NiA2MS40MzQ1LDU0LjA3OTYgNjEuNjA2NSw1NC42ODY2IEM2Mi4wMTE1LDU2LjEyMTYgNjIuODA3NSw1Ny40Mzc2IDYzLjkxMjUsNTguNDQyNiBDNjQuMzIwNSw1OC44MTI2IDY1LjExNDUsNTguOTM5NiA2NS41MDI1LDU4LjQ0MjYgQzY3LjE3OTUsNTYuMjk1NiA2OC44NTU1LDU0LjE0OTYgNzAuNTMyNSw1Mi4wMDM2IEM3MC44OTM1LDUxLjU0MDYgNzEuMDE2NSw1MC44NTc2IDcwLjUzMjUsNTAuNDEyNiBDNzAuMTI3NSw1MC4wNDE2IDY5LjMyNzUsNDkuOTE2NiA2OC45NDA1LDUwLjQxMjYiIGlkPSJGaWxsLTE2IiBmaWxsPSIjMzYzNzU3Ij48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==' + ); + cy.get(':nth-child(4) > .step-img > img').should( // Image 3/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iODVweCIgaGVpZ2h0PSI4NXB4IiB2aWV3Qm94PSIwIDAgODUgODUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+SWxsdXMvQmlnL0dyYXBoPC90aXRsZT4KICAgIDxnIGlkPSJJbGx1cy9CaWcvR3JhcGgiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGQ9Ik0zMS40NzQ3OCwxNC4xMTEwOCBDMjMuMjAyNzgsMTMuMjQ3NTggMTcuMjc3MDgsMTcuODkzOTggMTIuMzcxMDgsMjQuMzg1MDggQzcuNDY1MDgsMzAuODc3MjggNS45NjkwOCwzOS41Mjk4OCA3LjY4MTc4LDQ3LjQ0OTg4IEM4LjMyNDE4LDUwLjQxNTQ4IDkuNDA0MzgsNTMuMzI2MDggMTEuMTUyMjgsNTUuODQxNzggQzE1Ljk0MTY4LDYyLjcyNzc4IDI1LjAyMTA4LDY1LjY1Mzc4IDMzLjU0MDU4LDY1LjkxMDA4IEM0MC43MTQ3OCw2Ni4xMjU2OCA0OC4zODA2OCw2NC41MzUwOCA1My4zNTA0OCw1OS41MzIyOCBDNTcuMzEwNDgsNTUuNTQ1ODggNTkuMDM2MzgsNDkuOTQ0NjggNjAuMDEwOTgsNDQuNDcxMDggQzYwLjcxMzg4LDQwLjUyNzU4IDYxLjA5MjI4LDM2LjQ0NDM4IDYwLjE3NDg4LDMyLjUzMjc4IEM1OC41NTEyOCwyNS42MDkzOCA1Mi45MTA0OCwxOS45Njc0OCA0Ni4yNjUzOCwxNy4wMDUxOCBDMzkuNjE5MTgsMTQuMDQzOTggMzkuNjE5MTgsMTQuMDQzOTggMzEuNDc0NzgsMTQuMTExMDgiIGlkPSJGaWxsLTEiIGZpbGw9IiMwMUJGN0QiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNTQuNSw2OSBDNTYuNzAxMzMsNzEuNDYwNjcgNTguNSw3My41IDY0LjUsNzkuNSBDNjYuMDAzNjY0LDgxLjAwMzY2NCA2OC4zNzUzNTU2LDc4Ljc3Nzk1NSA2Ni45MDEzLDc3LjI0MDYgQzU5LDY5IDYwLjk5MDkzLDcxLjM4NzE3IDUzLjUsNjIuNSBDNTIuMTYzMjMwMiw2MC45MTQwNjg3IDQ5Ljc3NzksNjIuNzg2MiA1MSw2NC41IEw1NC41LDY5IFoiIGlkPSJGaWxsLTYiIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNMjQuMDE1NDYsMjQuNDYxMiBDMjcuNTc1MDYsMjQuNzA1NCAyOC4wNjg5NiwyNC4xMzAxIDMxLjYyODU2LDI0LjM3NDMgQzMwLjkwMzY2LDMzLjU3OCAzMS4yNDkwNiw0My43ODYgMzAuODUzMDYsNTMuNDA3NyBDMjkuMDIxNTYsNTMuNTkzNiAyNS42Mzc5Niw1My4zMjc0IDIzLjcyMDY2LDUzLjQwNzcgQzI0LjIxNjc2LDQzLjgyNzggMjMuMjY2MzYsMzMuODE3OCAyNC4wMTU0NiwyNC40NjEyIiBpZD0iRmlsbC04IiBmaWxsPSIjRkZEMzE0Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTMzLjg0NjcxLDMzLjgzMDU2IEMzNS4zODEyMSwzMy42ODg2NiAzNi45MjM0MSwzMy42MzI1NiAzOC40NjIzMSwzMy42NjMzNiBDMzguNzM0MDEsMzMuNjY4ODYgMzkuMDI1NTEsMzMuNjgzMTYgMzkuMjM4OTEsMzMuODUwMzYgQzM5LjU0MTQxLDM0LjA4NTc2IDM5LjU3MTExLDM0LjUyMjQ2IDM5LjU3NjYxLDM0LjkwNTI2IEMzOS42MzI3MSwzOS4zMDc0NiAzOS42Mjk0MSw0My43MTA3NiAzOS41NjU2MSw0OC4xMTQwNiBDMzkuNTQzNjEsNDkuNjk4MDYgMzkuMzgzMDEsNTMuMzIxNDYgMzkuMTQxMDEsNTMuNDc1NDYgQzM4Ljk0NDExLDUzLjYwMTk2IDM1Ljc4OTMxLDUzLjUwMDc2IDM0LjYwNDYxLDUzLjQwODM2IEMzNC4wNzk5MSw1My4zNjc2NiAzMy41NTc0MSw1My40MTgyNiAzMy4wODk5MSw1My4xNzczNiBMMzMuODQ2NzEsMzMuODMwNTYgWiIgaWQ9IkZpbGwtMTAiIGZpbGw9IiNGRkQzMTQiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNDEuNjQ5NzgsNDAuMDAyNDQgQzQyLjk4NjI4LDM5LjYxMzA0IDQ0LjI0Njg4LDM5LjkzNjQ0IDQ1LjY1NTk4LDM5LjgwNjY0IEM0NS44NzM3OCwzOS43ODY4NCA0Ni4xMTM1OCwzOS43NzM2NCA0Ni4yOTI4OCwzOS44ODAzNCBDNDYuNTMyNjgsNDAuMDIyMjQgNDYuNTU1NzgsNDAuMzA3MTQgNDYuNTU1NzgsNDAuNTUzNTQgQzQ2LjU0ODA4LDQ0LjU1MzE0IDQ2LjUzOTI4LDQ4LjU1MTY0IDQ2LjUzMTU4LDUyLjU1MTI0IEM0Ni41MzE1OCw1Mi44MzUwNCA0Ni41MDg0OCw1My4xNTk1NCA0Ni4yNDExOCw1My4zMzY2NCBDNDYuMDM2NTgsNTMuNDcxOTQgNDUuNzUxNjgsNTMuNDc0MTQgNDUuNDkyMDgsNTMuNDY5NzQgQzQ0LjQxODQ4LDUzLjQ1NDM0IDQyLjE1MDI4LDUzLjQ4NjI0IDQxLjA3NjY4LDUzLjQ3MDg0IEw0MS42NDk3OCw0MC4wMDI0NCBaIiBpZD0iRmlsbC0xMiIgZmlsbD0iI0ZGRDMxNCI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik02MC43NTM5NCwxNC40ODI3NyBDNjEuOTQwODQsMTIuMzM0NDcgNjMuMTI2NjQsMTAuMTg2MTcgNjQuMzEzNTQsOC4wMzc4NyBDNjQuNjQyNDQsNy40NDM4NyA2NC40Nzg1NCw2LjU5NTc3IDYzLjg0ODI0LDYuMjYzNTcgQzYzLjIzOTk0LDUuOTQxMjcgNjIuNDI1OTQsNi4wOTQxNyA2Mi4wNzUwNCw2LjcyODg3IEw1OC41MTQzNCwxMy4xNzM3NyBDNTguMTg2NTQsMTMuNzY3NzcgNTguMzQ5MzQsMTQuNjE1ODcgNTguOTc5NjQsMTQuOTQ4MDcgQzU5LjU4Nzk0LDE1LjI3MDM3IDYwLjQwMzA0LDE1LjExNzQ3IDYwLjc1Mzk0LDE0LjQ4Mjc3IiBpZD0iRmlsbC0xNCIgZmlsbD0iIzM2Mzc1NyI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik02Ny43MDI5NywxOS4zMzcxOCBDNjkuMzYzOTcsMTguMzQ0OTggNzEuMDI0OTcsMTcuMzUzODggNzIuNjgzNzcsMTYuMzYxNjggQzczLjI2Njc3LDE2LjAxNDA4IDczLjUzMTg3LDE1LjE4MDI4IDczLjE0OTA3LDE0LjU4NzM4IEM3Mi43NzE3NywxNC4wMDEwOCA3MS45OTg0NywxMy43NTAyOCA3MS4zNzU4NywxNC4xMjIwOCBDNjkuNzE0ODcsMTUuMTE0MjggNjguMDUzODcsMTYuMTA1MzggNjYuMzk1MDcsMTcuMDk3NTggQzY1LjgxMjA3LDE3LjQ0NjI4IDY1LjU0Njk3LDE4LjI4MDA4IDY1LjkyOTc3LDE4Ljg3MTg4IEM2Ni4zMDU5NywxOS40NTcwOCA2Ny4wODAzNywxOS43MDg5OCA2Ny43MDI5NywxOS4zMzcxOCIgaWQ9IkZpbGwtMTciIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNjcuOTE4OSwyNy41MzEzIEM3MC41ODQyLDI3Ljc2MzQgNzMuMjQ5NSwyNy45OTY2IDc1LjkxNDgsMjguMjI4NyBDNzYuNTkxMywyOC4yODgxIDc3LjI0MDMsMjcuNTkwNyA3Ny4yMTA2LDI2LjkzMTggQzc3LjE3NjUsMjYuMTc5NCA3Ni42Mzk3LDI1LjY5ODcgNzUuOTE0OCwyNS42MzQ5IEM3My4yNDk1LDI1LjQwMjggNzAuNTg0MiwyNS4xNzA3IDY3LjkxODksMjQuOTM4NiBDNjcuMjQxMywyNC44NzkyIDY2LjU5MjMsMjUuNTc2NiA2Ni42MjIsMjYuMjM0NCBDNjYuNjU2MSwyNi45ODc5IDY3LjE5MjksMjcuNDY3NSA2Ny45MTg5LDI3LjUzMTMiIGlkPSJGaWxsLTE5IiBmaWxsPSIjMzYzNzU3Ij48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==' + ); + + /* ==== Toolbox Section ==== */ + cy.get('.mcm-section-with-support--mac__image > .img-wrapper > .mcm-image.gatsby-image-wrapper > picture > img').should('have.attr', 'srcset', '/static/7dbc4a595f0dee946a877a9a28f025e1/fd013/support-mac.jpg 200w,\n/static/7dbc4a595f0dee946a877a9a28f025e1/25252/support-mac.jpg 400w,\n/static/7dbc4a595f0dee946a877a9a28f025e1/2f1b1/support-mac.jpg 800w,\n/static/7dbc4a595f0dee946a877a9a28f025e1/0ff54/support-mac.jpg 1200w,\n/static/7dbc4a595f0dee946a877a9a28f025e1/09428/support-mac.jpg 1449w'); // Image + cy.get('.mcm-section-with-support--mac__body > .mb-m').should('have.text', 'moB est un outil intégré qui vous accompagne dans chaque étape de votre processus.'); + cy.get('.mcm-ordered-list > :nth-child(1)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 1/3 + .should('have.text', 'Comprendre la mobilité des citoyen.ne.s de votre territoire.'); // Text 1/3 + cy.get('.mcm-ordered-list > :nth-child(2)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 2/3 + .should('have.text', 'Adapter sa politique d’aides afin qu’elle réponde aux aspirations des citoyen.ne.s.'); // Text 2/3 + cy.get('.mcm-ordered-list > :nth-child(3)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 3/3 + .should('have.text', 'Associer les parties prenantes (entreprises, opérateurs de mobilité, citoyen.ne.s) autour de votre projet.'); // Text 3/3 + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('.mcm-section-with-support--mac__body > .mb-m').should('have.text', 'MOB est un outil intégré qui vous accompagne dans chaque étape de votre processus.'); + cy.get('.mcm-ordered-list > :nth-child(1)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 1/3 + .should('have.text', 'Comprendre la mobilité des citoyen.ne.s de votre territoire.'); // Text 1/3 + cy.get('.mcm-ordered-list > :nth-child(2)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 2/3 + .should('have.text', 'Adapter sa politique d’aides afin qu’elle réponde aux aspirations des citoyen.ne.s.'); // Text 2/3 + cy.get('.mcm-ordered-list > :nth-child(3)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 3/3 + .should('have.text', 'Associer les parties prenantes (entreprises, opérateurs de mobilité, citoyen.ne.s) autour de votre projet.'); // Text 3/3 + + /* ==== moB banner Section ==== */ + cy.get('.mob-pattern__svg').should('be.visible'); // Logo image (mobile) + cy.viewport(1440, 900); // Switch back to desktop mode for next step + cy.get('.mob-pattern__svg').should('be.visible'); // Logo image (desktop) + + /* ==== Why join moB? Section ==== */ + cy.get('.mcm-section-with-image__image > .img-wrapper > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/58caf28cdccd720d1d0983acb72c511e/fd013/dame-veste-corail.jpg 200w,\n/static/58caf28cdccd720d1d0983acb72c511e/25252/dame-veste-corail.jpg 400w,\n/static/58caf28cdccd720d1d0983acb72c511e/2f1b1/dame-veste-corail.jpg 800w,\n/static/58caf28cdccd720d1d0983acb72c511e/768f4/dame-veste-corail.jpg 1160w');// Image TODO right-aligned + cy.get('h2.mb-s').should('have.text', 'Pourquoi rejoindre moB ?'); // Title + cy.get('.mb-xs').should('have.text', 'Rejoindre Mon Compte MOB vous permettra d’intégrer un écosystème constitué d’employeurs, d’opérateurs de la mobilité ainsi que d’autres Autorités Organisatrices de la Mobilité.'); // Text 1/2 + cy.get('p.mb-s').should('have.text', 'Vous aurez ainsi l’opportunité de créer des relations avec de nouveaux opérateurs de mobilité ou des employeurs pour ainsi proposer des aides plus innovantes et adaptées aux besoins de vos citoyens.'); // Text 2/2 + cy.get('#collectivite-contact2 > .button') + .should('have.text', 'Nous contacter') // Button text + .click(); // Button text + cy.url().should("match", /contact/); // Contact page + cy.go(-1); // Go back to homepage + cy.get('[href="/decouvrir-le-projet"] > .button') + .should('have.text', 'Découvrir le projet') // Button text + .should('have.class', 'button--secondary') // Different button style + .click(); // Redirect + cy.url().should("match", /decouvrir-le-projet/); // Search page + cy.go(-1); // Go back to homepage + cy.get('.img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO image right-aligned & other elements (title, text, button) left-aligned + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('.img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO image right-aligned & other elements (title, text, button) left-aligned & ordered + cy.viewport(1440, 900); // Switch back to desktop mode for next step + + /* ==== Partners Section ==== */ + cy.get('main.mcm-container__main > .mb-m').should('have.text', 'Tous nos partenaires'); // Title + cy.get(':nth-child(1) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/a23bb028afc39085b9153e5f10b77489/8ac63/logo-ministere.png 200w,\n/static/a23bb028afc39085b9153e5f10b77489/37d5a/logo-ministere.png 300w'); // Image 1/7 + cy.get('.partner-list > :nth-child(1) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', ' https://www.gouvernement.fr/ministere-de-la-transition-ecologique-charge-des-transports') // TODO DEV remove blank ^^ + .then((href) => { + cy.request(href.trim()).its('status').should('eq', 200); // TODO remove trim + }); + cy.get(':nth-child(2) > a > .mcm-image').should('have.attr', 'src', '/static/0a1183844844c732e6d2f1f748f5ddd8/logo-francemob.svg'); // Image 2/7 + cy.get('.partner-list > :nth-child(2) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.francemobilites.fr') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(3) > a > .mcm-image').should('have.attr', 'src', '/static/66e7dd5e7118c2530d603c629276e063/logo-fabmob.png'); // Image 3/7 + cy.get('.partner-list > :nth-child(3) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'http://lafabriquedesmobilites.fr/communs/mon-compte-mobilite/') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(4) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/86b6820a622b2cd351c5631eaac00851/8ac63/logo-igart.png 200w,\n/static/86b6820a622b2cd351c5631eaac00851/8bf6f/logo-igart.png 352w'); // Image 4/7 + cy.get('.partner-list > :nth-child(4) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.gart.org') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(5) > a > .mcm-image').should('have.attr', 'src', '/static/23ad41fdd697ba45ef1dfa568671ae55/logo-ademe.svg'); // Image 5/7 + cy.get('.partner-list > :nth-child(5) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.ademe.fr') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(6) > a > .mcm-image').should('have.attr', 'src', '/static/089f1ed33fee54b05556d02698f72f4e/logo-capgemini.svg'); // Image 6/7 + cy.get('.partner-list > :nth-child(6) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.capgemini.com/fr-fr/mon-compte-mobilite/') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(7) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/f906326c6cfd9c28f37ba0ed5f66fff0/8ac63/logo-certificats-%C3%A9conomies-%C3%A9nergie.png 200w,\n/static/f906326c6cfd9c28f37ba0ed5f66fff0/37d5a/logo-certificats-%C3%A9conomies-%C3%A9nergie.png 300w'); // Image 7/7 + cy.get('.partner-list > :nth-child(7) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.ecologie.gouv.fr/dispositif-des-certificats-deconomies-denergie') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + }); +}); \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/homepage-tests/enterprise_homepage_test.spec.js b/test/cypress/integration/functional-tests/test-mcm/homepage-tests/enterprise_homepage_test.spec.js new file mode 100644 index 0000000..1f516be --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/homepage-tests/enterprise_homepage_test.spec.js @@ -0,0 +1,152 @@ +// https://gitlab-dev.cicd.moncomptemobilite.fr/mcm/platform/-/issues/417 +describe("Enterprise homepage", function () { + it("Test of the enterprise homepage", function () { + cy.viewport(1440, 900); // Desktop mode + cy.justVisit("WEBSITE_FQDN"); // Open website homepage + + /* ==== Tabs Banner */ + cy.get('.nav-links__item--active > a').should('have.text', 'Citoyen.ne'); // Citizen tab + cy.get('.nav-links > :nth-child(2) > a').should('have.text', 'Employeur'); // Enterprise tab + cy.get('.nav-links > :nth-child(3) > a').should('have.text', 'Collectivité'); // Collectivity tab + cy.get('.nav-links > :nth-child(4) > a').should('have.text', 'Opérateur de mobilité'); // MSP tab + cy.get('.nav-links > :nth-child(2) > a').click(); // GOTO Enterprise tab + cy.url().should("match", /employeur/); // New page, new URL + // TODO default citizen tab & mobile : tabs slide + + /* ==== Page title ==== */ + cy.get('.page-container > .mt-m').should('have.text', 'Améliorez la mobilité de vos salarié.e.s, leurs conditions de vie et de travail tout en réduisant l\'impact carbone de votre entreprise.'); // Text + cy.get('#employeur-contact > .button').should('have.text', 'Nous contacter'); // Button text + + /* ==== Video Section ==== */ + cy.get('[data-testid="button"] > img').should('have.attr', 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxjaXJjbGUgZmlsbD0iI0ZGRkZGRiIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+PHBhdGggZD0iTTQ3IDQxLjZsMTIgOC4zODdMNDcgNTguNHoiIGZpbGw9IiMwMGE3NmUiLz48L2c+PC9zdmc+'); // Play icon TODO video plays onClick + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('[data-testid="button"] > img').should('have.attr', 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxjaXJjbGUgZmlsbD0iI0ZGRkZGRiIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+PHBhdGggZD0iTTQ3IDQxLjZsMTIgOC4zODdMNDcgNTguNHoiIGZpbGw9IiMwMGE3NmUiLz48L2c+PC9zdmc+'); // Play icon TODO mobile : video plays onClick & adapts to screen width + cy.viewport(1440, 900); // Switch back to desktop mode for next step + + /* ==== Steps Section ==== */ + cy.get('.mcm-steps') + .should('have.class', 'mcm-steps') // Gray card + .should('have.text', 'Mon Compte Mobilité, comment ça marche ?Je me connecte à moB Mon Compte MobilitéJe gère les demandes d’aides de mes salariésJe me connecte à moB Mon Compte Mobilité'); // Title & subs + cy.get(':nth-child(2) > .step-img > img').should( // Image 1/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODUiIGhlaWdodD0iODUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNNDIuODczIDYwLjkxMWMtNC43OTcgMC04Ljc1Ny0yLjYzNi05LjQ1LTYuMDdDMTguOTI3IDU3Ljk3MiA4LjIyNCA2OC40MTYgOCA4MC44OGg2OC45NzVjLS4yMi0xMi4yNTMtMTAuNTY1LTIyLjU1Ni0yNC42OS0yNS44NzQtLjc4MyAzLjM1NC00LjY5MiA1LjkwNi05LjQxMiA1LjkwNnoiIGZpbGw9IiNGRkQzMTQiLz48cGF0aCBkPSJNMjguNDg3IDE4LjM3OXMyLjkxNS04LjI5NSA1LjI4OC0xMC44NTFjMS44OTItMi4wNCA1LjA1MS0yLjM3NSA3Ljc2OC0xLjc4IDIuNzE3LjU5NiA1LjE5OCAxLjk1OSA3LjgyNyAyLjg2NyA0Ljc2MyAxLjY0NiA3LjYxNyAzLjI2NCAxMS42MTcgNS4yNjQtMiA2LTIuMzE1IDUuMTktNy41MzUgNi45NjgtMy41MDMgMS4xOTQtNy4zMTkuNjctMTEuMDIuNzM1LS44NDkuMDE0LTEuNzkzLjEwMy0yLjM2NS43My0uODUyLjkzNC0uNzMgMi4xMy0xLjU4IDMuMDY3LS41NTQuNjEtMS4yMDUuNzc5LTIgMS0xMy45OSAzLjg5OS04LTgtOC04IiBmaWxsPSIjMzYzNzU3Ii8+PHBhdGggZD0iTTYxLjc0NCAxMi41ODRjLTMuMTA0LTEuNTU3LTYuMTcyLTMuMTcyLTkuNDAyLTQuNDYtMS44MDUtLjcyLTMuNjQzLTEuMzM0LTUuNDM4LTIuMDgzLTEuNzI1LS43Mi0zLjQ3LTEuNDY3LTUuMzE4LTEuODExLTMuMDI3LS41NjMtNi40ODItLjE3NC04Ljc0NSAyLjEwOS0xLjY4NSAxLjctMi43MSA0LjExMi0zLjY1OCA2LjI2OWE3OC4xNzMgNzguMTczIDAgMDAtMi4wNyA1LjE4Yy0xLjQ4MyAzLjA1Ny0yLjU2OCA3Ljk1Ljk0MSAxMC4wMjIgMS4zNTUuNzk5IDMuMDI2LjkzNyA0LjU2My44MTUgMi4wOTEtLjE2NyA1LjA3MS0uNDkyIDYuNjg3LTEuOTUuNDc2LS40MjguODE1LS45NjQgMS4wNzYtMS41NDMuMjI1LS40OTguMzI1LTEuMzgxLjc0Ny0xLjc1OC40ODQtLjQzMyAxLjQwOC0uMjk3IDIuMDAxLS4yOTguOTMxLS4wMDEgMS44NjMuMDI4IDIuNzk0LjA0NyAzLjIwMy4wNjQgNi4wODMtLjI1NCA5LjEzNS0xLjIyNiAxLjYtLjUwOCAzLjQxOC0uODk0IDQuNjY4LTIuMDk0IDEuNDc4LTEuNDIgMi4wOC0zLjY1IDIuNzA4LTUuNTI1LjItLjU5MS0uMTI0LTEuNDExLS42ODktMS42OTRNNDEuOTM4IDcwLjQyMUw0MS45NiA3N2MuMDAyLjc4NC42ODggMS41MzYgMS41IDEuNS44MS0uMDM3IDEuNTAzLS42NiAxLjUtMS41LS4wMDctMi4xOTMtLjAxNS05LjM4Ni0uMDIyLTExLjU3OC0uMDAyLS43ODUtLjY4OC0xLjUzNy0xLjUtMS41LS44MS4wMzYtMS41MDIuNjYtMS41IDEuNSIgZmlsbD0iIzM2Mzc1NyIvPjxwYXRoIGQ9Ik00Ny44NzggMjcuNTcyYzEuOTMgMCAxLjkzNC0zIDAtMy0xLjkzMSAwLTEuOTM0IDMgMCAzIiBmaWxsPSIjMzYzNzU3Ii8+PHBhdGggZD0iTTU3Ljg0NSAxOS45MjVjLTMuNTQtMS41ODEtNi44OTctMy4wMy05LjkzMy01LjQ5NC0zLjE2LTIuNTY2LTYuNzQ2LTQuMzI4LTEwLjkzMi0zLjg0LTEuNzc1LjIwNy0zLjUzMS44MzctNC43NzIgMi4xNzgtMS4yMTEgMS4zMDktMS44MzUgMy4wMTgtMi4yODUgNC43MTMtLjQwMSAxLjUwNi0uNTk3IDMuMTMtMS4xOTYgNC41NzUtLjYzMiAxLjUyNC0yLjAwNyAxLjcwOS0zLjE3OCAyLjcwOS0yLjMxNiAxLjk3Ni0yLjAxNSA1LjA4NC0uMzkgNy4zOTUgMS4zNzUgMS45NTcgMy4zNzkgMy40MjggNS43NSAzLjY2OC4wMTkgNS42MjQuMDM4IDEzLjI0OC4wNTYgMTguODcuMDA1IDEuNDQ5IDIuMjU1IDEuNDUgMi4yNSAwLS4wMi02LjA2Mi0uMDQtMTQuMTI2LS4wNi0yMC4xOWExLjAyNyAxLjAyNyAwIDAwLS4zODQtLjgzMmMtLjIxNS0uNDQ3LS42NC0uNzktMS4yODMtLjc5OC0xLjA3MS0uMDE0LTEuMzQ1LS4yMi0yLjM1NC0uODU4LS41MDEtLjMxNy0xLjEwMy0uOTU0LTEuNTE3LTEuNTY2LS40MzItLjY0LS45LTEuNDgxLS43ODgtMi4yODcuMTQ2LTEuMDU3IDEuMTk3LTEuNDg3IDIuMDMyLTEuOTI2IDIuNTc4LTEuMzU0IDMuMDU1LTQuMjcgMy42NzYtNi44NTMuMzItMS4zMzEuNjItMi43NDggMS4zNjMtMy45Mi45NDUtMS40OTMgMi42MDMtMS45MzggNC4yODMtMS45NThhMTAuMjkgMTAuMjkgMCAwMTUuMTEyIDEuMjc3YzEuNjM4LjkwOCAyLjk4NiAyLjIzMiA0LjQ4MiAzLjM0MiAyLjU5NyAxLjkyOSA1LjYyNCAzLjA3NiA4LjU1NCA0LjM4NSAyLjU1IDEuMTQgNi40MyAyLjcxNyA3LjU2OSA1LjUyNC4yMjMuNTUuMzAzIDEuMjM1LjAzIDEuNzg3LS4zNS43MDctMS4xMS43ODMtMS44MTUuOS0xLjg1NC4zMS0zLjgyNS4zMTUtNS42NDUuNzgyLTEuNjQzLjQyMy0yLjQ5NyAxLjY2NS0yLjc0MyAzLjI4NC0uMzI4IDIuMTYyLS40MiA0LjM4Ni0uNTU2IDYuNTY3YTE3My4yMyAxNzMuMjMgMCAwMC0uMzA3IDEzLjYzNmMuMDMgMS45MjggMy4wMyAxLjkzNCAzIDBhMTczLjIzIDE3My4yMyAwIDAxLjMwNy0xMy42MzZjLjA3LTEuMTM1LjE1My0yLjI3LjI0Ny0zLjQwMi4wNzktLjk0Ny0uMDA4LTIuMTUyLjMyMy0zLjA0Ni4yOC0uNzU4IDEuNDc3LS42NiAyLjE4NC0uNzU1IDEuMDYzLS4xNDIgMi4xMjctLjI4MyAzLjE5LS40MjcgMS43NzItLjI0IDMuNDk0LS42OTMgNC40MDYtMi4zOS44MDktMS41MDIuNjk0LTMuMzExLS4wMy00LjgxNy0xLjU4LTMuMjktNS40ODQtNS4xODUtOC42NDYtNi41OTd6IiBmaWxsPSIjMzYzNzU3Ii8+PC9nPjwvc3ZnPgo=' + ); + cy.get(':nth-child(3) > .step-img > img').should( // Image 2/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iODVweCIgaGVpZ2h0PSI4NXB4IiB2aWV3Qm94PSIwIDAgODUgODUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+SWxsdXMvQmlnL01vYmlsZTwvdGl0bGU+CiAgICA8ZyBpZD0iSWxsdXMvQmlnL01vYmlsZSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTTI0LjE1NDQyOTcsMTMuNzY4NjgyIEMyNS4wODM5Mjk3LDguNTg1NDgyMDQgMjYuMjQ3NzI5Nyw1LjEwNjE4MjA0IDMwLjIwMDAyOTcsNS4wMDM4ODIwNCBDMzIuMTQxNTI5Nyw0Ljk1MzI4MjA0IDQ5LjE1NjMyOTcsNS4zNzM0ODIwNCA2MC44MTYzMjk3LDcuNjgwMTgyMDQgQzYyLjc0NDYyOTcsOC4wNjE4ODIwNCA2NC45Nzk4Mjk3LDguNDA4MzgyMDQgNjYuMjQ4MTI5Nyw5Ljc4MzM4MjA0IEM2Ny4zMTk1Mjk3LDEwLjk0NzE4MiA2Ny44NTUyMjk3LDEzLjE2NDc4MiA2Ny44NTY0MzMyLDE0LjY4Mzg4MiBDNjcuODczOTI5NywzNC44NTQ1ODIgNjUuNjQ2NDI5Nyw1NC45MzYxODIgNjEuNDAyNjI5Nyw3NC43MzM5ODIgQzYxLjA4NDcyOTcsNzYuMjE2NzgyIDYwLjY5NjQyOTcsNzcuNzk3NDgyIDU5LjQ4NTMyOTcsNzguODMxNDgyIEM1OC4wOTA1Mjk3LDgwLjAyMTY4MiA1NS45OTk0Mjk3LDgwLjE2MDI4MiA1NC4wODg3Mjk3LDgwLjIwMzE4MiBDNDEuODEyNzI5Nyw4MC40NzM3ODIgMzIuODUyMTI5Nyw3OS4wOTIxODIgMjAuODM2ODI5Nyw3Ni43ODU0ODIgQzE5LjkwNzMyOTcsNzYuNjA2MTgyIDE4Ljg0MjUyOTcsNzYuMzI0NTgyIDE3Ljg3NjcyOTcsNzUuMTY3MzgyIEMxNy4yNzYxMjk3LDc0LjQ0Nzk4MiAxNy4wMDExMjk3LDcyLjI2MjI4MiAxNyw3MS4zNjEzODIgQzE2Ljk4NzkyOTcsNTEuNjE3NDgyIDIwLjY3ODQyOTcsMzMuMjU4NDgyIDI0LjE1NDQyOTcsMTMuNzY4NjgyIiBpZD0iRmlsbC0xIiBmaWxsPSIjNDY0Q0QwIj48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTMyLjI3MTk2LDI5Ljg4NjMgQzM1Ljk4MTE2LDMwLjM0NjEgMzkuNjkwMzYsMzAuODA3IDQzLjQwMDY2LDMxLjI2NjggQzQ0LjA2NTA2LDMxLjM0OTMgNDQuNjM4MTYsMzAuNjM5OCA0NC42MzgxNiwzMC4wMjkzIEM0NC42MzgxNiwyOS4yOTIzIDQ0LjA2NzI2LDI4Ljg3NTQgNDMuNDAwNjYsMjguNzkxOCBDMzkuNjkwMzYsMjguMzMyIDM1Ljk4MTE2LDI3Ljg3MTEgMzIuMjcxOTYsMjcuNDExMyBDMzEuNjA3NTYsMjcuMzI4OCAzMS4wMzQ0NiwyOC4wMzk0IDMxLjAzNDQ2LDI4LjY0ODggQzMxLjAzNDQ2LDI5LjM4NTggMzEuNjA2NDYsMjkuODAzOCAzMi4yNzE5NiwyOS44ODYzIiBpZD0iRmlsbC00IiBmaWxsPSIjRkZEMzE0Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTQxLjIyODcxLDE0LjYzOTg2IEM0NC45Mzc5MSwxNS4wOTk2NiA0OC42NDcxMSwxNS41NjA1NiA1Mi4zNTYzMSwxNi4wMjAzNiBDNTMuMDIxODEsMTYuMTAyODYgNTMuNTkzODEsMTUuMzkzMzYgNTMuNTkzODEsMTQuNzgyODYgQzUzLjU5MzgxLDE0LjA0NTg2IDUzLjAyNDAxLDEzLjYyODk2IDUyLjM1NjMxLDEzLjU0NTM2IEM0OC42NDcxMSwxMy4wODU1NiA0NC45Mzc5MSwxMi42MjQ2NiA0MS4yMjg3MSwxMi4xNjQ4NiBDNDAuNTY0MzEsMTIuMDgyMzYgMzkuOTkxMjEsMTIuNzkyOTYgMzkuOTkxMjEsMTMuNDAyMzYgQzM5Ljk5MTIxLDE0LjEzOTM2IDQwLjU2MjExLDE0LjU1NzM2IDQxLjIyODcxLDE0LjYzOTg2IiBpZD0iRmlsbC02IiBmaWxsPSIjMzYzNzU3Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTMwLjMzMTg5LDQyLjcyOTY4IEMzNC4xNTU0OSw0My4yMDM3OCAzNy45Nzc5OSw0My42Nzc4OCA0MS44MDE1OSw0NC4xNTMwOCBDNDIuNDY1OTksNDQuMjM1NTggNDMuMDM5MDksNDMuNTI0OTggNDMuMDM5MDksNDIuOTE1NTggQzQzLjAzOTA5LDQyLjE3ODU4IDQyLjQ2ODE5LDQxLjc2MDU4IDQxLjgwMTU5LDQxLjY3ODA4IEMzNy45Nzc5OSw0MS4yMDI4OCAzNC4xNTU0OSw0MC43Mjg3OCAzMC4zMzE4OSw0MC4yNTQ2OCBDMjkuNjY3NDksNDAuMTcyMTggMjkuMDk0MzksNDAuODgyNzggMjkuMDk0MzksNDEuNDkyMTggQzI5LjA5NDM5LDQyLjIyOTE4IDI5LjY2NTI5LDQyLjY0NzE4IDMwLjMzMTg5LDQyLjcyOTY4IiBpZD0iRmlsbC04IiBmaWxsPSIjRkZEMzE0Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTI4LjM0MzUzLDU2Ljc1NjIyIEMzMi4yNDg1Myw1Ny4yNDAyMiAzNi4xNTM1Myw1Ny43MjUzMiA0MC4wNTc0Myw1OC4yMDkzMiBDNDAuNzIxODMsNTguMjkxODIgNDEuMjk0OTMsNTcuNTgyMzIgNDEuMjk0OTMsNTYuOTcxODIgQzQxLjI5NDkzLDU2LjIzNDgyIDQwLjcyNDAzLDU1LjgxNjgyIDQwLjA1NzQzLDU1LjczNDMyIEMzNi4xNTM1Myw1NS4yNTAzMiAzMi4yNDg1Myw1NC43NjUyMiAyOC4zNDM1Myw1NC4yODEyMiBDMjcuNjc5MTMsNTQuMTk4NzIgMjcuMTA2MDMsNTQuOTA5MzIgMjcuMTA2MDMsNTUuNTE4NzIgQzI3LjEwNjAzLDU2LjI1NTcyIDI3LjY3ODAzLDU2LjY3MzcyIDI4LjM0MzUzLDU2Ljc1NjIyIiBpZD0iRmlsbC0xMCIgZmlsbD0iI0ZGRDMxNCI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik01OS41OTM2NSwyNC44MTA1NyBDNTkuMDk5NzUsMjQuMzE2NjcgNTguMzQxODUsMjQuMzYxNzcgNTcuODQzNTUsMjQuODEwNTcgQzU1LjcyMjc1LDI2LjcyMDE3IDUzLjYwMzA1LDI4LjYyOTc3IDUxLjQ4MjI1LDMwLjUzOTM3IEM1MS4wNzA4NSwyOS43NDQwNyA1MC43ODE1NSwyOC44OTkyNyA1MC42OTQ2NSwyOC4wNDIzNyBDNTAuNjI3NTUsMjcuMzc2ODcgNTAuMTc0MzUsMjYuODA0ODcgNDkuNDU3MTUsMjYuODA0ODcgQzQ4Ljg0MTE1LDI2LjgwNDg3IDQ4LjE1MjU1LDI3LjM3MzU3IDQ4LjIxOTY1LDI4LjA0MjM3IEM0OC40MDU1NSwyOS44NjUwNyA0OS4wMTcxNSwzMS42MDk2NyA1MC4xNDM1NSwzMy4wNjgyNyBDNTAuNjQyOTUsMzMuNzE2MTcgNTEuNDM3MTUsMzMuOTA1MzcgNTIuMDg4MzUsMzMuMzE5MDcgQzU0LjU4OTc1LDMxLjA2NjI3IDU3LjA5MjI1LDI4LjgxMzQ3IDU5LjU5MzY1LDI2LjU2MDY3IEM2MC4wOTA4NSwyNi4xMTI5NyA2MC4wNDkwNSwyNS4yNjU5NyA1OS41OTM2NSwyNC44MTA1NyIgaWQ9IkZpbGwtMTIiIGZpbGw9IiNGRkQzMTQiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNTYuMTEyMDQsMzguNzYyNTMgQzUzLjk5MTI0LDQwLjY3MjEzIDUxLjg3MTU0LDQyLjU4MTczIDQ5Ljc1MDc0LDQ0LjQ5MTMzIEM0OS4zNDA0NCw0My42OTcxMyA0OS4wNTExNCw0Mi44NTEyMyA0OC45NjQyNCw0MS45OTQzMyBDNDguODk0OTQsNDEuMzI4ODMgNDguNDQyODQsNDAuNzU2ODMgNDcuNzI2NzQsNDAuNzU2ODMgQzQ3LjEwOTY0LDQwLjc1NjgzIDQ2LjQxOTk0LDQxLjMyNTUzIDQ2LjQ4OTI0LDQxLjk5NDMzIEM0Ni42NzQwNCw0My44MTgxMyA0Ny4yODY3NCw0NS41NjE2MyA0OC40MTMxNCw0Ny4wMjEzMyBDNDguOTEyNTQsNDcuNjY4MTMgNDkuNzA1NjQsNDcuODU3MzMgNTAuMzU2ODQsNDcuMjcxMDMgQzUyLjg1ODI0LDQ1LjAxODIzIDU1LjM1OTY0LDQyLjc2NTQzIDU3Ljg2MzI0LDQwLjUxMjYzIEM1OC4zNTkzNCw0MC4wNjYwMyA1OC4zMTc1NCwzOS4yMTc5MyA1Ny44NjMyNCwzOC43NjI1MyBDNTcuMzY5MzQsMzguMjY4NjMgNTYuNjEwMzQsMzguMzEzNzMgNTYuMTEyMDQsMzguNzYyNTMiIGlkPSJGaWxsLTE0IiBmaWxsPSIjRkZEMzE0Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTU0LjQ3MzkyLDUxLjk2NzkyIEM1Mi4zNTMxMiw1My44Nzc1MiA1MC4yMzM0Miw1NS43ODcxMiA0OC4xMTI2Miw1Ny42OTY3MiBDNDcuNzAyMzIsNTYuOTAyNTIgNDcuNDEzMDIsNTYuMDU2NjIgNDcuMzI1MDIsNTUuMTk5NzIgQzQ3LjI1NzkyLDU0LjUzNDIyIDQ2LjgwNDcyLDUzLjk2MjIyIDQ2LjA4NzUyLDUzLjk2MjIyIEM0NS40NzA0Miw1My45NjIyMiA0NC43ODE4Miw1NC41MzA5MiA0NC44NTAwMiw1NS4xOTk3MiBDNDUuMDM1OTIsNTcuMDIzNTIgNDUuNjQ4NjIsNTguNzY3MDIgNDYuNzc1MDIsNjAuMjI2NzIgQzQ3LjI3NDQyLDYwLjg3MzUyIDQ4LjA2NzUyLDYxLjA2MjcyIDQ4LjcxODcyLDYwLjQ3NjQyIEM1MS4yMjAxMiw1OC4yMjM2MiA1My43MjE1Miw1NS45NzA4MiA1Ni4yMjQwMiw1My43MTgwMiBDNTYuNzIxMjIsNTMuMjcxNDIgNTYuNjc5NDIsNTIuNDIzMzIgNTYuMjI0MDIsNTEuOTY3OTIgQzU1LjczMTIyLDUxLjQ3NDAyIDU0Ljk3MjIyLDUxLjUxOTEyIDU0LjQ3MzkyLDUxLjk2NzkyIiBpZD0iRmlsbC0xNiIgZmlsbD0iI0ZGRDMxNCI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik00Mi4xNTg5OCw2OS4zNjgxNiBDNDAuOTQ4OTgsNjguMzM4NTYgNDAuMjQzODgsNjguNTk3MDYgMzkuNTQ1MzgsNjguNTc1MDYgQzM4LjE0ODM4LDY4LjUzMzI2IDM3LjI0ODU4LDcwLjEyMDU2IDM3LjMzNTQ4LDcxLjUxNTM2IEMzNy4zODgyOCw3Mi4zNjM0NiAzNy43MzU4OCw3My4yMjE0NiAzOC40MDEzOCw3My43NTA1NiBDMzkuNDg0ODgsNzQuNjE0MDYgNDEuMjQzNzgsNzQuMzI0NzYgNDIuMTEyNzgsNzMuMjQ1NjYgQzQyLjk4MDY4LDcyLjE2NTQ2IDQyLjk0NzY4LDcwLjUwNzc2IDQyLjE1ODk4LDY5LjM2ODE2IiBpZD0iRmlsbC0xOCIgZmlsbD0iIzM2Mzc1NyI+PC9wYXRoPgogICAgPC9nPgo8L3N2Zz4=' + ); + cy.get(':nth-child(4) > .step-img > img').should( // Image 3/3 + 'have.attr', + 'src', 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iODVweCIgaGVpZ2h0PSI4NXB4IiB2aWV3Qm94PSIwIDAgODUgODUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+SWxsdXMvQmlnL0dyYXBoPC90aXRsZT4KICAgIDxnIGlkPSJJbGx1cy9CaWcvR3JhcGgiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGQ9Ik0zMS40NzQ3OCwxNC4xMTEwOCBDMjMuMjAyNzgsMTMuMjQ3NTggMTcuMjc3MDgsMTcuODkzOTggMTIuMzcxMDgsMjQuMzg1MDggQzcuNDY1MDgsMzAuODc3MjggNS45NjkwOCwzOS41Mjk4OCA3LjY4MTc4LDQ3LjQ0OTg4IEM4LjMyNDE4LDUwLjQxNTQ4IDkuNDA0MzgsNTMuMzI2MDggMTEuMTUyMjgsNTUuODQxNzggQzE1Ljk0MTY4LDYyLjcyNzc4IDI1LjAyMTA4LDY1LjY1Mzc4IDMzLjU0MDU4LDY1LjkxMDA4IEM0MC43MTQ3OCw2Ni4xMjU2OCA0OC4zODA2OCw2NC41MzUwOCA1My4zNTA0OCw1OS41MzIyOCBDNTcuMzEwNDgsNTUuNTQ1ODggNTkuMDM2MzgsNDkuOTQ0NjggNjAuMDEwOTgsNDQuNDcxMDggQzYwLjcxMzg4LDQwLjUyNzU4IDYxLjA5MjI4LDM2LjQ0NDM4IDYwLjE3NDg4LDMyLjUzMjc4IEM1OC41NTEyOCwyNS42MDkzOCA1Mi45MTA0OCwxOS45Njc0OCA0Ni4yNjUzOCwxNy4wMDUxOCBDMzkuNjE5MTgsMTQuMDQzOTggMzkuNjE5MTgsMTQuMDQzOTggMzEuNDc0NzgsMTQuMTExMDgiIGlkPSJGaWxsLTEiIGZpbGw9IiMwMUJGN0QiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNTQuNSw2OSBDNTYuNzAxMzMsNzEuNDYwNjcgNTguNSw3My41IDY0LjUsNzkuNSBDNjYuMDAzNjY0LDgxLjAwMzY2NCA2OC4zNzUzNTU2LDc4Ljc3Nzk1NSA2Ni45MDEzLDc3LjI0MDYgQzU5LDY5IDYwLjk5MDkzLDcxLjM4NzE3IDUzLjUsNjIuNSBDNTIuMTYzMjMwMiw2MC45MTQwNjg3IDQ5Ljc3NzksNjIuNzg2MiA1MSw2NC41IEw1NC41LDY5IFoiIGlkPSJGaWxsLTYiIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNMjQuMDE1NDYsMjQuNDYxMiBDMjcuNTc1MDYsMjQuNzA1NCAyOC4wNjg5NiwyNC4xMzAxIDMxLjYyODU2LDI0LjM3NDMgQzMwLjkwMzY2LDMzLjU3OCAzMS4yNDkwNiw0My43ODYgMzAuODUzMDYsNTMuNDA3NyBDMjkuMDIxNTYsNTMuNTkzNiAyNS42Mzc5Niw1My4zMjc0IDIzLjcyMDY2LDUzLjQwNzcgQzI0LjIxNjc2LDQzLjgyNzggMjMuMjY2MzYsMzMuODE3OCAyNC4wMTU0NiwyNC40NjEyIiBpZD0iRmlsbC04IiBmaWxsPSIjRkZEMzE0Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZD0iTTMzLjg0NjcxLDMzLjgzMDU2IEMzNS4zODEyMSwzMy42ODg2NiAzNi45MjM0MSwzMy42MzI1NiAzOC40NjIzMSwzMy42NjMzNiBDMzguNzM0MDEsMzMuNjY4ODYgMzkuMDI1NTEsMzMuNjgzMTYgMzkuMjM4OTEsMzMuODUwMzYgQzM5LjU0MTQxLDM0LjA4NTc2IDM5LjU3MTExLDM0LjUyMjQ2IDM5LjU3NjYxLDM0LjkwNTI2IEMzOS42MzI3MSwzOS4zMDc0NiAzOS42Mjk0MSw0My43MTA3NiAzOS41NjU2MSw0OC4xMTQwNiBDMzkuNTQzNjEsNDkuNjk4MDYgMzkuMzgzMDEsNTMuMzIxNDYgMzkuMTQxMDEsNTMuNDc1NDYgQzM4Ljk0NDExLDUzLjYwMTk2IDM1Ljc4OTMxLDUzLjUwMDc2IDM0LjYwNDYxLDUzLjQwODM2IEMzNC4wNzk5MSw1My4zNjc2NiAzMy41NTc0MSw1My40MTgyNiAzMy4wODk5MSw1My4xNzczNiBMMzMuODQ2NzEsMzMuODMwNTYgWiIgaWQ9IkZpbGwtMTAiIGZpbGw9IiNGRkQzMTQiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNDEuNjQ5NzgsNDAuMDAyNDQgQzQyLjk4NjI4LDM5LjYxMzA0IDQ0LjI0Njg4LDM5LjkzNjQ0IDQ1LjY1NTk4LDM5LjgwNjY0IEM0NS44NzM3OCwzOS43ODY4NCA0Ni4xMTM1OCwzOS43NzM2NCA0Ni4yOTI4OCwzOS44ODAzNCBDNDYuNTMyNjgsNDAuMDIyMjQgNDYuNTU1NzgsNDAuMzA3MTQgNDYuNTU1NzgsNDAuNTUzNTQgQzQ2LjU0ODA4LDQ0LjU1MzE0IDQ2LjUzOTI4LDQ4LjU1MTY0IDQ2LjUzMTU4LDUyLjU1MTI0IEM0Ni41MzE1OCw1Mi44MzUwNCA0Ni41MDg0OCw1My4xNTk1NCA0Ni4yNDExOCw1My4zMzY2NCBDNDYuMDM2NTgsNTMuNDcxOTQgNDUuNzUxNjgsNTMuNDc0MTQgNDUuNDkyMDgsNTMuNDY5NzQgQzQ0LjQxODQ4LDUzLjQ1NDM0IDQyLjE1MDI4LDUzLjQ4NjI0IDQxLjA3NjY4LDUzLjQ3MDg0IEw0MS42NDk3OCw0MC4wMDI0NCBaIiBpZD0iRmlsbC0xMiIgZmlsbD0iI0ZGRDMxNCI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik02MC43NTM5NCwxNC40ODI3NyBDNjEuOTQwODQsMTIuMzM0NDcgNjMuMTI2NjQsMTAuMTg2MTcgNjQuMzEzNTQsOC4wMzc4NyBDNjQuNjQyNDQsNy40NDM4NyA2NC40Nzg1NCw2LjU5NTc3IDYzLjg0ODI0LDYuMjYzNTcgQzYzLjIzOTk0LDUuOTQxMjcgNjIuNDI1OTQsNi4wOTQxNyA2Mi4wNzUwNCw2LjcyODg3IEw1OC41MTQzNCwxMy4xNzM3NyBDNTguMTg2NTQsMTMuNzY3NzcgNTguMzQ5MzQsMTQuNjE1ODcgNTguOTc5NjQsMTQuOTQ4MDcgQzU5LjU4Nzk0LDE1LjI3MDM3IDYwLjQwMzA0LDE1LjExNzQ3IDYwLjc1Mzk0LDE0LjQ4Mjc3IiBpZD0iRmlsbC0xNCIgZmlsbD0iIzM2Mzc1NyI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik02Ny43MDI5NywxOS4zMzcxOCBDNjkuMzYzOTcsMTguMzQ0OTggNzEuMDI0OTcsMTcuMzUzODggNzIuNjgzNzcsMTYuMzYxNjggQzczLjI2Njc3LDE2LjAxNDA4IDczLjUzMTg3LDE1LjE4MDI4IDczLjE0OTA3LDE0LjU4NzM4IEM3Mi43NzE3NywxNC4wMDEwOCA3MS45OTg0NywxMy43NTAyOCA3MS4zNzU4NywxNC4xMjIwOCBDNjkuNzE0ODcsMTUuMTE0MjggNjguMDUzODcsMTYuMTA1MzggNjYuMzk1MDcsMTcuMDk3NTggQzY1LjgxMjA3LDE3LjQ0NjI4IDY1LjU0Njk3LDE4LjI4MDA4IDY1LjkyOTc3LDE4Ljg3MTg4IEM2Ni4zMDU5NywxOS40NTcwOCA2Ny4wODAzNywxOS43MDg5OCA2Ny43MDI5NywxOS4zMzcxOCIgaWQ9IkZpbGwtMTciIGZpbGw9IiMzNjM3NTciPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNjcuOTE4OSwyNy41MzEzIEM3MC41ODQyLDI3Ljc2MzQgNzMuMjQ5NSwyNy45OTY2IDc1LjkxNDgsMjguMjI4NyBDNzYuNTkxMywyOC4yODgxIDc3LjI0MDMsMjcuNTkwNyA3Ny4yMTA2LDI2LjkzMTggQzc3LjE3NjUsMjYuMTc5NCA3Ni42Mzk3LDI1LjY5ODcgNzUuOTE0OCwyNS42MzQ5IEM3My4yNDk1LDI1LjQwMjggNzAuNTg0MiwyNS4xNzA3IDY3LjkxODksMjQuOTM4NiBDNjcuMjQxMywyNC44NzkyIDY2LjU5MjMsMjUuNTc2NiA2Ni42MjIsMjYuMjM0NCBDNjYuNjU2MSwyNi45ODc5IDY3LjE5MjksMjcuNDY3NSA2Ny45MTg5LDI3LjUzMTMiIGlkPSJGaWxsLTE5IiBmaWxsPSIjMzYzNzU3Ij48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==' + ); + + /* ==== Toolbox Section ==== */ + cy.get('.mcm-section-with-support--iphone__image > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/5b768200d371dcb5d2e8ae26f4f6d0e7/8ac63/support-iphone.png 200w,\n/static/5b768200d371dcb5d2e8ae26f4f6d0e7/3891b/support-iphone.png 400w,\n/static/5b768200d371dcb5d2e8ae26f4f6d0e7/bc8e0/support-iphone.png 800w,\n/static/5b768200d371dcb5d2e8ae26f4f6d0e7/e48ff/support-iphone.png 868w'); // Image + cy.get('.mcm-section-with-support--iphone__body > .mb-m').should('have.text', 'MOB est une boîte à outils qui vous permettra de :'); // Title + cy.get('.mcm-ordered-list > :nth-child(1)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 1/3 + .should('have.text', 'Mieux comprendre les déplacements de vos salarié.e.s et calculer l’impact carbone de votre entreprise.'); // Text 1/3 + cy.get('.mcm-ordered-list > :nth-child(2)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 2/3 + .should('have.text', 'Simplifier la mise en place et la gestion des aides de mobilité existantes, comme le forfait mobilité durable.'); // Text 2/3 + cy.get('.mcm-ordered-list > :nth-child(3)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 3/3 + .should('have.text', 'Expérimenter et évaluer de nouvelles aides adaptées à votre politique de mobilité.'); // Text 3/3 + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('.mcm-section-with-support--iphone__body > .mb-m').should('have.text', 'MOB est une boîte à outils qui vous permettra de :'); // Title + cy.get('.mcm-ordered-list > :nth-child(1)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 1/3 + .should('have.text', 'Mieux comprendre les déplacements de vos salarié.e.s et calculer l’impact carbone de votre entreprise.'); // Text 1/3 + cy.get('.mcm-ordered-list > :nth-child(2)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 2/3 + .should('have.text', 'Simplifier la mise en place et la gestion des aides de mobilité existantes, comme le forfait mobilité durable.'); // Text 2/3 + cy.get('.mcm-ordered-list > :nth-child(3)') + .should('have.class', 'mcm-ordered-list__item') // Bullet point 3/3 + .should('have.text', 'Expérimenter et évaluer de nouvelles aides adaptées à votre politique de mobilité.'); // Text 3/3 + + /* ==== moB banner Section ==== */ + cy.get('.mob-pattern__svg').should('be.visible'); // Logo image (mobile) + cy.viewport(1440, 900); // Switch back to desktop mode for next step + cy.get('.mob-pattern__svg').should('be.visible'); // Logo image (desktop) + + /* ==== Why join moB? Section ==== */ + cy.get('.img-wrapper > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/3af6040b800ec4cdb106f764b7dea9b8/fd013/homme-d-affaire.jpg 200w,\n/static/3af6040b800ec4cdb106f764b7dea9b8/25252/homme-d-affaire.jpg 400w,\n/static/3af6040b800ec4cdb106f764b7dea9b8/2f1b1/homme-d-affaire.jpg 800w,\n/static/3af6040b800ec4cdb106f764b7dea9b8/768f4/homme-d-affaire.jpg 1160w');// Image TODO right-aligned + cy.get('h2.mb-s').should('have.text', 'Pourquoi rejoindre moB ?'); // Title + cy.get('.mb-xs').should('have.text', 'Rejoindre Mon Compte MOB signifie rejoindre un écosystème comprenant des Collectivités, des Opérateurs de transports et d’autres Employeurs.'); // Text 1/2 + cy.get('p.mb-s').should('have.text', 'Via cet écosystème, vous aurez l\'opportunité de créer des liens avec les collectivités et différents opérateurs de mobilité. Vous pourrez ainsi proposer à vos salariés des aides plus adaptées à leurs besoins.'); // Text 2/2 + cy.get('#employeur-contact2 > .button') + .should('have.text', 'Nous contacter') // Button text + .click(); // Button text + cy.url().should("match", /contact/); // Contact page + cy.go(-1); // Go back to homepage + cy.get('[href="/decouvrir-le-projet"] > .button') + .should('have.text', 'Découvrir le projet') // Button text + .should('have.class', 'button--secondary') // Different button style + .click(); // Redirect + cy.url().should("match", /decouvrir-le-projet/); // Search page + cy.go(-1); // Go back to homepage + cy.get('.img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO image right-aligned & other elements (title, text, button) left-aligned + cy.viewport(365, 568); // Switch to mobile mode for responsive + cy.get('.img-wrapper > .mcm-image > picture > img').should('have.attr', 'style', 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center center; opacity: 1; transition: none 0s ease 0s;'); // TODO image right-aligned & other elements (title, text, button) left-aligned & ordered + cy.viewport(1440, 900); // Switch back to desktop mode for next step + + /* ==== Partners Section ==== */ + cy.get('main.mcm-container__main > .mb-m').should('have.text', 'Tous nos partenaires'); // Title + cy.get(':nth-child(1) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/a23bb028afc39085b9153e5f10b77489/8ac63/logo-ministere.png 200w,\n/static/a23bb028afc39085b9153e5f10b77489/37d5a/logo-ministere.png 300w'); // Image 1/7 + cy.get('.partner-list > :nth-child(1) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', ' https://www.gouvernement.fr/ministere-de-la-transition-ecologique-charge-des-transports') // TODO DEV remove blank ^^ + .then((href) => { + cy.request(href.trim()).its('status').should('eq', 200); // TODO remove trim + }); + cy.get(':nth-child(2) > a > .mcm-image').should('have.attr', 'src', '/static/0a1183844844c732e6d2f1f748f5ddd8/logo-francemob.svg'); // Image 2/7 + cy.get('.partner-list > :nth-child(2) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.francemobilites.fr') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(3) > a > .mcm-image').should('have.attr', 'src', '/static/66e7dd5e7118c2530d603c629276e063/logo-fabmob.png'); // Image 3/7 + cy.get('.partner-list > :nth-child(3) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'http://lafabriquedesmobilites.fr/communs/mon-compte-mobilite/') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(4) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/86b6820a622b2cd351c5631eaac00851/8ac63/logo-igart.png 200w,\n/static/86b6820a622b2cd351c5631eaac00851/8bf6f/logo-igart.png 352w'); // Image 4/7 + cy.get('.partner-list > :nth-child(4) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.gart.org') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(5) > a > .mcm-image').should('have.attr', 'src', '/static/23ad41fdd697ba45ef1dfa568671ae55/logo-ademe.svg'); // Image 5/7 + cy.get('.partner-list > :nth-child(5) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.ademe.fr') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(6) > a > .mcm-image').should('have.attr', 'src', '/static/089f1ed33fee54b05556d02698f72f4e/logo-capgemini.svg'); // Image 6/7 + cy.get('.partner-list > :nth-child(6) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.capgemini.com/fr-fr/mon-compte-mobilite/') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + cy.get(':nth-child(7) > a > .mcm-image > picture > img').should('have.attr', 'srcset', '/static/f906326c6cfd9c28f37ba0ed5f66fff0/8ac63/logo-certificats-%C3%A9conomies-%C3%A9nergie.png 200w,\n/static/f906326c6cfd9c28f37ba0ed5f66fff0/37d5a/logo-certificats-%C3%A9conomies-%C3%A9nergie.png 300w'); // Image 7/7 + cy.get('.partner-list > :nth-child(7) > a') + .should('have.attr', 'target', '_blank') + .should('have.attr', 'href') + .and('eq', 'https://www.ecologie.gouv.fr/dispositif-des-certificats-deconomies-denergie') + .then((href) => { + cy.request(href).its('status').should('eq', 200); + }); + }); +}); \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/incentive-tests/consult_incentive_test.spec.js b/test/cypress/integration/functional-tests/test-mcm/incentive-tests/consult_incentive_test.spec.js new file mode 100644 index 0000000..ac38059 --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/incentive-tests/consult_incentive_test.spec.js @@ -0,0 +1,193 @@ +const userName = "etudiant.mcm@yopmail.com"; +const userPassword = Cypress.env("STUDENT_PASSWORD"); +const websiteUrl = "https://" + Cypress.env("WEBSITE_FQDN") + +describe("Subscription incentive tests unconnected", function () { + it("test how to implement target blank", function () { + // Chose the viewport of the navigator window + cy.viewport(1536, 731); + // Website Starting page here + cy.justVisit("WEBSITE_FQDN"); + // Precise the route of the target element + cy.get( + "nav > .mcm-nav > .mcm-nav__desktop > .mcm-nav__item > #nav-recherche" + ).click(); + // Waiting element + cy.wait(2000); + cy.get('.mcm-dispositifs').then($dispositifs => { + if ($dispositifs.find("#aide-page-0 > div > div > h3", { + timeout: 4000, //for exemple timeout if you need attente + }).length > 0) { + // Create a @tag for save a string and reuse it after + cy.get("#aide-page-0 > div > div > h3").invoke("text").as("titleValue"); + // Methode to manage the target blank + cy.get("#aide-page-0") + .should("have.attr", "target", "_blank") + .should("have.attr", "href") + .and("include", "aide-page") + .then((href) => { + cy.visit(websiteUrl + href); + }); + // Check if the url contain match regex + cy.url().should("match", /aide-page/); + // Reuse @tag text on a other page + cy.get("@titleValue").then((value) => { + cy.log(value); + cy.get("h1").should("have.text", value); + }); + // Go to page with the same methode + cy.get("#subscriptions-incentive") + .should("have.attr", "target", "_blank") + .should("have.attr", "href") + .and("include", "subscriptions") + .then((href) => { + cy.visit(websiteUrl + href); + }); + // Conditionnig use case to test + if (cy.url().should("match", /moncomptemobilite.fr/)) { + cy.wait(2000); + // 2 use case : + cy.url().then(($url) => { + // not connected + cy.url().should( + "match", + /moncomptemobilite\.fr\/auth\/realms\/mcm\/protocol\/openid-connect/ + ); + cy.get( + "#kc-form-wrapper > #mcm-login > #kc-form-login > .form-group > #username" + ).type(userName); + cy.get( + "#kc-form-wrapper > #mcm-login > #kc-form-login > .form-group > #password" + ).type(userPassword); + cy.get( + "#mcm-login > #kc-form-login > .kc-inputs-form > .checkbox > label" + ).click(); + cy.get( + "#mcm-login > #kc-form-login > .kc-inputs-form > #kc-form-buttons > #kc-login" + ).click(); + cy.url().should( + "match", + /moncomptemobilite\.fr\/subscriptions\/new\/\?incentiveId=/ + ); + cy.wait(2000); + cy.get(".field > #mcm-select").click(); + cy.get("#react-select-2-option-0").click(); + cy.get( + "div > .mcm-demande__fields-section > .check-tos > .checkbox-radio > #consent" + ).check({ force: true }); + cy.get( + ".mcm-container > .mcm-container__main > .mcm-subscription > .mt-m > .button" + ).click(); + cy.get( + ".mcm-container > .mcm-container__main > .mcm-subscription > .mt-m > .button" + ).click(); + cy.get( + ".mcm-container > .mcm-container__main > .mcm-subscription > .mt-m > .button" + ).click(); + cy.get("#toast-mcm > div > div > div").should( + "contain", + "Votre souscription à l'aide Covoiturez à Simulation maas a bien été enregistrée auprès de nos services" + ); + }); + } + } else { + // TODO : write something else + cy.should((response) => { + expect(response.status).to.eq(200); + }); + } + }); + }); +}); + +describe("Subscription incentive tests already connected", function () { + it("test how to implement target blank", function () { + // Chose the viewport of the navigator window + cy.viewport(1536, 731); + // Website Starting page here + cy.justVisit("WEBSITE_FQDN"); + cy.get( + "nav > .mcm-nav > .mcm-nav__list > .mcm-nav__item > #nav-login2" + ).click(); + cy.url().should( + "match", + /moncomptemobilite\.fr\/auth\/realms\/mcm\/protocol\/openid-connect/ + ); + cy.get( + "#kc-form-wrapper > #mcm-login > #kc-form-login > .form-group > #username" + ).type(userName); + cy.get( + "#kc-form-wrapper > #mcm-login > #kc-form-login > .form-group > #password" + ).type(userPassword); + cy.get( + "#mcm-login > #kc-form-login > .kc-inputs-form > .checkbox > label" + ).click(); + cy.get( + "#mcm-login > #kc-form-login > .kc-inputs-form > #kc-form-buttons > #kc-login" + ).click(); + cy.url().should("match", /moncomptemobilite.fr\/mon-profil/); + cy.get( + "nav > .mcm-nav > .mcm-nav__desktop > .mcm-nav__item > #nav-recherche" + ).click(); + // Waiting element + if ( + cy.get("#aide-page-0 > div > div > h3", { + timeout: 3000, //for exemple timeout if you need attente + }) + .should("be.visible") + ) { + cy.get("#aide-page-0 > div > div > h3").invoke("text").as("titleValue"); + cy.get("#aide-page-0") + .should("have.attr", "target", "_blank") + .should("have.attr", "href") + .and("include", "aide-page") + .then((href) => { + cy.visit(websiteUrl + href); + }); + cy.url().should("match", /aide-page/); + cy.get("@titleValue").then((value) => { + cy.log(value); + cy.get("h1").should("have.text", value); + }); + cy.get("#subscriptions-incentive") + .should("have.attr", "target", "_blank") + .should("have.attr", "href") + .and("include", "subscriptions") + .then((href) => { + cy.visit(websiteUrl + href); + }); + if (cy.url().should("match", /moncomptemobilite.fr/)) { + cy.wait(2000); + cy.url().then(($url) => { + cy.url().should("match", /subscriptions\/new/); + cy.get(".field > #mcm-select").click(); + cy.get("#react-select-2-option-0").click(); + cy.get( + "div > .mcm-demande__fields-section > .check-tos > .checkbox-radio > #consent" + ).check({ force: true }); + cy.get( + ".mcm-container > .mcm-container__main > .mcm-subscription > .mt-m > .button" + ).click(); + cy.get( + ".mcm-container > .mcm-container__main > .mcm-subscription > .mt-m > .button" + ).click(); + cy.get( + ".mcm-container > .mcm-container__main > .mcm-subscription > .mt-m > .button" + ).click(); + cy.get("#toast-mcm > div > div > div").should( + "contain", + "Votre souscription à l'aide Covoiturez à Simulation maas a bien été enregistrée auprès de nos services" + ); + cy.get("#nav-logout2").click({ force: true }); + + cy.visit(`${websiteUrl}/mon-profil/#`); + }); + } else { + // do something else + cy.should((response) => { + expect(response.status).to.eq(200); + }); + } + } + }); +}); diff --git a/test/cypress/integration/functional-tests/test-mcm/mass_email_reception.spec.js b/test/cypress/integration/functional-tests/test-mcm/mass_email_reception.spec.js new file mode 100644 index 0000000..40add8e --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/mass_email_reception.spec.js @@ -0,0 +1,31 @@ +import faker from '@faker-js/faker'; + +describe("Mass User Creation Test", () => { + context("On souhaite repérer l'anomalie de non réception d'e-mail de bienvenue en effectuant une injection de masse d'utilisateurs", () => { + const mockUserList = []; + it("Création d'une centaine d'utilisateurs à la chaîne", () => { + Cypress._.times(50, () => { + const randomUserEmail = faker.internet.email(); + const user = { + email: randomUserEmail, + affiliation: { + enterpriseEmail: randomUserEmail + } + } + cy.injectUser(user); + mockUserList.push(user); + }); + }); + it("Confirmation des emails recus", () => { + cy.visit(`https://${Cypress.env("MAILHOG_FQDN")}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }).then(() => { + cy.get(mockUserList).each((user) => { + cy.assertEmailReceptionMailHog(user.email.toLowerCase()); + ; + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/cypress/integration/functional-tests/test-mcm/user_creation_test.spec.js b/test/cypress/integration/functional-tests/test-mcm/user_creation_test.spec.js new file mode 100644 index 0000000..688e78e --- /dev/null +++ b/test/cypress/integration/functional-tests/test-mcm/user_creation_test.spec.js @@ -0,0 +1,26 @@ +import mockUser from '../../../fixtures/mock_user.json' +import faker from '@faker-js/faker'; +const randomUserEmail = faker.internet.email(); + + +describe("Nominal User Creation Test", () => { + context("Test de création d'un utilisateur via website", () => { + it("Remplissage du formulaire pour la création d'un utilisateur", () => { + cy.visit(`https://${Cypress.env("WEBSITE_FQDN")}/inscription/formulaire`, { + failOnStatusCode: false, + redirectionLimit: 20, + }).then(() => { + cy.createUser(mockUser, randomUserEmail); + }); + }); + + it("Test de la réception de l'email de bienvenue", () => { + cy.visit(`https://${Cypress.env("MAILHOG_FQDN")}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }).then(() => { + cy.assertEmailReceptionMailHog(randomUserEmail.toLowerCase()); + }); + }); + }); +}) \ No newline at end of file diff --git a/test/cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js b/test/cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js new file mode 100644 index 0000000..93fd260 --- /dev/null +++ b/test/cypress/integration/smoke-tests/test-mcm/smoke_test.spec.js @@ -0,0 +1,32 @@ +describe("Smoke Test MOB", () => { + context("Test des différentes urls du landscape master", () => { + it("Test sur l'url IDP", () => { + cy.assertApplicationIsLoaded("IDP_FQDN", '[class="welcome-header"]').then( + () => { + cy.checkRequest("IDP_FQDN"); + } + ); + }); + it("Test sur l'url API", () => { + cy.assertApplicationIsLoaded("API_FQDN", '[class="info"]').then(() => { + cy.checkRequest("API_FQDN"); + }); + }); + it("Test sur l'url Website", () => { + cy.assertApplicationIsLoaded( + "WEBSITE_FQDN", + '[class="mcm-hero__actions"]' + ).then(() => { + cy.justVisit("WEBSITE_FQDN"); + }); + }); + it("Test sur l'url Admin", () => { + cy.assertApplicationIsLoaded( + "ADMIN_FQDN", + '[class="login-pf-header"]' + ).then(() => { + cy.checkRequest("ADMIN_FQDN"); + }); + }); + }); +}); diff --git a/test/cypress/plugins/index.js b/test/cypress/plugins/index.js new file mode 100644 index 0000000..38dfaff --- /dev/null +++ b/test/cypress/plugins/index.js @@ -0,0 +1,20 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars + +module.exports = (on, config) => {} \ No newline at end of file diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js new file mode 100644 index 0000000..a2bf36c --- /dev/null +++ b/test/cypress/support/commands.js @@ -0,0 +1,141 @@ +import faker from '@faker-js/faker'; + +/* +function checkRequest : +Cypress Command, sends a GET request, then checks response status is equal to 200. +Params : + - app : idp / admin / website etc... + - landscape : master, [branch name], etc.. + - ENVIRONNEMENT : prod, préprod, testing, etc... +*/ +Cypress.Commands.add("checkRequest", (FQDN) => { + cy.request({ + method: "GET", + url: `https://${Cypress.env(FQDN)}`, + failOnStatusCode: false, + redirectionLimit: 20, + }).should((response) => { + expect(response.status).to.eq(200); + }); +}); + +/* +function checkRequestRedirect : + * cy.request() sends request where type and url are configurable. +the function aims to check Website URL, which is slightly different than others URLs : +when requesting Website, the expected behaviour is to recieve a 404 status, then the page displays "keycloak is loading...", and redirects to Website homepage eventually +thus, this functions expects to get a 404 then to be redirected, then to get a 200. +*/ +Cypress.Commands.add("checkRequestRedirect", (FQDN) => { + cy.request({ + method: "GET", + url: `https://${Cypress.env(FQDN)}`, + failOnStatusCode: false, + redirectionLimit: 20, + }).should((response) => { + expect(response.status).to.eq(404); + }); + cy.on("url:redirection", () => { + cy.should((response) => { + expect(response.status).to.eq(200); + }); + }); +}); + +/* +fonction assertApplicationIsLoaded : + * cy.visit() : goes to mentionned URL. + * cy.get(assertionWitness).should("be.visible") : searchs for an element of the page called assertionWitness, and check it's available +the function asserts that the app is loaded, while checking the homepage is available. +*/ +Cypress.Commands.add("assertApplicationIsLoaded", (FQDN, assertionWitness) => { + cy.visit(`https://${Cypress.env(FQDN)}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }); + cy.get(assertionWitness, { timeout: 5000 }).should("be.visible"); +}); + +/* +fonction justVisit : + * cy.visit() : goes to mentionned URL. +the function asserts that the app is loaded, while checking the homepage is available. +*/ +Cypress.Commands.add("justVisit", (FQDN) => { + cy.visit(`https://${Cypress.env(FQDN)}`, { + failOnStatusCode: false, + redirectionLimit: 20, + }); +}); + +/* +Account creation function. +Fills each element of the register form and submits. +*/ +Cypress.Commands.add("createUser", (user, randomUserEmail) => { + // impossible to get the Select element "Statut professionnel", Cypress Studio solution : + /* ==== Generated with Cypress Studio ==== */ + cy.get(':nth-child(1) > #mcm-select > .mcm-select__control > .mcm-select__value-container > .mcm-select__placeholder').click({ force: true }); + cy.get(':nth-child(1) > #mcm-select > .mcm-select__control > .mcm-select__value-container > .mcm-select__placeholder').click(); + cy.get('#react-select-4-option-1').click(); + cy.get('.mcm-select__single-value').should('have.text', user.status); + /* ==== End Cypress Studio ==== */ + + cy.get('input[name="lastName"]').type(user.lastName); + cy.get('input[name="firstName"]').type(user.firstName); + cy.get('input[name="birthdate"]').type(user.birthdate); + cy.get('input[name="email"]').type(randomUserEmail); + cy.get('input[name="password"]').type(user.password); + cy.get('input[name="passwordConfirmation"]').type(user.passwordConfirmation); + cy.get('input[name="city"]').type(user.city); + cy.get('input[name="postcode"]').type(user.postcode); + + cy.get('input[id=companyNotFound]').check({ force: true }); + cy.get('input[id=hasNoEnterpriseEmail]').check({ force: true }); + + cy.get('input[id=tos1]').check({ force: true }); + cy.get('input[id=tos2]').check({ force: true }); + cy.get('button[type="submit"]').click(); +}); + + +/* +Asserts the welcome email has been recieved and is in Mailhog +*/ +Cypress.Commands.add('assertEmailReceptionMailHog', (userEmail) => { + cy.get('.col-sm-4').contains(userEmail); +}); + +/* +User creation function: sends a POST request directly to API. +*/ +Cypress.Commands.add('injectUser', (user) => { + cy.request({ + url: `https://${Cypress.env("API_FQDN")}/v1/citizens`, + method: 'POST', + headers: { + 'X-API-KEY': `${Cypress.env("API_KEY")}` + }, + body: { + email: user.email, + firstName: faker.name.lastName(), + lastName: faker.name.firstName(), + id: "", + password: "Jacq00illes!", + birthdate: "1970-01-01", + city: "Toulouse", + postcode: "31000", + status: "etudiant", + tos1: true, + tos2: true, + affiliation: { + enterpriseId: "", + enterpriseEmail: user.affiliation.enterpriseEmail, + affiliationStatus: "A_AFFILIER" + } + }, + failOnStatusCode: false + }).then((res) => { + expect(res.status).to.eq(200) + }); +}); diff --git a/test/cypress/support/index.js b/test/cypress/support/index.js new file mode 100644 index 0000000..d076cec --- /dev/null +++ b/test/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import "./commands"; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/test/package.json b/test/package.json new file mode 100644 index 0000000..1611794 --- /dev/null +++ b/test/package.json @@ -0,0 +1,20 @@ +{ + "name": "cypress-mcm", + "version": "1.10.1", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "cypress: open": "./node_modules/.bin/cypress open", + "cypress: run": "./node_modules/.bin/cypress run --spec ** / *. spec.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@faker-js/faker": "6.2.0", + "cypress": "^9.5.0", + "random-email": "^1.0.3", + "mochawesome": "^7.1.3" + } +} diff --git a/vault/.gitignore b/vault/.gitignore new file mode 100644 index 0000000..e86fe25 --- /dev/null +++ b/vault/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env + +# gatsby files +.cache/ +public + +# Mac files +.DS_Store + +# Yarn +yarn-error.log +.pnp/ +.pnp.js +# Yarn Integrity file +.yarn-integrity diff --git a/vault/.gitlab-ci.yml b/vault/.gitlab-ci.yml new file mode 100644 index 0000000..9b50a90 --- /dev/null +++ b/vault/.gitlab-ci.yml @@ -0,0 +1,16 @@ +include: + - local: "vault/.gitlab-ci/preview.yml" + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + +.vault-base: + variables: + MODULE_NAME: vault + MODULE_PATH: ${MODULE_NAME} + BASE_IMAGE_VAULT: ${NEXUS_DOCKER_REPOSITORY_URL}/vault:1.11.3 + VAULT_IMAGE_NAME: ${REGISTRY_BASE_NAME}/${MODULE_NAME}:${IMAGE_TAG_NAME} + only: + changes: + - "*" + - "commons/**/*" + - "vault/**/*" diff --git a/vault/.gitlab-ci/preview.yml b/vault/.gitlab-ci/preview.yml new file mode 100644 index 0000000..50a9aef --- /dev/null +++ b/vault/.gitlab-ci/preview.yml @@ -0,0 +1,53 @@ + +# Build of testing environement image and creation of the cache +vault_build: + extends: + - .build-job + - .vault-base + - .only-master + - .no-needs + script: + - echo 'build' + cache: + key: ${MODULE_NAME}-${CI_COMMIT_REF_SLUG} + paths: + - ${MODULE_PATH}/ + artifacts: + paths: + - ${MODULE_PATH}/ + expire_in: 5 days + +vault_image_build: + extends: + - .preview-image-job + - .only-master + - .vault-base + needs: ['vault_build'] + +vault_preview_deploy: + extends: + - .preview-deploy-job + - .vault-base + - .only-master + - .manual + script: + - | + if [ -n "$(pod_namespace vault-init)" ] + then + echo "### Vault-init pod is already up so we delete it before recreating it ! ###" + KUBE_NAMESPACE_VAULT_INIT=$(pod_namespace vault-init) + kubectl delete pod vault-init -n $KUBE_NAMESPACE_VAULT_INIT + fi + deploy + config_volume vault-data + config_volume vault-init-data + config_volume vault-cron-data + needs: ['vault_image_build'] + environment: + on_stop: vault_preview_cleanup + +vault_preview_cleanup: + extends: + - .commons_preview_cleanup + - .vault-base + - .only-master diff --git a/vault/Dockerfile b/vault/Dockerfile new file mode 100644 index 0000000..ae44ed1 --- /dev/null +++ b/vault/Dockerfile @@ -0,0 +1,42 @@ +ARG BASE_IMAGE_VAULT +FROM ${BASE_IMAGE_VAULT} + +ARG VAULT_CERT +ARG VAULT_KEY +ARG VAULT_ROOT_CA +ARG ADMIN_CERT +ARG ADMIN_CERT_KEY +ARG CLIENT_CA + +RUN apk add --update coreutils apk-cron curl jq util-linux && rm -rf /var/cache/apk/* + +COPY ./init-vault.sh /usr/local/bin/init-vault.sh +COPY ./renew-key.sh /usr/local/bin/renew-key.sh +COPY ./config.hcl /vault/config/config.hcl +COPY ./manager-policy.hcl /vault/config/manager-policy.hcl +COPY ./admin-policy.hcl /vault/config/admin-policy.hcl + +COPY ${VAULT_CERT} /etc/ssl/certs/vault-cert.pem +COPY ${VAULT_KEY} /etc/ssl/certs/vault-key.pem +COPY ${VAULT_ROOT_CA} /etc/ssl/certs/vault-ca.pem + +COPY ${CLIENT_CA} /etc/ssl/certs/client-ca.pem +COPY ${ADMIN_CERT} /etc/ssl/certs/admin-client-cert.pem +COPY ${ADMIN_CERT_KEY} /etc/ssl/certs/admin-client-key.pem + +COPY vault-crontab /etc/cron.d/vault-crontab + +ENV VAULT_CACERT=/etc/ssl/certs/vault-ca.pem + +RUN chmod 777 -R /vault/config +RUN chmod 644 /etc/ssl/certs/vault-ca.pem +RUN chmod 644 /etc/ssl/certs/vault-cert.pem +RUN chmod 644 /etc/ssl/certs/vault-key.pem +RUN chmod 644 /etc/ssl/certs/client-ca.pem +RUN chmod 644 /etc/ssl/certs/admin-client-cert.pem +RUN chmod 644 /etc/ssl/certs/admin-client-key.pem +RUN chmod +x /usr/local/bin/init-vault.sh +RUN chmod +x /usr/local/bin/renew-key.sh +RUN chmod 0644 /etc/cron.d/vault-crontab && crontab /etc/cron.d/vault-crontab + +EXPOSE 8200 \ No newline at end of file diff --git a/vault/README.md b/vault/README.md index ab4ed88..4dc6450 100644 --- a/vault/README.md +++ b/vault/README.md @@ -2,12 +2,11 @@ Le service vault se base sur la brique logicielle **[Vault](https://github.com/hashicorp/vault)** -Ce service est destiné aux financeurs partenaires qui souhaitent proposer des aides sur la plateforme MOB et qui ne possèdent pas leur propre solution de chiffrement de données. +Ce service est destiné aux financeurs partenaires qui souhaitent proposer des aides sur la plateforme MOB et qui ne possèdent pas leur propre solution de chiffrement de données. Il ne fait donc pas partie de l'architecture interne de moB mais est bien un **composant requis pour l'utilisation de la solution par les financeurs**. Il doit être installé dans le SI du financeur et va l'utiliser pour envoyer à MOB une clé publique permettant de chiffrer les justificatifs envoyés par un citoyen lors de la souscription à une aide de ce financeur. Le gestionnaire va ensuite déchiffrer ces justificatifs lors du traitement de la demande à l'aide de la clé privée stockée de manière sécurisée dans le Vault. -Son installation en local permet simuler les actions d'un financeur et de tester le chiffrement et le déchiffrement de justificatifs. - +Son installation en local permet de simuler les actions d'un financeur et de tester le chiffrement et le déchiffrement de justificatifs. # Installation en local @@ -29,43 +28,45 @@ Si vous utilisez WSL : il faut attribuer associer l'adresse IP de WSL dans les f ### Création de certificats -`. ./createCertificates.sh vault.example.com` - -Ajouter le certificat ***manager-client-cert.pfx*** dans les ***Certificats personnels*** de l'utilisateur - -Ajouter le certificat ***rootCA.pem*** dans les ***Autorités de certification racines de confiance*** - -## Variables - -| Variables | Description | Obligatoire | -| ----------- | ----------- | ----------- | -| CLIENT_ID | Client ID du client Keycloak créé pour le financeur | Oui | -| CLIENT_SECRET | Client Secret du client Keycloak créé pour le financeur | Oui | -| FUNDER_IDS | Liste des identifiants financeurs à autoriser | Oui | -| API_URL | URL du service api | Oui | -| IDP_URL | URL du service idp | Oui | -| AVAILABLE_KEYS | Nombre de clés à conserver dans le vault | Non | -| FUNDER_TOKEN | Token permettant de se connecter à l'UI du Vault | Oui | -| VAULT_ADDR | URL du Vault sans slash. (ex : **https://vault.example.com:8200** et pas **https://vault.example.com/** ) | Oui | -| VAULT_API_ADDR | URL de l’API du Vault (même URL que VAULT_ADDR) | Oui | -| VAULT_CERT | Chemin vers l’emplacement du certificat serveur sur la machine où est lancé le Vault, à utiliser pour le TLS dans le Vault. (ex : **./certs/simulation-vault.preview.moncomptemobilite.fr.crt**) | Oui | -| VAULT_KEY | Chemin vers l’emplacement de la clé privée du certificat serveur sur la machine où est lancé le Vault. (ex : **./certs/simulation-vault.preview.moncomptemobilite.fr.key**) | Oui | -| VAULT_ROOT_CA | Chemin vers l’emplacement du certificat de l’autorité de certification sur la machine où est lancé le Vault, utilisé pour vérifier le certificat du serveur SSL du Vault. (ex : **./certs/rootCA.pem**) | Oui | -| ADMIN_CERT | Chemin vers l’emplacement du certificat client sur la machine où est lancé le Vault, utilisé pour s’authentifier en tant qu’administrateur. (ex : **./certs/ admin-client-cert.pem**) | Oui | -| ADMIN_CERT_KEY | Chemin vers la clé privée du certificat client administrateur sur la machine où est lancé le Vault. (ex : **./certs/admin-client-key.pem**) | Oui | -| CLIENT_CA | Chemin vers l’emplacement du certificat de l’autorité de certification sur la machine où est lancé le Vault, utilisé pour vérifier les certificats clients utilisés pour l’authentification par certificat.(ex : **./certs/client-ca.pem**). Peut être le même que VAULT_ROOT_CA mais pas nécessairement. | Oui | +```sh +. ./createCertificates.sh vault.example.com +``` + +- Ajouter le certificat **_manager-client-cert.pfx_** dans les **_Certificats personnels_** de l'utilisateur +- Ajouter le certificat **_rootCA.pem_** dans les **_Autorités de certification racines de confiance_** + +## Variables + +| Variables | Description | Obligatoire | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| CLIENT_ID | Client ID du client Keycloak créé pour le financeur | Oui | +| CLIENT_SECRET | Client Secret du client Keycloak créé pour le financeur | Oui | +| FUNDER_IDS | Liste des identifiants financeurs à autoriser | Oui | +| API_URL | URL du service api | Oui | +| IDP_URL | URL du service idp | Oui | +| AVAILABLE_KEYS | Nombre de clés à conserver dans le vault | Non | +| FUNDER_TOKEN | Token permettant de se connecter à l'UI du Vault | Oui | +| VAULT_ADDR | URL du Vault sans slash. (ex : **https://vault.example.com:8200** et pas **https://vault.example.com/** ) | Oui | +| VAULT_API_ADDR | URL de l’API du Vault (même URL que VAULT_ADDR) | Oui | +| VAULT_CERT | Chemin vers l’emplacement du certificat serveur sur la machine où est lancé le Vault, à utiliser pour le TLS dans le Vault. (ex : **./certs/simulation-vault.preview.moncomptemobilite.fr.crt**) | Oui | +| VAULT_KEY | Chemin vers l’emplacement de la clé privée du certificat serveur sur la machine où est lancé le Vault. (ex : **./certs/simulation-vault.preview.moncomptemobilite.fr.key**) | Oui | +| VAULT_ROOT_CA | Chemin vers l’emplacement du certificat de l’autorité de certification sur la machine où est lancé le Vault, utilisé pour vérifier le certificat du serveur SSL du Vault. (ex : **./certs/rootCA.pem**) | Oui | +| ADMIN_CERT | Chemin vers l’emplacement du certificat client sur la machine où est lancé le Vault, utilisé pour s’authentifier en tant qu’administrateur. (ex : **./certs/ admin-client-cert.pem**) | Oui | +| ADMIN_CERT_KEY | Chemin vers la clé privée du certificat client administrateur sur la machine où est lancé le Vault. (ex : **./certs/admin-client-key.pem**) | Oui | +| CLIENT_CA | Chemin vers l’emplacement du certificat de l’autorité de certification sur la machine où est lancé le Vault, utilisé pour vérifier les certificats clients utilisés pour l’authentification par certificat.(ex : **./certs/client-ca.pem**). Peut être le même que VAULT_ROOT_CA mais pas nécessairement. | Oui | ## Démarrage -`docker network create dev_web-nw` - -`docker volume create vault-data` - -`docker compose -f vault-docker-compose.yml up` +```sh +docker network create dev_web-nw +docker volume create vault-data +docker compose -f vault-docker-compose.yml up +``` ## URL / Port Interface : + - URL : https://vault.example.com:8200 - Port : 8200 @@ -79,12 +80,25 @@ Un seul vault utilisé (sur la branche master) pour l'ensemble des branches de d Pas de précisions nécéssaires pour ce service - # Relation avec les autres services -L'api et l'idp sont les deux services appelés par le vault pour envoyer des clés publiques. -Le service website appelle le vault pour récupérer les clés privées associées et déchiffrer les justificatifs +L'[api](api) et l'[idp](idp) sont les deux services appelés par le vault pour envoyer des clés publiques. +Le service [website](website) appelle le vault du financeur de l'aide pour récupérer les clés privées associées et déchiffrer les justificatifs joints à la souscription. # Tests Unitaires -Pas de tests unitaires nécéssaires pour ce service +Pas de tests unitaires nécessaires pour ce service + +# Packaging + +Pour packager une nouvelle version du Vault à transmettre aux financeurs, il faut lancer le script **_create-vault-release.sh_** et fournir la version de la nouvelle release en paramètre : + +```sh +. ./create-vault-release.sh 1.0.0 +``` + +Une archive **_mcm-vault-v1.0.0.zip_** est générée. C'est cette archive qui devra être transmise aux financeurs. + +# Documentation complète Financeur + +La documentation complète de déploiement du Key Manager Vault chez le financeur est disponible dans les [docs](DIN-MCM_moB_KeyManager_Vault_V1.4.pdf) diff --git a/vault/admin-policy.hcl b/vault/admin-policy.hcl new file mode 100644 index 0000000..bc2ef34 --- /dev/null +++ b/vault/admin-policy.hcl @@ -0,0 +1,77 @@ +# Read system health check +path "sys/health" +{ + capabilities = ["read", "sudo"] +} + +# Create and manage ACL policies broadly across Vault + +# List existing policies +path "sys/policies/acl" +{ + capabilities = ["list"] +} + +# Create and manage ACL policies +path "sys/policies/acl/*" +{ + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +# Enable and manage authentication methods broadly across Vault + +# Manage auth methods broadly across Vault +path "auth/*" +{ + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +# Create, update, and delete auth methods +path "sys/auth/*" +{ + capabilities = ["create", "update", "delete", "sudo"] +} + +# List auth methods +path "sys/auth" +{ + capabilities = ["read"] +} + +# Configure vault +path "sys/config/*" +{ + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +# Enable and manage the key/value secrets engine at `secret/` path + +# List, create, update, and delete key/value secrets +path "secret/*" +{ + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "kv/*" { + capabilities = ["create", "read", "update", "list", "delete"] +} + +path "cubbyhole/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "transit/*" { + capabilities = ["create", "read", "update", "list"] +} + +# Manage secrets engines +path "sys/mounts/*" +{ + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +# List existing secrets engines. +path "sys/mounts" +{ + capabilities = ["read"] +} \ No newline at end of file diff --git a/vault/config.hcl b/vault/config.hcl new file mode 100644 index 0000000..02aa16c --- /dev/null +++ b/vault/config.hcl @@ -0,0 +1,12 @@ +storage "file" { + path = "/vault/file/data" +} + +listener "tcp" { + address = "0.0.0.0:8200" + tls_cert_file = "/etc/ssl/certs/vault-cert.pem" + tls_key_file = "/etc/ssl/certs/vault-key.pem" + tls_client_ca_file = "/etc/ssl/certs/vault-ca.pem" +} + +ui = true \ No newline at end of file diff --git a/vault/create-vault-release.sh b/vault/create-vault-release.sh new file mode 100644 index 0000000..43683a8 --- /dev/null +++ b/vault/create-vault-release.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +if [ "$#" -ne 1 ] +then + echo "Usage: Must supply a release version" + echo "Example : 1.0.0" +else + RELEASE_VERSION=$1 + + zip mcm-vault-v$RELEASE_VERSION.zip admin-policy.hcl config.hcl createCertificates.sh Dockerfile init-vault.sh manager-policy.hcl renew-key.sh vault-crontab vault-docker-compose.yml +fi + diff --git a/vault/createCertificates.sh b/vault/createCertificates.sh new file mode 100644 index 0000000..72b121d --- /dev/null +++ b/vault/createCertificates.sh @@ -0,0 +1,68 @@ +#!/bin/sh + +if [ "$#" -ne 1 ] +then + echo "Usage: Must supply a domain" + exit 1 +fi + +DOMAIN=$1 + +mkdir $DOMAIN +cd $DOMAIN + +### Certificate Authority + +## Certificate Authority - Generating the Private Key and Root Certificate +echo -e "\n***** Generate Root CA Private Key *****\n" +openssl genrsa -des3 -out rootCA.key 2048 +echo -e "\n***** Generate Root CA Certificate *****\n" +echo -e "\nDo not use the server domain as Common Name or it will not work\n" +openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1825 -out rootCA.pem + + +## Certificate Authority - Installing Root Certificate +# echo "Install Root CA in CA Store" +# cp rootCA.pem /usr/local/share/ca-certificates/rootCA.crt +# sudo update-ca-certificates + + +### CA Signed Certificates for Your Sites + +## Creating the Private Key for your site + +echo -e "\n***** Generate Server CA Private Key *****\n" +openssl genrsa -out $DOMAIN.key 2048 +## Creating a Certificate Signing Request +echo -e "\n***** Generate Server CA CSR *****\n" +openssl req -new -key $DOMAIN.key -out $DOMAIN.csr + +# +cat > $DOMAIN.ext << EOF +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names +[alt_names] +DNS.1 = $DOMAIN +EOF + +## Creating the Certificate from Certificate Signing Request, CA Private Key, CA Root Certificate and config file +echo -e "\n***** Generate Server Certificate for $DOMAIN *****\n" +openssl x509 -req -in $DOMAIN.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out $DOMAIN.crt -days 825 -sha256 -extfile $DOMAIN.ext +echo -e "\n***** Convert Server Certificate to p12 format *****\n" +openssl pkcs12 -export -clcerts -in $DOMAIN.crt -inkey $DOMAIN.key -out $DOMAIN.p12 + +echo -e "\n***** Generate Admin Client Certificate Private Key *****\n" +openssl req -newkey rsa:2048 -days 1000 -nodes -keyout admin-client-key.pem > admin-client-req.pem +echo -e "\n***** Generate Admin Client Certificate signed by Root CA *****\n" +openssl x509 -req -in admin-client-req.pem -days 1000 -CA rootCA.pem -CAkey rootCA.key > admin-client-cert.pem +echo -e "\n***** Convert Admin Client Certificate to PKCS #12 format *****\n" +openssl pkcs12 -export -in admin-client-cert.pem -inkey admin-client-key.pem -out admin-client-cert.pfx + +echo -e "\n***** Generate Manager Client Certificate Private Key *****\n" +openssl req -newkey rsa:2048 -days 1000 -nodes -keyout manager-client-key.pem > manager-client-req.pem +echo -e "\n***** Generate Manager Client Certificate signed by Root CA *****\n" +openssl x509 -req -in manager-client-req.pem -days 1000 -CA rootCA.pem -CAkey rootCA.key > manager-client-cert.pem +echo -e "\n***** Generate Manager Client Certificate to PKCS #12 format *****\n" +openssl pkcs12 -export -in manager-client-cert.pem -inkey manager-client-key.pem -out manager-client-cert.pfx \ No newline at end of file diff --git a/vault/init-vault.sh b/vault/init-vault.sh new file mode 100644 index 0000000..a5dd108 --- /dev/null +++ b/vault/init-vault.sh @@ -0,0 +1,541 @@ +#!/bin/sh + +sleep 10 + +check_vault_status () { + echo "************" + echo "Vault Status" + echo "************" + if [ -s /vault/file/status ]; then + rm /vault/file/status + fi + if [ -s /vault/file/status-error ]; then + rm /vault/file/status-error + fi + vault status -format="json" > /vault/file/status 2>/vault/file/status-error + if [ -s /vault/file/status-error ]; then + echo "Check vault status failed" + cat /vault/file/status-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + cat /vault/file/status +} + +init () { + echo "**********" + echo "Init Vault" + echo "**********" + VAULT_STATUS=$(cat /vault/file/status) + INITIALIZED=$(echo $VAULT_STATUS | jq '.initialized') + if [ "$INITIALIZED" = true ] ; then + echo -e '\nVault already Initialized\n' + else + vault operator init > /vault/file/keys 2>/vault/file/init-error + if [ -s /vault/file/init-error ]; then + echo "Vault init failed" + cat /vault/file/init-error + rm /vault/file/init-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e "\nVault Initialized\n" + fi +} + +unseal () { + echo "************" + echo "Unseal Vault" + echo "************" + VAULT_STATUS=$(cat /vault/file/status) + SEAL_STATUS=$(echo $VAULT_STATUS | jq '.sealed') + if [ "$SEAL_STATUS" = false ] ; then + echo -e '\nVault already Unsealed\n' + else + vault operator unseal $(grep 'Key 1:' /vault/file/keys | awk '{print $NF}') 2>/vault/file/unseal-error + vault operator unseal $(grep 'Key 2:' /vault/file/keys | awk '{print $NF}') 2>/vault/file/unseal-error + vault operator unseal $(grep 'Key 3:' /vault/file/keys | awk '{print $NF}') 2>/vault/file/unseal-error + if [ -s /vault/file/unseal-error ]; then + echo "Vault unseal failed" + cat /vault/file/unseal-error + rm /vault/file/unseal-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e '\nVault Unsealed\n' + fi +} + + +root_log_in () { + echo "***********" + echo "Root Log In" + echo "***********" + export ROOT_TOKEN=$(grep 'Initial Root Token:' /vault/file/keys | awk '{print $NF}') + vault login $ROOT_TOKEN -no-print=true > /dev/null + if [ -z "$(grep "$ROOT_TOKEN" "/root/.vault-token")" ]; then + echo "Login failed, retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e "\nRoot Login success\n" +} + +create_policy () { + echo "*************" + echo "Create Policy" + echo "*************" + vault policy write admin /vault/config/admin-policy.hcl 2>/vault/file/create-admin-policy-error + vault policy write manager /vault/config/manager-policy.hcl 2>/vault/file/create-manager-policy-error + if [ -s /vault/file/create-admin-policy-error ]; then + echo "Error creating admin policy" + cat /vault/file/create-admin-policy-error + rm /vault/file/create-admin-policy-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + if [ -s /vault/file/create-manager-policy-error ]; then + echo "Error creating cert policy" + cat /vault/file/create-manager-policy-error + rm /vault/file/create-manager-policy-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e "\nPolicies admin and manager created\n" +} + +enable_cert () { + echo "***********" + echo "Enable Cert" + echo "***********" + vault auth enable cert 2>/vault/file/enable-cert-error + if [ -s /vault/file/enable-cert-error ]; then + if [ -z "$(grep '* path is already in use at cert/' /vault/file/enable-cert-error)" ]; then + echo "Error enabling cert" + cat /vault/file/enable-cert-error + rm /vault/file/enable-cert-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + fi + echo -e "\nCert auth enabled\n" +} + +create_cert_role_admin () { + echo "**********************" + echo "Create Cert Role Admin" + echo "**********************" + vault write auth/cert/certs/admin display_name=admin policies=admin certificate=@/etc/ssl/certs/client-ca.pem 2>/vault/file/cert-admin-role-error + if [ -s /vault/file/cert-admin-role-error ]; then + echo "Error creating cert role admin" + cat /vault/file/cert-admin-role-error + rm /vault/file/cert-admin-role-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e "\nCert role admin created\n" +} + +create_cert_role_manager () { + echo "***********************" + echo "Create Cert Role Manager" + echo "***********************" + vault write auth/cert/certs/manager display_name=manager policies=manager certificate=@/etc/ssl/certs/client-ca.pem 2>/vault/file/cert-role-error + if [ -s /vault/file/cert-role-error ]; then + echo "Error creating cert role manager" + cat /vault/file/cert-role-error + rm /vault/file/cert-role-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e "\nCert role manager created\n" +} + +create_token () { + echo "*******************" + echo "Create Funder Token" + echo "*******************" + vault token create -id $FUNDER_TOKEN -policy="admin" > /vault/file/admin-token 2>/vault/file/create-token-error + if [ ! -s /vault/file/admin-token ]; then + if [ -z "$(grep '* cannot create a token with a duplicate ID' /vault/file/create-token-error)" ]; then + echo "Error creating admin token" + cat /vault/file/create-token-error + rm /vault/file/create-token-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + fi + echo -e "\nFunder token created\n" +} + +cert_log_in () { + echo "************" + echo "Cert Log In" + echo "************" + ADMIN_CERT_ROLE='{"name": "admin"}' + CERT_LOGIN=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --cert /etc/ssl/certs/admin-client-cert.pem --key /etc/ssl/certs/admin-client-key.pem --data "$ADMIN_CERT_ROLE" --request POST $VAULT_ADDR/v1/auth/cert/login) + CERT_LOGIN_RESP=$(echo "$CERT_LOGIN" | head -n -1) + STATUS_CODE=$(echo "$CERT_LOGIN" | tail -n 1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Status Code : " $STATUS_CODE + echo "Login failed : " $CERT_LOGIN_RESP + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + export AUTH_TOKEN=$(echo $CERT_LOGIN_RESP | jq '.auth.client_token' | sed "s/\"//g" ) + echo -e "\nCert Login success\n" +} + +setup_cors () { + echo "**********" + echo "Setup CORS" + echo "**********" + CORS_SETTINGS='{"allowed_origins": "*"}' + POST_CORS_SETTINGS=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --request POST --data "$CORS_SETTINGS" --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/sys/config/cors) + STATUS_CODE=$(echo "$POST_CORS_SETTINGS" | tail -n 1) + POST_CORS_RESP=$(echo "$POST_CORS_SETTINGS" | head -n -1) + if [ $STATUS_CODE -ne 204 ]; then + echo "Status Code : " $STATUS_CODE + echo "Error setting CORS : " $POST_CORS_RESP + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + echo -e "\nCORS configured\n" +} + +enable_transit () { + echo "**************" + echo "Enable transit" + echo "**************" + vault secrets enable transit 2>/vault/file/enable-transit-error + if [ -s /vault/file/enable-transit-error ]; then + if [ -z "$(grep '* path is already in use at transit/' /vault/file/enable-transit-error)" ]; then + echo "Error enabling transit" + cat /vault/file/enable-transit-error + rm /vault/file/enable-transit-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + fi + echo -e "\nTransit engine enabled\n" +} + +enable_secrets () { + echo "**************" + echo "Enable secrets" + echo "**************" + vault secrets enable -path=kv kv-v2 2>/vault/file/enable-kv-secrets-error + if [ -s /vault/file/enable-kv-secrets-error ]; then + if [ -z "$(grep '* path is already in use at kv/' /vault/file/enable-kv-secrets-error)" ]; then + echo "Error enabling transit" + cat /vault/file/enable-kv-secrets-error + rm /vault/file/enable-kv-secrets-error + echo "Retry in 15 minutes" + sleep 15m + start_vault + fi + fi + echo -e "\nSecret engine kv enabled\n" +} + +create_key_pair () { + echo "***************" + echo "Create Key Pair" + echo "***************" + + echo -e "\n********** Retrieving Funder ID List from kv secrets engine **********\n" + FUNDER_SECRETS_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$CLIENT_ID) + STATUS_CODE=$(echo "$FUNDER_SECRETS_RESP" | tail -n 1) + FUNDER_SECRETS=$(echo "$FUNDER_SECRETS_RESP" | head -n -1) + FUNDER_ID_LIST=$(echo $FUNDER_SECRETS | jq '.data.data["funderIdList"]' | sed "s/\"//g") + + if [ $STATUS_CODE -eq 404 ]; then + if [ ! -z "$FUNDER_IDS" ]; then + echo "save funderIdList to KV Path" + POST_FUNDER_ID_LIST_BODY=$( jq -n --arg funderIdList "$FUNDER_IDS" '{data: { funderIdList: $funderIdList }}') + POST_FUNDER_ID_LIST_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_FUNDER_ID_LIST_BODY" $VAULT_ADDR/v1/kv/data/$CLIENT_ID) + STATUS_CODE=$(echo "$POST_FUNDER_ID_LIST_RESP" | tail -n 1) + POST_FUNDER_ID_LIST=$(echo "$POST_FUNDER_ID_LIST_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Error saving funderIdList" + echo "Status Code : " $STATUS_CODE + echo $POST_FUNDER_ID_LIST + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + FUNDER_ID_LIST=$FUNDER_IDS + else + if [ $STATUS_CODE -ne 200 ]; then + echo "Status Code : " $STATUS_CODE + echo "Error retrieving $CLIENT_ID kv path" + echo $FUNDER_SECRETS + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + fi + fi + + + if [ $FUNDER_ID_LIST = "null" ]; then + if [ -z "$FUNDER_IDS" ]; then + echo "Error retrieving Funder ID List" + echo "Retry in 15 minutes" + sleep 15m + restart_vault + else + POST_FUNDER_ID_LIST_BODY=$( jq -n --arg funderIdList "$FUNDER_IDS" '{data: { funderIdList: $funderIdList }}') + POST_FUNDER_ID_LIST_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_FUNDER_ID_LIST_BODY" $VAULT_ADDR/v1/kv/data/$CLIENT_ID) + POST_FUNDERS_STATUS_CODE=$(echo "$POST_FUNDER_ID_LIST_RESP" | tail -n 1) + POST_FUNDER_ID_LIST=$(echo "$POST_FUNDER_ID_LIST_RESP" | head -n -1) + if [ $POST_FUNDERS_STATUS_CODE -ne 200 ]; then + echo "Error saving funderIdList" + echo "Status Code : " $POST_FUNDERS_STATUS_CODE + echo $POST_FUNDER_ID_LIST + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + fi + else + echo "Funder ID List successfully retrieved" + echo "Funder ID List : " $FUNDER_ID_LIST + echo "Funder ID List Env : " $FUNDER_IDS + + if [ -z "$FUNDER_IDS" ]; then + FUNDER_IDS=$FUNDER_ID_LIST + else + if [ "$FUNDER_ID_LIST" != "$FUNDER_IDS" ]; then + echo "save funderIdList to KV Path" + POST_FUNDER_ID_LIST_BODY=$( jq -n --arg funderIdList "$FUNDER_IDS" '{data: { funderIdList: $funderIdList }}') + POST_FUNDER_ID_LIST_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_FUNDER_ID_LIST_BODY" $VAULT_ADDR/v1/kv/data/$CLIENT_ID) + STATUS_CODE=$(echo "$POST_FUNDER_ID_LIST_RESP" | tail -n 1) + POST_FUNDER_ID_LIST=$(echo "$POST_FUNDER_ID_LIST_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Error saving funderIdList" + echo "Status Code : " $STATUS_CODE + echo $POST_FUNDER_ID_LIST + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + fi + fi + fi + FUNDER_ID_ARRAY=${FUNDER_IDS//,/ } + + echo -e "\n********** Generate RSA Key Pair from Vault **********\n" + POST_VAULT_KEY_BODY='{"type": "rsa-2048", "exportable": true}' + curl -s --cacert /etc/ssl/certs/vault-ca.pem --request POST --data "$POST_VAULT_KEY_BODY" --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/transit/keys/$CLIENT_ID + + GET_PUB_KEYS_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/transit/keys/$CLIENT_ID) + STATUS_CODE=$(echo "$GET_PUB_KEYS_RESP" | tail -n 1) + PUBLIC_KEYS=$(echo "$GET_PUB_KEYS_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Status Code : " $STATUS_CODE + echo "Error getting encryption key from vault : " $PUBLIC_KEYS + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + echo "Key Pair $CLIENT_ID successfully created in Vault" + + # Encryption Key Informations + LOGIN_URL="$VAULT_ADDR/v1/auth/cert/login" + GET_KEY_URL="$VAULT_ADDR/v1/transit/export/encryption-key/$CLIENT_ID/1" + LATEST_VERSION=$(echo $PUBLIC_KEYS | jq '.data.latest_version') + LATEST_PUBLIC_KEY_CREATION_TIME=$(echo $PUBLIC_KEYS | jq --arg latest_version "$LATEST_VERSION" '.data.keys[$latest_version].creation_time') + CREATION_TIME_FORMATTED=$(echo ${LATEST_PUBLIC_KEY_CREATION_TIME%Z*} | sed "s/\"//g") + PUBLIC_KEY_EXPIRATION_DATE=$(date -d "$CREATION_TIME_FORMATTED 6 months" +"%Y-%m-%dT%H:%M:%SZ" | sed "s/\"//g") + LAST_UPDATE_DATE=$(date +"%Y-%m-%dT%H:%M:%SZ" | sed "s/\"//g") + LATEST_PUBLIC_KEY=$(echo $PUBLIC_KEYS | jq --arg latest_version "$LATEST_VERSION" '.data.keys[$latest_version].public_key' | sed "s/\"//g") + + echo -e "\n********** Save Key Pair ID to kv secrets engine **********\n" + FUNDER_SECRETS_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/key-version) + STATUS_CODE=$(echo "$FUNDER_SECRETS_RESP" | tail -n 1) + FUNDER_SECRETS=$(echo "$FUNDER_SECRETS_RESP" | head -n -1) + KEY_PAIR_ID=$(echo $FUNDER_SECRETS | jq '.data.data["keyPairId"]' | sed "s/\"//g") + if [ $STATUS_CODE -eq 404 ]; then + if [ $LATEST_VERSION -eq 1 ]; then + echo "key-version path not found, creating Key Pair ID" + KEY_PAIR_ID=$(uuidgen) + POST_KEY_PAIR_ID_BODY=$( jq -n --arg keyPairId "$KEY_PAIR_ID" --argjson version "$LATEST_VERSION" '{data: { keyPairId: $keyPairId, version: $version }}') + POST_KEY_PAIR_ID_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_KEY_PAIR_ID_BODY" $VAULT_ADDR/v1/kv/data/key-version) + STATUS_CODE=$(echo "$POST_KEY_PAIR_ID_RESP" | tail -n 1) + KEY_PAIR_ID_RESP=$(echo "$POST_KEY_PAIR_ID_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Error saving keyPairId" + echo "Status Code : " $STATUS_CODE + echo $KEY_PAIR_ID_RESP + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + echo "Key Pair ID succesfully saved to kv secrets engine" + else + echo "Error retrieving keyPairId" + echo "Status Code : " $STATUS_CODE + echo $FUNDER_SECRETS + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + else + if [ $STATUS_CODE -ne 200 ]; then + echo "Error retrieving keyPairId" + echo "Status Code : " $STATUS_CODE + echo $FUNDER_SECRETS + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + fi + if [ $KEY_PAIR_ID = "null" ]; then + if [ $LATEST_VERSION -eq 1 ]; then + echo "Key Pair ID not found, creating Key Pair ID secret" + KEY_PAIR_ID=$(uuidgen) + POST_KEY_PAIR_ID_BODY=$( jq -n --arg keyPairId "$KEY_PAIR_ID" --argjson version "$LATEST_VERSION" '{data: { keyPairId: $keyPairId, version: $version }}') + POST_KEY_PAIR_ID_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_KEY_PAIR_ID_BODY" $VAULT_ADDR/v1/kv/data/key-version) + STATUS_CODE=$(echo "$POST_KEY_PAIR_ID_RESP" | tail -n 1) + KEY_PAIR_ID_RESP=$(echo "$POST_KEY_PAIR_ID_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Error saving keyPairId" + echo "Status Code : " $STATUS_CODE + echo $KEY_PAIR_ID_RESP + echo "Key Pair not saved to MOB : need to retry" > /vault/file/save-key-mob-error + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + echo "Key Pair ID succesfully saved to kv secrets engine" + echo "Key Pair ID : " $KEY_PAIR_ID + else + echo "Error retrieving keyPairId" + echo "Status Code : " $STATUS_CODE + echo $FUNDER_SECRETS + echo "Key Pair not saved to MOB : need to retry" > /vault/file/save-key-mob-error + echo "Retry in 15 minutes" + sleep 15m + restart_vault + fi + else + echo "Key Pair ID successfully retrieved" + echo "Key Pair ID : " $KEY_PAIR_ID + fi + + echo -e "\n********** Send Public Key to MOB For all funders **********\n" + for funderId in $FUNDER_ID_ARRAY; do + GET_FUNDER_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId) + GET_FUNDER_STATUS_CODE=$(echo "$GET_FUNDER_RESP" | tail -n 1) + FUNDER_INFO=$(echo "$GET_FUNDER_RESP" | head -n -1) + FUNDER_KEY_PAIR_ID=$(echo $FUNDER_INFO | jq '.data.data["keyPairId"]' | sed "s/\"//g") + FUNDER_KEY_VERSION=$(echo $FUNDER_INFO | jq '.data.data["version"]' | sed "s/\"//g") + if [ $GET_FUNDER_STATUS_CODE -eq 404 ]; then + echo -e "\nSaving Key to MOB for funder $funderId\n" + POST_FUNDER_INFO_BODY=$( jq -n --arg keyPairId "$KEY_PAIR_ID" --argjson version "$LATEST_VERSION" '{data: { keyPairId: $keyPairId, version: $version }}') + POST_FUNDER_INFO_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_FUNDER_INFO_BODY" $VAULT_ADDR/v1/kv/data/$funderId) + POST_FUNDER_INFO_STATUS_CODE=$(echo "$POST_FUNDER_INFO_RESP" | tail -n 1) + FUNDER_INFO=$(echo "$POST_FUNDER_INFO_RESP" | head -n -1) + if [ $POST_FUNDER_INFO_STATUS_CODE -ne 200 ]; then + echo "Error saving funderInfo to kv secrets engine" + echo "Status Code : " $POST_FUNDER_INFO_STATUS_CODE + echo $FUNDER_INFO | tee /vault/file/save-key-mob-error + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + else + KEYCLOAK_DATA="grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" + KEYCLOAK_LOGIN_RESP=$(curl -s -w "\n%{http_code}" --request POST --data "$KEYCLOAK_DATA" $IDP_URL/auth/realms/mcm/protocol/openid-connect/token) + STATUS_CODE=$(echo "$KEYCLOAK_LOGIN_RESP" | tail -n 1) + KEYCLOAK_LOGIN=$(echo "$KEYCLOAK_LOGIN_RESP" | head -n -1) + if [ $STATUS_CODE -eq 200 ]; then + KEYCLOAK_TOKEN=$(echo $KEYCLOAK_LOGIN | jq '.access_token' | sed "s/\"//g") + PUT_PUBLIC_KEY_BODY=$( jq -n --arg id "$KEY_PAIR_ID" --arg pubKey "$(echo -e "$LATEST_PUBLIC_KEY")" --arg getKeyURL "$GET_KEY_URL" --arg loginURL "$LOGIN_URL" --arg expDate "$PUBLIC_KEY_EXPIRATION_DATE" --arg lastUpdateDate "$LAST_UPDATE_DATE" '{id: $id, version: 1, publicKey: $pubKey, expirationDate: $expDate, privateKeyAccess: { loginURL: $loginURL, getKeyURL: $getKeyURL }, lastUpdateDate: $lastUpdateDate}' ) + PUT_PUBLIC_KEY_RESPONSE=$(curl -s -w "\n%{http_code}" --header "Authorization: Bearer $KEYCLOAK_TOKEN" --header "Content-Type: application/json" --request PUT --data "$PUT_PUBLIC_KEY_BODY" $API_URL/v1/funders/$funderId/encryption_key) + STATUS_CODE=$(echo "$PUT_PUBLIC_KEY_RESPONSE" | tail -n 1) + PUT_PUBLIC_KEY=$(echo "$PUT_PUBLIC_KEY_RESPONSE" | head -n -1) + if [ $STATUS_CODE -eq 204 ]; then + echo -e "\n********************************** Encryption Key Details **********************************\n" + echo "id : " $KEY_PAIR_ID + echo "version : " $LATEST_VERSION + echo -e "publicKey : $LATEST_PUBLIC_KEY" + echo "expirationDate : " $PUBLIC_KEY_EXPIRATION_DATE + echo "lastUpdateDate : " $LAST_UPDATE_DATE + echo "loginURL : " $LOGIN_URL + echo "getKeyURL : " $GET_KEY_URL + echo -e "\nEncryption Key $CLIENT_ID, version $LATEST_VERSION successfully saved to MOB for funder $funderId\n" | tee /vault/file/init-success + echo -e "\n********************************************************************************************\n" + else + echo "Error saving encryption key to MOB" + echo "Status Code : " $STATUS_CODE + echo $PUT_PUBLIC_KEY | tee /vault/file/save-key-mob-error + curl -s -w "\n%{http_code}" -X DELETE --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + else + echo "Keycloak login error" + echo "Status Code : " $STATUS_CODE + echo "$KEYCLOAK_LOGIN" | tee /vault/file/save-key-mob-error + curl -s -w "\n%{http_code}" -X DELETE --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + fi + else + if [ $GET_FUNDER_STATUS_CODE -eq 200 ]; then + echo -e "\nEncryption Key already sent to MOB for funder $funderId\n" + else + echo "Error retrieving keyPairId and version for funder $funderId" + echo "Status Code : " $GET_FUNDER_STATUS_CODE + echo $FUNDER_INFO | tee /vault/file/save-key-mob-error + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + fi + done + if [ -s /vault/file/save-key-mob-error ]; then + rm /vault/file/save-key-mob-error + sleep 15m + restart_vault + fi +} + +start_vault () { + check_vault_status + init + unseal + root_log_in + create_policy + enable_cert + create_cert_role_admin + create_cert_role_manager + create_token + cert_log_in + setup_cors + enable_transit + enable_secrets + create_key_pair +} + +restart_vault () { + check_vault_status + init + unseal + cert_log_in + create_key_pair +} + +start_vault diff --git a/vault/kompose.yml b/vault/kompose.yml new file mode 100644 index 0000000..4bfd30f --- /dev/null +++ b/vault/kompose.yml @@ -0,0 +1,94 @@ +version: '3' + +services: + vault: + image: ${VAULT_IMAGE_NAME} + build: + context: . + dockerfile: vault-dockerfile + args: + BASE_IMAGE_VAULT: ${BASE_IMAGE_VAULT} + networks: + - web-nw + cap_add: + - IPC_LOCK + volumes: + - vault-data:/vault/file/ + environment: + VAULT_ADDR: https://${VAULT_FQDN} + VAULT_API_ADDR: https://${VAULT_FQDN} + VAULT_LOCAL_CONFIG: '{"backend": {"file": {"path": "/vault/file"}}, "default_lease_ttl": "168h", "max_lease_ttl": "720h"}' + CLIENT_ID: 'simulation-maas-backend' + CLIENT_SECRET: ${IDP_SIMULATION_MAAS_CLIENT_SECRET} + FUNDER_TOKEN: ${VAULT_FUNDER_TOKEN} + AVAILABLE_KEYS: 2 + ports: + - '8200' + command: server + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + + vault-init: + image: ${VAULT_IMAGE_NAME} + build: + context: . + dockerfile: vault-dockerfile + args: + BASE_IMAGE_VAULT: ${BASE_IMAGE_VAULT} + environment: + VAULT_ADDR: https://${VAULT_FQDN} + CLIENT_ID: 'simulation-maas-backend' + CLIENT_SECRET: ${IDP_SIMULATION_MAAS_CLIENT_SECRET} + FUNDER_TOKEN: ${VAULT_FUNDER_TOKEN} + API_URL: https://api.preprod.moncomptemobilite.fr + IDP_URL: https://idp.preprod.moncomptemobilite.fr + # API_URL: https://${API_FQDN} + # IDP_URL: https://${IDP_FQDN} + AVAILABLE_KEYS: 2 + networks: + - web-nw + volumes: + - vault-init-data:/vault/file/ + command: /bin/sh -c ". /usr/local/bin/init-vault.sh" + restart: on-failure + depends_on: + - vault + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + + vault-cron: + image: ${VAULT_IMAGE_NAME} + build: + context: . + dockerfile: vault-dockerfile + args: + BASE_IMAGE_VAULT: ${BASE_IMAGE_VAULT} + environment: + VAULT_ADDR: https://${VAULT_FQDN} + CLIENT_ID: 'simulation-maas-backend' + CLIENT_SECRET: ${IDP_SIMULATION_MAAS_CLIENT_SECRET} + FUNDER_TOKEN: ${VAULT_FUNDER_TOKEN} + API_URL: https://api.preprod.moncomptemobilite.fr + IDP_URL: https://idp.preprod.moncomptemobilite.fr + # API_URL: https://${API_FQDN} + # IDP_URL: https://${IDP_FQDN} + AVAILABLE_KEYS: 2 + networks: + - web-nw + volumes: + - vault-cron-data:/vault/file/ + command: crond -f + depends_on: + - vault-init + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + +volumes: + vault-data: + vault-init-data: + vault-cron-data: +networks: + web-nw: diff --git a/vault/manager-policy.hcl b/vault/manager-policy.hcl new file mode 100644 index 0000000..4661295 --- /dev/null +++ b/vault/manager-policy.hcl @@ -0,0 +1,3 @@ +path "transit/*" { + capabilities = ["read"] +} diff --git a/vault/overlays/kustomization.yaml b/vault/overlays/kustomization.yaml new file mode 100644 index 0000000..9cad273 --- /dev/null +++ b/vault/overlays/kustomization.yaml @@ -0,0 +1,10 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - vault-ingressrouteTCP.yml + +patchesStrategicMerge: + - web_nw_networkpolicy_namespaceselector.yml \ No newline at end of file diff --git a/vault/overlays/vault-certificate.yml b/vault/overlays/vault-certificate.yml new file mode 100644 index 0000000..ca98fc8 --- /dev/null +++ b/vault/overlays/vault-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: vault-cert +spec: + dnsNames: + - "*.${landscape_subdomain}" + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: vault-tls diff --git a/vault/overlays/vault-ingressroute.yml b/vault/overlays/vault-ingressroute.yml new file mode 100644 index 0000000..e2fb2fb --- /dev/null +++ b/vault/overlays/vault-ingressroute.yml @@ -0,0 +1,22 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + labels: + app: external + name: vault + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`${VAULT_FQDN}`) + services: + - kind: Service + name: vault + port: 8200 + scheme: https + serversTransport: vault + tls: + secretName: cert-dev \ No newline at end of file diff --git a/vault/overlays/vault-ingressrouteTCP.yml b/vault/overlays/vault-ingressrouteTCP.yml new file mode 100644 index 0000000..461c711 --- /dev/null +++ b/vault/overlays/vault-ingressrouteTCP.yml @@ -0,0 +1,25 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRouteTCP +metadata: + labels: + app: external + name: external-traefik-ingressroutetcp-vault + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - websecure + routes: + - match: HostSNI(`${VAULT_FQDN}`) + priority: 50 + services: + - name: vault + port: 8200 + terminationDelay: 400 + + tls: + options: + name: default + namespace: default + secretName: cert-dev + passthrough: true diff --git a/vault/overlays/vault_configmap_volumes.yml b/vault/overlays/vault_configmap_volumes.yml new file mode 100644 index 0000000..beba89a --- /dev/null +++ b/vault/overlays/vault_configmap_volumes.yml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vault +spec: + template: + spec: + securityContext: + fsGroup: 1000 + containers: + - name: vault + volumeMounts: + - name: vault-cert-config + mountPath: /etc/ssl/certs/vault-cert.pem + subPath: vault-cert.pem + - name: vault-key-config + mountPath: /etc/ssl/certs/vault-key.pem + subPath: vault-key.pem + - name: vault-ca-config + mountPath: /etc/ssl/certs/vault-ca.pem + subPath: vault-ca.pem + volumes: + - name: vault-cert-config + configMap: + name: vault-cert-configmap + - name: vault-key-config + configMap: + name: vault-key-configmap + - name: vault-ca-config + configMap: + name: vault-ca-configmap diff --git a/vault/overlays/web_nw_networkpolicy_namespaceselector.yml b/vault/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..53e6193 --- /dev/null +++ b/vault/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: "true" diff --git a/vault/renew-key.sh b/vault/renew-key.sh new file mode 100644 index 0000000..1d033d5 --- /dev/null +++ b/vault/renew-key.sh @@ -0,0 +1,279 @@ +#!/bin/sh + +cert_log_in () { + echo "************" + echo "Cert Log In" + echo "************" + ADMIN_CERT_ROLE='{"name": "admin"}' + CERT_LOGIN=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --cert /etc/ssl/certs/admin-client-cert.pem --key /etc/ssl/certs/admin-client-key.pem --data "$ADMIN_CERT_ROLE" --request POST $VAULT_ADDR/v1/auth/cert/login) + CERT_LOGIN_RESP=$(echo "$CERT_LOGIN" | head -n -1) + STATUS_CODE=$(echo "$CERT_LOGIN" | tail -n 1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Status Code : " $STATUS_CODE + echo "Login failed : " $CERT_LOGIN_RESP + echo "Retry in 15 minutes" + sleep 15m + renew_key + fi + export AUTH_TOKEN=$(echo $CERT_LOGIN_RESP | jq '.auth.client_token' | sed "s/\"//g" ) + vault login $AUTH_TOKEN > /dev/null + echo -e "\nCert Login success\n" +} + +renew_key () { + echo -e "\n**********" + echo "Renew Key" + echo -e "**********\n" + + cert_log_in + + echo -e "\n********** Retrieving Encryption Key **********\n" + VAULT_KEYS_RESP=$(curl --cacert /etc/ssl/certs/vault-ca.pem -s -w "\n%{http_code}" --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/transit/keys/$CLIENT_ID) + STATUS_CODE=$(echo "$VAULT_KEYS_RESP" | tail -n 1) + VAULT_KEYS=$(echo "$VAULT_KEYS_RESP" | head -n -1) + if [ $STATUS_CODE -eq 200 ]; then + GET_KEY_VERSION_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/key-version) + GET_KEY_VERSION_STATUS_CODE=$(echo "$GET_KEY_VERSION_RESP" | tail -n 1) + KEY_PAIR_INFO=$(echo "$GET_KEY_VERSION_RESP" | head -n -1) + KEY_PAIR_ID=$(echo $KEY_PAIR_INFO | jq '.data.data["keyPairId"]' | sed "s/\"//g") + KEY_VERSION=$(echo $KEY_PAIR_INFO | jq '.data.data["version"]' | sed "s/\"//g") + if [ $GET_KEY_VERSION_STATUS_CODE -eq 200 ]; then + echo -e "\nEncryption Key retrieved\n" + echo " Key Pair ID : " $KEY_PAIR_ID + echo " Key Version : " $KEY_VERSION + else + echo -e "\nKey Pair ID not yet created, wait for init script to complete or restart vault-init\n" + echo "Retry in 15 minutes" + sleep 15m + renew_key + fi + else + echo "Status Code : " $STATUS_CODE + echo "Error retrieving Encryption Key : " $VAULT_KEYS + echo "Retry in 15 minutes" + sleep 15m + renew_key + fi + + AVAILABLE_KEYS="${AVAILABLE_KEYS:=2}" + LATEST_VERSION=$(echo $VAULT_KEYS | jq '.data.latest_version') + MIN_AVAILABLE_VERSION=$(echo $VAULT_KEYS | jq '.data.min_available_version') + LATEST_PUBLIC_KEY=$(echo $VAULT_KEYS | jq --arg lastver "$LATEST_VERSION" '.data.keys[$lastver].public_key') + + LATEST_PUBLIC_KEY_CREATION_TIME=$(echo $VAULT_KEYS | jq --arg lastver "$LATEST_VERSION" '.data.keys[$lastver].creation_time') + CREATION_TIME_FORMATTED=$(echo ${LATEST_PUBLIC_KEY_CREATION_TIME%Z*} | sed "s/\"//g") + PUBLIC_KEY_EXPIRATION_DATE=$(date -d "$CREATION_TIME_FORMATTED 6 months" +"%Y-%m-%dT%H:%M:%SZ" | sed "s/\"//g") + CURRENT_DATE=$(date +"%Y-%m-%d %H:%M:%S") + CURRENT_DATE_SECONDS=$(date +"%s") + LAST_UPDATE_DATE=$(date +"%Y-%m-%dT%H:%M:%SZ" | sed "s/\"//g") + TWO_WEEKS_BEFORE_EXPIRATION_DATE=$(date -d "$CREATION_TIME_FORMATTED 168 days" +"%Y-%m-%d %H:%M:%S") + TWO_WEEKS_BEFORE_EXPIRATION_DATE_SECONDS=$(date -d "$TWO_WEEKS_BEFORE_EXPIRATION_DATE" +"%s") + + if [ -z "$FUNDER_IDS" ]; then + echo -e "\n********** Retrieving Funder ID List from kv secrets engine **********\n" + FUNDER_SECRETS_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$CLIENT_ID) + STATUS_CODE=$(echo "$FUNDER_SECRETS_RESP" | tail -n 1) + FUNDER_SECRETS=$(echo "$FUNDER_SECRETS_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Status Code : " $STATUS_CODE + echo "Error retrieving funder secrets" + echo $FUNDER_SECRETS + echo "Retry in 15 minutes" + sleep 15m + renew_key + fi + FUNDER_IDS=$(echo $FUNDER_SECRETS | jq '.data.data["funderIdList"]' | sed "s/\"//g") + if [ $FUNDER_IDS = "null" ]; then + echo "Error retrieving Funder ID List" + echo "Retry in 15 minutes" + sleep 15m + renew_key + fi + echo "Funder ID List successfully retrieved" + echo "Funder ID List : " $FUNDER_IDS + fi + + LOGIN_URL="$VAULT_ADDR/v1/auth/cert/login" + CURRENT_VERSION=$LATEST_VERSION + + if [ $CURRENT_DATE_SECONDS -gt $TWO_WEEKS_BEFORE_EXPIRATION_DATE_SECONDS ]; then + echo "Encryption Key Expired" + echo -e "\n********** Sending new version of Encryption Key to MOB **********\n" + CURRENT_VERSION="$(($LATEST_VERSION + 1))" + MIN_VERSION="$(($CURRENT_VERSION - $AVAILABLE_KEYS + 1))" + if [ $MIN_VERSION -gt 1 ]; then + vault write -f transit/keys/$CLIENT_ID/config min_decryption_version=$MIN_VERSION min_encryption_version=$MIN_VERSION + vault write -f transit/keys/$CLIENT_ID/trim min_available_version=$MIN_VERSION + else + vault write -f transit/keys/$CLIENT_ID/config min_decryption_version=1 min_encryption_version=1 + vault write -f transit/keys/$CLIENT_ID/trim min_available_version=1 + fi + vault write -f transit/keys/$CLIENT_ID/rotate + + PUBLIC_KEYS=$(curl -s --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/transit/keys/$CLIENT_ID) + + LATEST_PUBLIC_KEY_CREATION_TIME=$(echo $PUBLIC_KEYS | jq --arg lastver "$CURRENT_VERSION" '.data.keys[$lastver].creation_time') + CREATION_TIME_FORMATTED=$(echo ${LATEST_PUBLIC_KEY_CREATION_TIME%Z*} | sed "s/\"//g") + PUBLIC_KEY_EXPIRATION_DATE=$(date -d "$CREATION_TIME_FORMATTED 6 months" +"%Y-%m-%dT%H:%M:%SZ" | sed "s/\"//g") + + LATEST_PUBLIC_KEY=$(echo $PUBLIC_KEYS | jq --arg lastver "$CURRENT_VERSION" '.data.keys[$lastver].public_key' | sed "s/\"//g") + + echo -e "\n********** Save Key Pair ID to kv secrets engine **********\n" + KEY_PAIR_ID=$(uuidgen) + POST_KEY_PAIR_ID_BODY=$( jq -n --arg keyPairId "$KEY_PAIR_ID" --argjson version "$CURRENT_VERSION" '{data: { keyPairId: $keyPairId, version: $version }}') + POST_KEY_PAIR_ID_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_KEY_PAIR_ID_BODY" $VAULT_ADDR/v1/kv/data/key-version) + STATUS_CODE=$(echo "$POST_KEY_PAIR_ID_RESP" | tail -n 1) + KEY_PAIR_ID_RESP=$(echo "$POST_KEY_PAIR_ID_RESP" | head -n -1) + if [ $STATUS_CODE -ne 200 ]; then + echo "Error saving keyPairId" + echo "Status Code : " $STATUS_CODE + echo $KEY_PAIR_ID_RESP + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + else + echo "Key Pair ID succesfully saved to kv secrets engine" + fi + else + echo "Encryption Key is still valid" + echo "Encryption Key will be rotated 2 weeks before the expiration date" + echo "Rotation date" $TWO_WEEKS_BEFORE_EXPIRATION_DATE + fi + GET_KEY_URL="$VAULT_ADDR/v1/transit/export/encryption-key/$CLIENT_ID/$CURRENT_VERSION" + + echo -e "\n********** Send Public Key to MOB For all funders **********\n" + FUNDER_ID_ARRAY=${FUNDER_IDS//,/ } + for funderId in $FUNDER_ID_ARRAY; do + echo -e "\nSaving Key to MOB for funder $funderId\n" + + GET_FUNDER_RESP=$(curl -s -w "\n%{http_code}" --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId) + GET_FUNDER_STATUS_CODE=$(echo "$GET_FUNDER_RESP" | tail -n 1) + FUNDER_INFO=$(echo "$GET_FUNDER_RESP" | head -n -1) + FUNDER_KEY_PAIR_ID=$(echo $FUNDER_INFO | jq '.data.data["keyPairId"]' | sed "s/\"//g") + FUNDER_KEY_VERSION=$(echo $FUNDER_INFO | jq '.data.data["version"]' | sed "s/\"//g") + if [ $GET_FUNDER_STATUS_CODE -eq 404 ]; then + echo -e "\nSaving Key to MOB for funder $funderId\n" + POST_FUNDER_INFO_BODY=$( jq -n --arg keyPairId "$KEY_PAIR_ID" --argjson version "$CURRENT_VERSION" '{data: { keyPairId: $keyPairId, version: $version }}') + POST_FUNDER_INFO_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_FUNDER_INFO_BODY" $VAULT_ADDR/v1/kv/data/$funderId) + POST_FUNDER_INFO_STATUS_CODE=$(echo "$POST_FUNDER_INFO_RESP" | tail -n 1) + FUNDER_INFO=$(echo "$POST_FUNDER_INFO_RESP" | head -n -1) + if [ $POST_FUNDER_INFO_STATUS_CODE -ne 200 ]; then + echo "Error saving funderInfo to kv secrets engine" + echo "Status Code : " $POST_FUNDER_INFO_STATUS_CODE + echo $FUNDER_INFO | tee -a /vault/file/update-key-mob-error + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + else + KEYCLOAK_DATA="grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" + KEYCLOAK_LOGIN_RESP=$(curl -s -w "\n%{http_code}" --request POST --data "$KEYCLOAK_DATA" $IDP_URL/auth/realms/mcm/protocol/openid-connect/token) + STATUS_CODE=$(echo "$KEYCLOAK_LOGIN_RESP" | tail -n 1) + KEYCLOAK_LOGIN=$(echo "$KEYCLOAK_LOGIN_RESP" | head -n -1) + if [ $STATUS_CODE -eq 200 ]; then + KEYCLOAK_TOKEN=$(echo $KEYCLOAK_LOGIN | jq '.access_token' | sed "s/\"//g") + PUT_PUBLIC_KEY_BODY=$( jq -n --arg id "$KEY_PAIR_ID" --argjson version "$CURRENT_VERSION" --arg pubKey "$(echo -e "$LATEST_PUBLIC_KEY")" --arg getKeyURL "$GET_KEY_URL" --arg loginURL "$LOGIN_URL" --arg expDate "$PUBLIC_KEY_EXPIRATION_DATE" --arg lastUpdateDate "$LAST_UPDATE_DATE" '{id: $id, version: $version, publicKey: $pubKey, expirationDate: $expDate, privateKeyAccess: { loginURL: $loginURL, getKeyURL: $getKeyURL }, lastUpdateDate: $lastUpdateDate}' ) + PUT_PUBLIC_KEY_RESPONSE=$(curl -s -w "\n%{http_code}" --header "Authorization: Bearer $KEYCLOAK_TOKEN" --header "Content-Type: application/json" --request PUT --data "$PUT_PUBLIC_KEY_BODY" $API_URL/v1/funders/$funderId/encryption_key) + STATUS_CODE=$(echo "$PUT_PUBLIC_KEY_RESPONSE" | tail -n 1) + PUT_PUBLIC_KEY=$(echo "$PUT_PUBLIC_KEY_RESPONSE" | head -n -1) + if [ $STATUS_CODE -eq 204 ]; then + echo -e "\n********************************** Encryption Key Details **********************************\n" + echo "id : " $KEY_PAIR_ID + echo "version : " $CURRENT_VERSION + echo -e "publicKey : $LATEST_PUBLIC_KEY" + echo "expirationDate : " $PUBLIC_KEY_EXPIRATION_DATE + echo "lastUpdateDate : " $LAST_UPDATE_DATE + echo "loginURL : " $LOGIN_URL + echo "getKeyURL : " $GET_KEY_URL + echo -e "\nEncryption Key $CLIENT_ID, version $CURRENT_VERSION successfully saved to MOB for funder $funderId\n" | tee -a /vault/file/init-success + echo -e "\n********************************************************************************************\n" + else + echo "Error saving encryption key to MOB" + echo "Status Code : " $STATUS_CODE + echo $PUT_PUBLIC_KEY | tee -a /vault/file/update-key-mob-error + curl -s -w "\n%{http_code}" -X DELETE --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + else + echo "Keycloak login error" + echo "Status Code : " $STATUS_CODE + echo "$KEYCLOAK_LOGIN" | tee -a /vault/file/update-key-mob-error + curl -s -w "\n%{http_code}" -X DELETE --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + fi + else + if [ $GET_FUNDER_STATUS_CODE -eq 200 ]; then + echo "FUNDER_KEY_PAIR_ID : " $FUNDER_KEY_PAIR_ID + echo "KEY_PAIR_ID : " $KEY_PAIR_ID + if [ "$FUNDER_KEY_PAIR_ID" = "$KEY_PAIR_ID" ]; then + echo -e "\nEncryption Key already sent to MOB for funder $funderId\n" + else + echo -e "\nSave Encryption Key to MOB for funder $funderId\n" + POST_FUNDER_INFO_BODY=$( jq -n --arg keyPairId "$KEY_PAIR_ID" --argjson version "$CURRENT_VERSION" '{data: { keyPairId: $keyPairId, version: $version }}') + POST_FUNDER_INFO_RESP=$(curl -s -w "\n%{http_code}" -X POST --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" --data "$POST_FUNDER_INFO_BODY" $VAULT_ADDR/v1/kv/data/$funderId) + POST_FUNDER_INFO_STATUS_CODE=$(echo "$POST_FUNDER_INFO_RESP" | tail -n 1) + FUNDER_INFO=$(echo "$POST_FUNDER_INFO_RESP" | head -n -1) + if [ $POST_FUNDER_INFO_STATUS_CODE -ne 200 ]; then + echo "Error saving funderInfo to kv secrets engine" + echo "Status Code : " $POST_FUNDER_INFO_STATUS_CODE + echo $FUNDER_INFO | tee -a /vault/file/update-key-mob-error + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + else + KEYCLOAK_DATA="grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" + KEYCLOAK_LOGIN_RESP=$(curl -s -w "\n%{http_code}" --request POST --data "$KEYCLOAK_DATA" $IDP_URL/auth/realms/mcm/protocol/openid-connect/token) + STATUS_CODE=$(echo "$KEYCLOAK_LOGIN_RESP" | tail -n 1) + KEYCLOAK_LOGIN=$(echo "$KEYCLOAK_LOGIN_RESP" | head -n -1) + if [ $STATUS_CODE -eq 200 ]; then + KEYCLOAK_TOKEN=$(echo $KEYCLOAK_LOGIN | jq '.access_token' | sed "s/\"//g") + PUT_PUBLIC_KEY_BODY=$( jq -n --arg id "$KEY_PAIR_ID" --argjson version "$CURRENT_VERSION" --arg pubKey "$(echo -e "$LATEST_PUBLIC_KEY")" --arg getKeyURL "$GET_KEY_URL" --arg loginURL "$LOGIN_URL" --arg expDate "$PUBLIC_KEY_EXPIRATION_DATE" --arg lastUpdateDate "$LAST_UPDATE_DATE" '{id: $id, version: $version, publicKey: $pubKey, expirationDate: $expDate, privateKeyAccess: { loginURL: $loginURL, getKeyURL: $getKeyURL }, lastUpdateDate: $lastUpdateDate}' ) + PUT_PUBLIC_KEY_RESPONSE=$(curl -s -w "\n%{http_code}" --header "Authorization: Bearer $KEYCLOAK_TOKEN" --header "Content-Type: application/json" --request PUT --data "$PUT_PUBLIC_KEY_BODY" $API_URL/v1/funders/$funderId/encryption_key) + STATUS_CODE=$(echo "$PUT_PUBLIC_KEY_RESPONSE" | tail -n 1) + PUT_PUBLIC_KEY=$(echo "$PUT_PUBLIC_KEY_RESPONSE" | head -n -1) + if [ $STATUS_CODE -eq 204 ]; then + echo -e "\n********************************** Encryption Key Details **********************************\n" + echo "id : " $KEY_PAIR_ID + echo "version : " $CURRENT_VERSION + echo -e "publicKey : $LATEST_PUBLIC_KEY" + echo "expirationDate : " $PUBLIC_KEY_EXPIRATION_DATE + echo "lastUpdateDate : " $LAST_UPDATE_DATE + echo "loginURL : " $LOGIN_URL + echo "getKeyURL : " $GET_KEY_URL + echo -e "\nEncryption Key $CLIENT_ID, version $CURRENT_VERSION successfully saved to MOB for funder $funderId\n" | tee -a /vault/file/init-success + echo -e "\n********************************************************************************************\n" + else + echo "Error saving encryption key to MOB" + echo "Status Code : " $STATUS_CODE + echo $PUT_PUBLIC_KEY | tee -a /vault/file/update-key-mob-error + curl -s -w "\n%{http_code}" -X DELETE --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + else + echo "Keycloak login error" + echo "Status Code : " $STATUS_CODE + echo "$KEYCLOAK_LOGIN" | tee -a /vault/file/update-key-mob-error + curl -s -w "\n%{http_code}" -X DELETE --cacert /etc/ssl/certs/vault-ca.pem --header "X-Vault-Token: $AUTH_TOKEN" $VAULT_ADDR/v1/kv/data/$funderId + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + fi + fi + else + echo "Error retrieving keyPairId and version for funder $funderId" + echo "Status Code : " $GET_FUNDER_STATUS_CODE + echo $FUNDER_INFO | tee -a /vault/file/update-key-mob-error + echo "Retry in 15 minutes" + echo -e "\n********************************************************************************************\n" + fi + fi + done + if [ -s /vault/file/update-key-mob-error ]; then + cat /vault/file/update-key-mob-error + rm /vault/file/update-key-mob-error + sleep 15m + renew_key + fi +} + +renew_key \ No newline at end of file diff --git a/vault/vault-crontab b/vault/vault-crontab new file mode 100644 index 0000000..07b800e --- /dev/null +++ b/vault/vault-crontab @@ -0,0 +1 @@ +0 3 * * 6 /usr/local/bin/renew-key.sh diff --git a/vault/vault-docker-compose.yml b/vault/vault-docker-compose.yml new file mode 100644 index 0000000..c8c2dd1 --- /dev/null +++ b/vault/vault-docker-compose.yml @@ -0,0 +1,105 @@ +version: '3.4' + +services: + vault: + build: + context: . + network: host + args: + BASE_IMAGE_VAULT: vault:1.11.3 + VAULT_CERT: ${VAULT_CERT} + VAULT_KEY: ${VAULT_KEY} + VAULT_ROOT_CA: ${VAULT_ROOT_CA} + CLIENT_CA: ${CLIENT_CA} + ADMIN_CERT: ${ADMIN_CERT} + ADMIN_CERT_KEY: ${ADMIN_CERT_KEY} + container_name: vault + networks: + - dev_web-nw + cap_add: + - IPC_LOCK + volumes: + - vault-data:/vault/file/ + environment: + VAULT_ADDR: ${VAULT_ADDR} + VAULT_API_ADDR: ${VAULT_API_ADDR} + CLIENT_ID: ${CLIENT_ID} + CLIENT_SECRET: ${CLIENT_SECRET} + FUNDER_TOKEN: ${FUNDER_TOKEN} + API_URL: ${API_URL} + IDP_URL: ${IDP_URL} + FUNDER_IDS: ${FUNDER_IDS} + ports: + - '8200:8200' + restart: always + command: server + vault-init: + build: + context: . + network: host + args: + BASE_IMAGE_VAULT: vault:1.11.3 + VAULT_CERT: ${VAULT_CERT} + VAULT_KEY: ${VAULT_KEY} + VAULT_ROOT_CA: ${VAULT_ROOT_CA} + CLIENT_CA: ${CLIENT_CA} + ADMIN_CERT: ${ADMIN_CERT} + ADMIN_CERT_KEY: ${ADMIN_CERT_KEY} + container_name: vault-init + cap_add: + - IPC_LOCK + environment: + VAULT_ADDR: ${VAULT_ADDR} + CLIENT_ID: ${CLIENT_ID} + CLIENT_SECRET: ${CLIENT_SECRET} + FUNDER_TOKEN: ${FUNDER_TOKEN} + API_URL: ${API_URL} + IDP_URL: ${IDP_URL} + FUNDER_IDS: ${FUNDER_IDS} + volumes: + - vault-data:/vault/file/ + networks: + - dev_web-nw + command: /bin/sh -c ". /usr/local/bin/init-vault.sh" + restart: on-failure + depends_on: + - vault + vault-cron: + build: + context: . + network: host + args: + BASE_IMAGE_VAULT: vault:1.11.3 + VAULT_CERT: ${VAULT_CERT} + VAULT_KEY: ${VAULT_KEY} + VAULT_ROOT_CA: ${VAULT_ROOT_CA} + CLIENT_CA: ${CLIENT_CA} + ADMIN_CERT: ${ADMIN_CERT} + ADMIN_CERT_KEY: ${ADMIN_CERT_KEY} + container_name: vault-cron + cap_add: + - IPC_LOCK + environment: + VAULT_ADDR: ${VAULT_ADDR} + CLIENT_ID: ${CLIENT_ID} + CLIENT_SECRET: ${CLIENT_SECRET} + FUNDER_TOKEN: ${FUNDER_TOKEN} + API_URL: ${API_URL} + IDP_URL: ${IDP_URL} + FUNDER_IDS: ${FUNDER_IDS} + AVAILABLE_KEYS: ${AVAILABLE_KEYS} + volumes: + - vault-data:/vault/file/ + networks: + - dev_web-nw + command: crond -f + restart: on-failure + depends_on: + - vault + +volumes: + vault-data: + +networks: + dev_web-nw: + external: true \ No newline at end of file diff --git a/vault/vault-dockerfile b/vault/vault-dockerfile new file mode 100644 index 0000000..8fbca5c --- /dev/null +++ b/vault/vault-dockerfile @@ -0,0 +1,33 @@ +ARG BASE_IMAGE_VAULT +FROM ${BASE_IMAGE_VAULT} + +RUN apk add --update coreutils apk-cron curl jq util-linux && rm -rf /var/cache/apk/* + +COPY ./init-vault.sh /usr/local/bin/init-vault.sh +COPY ./renew-key.sh /usr/local/bin/renew-key.sh +COPY ./config.hcl /vault/config/config.hcl +COPY ./manager-policy.hcl /vault/config/manager-policy.hcl +COPY ./admin-policy.hcl /vault/config/admin-policy.hcl + +COPY ./certs/cert.pem /etc/ssl/certs/vault-cert.pem +COPY ./certs/privkey.pem /etc/ssl/certs/vault-key.pem +COPY ./certs/chain.pem /etc/ssl/certs/vault-ca.pem + +COPY ./certs/client/client-ca.pem /etc/ssl/certs/client-ca.pem +COPY ./certs/client/admin-client-cert.pem /etc/ssl/certs/admin-client-cert.pem +COPY ./certs/client/admin-client-key.pem /etc/ssl/certs/admin-client-key.pem + +COPY vault-crontab /etc/cron.d/vault-crontab + +RUN chmod 777 -R /vault/config +RUN chmod 644 /etc/ssl/certs/vault-ca.pem +RUN chmod 644 /etc/ssl/certs/vault-cert.pem +RUN chmod 644 /etc/ssl/certs/vault-key.pem +RUN chmod 644 /etc/ssl/certs/client-ca.pem +RUN chmod 644 /etc/ssl/certs/admin-client-cert.pem +RUN chmod 644 /etc/ssl/certs/admin-client-key.pem +RUN chmod +x /usr/local/bin/init-vault.sh +RUN chmod +x /usr/local/bin/renew-key.sh +RUN chmod 0644 /etc/cron.d/vault-crontab && crontab /etc/cron.d/vault-crontab + +EXPOSE 8200 \ No newline at end of file diff --git a/website/.eslintrc.js b/website/.eslintrc.js new file mode 100644 index 0000000..fcc7d6c --- /dev/null +++ b/website/.eslintrc.js @@ -0,0 +1,69 @@ +module.exports = { + extends: [ + 'airbnb-typescript', + 'airbnb/hooks', + 'plugin:@typescript-eslint/recommended', + 'plugin:jest/recommended', + 'prettier', + 'plugin:prettier/recommended', + ], + plugins: ['react', '@typescript-eslint', 'jest'], + env: { + browser: true, + es2020: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 12, + sourceType: 'module', + project: ['./tsconfig.json'], + }, + rules: { + 'max-len': 0, + 'react/prop-types': 0, + 'prettier/prettier': ['error', { singleQuote: true }], + 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.tsx'] }], + 'react/jsx-one-expression-per-line': 0, + '@typescript-eslint/ban-ts-comment': 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: ['**/*.test.tsx', '*/jest-configs/*'], + }, + ], + }, + settings: { + 'import/resolver': { + "alias": [ + ["@components", "./src/components"], + ["@customHooks", "./src/customHooks"], + ["@helpers", "./src/helpers"], + ["@pages", "./src/pages"], + ["@templates", "./src/templates"], + ["@utils/*", "./src/utils/*"], + ["@api", "./src/apiMob"], + ["@cms", "./src/cms"], + ["@modules", "./src/modules"], + ["@assets", "./src/assets"], + ["@constants", "./src/constants"], + ["@environment", "./src/environment"], + ], + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], + }, + }, + }, +}; diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..c70cf16 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,97 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env +.env.mcm +.secrets + +# gatsby files +.cache/ +public + +# Mac files +.DS_Store + +# Yarn +yarn-error.log +.pnp/ +.pnp.js +# Yarn Integrity file +.yarn-integrity + +# Sonarqube-verify +.scannerwork + +# Sonarqube -> Gitlab CodeQuality +sonarqube_issues +sonarqube_quality_gate_report + +# Jest +junit.xml +coverage +mock-private-key.pem + +# local +static/keycloak.json +.secrets + +# Tests +junit.xml + +# Gatsby dockerfile +gatsby-dockerfile.yml + +# Netlify config +static/admin/config.yml + diff --git a/website/.gitlab-ci.yml b/website/.gitlab-ci.yml new file mode 100644 index 0000000..9b1e84d --- /dev/null +++ b/website/.gitlab-ci.yml @@ -0,0 +1,62 @@ +include: + - local: 'website/.gitlab-ci/preview.yml' + rules: + - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule" + - local: 'website/.gitlab-ci/testing.yml' + rules: + - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" + - local: 'website/.gitlab-ci/helm.yml' + rules: + - if: $CI_PIPELINE_SOURCE == "trigger" + +.website-base: + variables: + MODULE_NAME: website + MODULE_PATH: ${MODULE_NAME} + NODE_BASE_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/node:16.15.0-stretch + MATOMO_ID: ${ANALYTICS_MCM_WEBSITE_ID} + PATH_API: '/api' + GATSBY_DESIGNER_URL: https://${BASE_DOMAIN} + GATSBY_CI_PROJECT_URL: ${CI_PROJECT_URL} + GATSBY_CI_COMMIT_REF_NAME: ${CI_COMMIT_REF_SLUG} + GATSBY_CI_COMMIT_SHORT_SHA: ${CI_COMMIT_SHORT_SHA} + GATSBY_CI_COMMIT_SHA: ${CI_COMMIT_SHA} + GATSBY_CI_PIPELINE_ID: ${CI_PIPELINE_ID} + GATSBY_CI_PIPELINE_URL: ${CI_PIPELINE_URL} + NEXUS_IMAGE_NGINX: ${NEXUS_DOCKER_REPOSITORY_URL}/nginx:1.21 + NETLIFYCMS_APP_ID: ${NETLIFYCMS_APP_ID} + WEBSITE_IMAGE_NAME: ${REGISTRY_BASE_NAME}/website:${IMAGE_TAG_NAME} + only: + changes: + - '*' + - 'commons/**/*' + - 'website/**/*' + +.website_build_script: &website_build_script | + yarn install --ignore-engines + npm version ${PACKAGE_VERSION} + npx gatsby build --no-uglify + +website_build: + stage: build + image: ${NODE_BASE_IMAGE_NAME} + extends: + - .commons + - .build-n-sast-job-tags + - .website-base + - .except-clean-or-release + script: + - *website_build_script + artifacts: + paths: + - ${MODULE_PATH}/node_modules/ + - ${MODULE_PATH}/public + expire_in: 5 days + +# Static Application Security Testing for known vulnerabilities +website_sast: + extends: + - .sast-job + - .build-n-sast-job-tags + - .website-base + needs: ['website_build'] diff --git a/website/.gitlab-ci/helm.yml b/website/.gitlab-ci/helm.yml new file mode 100644 index 0000000..2c355b8 --- /dev/null +++ b/website/.gitlab-ci/helm.yml @@ -0,0 +1,4 @@ +website_image_push: + extends: + - .helm-push-image-job + - .website-base \ No newline at end of file diff --git a/website/.gitlab-ci/preview.yml b/website/.gitlab-ci/preview.yml new file mode 100644 index 0000000..cb3e9dc --- /dev/null +++ b/website/.gitlab-ci/preview.yml @@ -0,0 +1,43 @@ +website_image_build: + extends: + - .preview-image-job + - .website-base + needs: ['website_build'] + +.website_test_script: &website_test_script | + yarn test --coverage --ci --reporters=default --reporters=jest-junit + +website_test: + image: ${NODE_BASE_IMAGE_NAME} + extends: + - .test-job + - .website-base + script: + - *website_test_script + artifacts: + when: always + paths: + - ${MODULE_PATH}/coverage/lcov.info + expire_in: 5 days + needs: ['website_build'] + +website_verify: + extends: + - .verify-job + - .website-base + variables: + SONAR_SOURCES: . + needs: ['website_test'] + +website_preview_deploy: + extends: + - .preview-deploy-job + - .website-base + needs: ['website_image_build'] + environment: + on_stop: website_preview_cleanup + +website_preview_cleanup: + extends: + - .commons_preview_cleanup + - .website-base diff --git a/website/.gitlab-ci/testing.yml b/website/.gitlab-ci/testing.yml new file mode 100644 index 0000000..e96b95c --- /dev/null +++ b/website/.gitlab-ci/testing.yml @@ -0,0 +1,11 @@ +website_testing_image_build: + extends: + - .testing-image-job + - .website-base + needs: ['website_build'] + +website_testing_deploy: + extends: + - .testing-deploy-job + - .website-base + needs: ['website_testing_image_build'] diff --git a/website/.prettierignore b/website/.prettierignore new file mode 100644 index 0000000..bd404d3 --- /dev/null +++ b/website/.prettierignore @@ -0,0 +1,7 @@ +.cache +.gitlab-ci.yml +coverage +.scannerwork +public/ +gatsby-config.js +.eslintrc.js diff --git a/website/.prettierrc.json b/website/.prettierrc.json new file mode 100644 index 0000000..a574763 --- /dev/null +++ b/website/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "always", + "endOfLine": "auto" +} diff --git a/website/Chart.yaml b/website/Chart.yaml new file mode 100644 index 0000000..c3dcb0d --- /dev/null +++ b/website/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +description: A Helm chart for Kubernetes +name: ${MODULE_PATH} +type: application +version: 1.0.0 diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..8697c35 --- /dev/null +++ b/website/README.md @@ -0,0 +1,126 @@ +# Description + +Le service website se base sur le framework **[GATSBY](https://www.gatsbyjs.com/)** basé sur react js + +Il s'agit du portail de la plateforme [MCM](https://moncomptemobilite.fr/). +Il fournit le contenu rédactionnel du site ainsi que les ressources statiques permettant d'initialiser l'application web monopage. + +Il est directement connecté avec l'[api](/api/README.md) moB qui fournit le catalogue d'aides à la mobilité, mais aussi des [fonctionnalités](/CHANGELOG.md) : + +- Inscription +- Recherche d'aide +- Souscriptions à une aide +- Gestion des demandes des citoyens +- ... + +## Principes généraux et vue simplifiée + +Les rédacteurs peuvent intervenir sur les pages grâce au CMS Headless [netlify_cms](https://www.netlifycms.org/). +Ce dernier fonctionne sans base de données et stocke ses enregistrements dans GitLab. + +Un site statique fortement optimisé est généré par le pipeline de build, à l'aide du SSG (Static Site Generator) Gatsby et Webpack. + +# Configurer Netlify + +- Changer le nom du fichier _config.yml.with-variables_ en _config.yml_ +- Lancer `npx netlify-cms-proxy-server` +- Aller sur l'url http://localhost:8000/admin/ + +# Installation en local + +## Variables + +| Variables | Description | Obligatoire | +| --------------------------------- | -------------------------------------- | ----------- | +| CLIENT_SECRET_KEY_KEYCLOAK_API | Sécurisation de l'accès à l'api | Oui | +| IDP_MCM_REALM | Nom du realm Keycloak | Non | +| IDP_MCM_SIMULATION_MAAS_CLIENT_ID | Nom du client dans Keycloak "clientId" | Non | + +## Lancement de l'application + +### Prérequis + +Avoir la version 14.17.6 de [NodeJS](https://nodejs.org/) + +## Sous Linux + +### Passer en root + +```sh +sudo -s +``` + +### Installer nvm qui permet de switcher de version de npm au besoin + +```sh +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash +exit +``` + +### Repasser en compte sans privilèges (non-root) + +```sh +nvm --version +``` + +### Installer la v14 de node, la 16 n’étant pas compatible + +```sh +nvm install v14.17.6 +``` + +### Sélectionner la version installée comme celle qu’on souhaite utiliser + +```sh +nvm use v14.17.6 +``` + +### Redémarrer Ubuntu (fermer et rouvrir Ubuntu) + +```sh +npm install -g gatsby-cli +``` + +### Installation lib libpng-dev + +```sh +sudo apt-get update -y +sudo apt-get install -y libpng-dev +``` + +### Ajouter dans website/static un fichier keycloak.json + +```json +{ + "url": "http://localhost:9000/auth", + "realm": "", + "clientId": "", + "enable-cors": true +} +``` + +### Ajouter dans website/static un fichier analytics.json + +```json +{ + "url": "http://localhost:8084" +} +``` + +### Exécuter la commande pour installer les packages + +```sh +yarn install +``` + +### Exécuter la commande pour lancer l’application + +```sh +gatsby develop +``` + +# Tests Unitaires + +```sh +yarn test +``` diff --git a/website/gatsby-browser.js b/website/gatsby-browser.js new file mode 100644 index 0000000..f53f49e --- /dev/null +++ b/website/gatsby-browser.js @@ -0,0 +1,61 @@ +/** + * Implement Gatsby's Browser APIs in this file. + * + * See: https://www.gatsbyjs.org/docs/browser-apis/ + */ + +import React from 'react'; +import { Router as MyRouter } from '@reach/router'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Helmet } from 'react-helmet'; + +import MatomoWrapper from './src/context/MatomoWrapper'; +import { UserProvider, KeycloakProvider } from './src/context'; + +import NavigateDefault from '@helpers/configs/navigate'; + +import 'tailwindcss/tailwind.css'; +import './src/assets/scss/main.scss'; + +import mobFavicon from './src/assets/svg/mob-favicon.svg'; + +/** + * Wrapping gatsby app in Keycloak wrapper. + * @param element + * @returns {JSX.Element} + */ + +// Called when the gatsby browser runtime first starts +export const onClientEntry = () => { + return ( + + + + ); +}; + +export const wrapRootElement = ({ element }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, + }); + + return ( + <> + + + + + + {element} + + + + + ); +}; diff --git a/website/gatsby-config.js b/website/gatsby-config.js new file mode 100644 index 0000000..6d7c4c7 --- /dev/null +++ b/website/gatsby-config.js @@ -0,0 +1,160 @@ +module.exports = { + siteMetadata: { + title: 'Mon Compte Mobilité', + description: + "Mon Compte Mobilité (moB) est un service numérique qui encourage les citoyens et salariés à recourir aux mobilités douces en facilitant l'accès aux aides et services de mobilité via un moteur de recherche et de souscription.", + siteUrl: `https://moncomptemobilite.fr/`, + }, + plugins: [ + { + resolve: `gatsby-plugin-netlify-cms`, + options: { + modulePath: `${__dirname}/src/cms/cms.js`, + }, + }, + { + resolve: `gatsby-plugin-breadcrumb`, + options: { + useAutoGen: true, + autoGenHomeLabel: 'Accueil', + crumbLabelUpdates: [ + { + pathname: '/decouvrir-le-projet', + crumbLabel: 'Découvrir le projet', + }, + { + pathname: '/contact', + crumbLabel: 'Nous contacter', + }, + { + pathname: '/faq', + crumbLabel: 'Foire aux questions', + }, + { + pathname: '/aide-page', + crumbLabel: "Fiche d'aide", + }, + { + pathname: '/recherche', + crumbLabel: 'Trouver une aide', + }, + { + pathname: '/mentions-legales-cgu', + crumbLabel: + 'Mentions légales et Conditions Générales d’utilisation', + }, + { + pathname: '/politique-gestion-cookies', + crumbLabel: 'Politique et gestion des cookies', + }, + { + pathname: '/charte-protection-donnees-personnelles', + crumbLabel: 'Charte de Protection des Données Personnelles', + }, + { + pathname: '/mon-profil', + crumbLabel: 'Mon profil', + }, + { + pathname: '/gerer-salaries', + crumbLabel: 'Gérer mes salariés', + }, + { + pathname: '/gerer-citoyens', + crumbLabel: 'Gérer mes citoyens', + }, + { + pathname: '/administrer-demandes', + crumbLabel: 'Administrer les demandes', + }, + { + pathname: '/mon-dashboard', + crumbLabel: 'Mon tableau de bord', + }, + ], + }, + }, + { + resolve: 'gatsby-plugin-postcss', + options: { postCssPlugins: [require('tailwindcss')] }, + }, + 'gatsby-plugin-typescript', + 'gatsby-plugin-sass', + { + resolve: 'gatsby-source-filesystem', + options: { + path: `${__dirname}/src/pages`, + name: 'pages', + }, + }, + { + resolve: 'gatsby-source-filesystem', + options: { + path: `${__dirname}/src/assets/images`, + name: 'images', + }, + }, + { + resolve: 'gatsby-source-filesystem', + options: { + path: `${__dirname}/src/assets/svg`, + name: 'images', + }, + }, + 'gatsby-transformer-remark', + { + resolve: `gatsby-plugin-env-variables`, + options: { + allowList: [ + 'ADMIN_ACCES_ROLE', + 'PATH_API', + 'API_KEY', + 'PACKAGE_VERSION', + ], + }, + }, + { + resolve: 'gatsby-plugin-purgecss', // purges all unused/unreferenced css rules + options: { + develop: true, + ignore: [ + '_aides-nationales.scss', + '_select-field.scss', + '_text-field.scss', + '_connexion-inscription.scss', + '_form-section.scss', + '_status-nav.scss', + '_base.scss', + '_card.scss', + '_tabs-menu.scss', + '_datePicker.scss', + 'node_modules/react-date-picker/dist/DatePicker.css', + 'node_modules/react-calendar/dist/Calendar.css', + ], + whitelist: [ + 'breadcrumb__list', + 'breadcrumb__list__item', + 'breadcrumb__link__active', + 'breadcrumb__separator', + ], // Activates purging in npm run develop + }, + }, // must be after other CSS plugins + 'gatsby-plugin-use-query-params', + 'gatsby-plugin-image', + 'gatsby-plugin-sharp', + 'gatsby-transformer-sharp', + { + resolve: 'gatsby-plugin-breakpoints', + options: { + queries: { + sm: '(min-width: 576px)', + md: '(min-width: 768px)', + l: '(min-width: 1024px)', + xl: '(min-width: 1440px)', + portrait: '(orientation: portrait)', + }, + }, + }, + 'gatsby-plugin-react-helmet', + ], +}; diff --git a/website/gatsby-node.esm.js b/website/gatsby-node.esm.js new file mode 100644 index 0000000..31575ca --- /dev/null +++ b/website/gatsby-node.esm.js @@ -0,0 +1,130 @@ +import path from 'path'; +import webpack from 'webpack'; + +exports.createPages = async ({ actions, graphql }) => { + const { createPage } = actions; + + return graphql(` + { + allMarkdownRemark { + edges { + node { + id + fields { + slug + } + frontmatter { + title + templateKey + } + } + } + } + } + `).then((result) => { + if (result.errors) { + result.errors.forEach((e) => console.error(e.toString())); + return Promise.reject(result.errors); + } + + const posts = result.data.allMarkdownRemark.edges; + + posts.forEach((edge) => { + if (edge.node.frontmatter.templateKey !== 'signup-settings') { + const { id } = edge.node; + createPage({ + path: edge.node.fields.slug, + component: path.resolve( + `src/templates/${String(edge.node.frontmatter.templateKey)}.tsx` + ), + // additional data can be passed via context + context: { + id, + }, + }); + } + }); + }); +}; + +exports.onCreatePage = async ({ page, actions }) => { + const { createPage } = actions; + + if (page.path.match(/^\/inscription\/inscription/)) { + createPage({ + path: `/inscription`, + matchPath: `/inscription/*`, + component: path.resolve(`src/pages/inscription/inscription.tsx`), + }); + } + if (page.path.match(/^\/app\/administrer-demandes/)) { + createPage({ + path: `/administrer-demandes`, + matchPath: `/administrer-demandes/*`, + component: path.resolve(`src/pages/app/administrer-demandes.tsx`), + }); + } + if (page.path.match(/^\/app\/gerer-salaries/)) { + createPage({ + path: `/gerer-salaries`, + matchPath: `/gerer-salaries/*`, + component: path.resolve(`src/pages/app/gerer-salaries.tsx`), + }); + } + if (page.path.match(/^\/app\/gerer-citoyens/)) { + createPage({ + path: `/gerer-citoyens`, + matchPath: `/gerer-citoyens/*`, + component: path.resolve(`src/pages/app/gerer-citoyens.tsx`), + }); + } + if (page.path.match(/^\/app\/subscriptions/)) { + createPage({ + path: `/subscriptions/new`, + matchPath: `/subscriptions/new`, + component: path.resolve(`src/pages/app/subscriptions.tsx`), + }); + } +}; + +exports.onCreateWebpackConfig = ({ actions, loaders }) => { + actions.setWebpackConfig({ + resolve: { + alias: { + // Absolute imports instead of @/components it will be 'components/example' + // PS we can put other aliases in here though + '@components': path.resolve(__dirname, 'src/components'), + '@customHooks': path.resolve(__dirname, 'src/customHooks'), + '@helpers': path.resolve(__dirname, 'src/helpers'), + '@pages': path.resolve(__dirname, 'src/pages'), + '@templates': path.resolve(__dirname, 'src/templates'), + '@utils': path.resolve(__dirname, 'src/utils'), + '@api': path.resolve(__dirname, 'src/apiMob'), + '@fixtures': path.resolve(__dirname, 'src/fixtures'), + '@cms': path.resolve(__dirname, 'src/cms'), + '@modules': path.resolve(__dirname, 'src/modules'), + '@assets': path.resolve(__dirname, 'src/assets'), + '@constants': path.resolve(__dirname, 'src/constants'), + '@environment': path.resolve(__dirname, 'src/environment'), + }, + fallback: { + crypto: false, + buffer: require.resolve('buffer/'), + assert: require.resolve('assert'), + stream: false, + constants: false, + }, + }, + plugins: [ + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), + new webpack.ProvidePlugin({ + process: 'process/browser', + }), + new webpack.IgnorePlugin({ + resourceRegExp: /^netlify-identity-widget$/, + }), + ], + }); +}; diff --git a/website/gatsby-node.js b/website/gatsby-node.js new file mode 100644 index 0000000..673dbd7 --- /dev/null +++ b/website/gatsby-node.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line no-global-assign,@typescript-eslint/no-var-requires +const { createFilePath } = require('gatsby-source-filesystem'); + +const requireEsm = require('esm')(module); + +// Fix to bypass gatsby-source-filesystem error +// TODO Must do : Full ts supported since gatsby 4.9 +// https://www.gatsbyjs.com/docs/how-to/custom-configuration/typescript/#initializing-a-new-project-with-typescript +module.exports = { + ...requireEsm('./gatsby-node.esm.js'), + onCreateNode: ({ node, actions, getNode }) => { + const { createNodeField } = actions; + + if (node.internal.type === 'MarkdownRemark') { + const value = createFilePath({ node, getNode }); + createNodeField({ + name: 'slug', + node, + value, + }); + } + } +} \ No newline at end of file diff --git a/website/gatsby-ssr.js b/website/gatsby-ssr.js new file mode 100644 index 0000000..72499d2 --- /dev/null +++ b/website/gatsby-ssr.js @@ -0,0 +1,29 @@ +/** + * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. + * + * See: https://www.gatsbyjs.org/docs/ssr-apis/ + */ + +// You can delete this file if you're not using it +const React = require('react'); +const { QueryClient, QueryClientProvider } = require('react-query'); +const { UserProvider, KeycloakProvider } = require('./src/context'); + +function wrapRootElement({ element }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, + }); + return ( + + + {element} + + + ); +} + +exports.wrapRootElement = wrapRootElement; diff --git a/website/jest-configs/__mocks__/file-mocks.js b/website/jest-configs/__mocks__/file-mocks.js new file mode 100644 index 0000000..86059f3 --- /dev/null +++ b/website/jest-configs/__mocks__/file-mocks.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/website/jest-configs/__mocks__/gatsby.js b/website/jest-configs/__mocks__/gatsby.js new file mode 100644 index 0000000..3953a49 --- /dev/null +++ b/website/jest-configs/__mocks__/gatsby.js @@ -0,0 +1,28 @@ +const React = require('react'); + +const gatsby = jest.requireActual('gatsby'); +module.exports = { + ...gatsby, + graphql: jest.fn(), + Link: jest.fn().mockImplementation( + // these props are invalid for an `a` tag + ({ + activeClassName, + activeStyle, + getProps, + innerRef, + partiallyActive, + ref, + replace, + to, + ...rest + }) => + React.createElement('a', { + ...rest, + href: to, + }) + ), + StaticQuery: jest.fn(), + useStaticQuery: jest.fn(), + navigate: jest.fn(), +}; diff --git a/website/jest-configs/__mocks__/svgTransform.jsx b/website/jest-configs/__mocks__/svgTransform.jsx new file mode 100644 index 0000000..5172ff3 --- /dev/null +++ b/website/jest-configs/__mocks__/svgTransform.jsx @@ -0,0 +1,13 @@ +const React = require('react'); +const { forwardRef } = React; + +const component = (props = {}, ref = {}) => { + return ; +}; + +const ReactComponent = forwardRef(component); + +module.exports = { + ReactComponent, + default: 'file.svg', +}; diff --git a/website/jest-configs/jest-preprocess.js b/website/jest-configs/jest-preprocess.js new file mode 100644 index 0000000..46fa6b4 --- /dev/null +++ b/website/jest-configs/jest-preprocess.js @@ -0,0 +1,9 @@ +const babelOptions = { + presets: [ + '@babel/preset-react', + 'babel-preset-gatsby', + '@babel/preset-typescript', + ], +}; + +module.exports = require('babel-jest').createTransformer(babelOptions); diff --git a/website/jest-configs/setup-test-env.js b/website/jest-configs/setup-test-env.js new file mode 100644 index 0000000..666127a --- /dev/null +++ b/website/jest-configs/setup-test-env.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom/extend-expect'; diff --git a/website/jest.config.js b/website/jest.config.js new file mode 100644 index 0000000..99bf065 --- /dev/null +++ b/website/jest.config.js @@ -0,0 +1,63 @@ +const path = require('path'); + +module.exports = { + setupFilesAfterEnv: [ + path.resolve(__dirname, './jest-configs/setup-test-env.js'), + ], + transform: { + '^.+\\.(tsx?|jsx?)$': `/jest-configs/jest-preprocess.js`, + '\\.svg': '/jest-configs/__mocks__/svgTransform.jsx', + }, + moduleDirectories: ['node_modules', 'src'], + moduleNameMapper: { + '^@components/(.*)$': '/src/components/$1', + '@helpers/(.*)': '/src/helpers/$1', + '@pages/(.*)': '/src/pages/$1', + '@templates/(.*)': '/src/templates/$1', + '@utils/(.*)': '/src/utils/$1', + '@api/(.*)': '/src/apiMob/$1', + '@fixtures/(.*)': '/src/fixtures/$1', + '@cms/(.*)': '/src/cms/$1', + '@modules/(.*)': '/src/modules/$1', + '@assets/(.*)': '/src/assets/$1', + '@constants': '/src/constants', + '@environment': '/src/environment', + '^gatsby-core-utils/(.*)$': `gatsby-core-utils/dist/$1`, + '^gatsby-page-utils/(.*)$': `gatsby-page-utils/dist/$1`, + '\\.svg': `/jest-configs/__mocks__/svgTransform.jsx`, + '.+\\.(png|jpg)$': 'identity-obj-proxy', + 'src/(.*)': '/src/$1', + 'typeface-*': 'identity-obj-proxy', + '.+\\.(css|styl|less|sass|scss)$': `identity-obj-proxy`, + '.+\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': `/__mocks__/file-mocks.js`, + // Jest workaround for latest version of axios + '^axios$': require.resolve('axios') + }, + testPathIgnorePatterns: [ + `node_modules`, + `\\.cache`, + `.*/public`, + '/src/helpers/tests/', + ], + coveragePathIgnorePatterns: [ + '/src/helpers/tests/', + '/src/utils/https.ts', + '/src/utils/mockKeycloak.ts', + '/src/modules/routes/mockKeycloak.ts', + '/src/constants.ts', + '/src/context/index.ts', + '/src/utils/demandes.ts', + '/src/utils/citoyens.ts', + '/src/utils/matomo.ts', + '/src/modules/inscription/components/index.ts', + ], + transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`, `\\.svg`], + modulePathIgnorePatterns: ['/src/helpers/tests/'], + globals: { + __PATH_PREFIX__: ``, + }, + testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js'], + collectCoverage: false, + coverageReporters: ['lcov', 'text', 'html'], +}; diff --git a/website/kompose.yml b/website/kompose.yml new file mode 100644 index 0000000..397580f --- /dev/null +++ b/website/kompose.yml @@ -0,0 +1,40 @@ +version: '3' + +services: + website: + image: ${WEBSITE_IMAGE_NAME} + build: + context: . + dockerfile: ./website-dockerfile.yml + args: + BASE_IMAGE_NGINX: ${NEXUS_IMAGE_NGINX} + CI_PROJECT_ID: ${CI_PROJECT_ID} + MCM_GITLAB_DEPLOY_NPM_TOKEN: ${GITLAB_REGISTRY_NPM_TOKEN} + NEXUS_NPM_PROXY_TOKEN: ${NEXUS_NPM_PROXY_TOKEN} + MCM_CMS_ACCESS_ROLE: ${MCM_CMS_ACCESS_ROLE} + MATOMO_FQDN: ${MATOMO_FQDN} + MATOMO_ID: ${MATOMO_ID} + PATH_API: ${PATH_API} + environment: + - GITLAB_PROJECT_PATH + - GITLAB_BRANCH + - IDP_FQDN + - IDP_MCM_REALM + - IDP_MCM_WEBSITE_CLIENT_ID + - MATOMO_FQDN + - MATOMO_ID="${MATOMO_ID}" + - API_KEY + - LANDSCAPE + - NETLIFYCMS_APP_ID + networks: + - web-nw + - storage-nw + ports: + - '80' + labels: + - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}' + - 'kompose.service.type=clusterip' + +networks: + web-nw: + storage-nw: diff --git a/website/nginx-fix.yml b/website/nginx-fix.yml new file mode 100644 index 0000000..3e419e0 --- /dev/null +++ b/website/nginx-fix.yml @@ -0,0 +1,3 @@ +FROM nginx:1.21 +COPY public/ /usr/share/nginx/html/ +COPY webserver-docker-context/etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf diff --git a/website/overlays/config/analytics.json b/website/overlays/config/analytics.json new file mode 100644 index 0000000..1e8838a --- /dev/null +++ b/website/overlays/config/analytics.json @@ -0,0 +1,3 @@ +{ + "url": "https://${MATOMO_FQDN}" +} diff --git a/website/overlays/config/keycloak.json b/website/overlays/config/keycloak.json new file mode 100644 index 0000000..dd87081 --- /dev/null +++ b/website/overlays/config/keycloak.json @@ -0,0 +1,6 @@ +{ + "url": "https://${IDP_FQDN}/auth", + "realm": "${IDP_MCM_REALM}", + "clientId": "${IDP_MCM_WEBSITE_CLIENT_ID}", + "enable-cors": true +} diff --git a/website/overlays/config/netlifycms-config.yml b/website/overlays/config/netlifycms-config.yml new file mode 100644 index 0000000..ec61477 --- /dev/null +++ b/website/overlays/config/netlifycms-config.yml @@ -0,0 +1,146 @@ +backend: + name: gitlab + repo: ${CI_PROJECT_PATH} # Path to your GitLab repository + auth_type: pkce + app_id: ${NETLIFYCMS_APP_ID} + api_root: ${CI_API_V4_URL} + base_url: ${CI_SERVER_URL} + auth_endpoint: oauth/authorize + squash_merges: true + branch: ${CI_COMMIT_REF_SLUG} # Used for making changes on current branch + + ## CHANGE COMMIT MESSAGES // MERGE REQUEST NAME + commit_messages: + create: NETLIFY-CMS {{author-login}} request to Create {{collection}} “{{slug}}” + update: NETLIFY-CMS {{author-login}} request to Update {{collection}} “{{slug}}” + delete: NETLIFY-CMS {{author-login}} request to Delete {{collection}} “{{slug}}” + uploadMedia: "[skip ci] NETLIFY-CMS {{author-login}} request to Upload “{{path}}”" + deleteMedia: "[skip ci] NETLIFY-CMS {{author-login}} request to Delete “{{path}}”" + +## FOR ENABLING MERGE REQUEST PROCESS +publish_mode: editorial_workflow + +locale: fr + +## FOR DISABLING MEDIA LIBRARY +media_library: + name: disabled + +## FOR ENABLING USE OF PROJECT MEDIA FOLDER INSTEAD OF MEDIA LIBRARY +# media_folder: 'website/static/uploads' +# public_folder: /uploads + +logo_url: "/uploads/netlify_cms.logo.png" + +collections: + - name: "projet" + label: "Projet" + media_folder: "" + public_folder: "" + files: + - name: "projet" + label: "Page projet" + file: "website/src/pages/decouvrir-le-projet.md" + fields: + - { label: "Titre de la page", name: "title", widget: "text" } + - { label: "Description", name: "description", widget: "text" } + - { label: "Sous-titre", name: "subtitle", widget: "text" } + - { label: "Sous-texte", name: "subText", widget: "text" } + - label: Cards Section + name: cardSection + widget: object + fields: + - { label: "Titre de la section", name: "title", widget: "text" } + - label: Cards + name: cards + widget: list + collapsed: true + min: 2 + max: 3 + fields: + - { label: "Titre de la card", name: "title", widget: "text" } + - { label: "Sous-titre", name: "subtitle", widget: "text" } + - { label: "Liste", name: "list", widget: "list" } + - label: Bouton + name: button + widget: object + collapsed: true + fields: + - { + label: "Texte du bouton", + name: "label", + widget: "string", + } + - { label: "URL", name: "href", widget: "string" } + - label: Links Section + name: linkSection + widget: object + fields: + - { + label: "Titre de la section", + name: "title", + widget: "string", + } + - label: Liens + name: links + widget: list + collapsed: true + fields: + - { + label: "Description du fichier / lien", + name: "label", + widget: "string", + } + - { + label: "Fichier", + name: "file", + widget: "file", + default: "/uploads/new-pdf", + required: false, + } + - { + label: "Lien", + name: "url", + widget: "string", + required: false, + } + - name: "faq" + label: "Faq" + media_folder: "" + public_folder: "" + files: + - name: "faq" + label: "Page faq" + file: "website/src/pages/faq.md" + fields: + - label: catégories + label_singular: "catégorie" + name: faqItems + widget: list + fields: + - { + label: "Titre de la catégorie", + name: "categoryTitle", + widget: "string", + } + - label: blocs + label_singular: "bloc" + name: bloc + widget: list + fields: + - { + label: "Titre de bloc", + name: "blocTitle", + widget: "string", + } + - label: "Questions" + label_singular: "Question" + name: "questions" + widget: list + fields: + - { label: "Question", name: "title", widget: "string" } + - { + label: "Réponse", + name: "answer", + widget: "markdown", + } diff --git a/website/overlays/kustomization.yaml b/website/overlays/kustomization.yaml new file mode 100644 index 0000000..5a2b807 --- /dev/null +++ b/website/overlays/kustomization.yaml @@ -0,0 +1,25 @@ +commonAnnotations: + app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG} + app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG} + kubernetes.io/ingress.class: traefik + +resources: + - website-ingressroute.yml + - website_api_middleware.yml + # - website-certificate.yml + # - website_middleware.yml + +patchesStrategicMerge: + - web_nw_networkpolicy_namespaceselector.yml + - website_configmap_volumes.yml + +configMapGenerator: + - name: website-keycloak-config + files: + - config/keycloak.json + - name: website-netlifycms-config + files: + - config/netlifycms-config.yml + - name: website-matomo-config + files: + - config/analytics.json diff --git a/website/overlays/web_nw_networkpolicy_namespaceselector.yml b/website/overlays/web_nw_networkpolicy_namespaceselector.yml new file mode 100644 index 0000000..32da467 --- /dev/null +++ b/website/overlays/web_nw_networkpolicy_namespaceselector.yml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: web-nw +spec: + ingress: + - from: + - namespaceSelector: + matchLabels: + com.capgemini.mcm.ingress: 'true' diff --git a/website/overlays/website-certificate.yml b/website/overlays/website-certificate.yml new file mode 100644 index 0000000..5b2bdf3 --- /dev/null +++ b/website/overlays/website-certificate.yml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: website-cert +spec: + dnsNames: + - '*.${landscape_subdomain}' + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: ${CLUSTER_ISSUER} + secretName: ${SECRET_NAME} diff --git a/website/overlays/website-ingressroute.yml b/website/overlays/website-ingressroute.yml new file mode 100644 index 0000000..d984872 --- /dev/null +++ b/website/overlays/website-ingressroute.yml @@ -0,0 +1,46 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: website +spec: + entryPoints: + - web + # - websecure + routes: + - match: Host(`${WEBSITE_FQDN}`) + kind: Rule + services: + - name: website + port: 80 + - match: Host(`${WEBSITE_FQDN}`) && PathPrefix(`/api/`) + kind: Rule + middlewares: + - name: stripprefix + services: + - name: api + namespace: api-${CI_COMMIT_REF_SLUG}-${LANDSCAPE} + port: 3000 +# tls: +# secretName: ${SECRET_NAME} # website-tls +# domains: +# - main: ${BASE_DOMAIN} +# sans: +# - '*.preview.${BASE_DOMAIN}' +# - '*.testing.${BASE_DOMAIN}' +# --- +# apiVersion: traefik.containo.us/v1alpha1 +# kind: IngressRoute +# metadata: +# name: website-http +# spec: +# entryPoints: +# - web +# routes: +# - match: Host(`${WEBSITE_FQDN}`) +# kind: Rule +# middlewares: +# - name: redirectscheme +# services: +# - name: website +# port: 80 + diff --git a/website/overlays/website_api_middleware.yml b/website/overlays/website_api_middleware.yml new file mode 100644 index 0000000..8dd0fc4 --- /dev/null +++ b/website/overlays/website_api_middleware.yml @@ -0,0 +1,8 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: stripprefix +spec: + stripPrefix: + prefixes: + - /api diff --git a/website/overlays/website_configmap_volumes.yml b/website/overlays/website_configmap_volumes.yml new file mode 100644 index 0000000..b6df7cd --- /dev/null +++ b/website/overlays/website_configmap_volumes.yml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: website +spec: + template: + spec: + containers: + - name: website + volumeMounts: + - name: keycloak-config + mountPath: /usr/share/nginx/html/keycloak.json + subPath: keycloak.json + - name: netlifycms-config + mountPath: /usr/share/nginx/html/admin/config.yml + subPath: config.yml + - name: matomo-config + mountPath: /usr/share/nginx/html/analytics.json + subPath: analytics.json + securityContext: + fsGroup: 1000 + volumes: + - name: keycloak-config + configMap: + name: website-keycloak-config + - name: netlifycms-config + configMap: + name: website-netlifycms-config + items: + - key: netlifycms-config.yml + path: config.yml + - name: matomo-config + configMap: + name: website-matomo-config diff --git a/website/overlays/website_middleware.yml b/website/overlays/website_middleware.yml new file mode 100644 index 0000000..afb9326 --- /dev/null +++ b/website/overlays/website_middleware.yml @@ -0,0 +1,8 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: redirectscheme +spec: + redirectScheme: + scheme: https + permanent: true diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..f47bb6a --- /dev/null +++ b/website/package.json @@ -0,0 +1,149 @@ +{ + "name": "@mcm/mcm-website", + "description": "JAMstack project based on Gatsby and Netlify CMS", + "version": "1.10.1", + "author": "Capgemini", + "dependencies": { + "@datapunt/matomo-tracker-react": "0.5.1", + "@gatsbyjs/reach-router": "1.3.9", + "@hookform/error-message": "2.0.0", + "@hookform/resolvers": "2.8.10", + "@reach/router": "1.3.4", + "@tippyjs/react": "4.2.6", + "@types/file-saver": "2.0.5", + "assert": "2.0.0", + "axios": "1.1.3", + "buffer": "6.0.3", + "classnames": "2.3.1", + "date-fns": "2.28.0", + "esm": "3.2.25", + "file-saver": "2.0.5", + "gatsby": "4.24.5", + "gatsby-cli": "4.14.0", + "gatsby-core-utils": "3.24.0", + "gatsby-image": "3.11.0", + "gatsby-link": "2.4.13", + "gatsby-plugin-breadcrumb": "12.3.1", + "gatsby-plugin-breakpoints": "1.3.9", + "gatsby-plugin-create-client-paths": "3.14.0", + "gatsby-plugin-env-variables": "2.2.0", + "gatsby-plugin-image": "2.24.0", + "gatsby-plugin-json-output": "1.2.0", + "gatsby-plugin-netlify-cms": "6.24.0", + "gatsby-plugin-postcss": "5.24.0", + "gatsby-plugin-purgecss": "6.1.2", + "gatsby-plugin-react-helmet": "5.24.0", + "gatsby-plugin-sass": "5.24.0", + "gatsby-plugin-sharp": "4.24.0", + "gatsby-plugin-sitemap": "3.3.0", + "gatsby-plugin-typescript": "4.24.0", + "gatsby-plugin-use-query-params": "1.0.1", + "gatsby-react-router-scroll": "4.14.0", + "gatsby-source-filesystem": "4.24.0", + "gatsby-transformer-remark": "5.24.0", + "gatsby-transformer-sharp": "4.24.0", + "global": "4.4.0", + "graphql": "15.8.0", + "http-proxy-middleware": "1.3.1", + "jwt-decode": "3.1.2", + "keycloak-js": "12.0.4", + "markdown-to-jsx": "7.1.7", + "netlify-cms-app": "2.15.72", + "node-sass": "6.0.1", + "postcss": "8.4.13", + "process": "0.11.10", + "rasha": "1.2.5", + "react": "17.0.2", + "react-date-picker": "8.4.0", + "react-dom": "17.0.2", + "react-dropzone": "12.1.0", + "react-helmet": "6.1.0", + "react-hook-form": "7.31.1", + "react-hot-toast": "2.2.0", + "react-lines-ellipsis": "0.15.0", + "react-markdown": "6.0.3", + "react-modal": "3.8.1", + "react-player": "2.10.1", + "react-query": "3.39.0", + "react-select": "4.3.1", + "redux": "4.2.0", + "replace-in-file": "6.3.2", + "typescript": "4.6.4", + "use-query-params": "1.2.3", + "uuid": "8.3.2", + "yup": "0.32.11" + }, + "keywords": [ + "Gatsby", + "Netlify", + "JAMstack" + ], + "license": "MIT", + "main": "n/a", + "scripts": { + "start": "npm run develop", + "develop": "gatsby develop", + "clean": "rimraf .cache public", + "build": "npm run clean && gatsby build", + "serve": "gatsby serve", + "format": "prettier --trailing-comma es5 --single-quote --write \"{gatsby-*.js,src/**/*.js}\"", + "lint": "eslint src/* --ext js,ts,tsx", + "lint:fix": "npm run lint -- --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:ci": "jest --coverage --ci --reporters=default --reporters=jest-junit", + "verify": "sonarqube-verify", + "postinstall": "patch-package" + }, + "devDependencies": { + "@datapunt/matomo-tracker-react": "0.5.1", + "@peculiar/webcrypto": "1.4.0", + "@testing-library/dom": "7.31.2", + "@testing-library/jest-dom": "5.16.4", + "@testing-library/react": "11.2.7", + "@testing-library/react-hooks": "7.0.2", + "@types/classnames": "2.3.1", + "@types/gatsby-plugin-breakpoints": "1.3.2", + "@types/jest": "26.0.22", + "@types/node": "14.18.18", + "@types/rasha": "1.2.3", + "@types/react": "17.0.45", + "@types/react-dom": "17.0.17", + "@types/react-helmet": "6.1.5", + "@types/react-select": "4.0.18", + "@types/yup": "0.29.14", + "@typescript-eslint/eslint-plugin": "4.33.0", + "@typescript-eslint/parser": "4.33.0", + "babel-jest": "26.6.3", + "babel-preset-gatsby": "1.14.0", + "eslint": "7.24.0", + "eslint-config-airbnb": "18.2.1", + "eslint-config-airbnb-typescript": "12.3.1", + "eslint-config-prettier": "8.5.0", + "eslint-import-resolver-alias": "1.1.2", + "eslint-plugin-import": "2.22.1", + "eslint-plugin-jest": "24.7.0", + "eslint-plugin-jsx-a11y": "6.4.1", + "eslint-plugin-prettier": "3.4.1", + "eslint-plugin-react": "7.23.2", + "eslint-plugin-react-hooks": "4.2.0", + "identity-obj-proxy": "3.0.0", + "jest": "26.6.3", + "jest-junit": "12.3.0", + "patch-package": "6.4.7", + "postinstall-postinstall": "2.1.0", + "prettier": "2.6.2", + "react-select-event": "5.5.0", + "rimraf": "3.0.2", + "sonarqube-verify": "1.0.2", + "tailwindcss": "2.2.19" + }, + "resolutions": { + "execa": "2.0.0", + "unset-value": "2.0.1", + "loader-utils": "2.0.3", + "sanitize-html": "2.7.1", + "trim": "1.0.1" + } +} diff --git a/website/postcss.config.js b/website/postcss.config.js new file mode 100644 index 0000000..ad986c0 --- /dev/null +++ b/website/postcss.config.js @@ -0,0 +1,3 @@ +module.exports = () => ({ + plugins: [require('tailwindcss')], +}); diff --git a/website/sonar-project.properties b/website/sonar-project.properties new file mode 100644 index 0000000..d4dca3a --- /dev/null +++ b/website/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.sources=src +sonar.sourceEncoding=UTF-8 +# **/form.ts,src/modules/request/components/ProcessRequest/RequestConfirm/**,src/modules/inscription/pages/dist/InscriptionPageLocalisation.js,**/helpers.ts,**/DownloadJustifs.tsx,**/RequestReject.tsx,**/Toast.tsx,,**/Profile.tsx src/components/Form/SignUpForm/index.tsx +sonar.exclusions=**/matomo.ts,**/decryption.ts,**/ProcessRequest.tsx,**/DemandeService.ts,src/modules/request/components/ProcessRequest/RequestValidate/**,src/modules/request/components/ProcessRequest/RequestInformation/**,src/modules/mon-dashboard/pages/**,src/modules/admin/**,src/modules/routes/mockKeycloak.ts,src/module/routes/index.tsx,src/modules/inscription/components/CreationCompteSuccesMessage.tsx,src/modules/inscription/components/Pilote1Message.tsx,src/modules/inscription/constants.tsx,src/modules/inscription/index.tsx,src/modules/inscription/pages/index.tsx,src/modules/inscription/pages/InscriptionPageIndisponible.tsx,src/utils/mockKeycloak.ts,src/utils/form.ts,src/utils/partners.ts,src/utils/https.ts,src/utils/handleError.ts,src/components/Keycloak/**,src/components/Content.tsx,src/components/Form/SignInForm/ConnexionForm.tsx,src/components/FiltersSelect/FiltersSelect.tsx,src/components/Header/Header.tsx,src/components/common/AdminRedirect.tsx,src/components/PartnerList/PartnerList.tsx,src/components/SectionWithImage/SectionWithImage.tsx,src/components/Header/Header.tsx,src/components/TextareaMarkdownField/TextareaMarkdownField.tsx,src/components/ScrollTopButton/ScrollTopButton.tsx,src/components/AideSearch/AideSearchForm/AideSearchForm.tsx,src/components/Spinner/index.tsx,src/components/Admin/**,src/components/Video/VideoPlayer.tsx,src/assets/index.d.ts,src/cms/cms.js,src/cms/preview-templates/IndexPagePreview.jsx,src/pages/**,src/templates/**,src/apiMob/AideService.ts,src/helpers/configs/function.ts,**.test.ts,.cache/**,**/node_modules/**,public/**,scripts/**,static/**,jest-configs/**,coverage/**,**/__mocks__/**,**.yml,**.json,**.jpg,**.png,**.scss,**.css,**.md,**.svg,**.test.tsx,src/helpers/tests/**,**.d.ts,eslintrc.js,gatsby-config.js,gatsby-browser.js,gatsby-node.esm.js,gatsby-node.js,gatsby-ssr.js,jest-config.js,postcss.config.js,src/constants.ts,**/navigate.tsx,**/Demande.ts,**/demandes.ts +sonar.coverage.exclusions=**/matomo.ts,**/ProcessRequest.tsx,**/DemandeService.ts,src/modules/request/components/ProcessRequest/RequestValidate/**,src/modules/request/components/ProcessRequest/RequestInformation/**,src/modules/mon-dashboard/pages/**,src/modules/admin/**,src/modules/routes/mockKeycloak.ts,src/module/routes/index.tsx,src/modules/inscription/components/CreationCompteSuccesMessage.tsx,src/modules/inscription/components/Pilote1Message.tsx,src/modules/inscription/constants.tsx,src/modules/inscription/index.tsx,src/modules/inscription/pages/index.tsx,src/modules/inscription/pages/InscriptionPageIndisponible.tsx,src/utils/mockKeycloak.ts,src/utils/form.ts,src/utils/partners.ts,src/utils/https.ts,src/utils/handleError.ts,src/components/Keycloak/**,src/components/Content.tsx,src/components/Form/SignInForm/ConnexionForm.tsx,src/components/FiltersSelect/FiltersSelect.tsx,src/components/Header/Header.tsx,src/components/common/AdminRedirect.tsx,src/components/PartnerList/PartnerList.tsx,src/components/SectionWithImage/SectionWithImage.tsx,src/components/Header/Header.tsx,src/components/TextareaMarkdownField/TextareaMarkdownField.tsx,src/components/ScrollTopButton/ScrollTopButton.tsx,src/components/AideSearch/AideSearchForm/AideSearchForm.tsx,src/components/Spinner/index.tsx,src/components/Admin/**,src/components/Video/VideoPlayer.tsx,src/assets/index.d.ts,src/cms/cms.js,src/cms/preview-templates/IndexPagePreview.jsx,src/pages/**,src/templates/**,src/apiMob/AideService.ts,src/helpers/configs/function.ts,**.test.ts,.cache/**,**/node_modules/**,public/**,scripts/**,static/**,jest-configs/**,coverage/**,**/__mocks__/**,**.yml,**.json,**.jpg,**.png,**.scss,**.css,**.md,**.svg,**.test.tsx,src/helpers/tests/**,**.d.ts,eslintrc.js,gatsby-config.js,gatsby-browser.js,gatsby-node.esm.js,gatsby-node.js,gatsby-ssr.js,jest-config.js,postcss.config.js,src/constants.ts,**/navigate.tsx,**/Demande.ts,**/demandes.ts +sonar.cpd.exclusions=src/components/Admin/AideCreationForm/AideCreationForm.tsx,src/components/Form/SignUpForm/SignUpForm.tsx,src/components/AideSearch/AideSearchList/AideSearchList.tsx,src/components/TextField/TextField.tsx,src/components/TextareaMarkdownField/TextareaMarkdownField.tsx,src/components/Checkbox/Checkbox.tsx,src/utils/https.ts,src/utils/helper.ts +sonar.javascript.lcov.reportPaths=coverage/lcov.info diff --git a/website/src/@types/gatsby-plugin-breadcrumb/index.d.ts b/website/src/@types/gatsby-plugin-breadcrumb/index.d.ts new file mode 100644 index 0000000..23ee192 --- /dev/null +++ b/website/src/@types/gatsby-plugin-breadcrumb/index.d.ts @@ -0,0 +1 @@ +declare module 'gatsby-plugin-breadcrumb/'; diff --git a/website/src/@types/react-lines-ellipsis/index.d.ts b/website/src/@types/react-lines-ellipsis/index.d.ts new file mode 100644 index 0000000..bff446f --- /dev/null +++ b/website/src/@types/react-lines-ellipsis/index.d.ts @@ -0,0 +1,31 @@ +declare module 'react-lines-ellipsis' { + import * as React from 'react'; + + interface ReactLinesEllipsisProps { + basedOn?: 'letters' | 'words'; + className?: string; + component?: string; + ellipsis?: string; + isClamped?: () => boolean; + maxLine?: number | string; + onReflow?: ({ clamped, text }: { clamped: boolean; text: string }) => any; + style?: React.CSSProperties; + text?: string; + trimRight?: boolean; + winWidth?: number; + } + + class LinesEllipsis extends React.Component { + static defaultProps?: ReactLinesEllipsisProps; + } + + export default LinesEllipsis; +} + +declare module 'react-lines-ellipsis/lib/responsiveHOC' { + import * as React from 'react'; + + export default function responsiveHOC():

( + WrappedComponent: React.ComponentType

+ ) => React.ComponentClass

; +} diff --git a/website/src/apiMob/AideService.ts b/website/src/apiMob/AideService.ts new file mode 100644 index 0000000..21e2d5b --- /dev/null +++ b/website/src/apiMob/AideService.ts @@ -0,0 +1,52 @@ +import { https } from '@utils/https'; +import { stringifyParams } from '@utils/helpers'; + +export type AideCreation = { + title: string; + description: string; + territoryName: string; + funderName: string; + incentiveType: string; + conditions: string; + paymentMethod: string; + allocatedAmount: string; + minAmount: string; + transportList: string[]; + attachments: string[]; + additionalInfos?: string; + contact?: string; + validityDate?: string; + isMCMStaff: boolean; +}; + +export const getAide = async (incentiveId: string): Promise<{}> => { + const { data } = await https.get(`v1/incentives/${incentiveId}`); + return data; +}; + +export const searchAide = async ( + searchTerm?: string, + incentiveType?: string | string[], + enterpriseId?: string +): Promise<{}> => { + const params: { + [key: string]: string[] | string | undefined | number; + } = { + _q: searchTerm, + incentiveType, + enterpriseId, + }; + const newUrl = `v1/incentives/search${stringifyParams(params)}`; + const { data } = await https.get(newUrl); + return data; +}; + +/** + * get the aid list relative to the connected enterprise or collectivity + * @returns + */ +export const listAide = async (): Promise<{ id: string; title: string }[]> => { + const newUrl = `v1/incentives`; + const { data } = await https.get<{ id: string; title: string }[]>(newUrl); + return data; +}; diff --git a/website/src/apiMob/CitizenService.ts b/website/src/apiMob/CitizenService.ts new file mode 100644 index 0000000..0ffaef1 --- /dev/null +++ b/website/src/apiMob/CitizenService.ts @@ -0,0 +1,118 @@ +import { https } from '@utils/https'; +import { stringifyParams } from '@utils/helpers'; +import { Citizen, ClientOfConsent, Consent } from '@utils/citoyens'; + +export const createCitizen = async (userData: Citizen): Promise<{}> => { + const { data } = await https.post( + `v1/citizens`, + JSON.stringify(userData) + ); + return data; +}; + +export const searchSalaries = async ( + status?: string, + lastName?: string, + skip?: number +): Promise<{}> => { + const params: { + [key: string]: string[] | string | undefined | number; + } = { + status, + lastName, + skip, + }; + + const newUrl = `v1/citizens${stringifyParams(params)}`; + const { data } = await https.get(newUrl); + return data; +}; + +export const getCitizenById = async (id: string): Promise<{}> => { + const { data } = await https.get(`v1/citizens/profile/${id}`); + return data; +}; + +export const getConsentsById = async (id: string): Promise => { + const { data } = await https.get( + `v1/citizens/${id}/linkedAccounts` + ); + return data; +}; + +export const deleteConsent = async ( + id: string, + clientId: string +): Promise => { + await https.delete(`v1/citizens/${id}/linkedAccounts/${clientId}`); +}; + +export const getCitizens = async ( + lastName: string, + skip: number +): Promise<{}> => { + let params = '?'; + params += lastName ? `lastName=${lastName}` : ''; + params += skip ? `&skip=${skip}` : ''; + const { data } = await https.get( + `/v1/collectivitiesCitizens${params}` + ); + return data; +}; + +export const getCitizenName = async (id: string): Promise => { + const { data } = await https.get(`v1/citizens/${id}`); + return data; +}; + +export const downloadRgpdFileXlsx = async (id: string): Promise<{}> => { + const { data } = await https.get(`v1/citizens/${id}/export`, { + responseType: 'blob', + }); + return data; +}; + +export const updateCitizenById = async ( + id: string, + citizenData: Partial +): Promise => { + await https.patch(`v1/citizens/${id}`, JSON.stringify(citizenData)); +}; + +/** + * Create citizen linked to FC by id + * @param id id of citizen + * @param citizenData schema + */ +export const createCitizenFcById = async ( + id: string, + citizenData: Partial +): Promise => { + await https.post( + `/v1/citizens/${id}/complete`, + JSON.stringify(citizenData) + ); +}; + +export const putCitizenAffiliation = async ( + citizenId: string, + token = '' +): Promise<{}> => { + const { data } = await https.put( + `v1/citizens/${citizenId}/affiliate`, + JSON.stringify({ token }) + ); + return data; +}; + +export const putCitizenDesaffiliation = async ( + citizenId: string +): Promise<{}> => { + const { data } = await https.put(`/v1/citizens/${citizenId}/disaffiliate`); + return data; +}; + +export const deleteCitizenAccount = async (citizenId: string): Promise<{}> => { + const { data } = await https.put(`v1/citizens/${citizenId}/delete`); + return data; +}; diff --git a/website/src/apiMob/ContactService.ts b/website/src/apiMob/ContactService.ts new file mode 100644 index 0000000..29bfd76 --- /dev/null +++ b/website/src/apiMob/ContactService.ts @@ -0,0 +1,16 @@ +import { https } from '@utils/https'; + +export type Contact = { + lastName: string; + firstName: string; + userType: string; + email: string; + postcode: string; + message?: string; + tos: boolean; +}; + +export const send = async (contactData: Contact): Promise<{}> => { + const { data } = await https.post(`/v1/contact`, contactData); + return data; +}; diff --git a/website/src/apiMob/DashboardService.ts b/website/src/apiMob/DashboardService.ts new file mode 100644 index 0000000..7bb9796 --- /dev/null +++ b/website/src/apiMob/DashboardService.ts @@ -0,0 +1,30 @@ +import { https } from '@utils/https'; +import { STATUS } from '@utils/demandes'; + +export type CitoyenAideDashboard = { + result: Array<{ status: STATUS; count: number }>; + totalCount: number; +}; + +export type DemandeAideDashboard = { + result: Array<{ status: STATUS; count: number }>; + totalCount: number; +}; + +export const getCitoyensDashboard = async ( + year: number, + semester: number +): Promise => { + const url = `v1/dashboards/citizens?year=${year}&semester=${semester}`; + const { data } = await https.get(url); + return data; +}; + +export const getDemandesDashboard = async ( + year: string, + semester: string +): Promise => { + let url = `v1/dashboards/subscriptions?year=${year}&semester=${semester}`; + const { data } = await https.get(url); + return data; +}; diff --git a/website/src/apiMob/DemandeService.ts b/website/src/apiMob/DemandeService.ts new file mode 100644 index 0000000..69c0a99 --- /dev/null +++ b/website/src/apiMob/DemandeService.ts @@ -0,0 +1,152 @@ +import { https } from '@utils/https'; +import { + Subscription, + STATUS, + MultiplePayment, + NoPayment, + SinglePayment, + SubscriptionRejection, +} from '@utils/demandes'; +import { stringifyParams, isExpired } from '@utils/helpers'; + +export type Metadata = { + incentiveId: string; + attachmentMetadata: { fileName: string }[]; + citizenId: string; +}; + +export const subscriptionList = async ( + status: STATUS, + incentiveIds?: string[], + communitiesId?: string[], + lastName?: string, + citizenId?: string, + skip?: number +): Promise => { + let params = `status=${status}`; + params += incentiveIds?.length + ? `&incentiveId=${incentiveIds.join(',')}` + : ''; + params += lastName ? `&lastName=${lastName}` : ''; + params += communitiesId?.length + ? `&idCommunities=${communitiesId.join(',')}` + : ''; + params += citizenId ? `&citizenId=${citizenId}` : ''; + params += skip ? `&skip=${skip}` : ''; + + const { data } = await https.get( + `v1/subscriptions?${params}` + ); + return data; +}; +interface GetSubscriptionFilters { + citizenId: string; + status: STATUS[]; + year?: string[]; + funderType?: string; +} +export const getCitizenSubscriptions = async ( + filter: GetSubscriptionFilters +): Promise => { + const getSubscriptionsUrl = `v1/subscriptions${stringifyParams(filter)}`; + const { data } = await https.get<{ + subscriptions: Subscription[]; + count: number; + }>(getSubscriptionsUrl); + return data.subscriptions; +}; + +export const getDemandeById = async (subscriptionId: string): Promise<{}> => { + const { data } = await https.get( + `v1/subscriptions/${subscriptionId}` + ); + return data; +}; + +export const getDemandeFileByName = async ( + subscriptionId: string, + filename: string +): Promise => { + const data = await https.get( + `v1/subscriptions/${subscriptionId}/attachments/${filename}` + ); + return data; +}; + +export const putSubscriptionValidate = async ( + subscriptionId: string, + demandeValidateData: SinglePayment | MultiplePayment | NoPayment +): Promise => { + const data = await https.put( + `v1/subscriptions/${subscriptionId}/validate`, + JSON.stringify(demandeValidateData) + ); + return data; +}; + +export const putSubscriptionReject = async ( + subscriptionId: string, + demandeRejectData: SubscriptionRejection +): Promise => { + const data = await https.put( + `v1/subscriptions/${subscriptionId}/reject`, + JSON.stringify(demandeRejectData) + ); + return data; +}; + +export const demandesValideesXlsx = async (): Promise => { + const data = await https.get(`v1/subscriptions/export`, { + responseType: 'blob', + }); + + return data; +}; + +export const getMetadata = async (id: string): Promise => { + const { data } = await https.get(`v1/subscriptions/metadata/${id}`); + return data; +}; + +export const postV1Subscription = async (subscriptionData: { + incentiveId: string; + consent: boolean; +}): Promise<{ subscriptionId: string }> => { + const { data } = await https.post( + `v1/maas/subscriptions`, + JSON.stringify(subscriptionData) + ); + return data; +}; + +export const postV1SubscriptionAttachments = async ( + subscriptionId: string, + attachmentData: FormData +): Promise<{ subscriptionId: string }> => { + const { data } = await https.post( + `v1/maas/subscriptions/${subscriptionId}/attachments`, + attachmentData, + { headers: { 'Content-Type': 'multipart/form-data' }} + ); + return data; +}; + +export const postV1SubscriptionVerify = async ( + subscriptionId: string +): Promise<{ subscriptionId: string }> => { + const { data } = await https.post( + `v1/maas/subscriptions/${subscriptionId}/verify` + ); + return data; +}; + +export const triggerSubscriptionMaasRedirect = async():Promise => { + return await https.get(`${window.location.origin}/recherche/`); +} + +export const getFunderById = async ( + funderId: string, +): Promise => { + const { data } = await https.get(`v1/funders/${funderId}`); + return data; +}; diff --git a/website/src/apiMob/EntrepriseService.ts b/website/src/apiMob/EntrepriseService.ts new file mode 100644 index 0000000..4236cc2 --- /dev/null +++ b/website/src/apiMob/EntrepriseService.ts @@ -0,0 +1,33 @@ +import { https } from '@utils/https'; + +export type Enterprise = { + id: string; + name: string; + firstName: string; + lastName: string; + email: string; + password: string; + emailFormat: string[]; + siretNumber: number; + employeesCount: string; + budgetAmount: number; + hasManualAffiliation: boolean; +}; + +export type EnterpriseName = { + id: string; + name: string; + emailFormat: string[]; +}; + +export const getEntreprisesList = async (): Promise<{}> => { + const { data } = await https.get( + 'v1/enterprises/email_format_list' + ); + return data; +}; + +export const getEntreprises = async (): Promise<{}> => { + const { data } = await https.get('v1/enterprises/'); + return data; +}; diff --git a/website/src/apiMob/FunderService.ts b/website/src/apiMob/FunderService.ts new file mode 100644 index 0000000..656da67 --- /dev/null +++ b/website/src/apiMob/FunderService.ts @@ -0,0 +1,11 @@ +import { https } from '@utils/https'; +import { Community } from '@utils/funders'; + +export const getFunderCommunities = async ( + funderId: string +): Promise => { + const { data } = await https.get( + `/v1/funders/${funderId}/communities` + ); + return data; +}; diff --git a/website/src/apiMob/TerritoryService.ts b/website/src/apiMob/TerritoryService.ts new file mode 100644 index 0000000..ed05d31 --- /dev/null +++ b/website/src/apiMob/TerritoryService.ts @@ -0,0 +1,13 @@ +import { https } from '@utils/https'; + +export interface Territory { + id: string; + name: string; +} + +export const getTerritories = async (): Promise => { + const { data } = await https.get( + `/v1/territories` + ); + return data; +}; diff --git a/website/src/apiMob/UserFunderService.ts b/website/src/apiMob/UserFunderService.ts new file mode 100644 index 0000000..667173d --- /dev/null +++ b/website/src/apiMob/UserFunderService.ts @@ -0,0 +1,34 @@ +import { https } from '@utils/https'; +import { Community } from '@utils/funders'; +import { Roles } from '../constants'; +import { getFunderCommunities } from './FunderService'; + +export interface UserFunder { + email: string; + firstName: string; + lastName: string; + id: string; + funderId: string; + communityIds: string[]; + roles: Roles[]; +} + +export const getUserProfileById = async ( + idUserFunder: string +): Promise => { + const { data } = await https.get<{ data: UserFunder }>( + `v1/users/${idUserFunder}` + ); + return data; +}; + +export const getUserFunderCommunities = async ( + userFunder: UserFunder +): Promise => { + const { communityIds, funderId } = userFunder; + const funderCommunities = await getFunderCommunities(funderId); + const userFunderCommunities = funderCommunities.filter( + (community: Community) => communityIds.includes(community.id) + ); + return userFunderCommunities; +}; diff --git a/website/src/assets/images/bridge.jpg b/website/src/assets/images/bridge.jpg new file mode 100644 index 0000000..a4769f0 Binary files /dev/null and b/website/src/assets/images/bridge.jpg differ diff --git a/website/src/assets/images/dame-veste-corail.jpg b/website/src/assets/images/dame-veste-corail.jpg new file mode 100644 index 0000000..97d209a Binary files /dev/null and b/website/src/assets/images/dame-veste-corail.jpg differ diff --git a/website/src/assets/images/girl-bike.jpg b/website/src/assets/images/girl-bike.jpg new file mode 100644 index 0000000..9cb2ba0 Binary files /dev/null and b/website/src/assets/images/girl-bike.jpg differ diff --git a/website/src/assets/images/girl-smiling.jpg b/website/src/assets/images/girl-smiling.jpg new file mode 100644 index 0000000..ac798df Binary files /dev/null and b/website/src/assets/images/girl-smiling.jpg differ diff --git a/website/src/assets/images/girls-laughing.jpg b/website/src/assets/images/girls-laughing.jpg new file mode 100644 index 0000000..951e815 Binary files /dev/null and b/website/src/assets/images/girls-laughing.jpg differ diff --git a/website/src/assets/images/homme-d-affaire.jpg b/website/src/assets/images/homme-d-affaire.jpg new file mode 100644 index 0000000..367a3ba Binary files /dev/null and b/website/src/assets/images/homme-d-affaire.jpg differ diff --git a/website/src/assets/images/idfm-poster.png b/website/src/assets/images/idfm-poster.png new file mode 100644 index 0000000..e99a9bb Binary files /dev/null and b/website/src/assets/images/idfm-poster.png differ diff --git a/website/src/assets/images/image-video.png b/website/src/assets/images/image-video.png new file mode 100644 index 0000000..28b222c Binary files /dev/null and b/website/src/assets/images/image-video.png differ diff --git a/website/src/assets/images/index.d.ts b/website/src/assets/images/index.d.ts new file mode 100644 index 0000000..fc781e8 --- /dev/null +++ b/website/src/assets/images/index.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const src: string; + export default src; +} diff --git a/website/src/assets/images/logo-fabmob.png b/website/src/assets/images/logo-fabmob.png new file mode 100644 index 0000000..2fc25ec Binary files /dev/null and b/website/src/assets/images/logo-fabmob.png differ diff --git a/website/src/assets/images/logo-igart.png b/website/src/assets/images/logo-igart.png new file mode 100644 index 0000000..f804bbf Binary files /dev/null and b/website/src/assets/images/logo-igart.png differ diff --git a/website/src/assets/images/logo-ministere.png b/website/src/assets/images/logo-ministere.png new file mode 100644 index 0000000..0fc0350 Binary files /dev/null and b/website/src/assets/images/logo-ministere.png differ diff --git a/website/src/assets/images/man-riding-bike.jpg b/website/src/assets/images/man-riding-bike.jpg new file mode 100644 index 0000000..ab39401 Binary files /dev/null and b/website/src/assets/images/man-riding-bike.jpg differ diff --git a/website/src/assets/images/support-iphone.png b/website/src/assets/images/support-iphone.png new file mode 100644 index 0000000..8c299b4 Binary files /dev/null and b/website/src/assets/images/support-iphone.png differ diff --git a/website/src/assets/images/support-mac.jpg b/website/src/assets/images/support-mac.jpg new file mode 100644 index 0000000..181576c Binary files /dev/null and b/website/src/assets/images/support-mac.jpg differ diff --git a/website/src/assets/images/tramway.jpg b/website/src/assets/images/tramway.jpg new file mode 100644 index 0000000..b0fa738 Binary files /dev/null and b/website/src/assets/images/tramway.jpg differ diff --git a/website/src/assets/images/trees.jpg b/website/src/assets/images/trees.jpg new file mode 100644 index 0000000..b2fbf2d Binary files /dev/null and b/website/src/assets/images/trees.jpg differ diff --git a/website/src/assets/images/woman-helmet.jpg b/website/src/assets/images/woman-helmet.jpg new file mode 100644 index 0000000..5afaf17 Binary files /dev/null and b/website/src/assets/images/woman-helmet.jpg differ diff --git a/website/src/assets/images/woman-smiling.jpg b/website/src/assets/images/woman-smiling.jpg new file mode 100644 index 0000000..0c92a36 Binary files /dev/null and b/website/src/assets/images/woman-smiling.jpg differ diff --git a/website/src/assets/images/woman-yellow-coat.jpg b/website/src/assets/images/woman-yellow-coat.jpg new file mode 100644 index 0000000..cf67b5a Binary files /dev/null and b/website/src/assets/images/woman-yellow-coat.jpg differ diff --git a/website/src/assets/index.d.ts b/website/src/assets/index.d.ts new file mode 100644 index 0000000..f784797 --- /dev/null +++ b/website/src/assets/index.d.ts @@ -0,0 +1,7 @@ +declare module '*.svg' { + import React, { FC } from 'react'; + + export const ReactComponent: FC>; + const src: string; + export default src; +} diff --git a/website/src/assets/scss/animations/_bounce.scss b/website/src/assets/scss/animations/_bounce.scss new file mode 100644 index 0000000..0a946a1 --- /dev/null +++ b/website/src/assets/scss/animations/_bounce.scss @@ -0,0 +1,197 @@ +@mixin timing-function { + animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); +} + +@keyframes #{$rt-namespace}__bounceInRight { + from, + 60%, + 75%, + 90%, + to { + @include timing-function; + } + from { + opacity: 0; + transform: translate3d(3000px, 0, 0); + } + 60% { + opacity: 1; + transform: translate3d(-25px, 0, 0); + } + 75% { + transform: translate3d(10px, 0, 0); + } + 90% { + transform: translate3d(-5px, 0, 0); + } + to { + transform: none; + } +} + +@keyframes #{$rt-namespace}__bounceOutRight { + 20% { + opacity: 1; + transform: translate3d(-20px, 0, 0); + } + to { + opacity: 0; + transform: translate3d(2000px, 0, 0); + } +} + +@keyframes #{$rt-namespace}__bounceInLeft { + from, + 60%, + 75%, + 90%, + to { + @include timing-function; + } + 0% { + opacity: 0; + transform: translate3d(-3000px, 0, 0); + } + 60% { + opacity: 1; + transform: translate3d(25px, 0, 0); + } + 75% { + transform: translate3d(-10px, 0, 0); + } + 90% { + transform: translate3d(5px, 0, 0); + } + to { + transform: none; + } +} + +@keyframes #{$rt-namespace}__bounceOutLeft { + 20% { + opacity: 1; + transform: translate3d(20px, 0, 0); + } + to { + opacity: 0; + transform: translate3d(-2000px, 0, 0); + } +} + +@keyframes #{$rt-namespace}__bounceInUp { + from, + 60%, + 75%, + 90%, + to { + @include timing-function; + } + from { + opacity: 0; + transform: translate3d(0, 3000px, 0); + } + 60% { + opacity: 1; + transform: translate3d(0, -20px, 0); + } + 75% { + transform: translate3d(0, 10px, 0); + } + 90% { + transform: translate3d(0, -5px, 0); + } + to { + transform: translate3d(0, 0, 0); + } +} + +@keyframes #{$rt-namespace}__bounceOutUp { + 20% { + transform: translate3d(0, -10px, 0); + } + 40%, + 45% { + opacity: 1; + transform: translate3d(0, 20px, 0); + } + to { + opacity: 0; + transform: translate3d(0, -2000px, 0); + } +} + +@keyframes #{$rt-namespace}__bounceInDown { + from, + 60%, + 75%, + 90%, + to { + @include timing-function; + } + 0% { + opacity: 0; + transform: translate3d(0, -3000px, 0); + } + 60% { + opacity: 1; + transform: translate3d(0, 25px, 0); + } + 75% { + transform: translate3d(0, -10px, 0); + } + 90% { + transform: translate3d(0, 5px, 0); + } + to { + transform: none; + } +} + +@keyframes #{$rt-namespace}__bounceOutDown { + 20% { + transform: translate3d(0, 10px, 0); + } + 40%, + 45% { + opacity: 1; + transform: translate3d(0, -20px, 0); + } + to { + opacity: 0; + transform: translate3d(0, 2000px, 0); + } +} + +.#{$rt-namespace}__bounce-enter { + &--top-left, + &--bottom-left { + animation-name: #{$rt-namespace}__bounceInLeft; + } + &--top-right, + &--bottom-right { + animation-name: #{$rt-namespace}__bounceInRight; + } + &--top-center { + animation-name: #{$rt-namespace}__bounceInDown; + } + &--bottom-center { + animation-name: #{$rt-namespace}__bounceInUp; + } +} + +.#{$rt-namespace}__bounce-exit { + &--top-left, + &--bottom-left { + animation-name: #{$rt-namespace}__bounceOutLeft; + } + &--top-right, + &--bottom-right { + animation-name: #{$rt-namespace}__bounceOutRight; + } + &--top-center { + animation-name: #{$rt-namespace}__bounceOutUp; + } + &--bottom-center { + animation-name: #{$rt-namespace}__bounceOutDown; + } +} diff --git a/website/src/assets/scss/animations/_flip.scss b/website/src/assets/scss/animations/_flip.scss new file mode 100644 index 0000000..a6f4ef4 --- /dev/null +++ b/website/src/assets/scss/animations/_flip.scss @@ -0,0 +1,43 @@ +@keyframes #{$rt-namespace}__flipIn { + from { + transform: perspective(400px) rotate3d(1, 0, 0, 90deg); + animation-timing-function: ease-in; + opacity: 0; + } + 40% { + transform: perspective(400px) rotate3d(1, 0, 0, -20deg); + animation-timing-function: ease-in; + } + 60% { + transform: perspective(400px) rotate3d(1, 0, 0, 10deg); + opacity: 1 + } + 80% { + transform: perspective(400px) rotate3d(1, 0, 0, -5deg); + } + to { + transform: perspective(400px); + } +} + +@keyframes #{$rt-namespace}__flipOut { + from { + transform: perspective(400px); + } + 30% { + transform: perspective(400px) rotate3d(1, 0, 0, -20deg); + opacity: 1 + } + to { + transform: perspective(400px) rotate3d(1, 0, 0, 90deg); + opacity: 0 + } +} + +.#{$rt-namespace}__flip-enter { + animation-name: #{$rt-namespace}__flipIn; +} + +.#{$rt-namespace}__flip-exit { + animation-name: #{$rt-namespace}__flipOut; +} diff --git a/website/src/assets/scss/animations/_slide.scss b/website/src/assets/scss/animations/_slide.scss new file mode 100644 index 0000000..6b96ca6 --- /dev/null +++ b/website/src/assets/scss/animations/_slide.scss @@ -0,0 +1,117 @@ +@mixin transform { + transform: translate3d(0, 0, 0); +} + +@keyframes #{$rt-namespace}__slideInRight { + from { + transform: translate3d(110%, 0, 0); + visibility: visible; + } + to { + @include transform; + } +} + +@keyframes #{$rt-namespace}__slideInLeft { + from { + transform: translate3d(-110%, 0, 0); + visibility: visible; + } + to { + @include transform; + } +} + +@keyframes #{$rt-namespace}__slideInUp { + from { + transform: translate3d(0, 110%, 0); + visibility: visible; + } + to { + @include transform; + } +} + +@keyframes #{$rt-namespace}__slideInDown { + from { + transform: translate3d(0, -110%, 0); + visibility: visible; + } + to { + @include transform; + } +} + +@keyframes #{$rt-namespace}__slideOutRight { + from { + @include transform; + } + to { + visibility: hidden; + transform: translate3d(110%, 0, 0); + } +} + +@keyframes #{$rt-namespace}__slideOutLeft { + from { + @include transform; + } + to { + visibility: hidden; + transform: translate3d(-110%, 0, 0); + } +} + +@keyframes #{$rt-namespace}__slideOutDown { + from { + @include transform; + } + to { + visibility: hidden; + transform: translate3d(0, 500px, 0); + } +} + +@keyframes #{$rt-namespace}__slideOutUp { + from { + @include transform; + } + to { + visibility: hidden; + transform: translate3d(0, -500px, 0); + } +} + +.#{$rt-namespace}__slide-enter { + &--top-left, + &--bottom-left { + animation-name: #{$rt-namespace}__slideInLeft; + } + &--top-right, + &--bottom-right { + animation-name: #{$rt-namespace}__slideInRight; + } + &--top-center { + animation-name: #{$rt-namespace}__slideInDown; + } + &--bottom-center { + animation-name: #{$rt-namespace}__slideInUp; + } +} + +.#{$rt-namespace}__slide-exit { + &--top-left, + &--bottom-left { + animation-name: #{$rt-namespace}__slideOutLeft; + } + &--top-right, + &--bottom-right { + animation-name: #{$rt-namespace}__slideOutRight; + } + &--top-center { + animation-name: #{$rt-namespace}__slideOutUp; + } + &--bottom-center { + animation-name: #{$rt-namespace}__slideOutDown; + } +} diff --git a/website/src/assets/scss/animations/_zoom.scss b/website/src/assets/scss/animations/_zoom.scss new file mode 100644 index 0000000..5289069 --- /dev/null +++ b/website/src/assets/scss/animations/_zoom.scss @@ -0,0 +1,30 @@ +@keyframes #{$rt-namespace}__zoomIn { + from { + opacity: 0; + transform: scale3d(0.3, 0.3, 0.3); + } + 50% { + opacity: 1; + } +} + +@keyframes #{$rt-namespace}__zoomOut { + from { + opacity: 1; + } + 50% { + opacity: 0; + transform: scale3d(0.3, 0.3, 0.3); + } + to { + opacity: 0 + } +} + +.#{$rt-namespace}__zoom-enter { + animation-name: #{$rt-namespace}__zoomIn; +} + +.#{$rt-namespace}__zoom-exit { + animation-name: #{$rt-namespace}__zoomOut; +} diff --git a/website/src/assets/scss/base/_base.scss b/website/src/assets/scss/base/_base.scss new file mode 100644 index 0000000..c201da9 --- /dev/null +++ b/website/src/assets/scss/base/_base.scss @@ -0,0 +1,365 @@ +* { + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + font-weight: 400; + color: var(--color-dark); +} + +button:focus { + outline: none; +} + +// Utilities + +// margin-top +.mt-xxs { + margin-top: var(--spacing-xxs); +} + +.mt-xs { + margin-top: var(--spacing-xs); +} + +.mt-s { + margin-top: var(--spacing-s); +} + +.mt-m { + margin-top: var(--spacing-m); +} + +.mt-l { + margin-top: var(--spacing-l); +} + +.mt-xl { + margin-top: var(--spacing-xl); +} + +// margin-right +.mr-xxs { + margin-right: var(--spacing-xxs); +} + +.mr-xs { + margin-right: var(--spacing-xs); +} + +.mr-s { + margin-right: var(--spacing-s); +} + +.mr-m { + margin-right: var(--spacing-m); +} + +.mr-l { + margin-right: var(--spacing-l); +} + +.mr-xl { + margin-right: var(--spacing-xl); +} + +// margin-bottom +.mb-xxs { + margin-bottom: var(--spacing-xxs); +} + +.mb-xs { + margin-bottom: var(--spacing-xs); +} + +.mb-s { + margin-bottom: var(--spacing-s); +} + +.mb-m { + margin-bottom: var(--spacing-m); +} + +.mb-l { + margin-bottom: var(--spacing-l); +} + +.mb-xl { + margin-bottom: var(--spacing-xl); +} + +// margin-left +.ml-xxs { + margin-left: var(--spacing-xxs); +} + +.ml-xs { + margin-left: var(--spacing-xs); +} + +.ml-s { + margin-left: var(--spacing-s); +} + +.ml-m { + margin-left: var(--spacing-m); +} + +.ml-l { + margin-left: var(--spacing-l); +} + +.ml-xl { + margin-left: var(--spacing-xl); +} + +// padding-top +.pt-xxs { + padding-top: var(--spacing-xxs); +} + +.pt-xs { + padding-top: var(--spacing-xs); +} + +.pt-s { + padding-top: var(--spacing-s); +} + +.pt-m { + padding-top: var(--spacing-m); +} + +.pt-l { + padding-top: var(--spacing-l); +} + +.pt-xl { + padding-top: var(--spacing-xl); +} + +.breadcrumb { + margin: var(--spacing-s) 0; + + ol { + display: flex; + + a { + margin-right: 8px; + } + + span { + margin-right: 8px; + } + } + + ol li:last-child { + font-weight: 600; + } +} + +.link-in-text { + font-size: 18px; + font-weight: 600; + color: var(--color-green-leaf); + text-decoration: underline; + + &:hover { + filter: brightness(85%); + } +} + +.link-in-text_blue { + color: var(--color-blue-petrol); + + &:hover { + text-decoration: underline; + } +} + +.o-bg-wrapper { + position: relative; + + &::before { + content: ''; + position: absolute; + background: url('../svg/letter-o.svg') no-repeat; + top: -50px; + left: -43vw; + width: 100%; + padding-top: 38%; + min-width: 340px; + max-width: 567px; + z-index: -1; + + @media screen and (min-width: 768px) { + left: -365px; + } + + @media screen and (min-width: 1024px) { + top: -70px; + } + } +} + +.o-bg-wrapper-right { + position: relative; + + &::before { + content: ''; + position: absolute; + background: url('../svg/letter-o.svg') no-repeat; + top: -50px; + right: -43vw; + width: 100%; + padding-top: 38%; + min-width: 340px; + max-width: 567px; + z-index: -1; + + @media screen and (min-width: 768px) { + right: -365px; + } + + @media screen and (min-width: 1024px) { + top: -70px; + } + } +} + +.m-bg-wrapper { + position: relative; + + &::after { + content: none; + position: absolute; + background: url('../svg/letter-m-blue.svg') no-repeat; + + @media screen and (min-width: 1024px) { + content: ''; + bottom: -70px; + right: -7vw; + width: 382px; + height: 212px; + z-index: -1; + } + } +} + +.m-yellow-bg-wrapper { + position: relative; + + &::after { + content: none; + position: absolute; + background: url('../svg/letter-m.svg') no-repeat; + + @media screen and (min-width: 1024px) { + content: ''; + bottom: -210px; + right: -7vw; + width: 382px; + height: 212px; + z-index: -1; + } + } +} + +.o-blue-bg-wrapper { + position: relative; + + &::after { + content: ''; + z-index: -1; + transition: all 500ms ease-out; + position: absolute; + background: url('../svg/letter-o-blue.svg') no-repeat; + + @media screen and (max-width: 600px) { + bottom: -80px; + left: -36vw; + width: 340px; + height: 172px; + } + + @media screen and (min-width: 601px) and (max-width: 1023px) { + bottom: -122px; + left: -25vw; + width: 453px; + height: 229px; + } + + @media screen and (min-width: 1024px) { + bottom: -126px; + left: -21vw; + width: 567px; + height: 286px; + } + } +} + +.mcm-informations-text { + display: flex; + flex-direction: column; + margin-bottom: var(--spacing-l); + + h1 { + margin-bottom: var(--spacing-s); + margin-top: var(--spacing-xs); + } + + h2 { + margin-bottom: var(--spacing-xxs); + margin-top: var(--spacing-s); + } + + p { + margin: var(--spacing-xxs) 0px; + + a { + color: blue; + text-decoration-line: none !important; + } + + a:hover { + text-decoration-line: underline !important; + } + } + + ul { + list-style-type: none; + padding-left: 50px; + font-weight: 400; + font-size: 18px; + color: var(--color-dark); + } + + ul li { + margin: var(--spacing-xxs) 0px; + line-height: 150%; + } + + .breaked-p p{ + margin: 0px; + } +} + +// @see https://snook.ca/archives/html_and_css/hiding-content-for-accessibility +.is-hidden { + position: absolute !important; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px 1px 1px 1px); + /* IE6, IE7 */ + clip: rect(1px, 1px, 1px, 1px); +} + +.highlighting { + background-color: var(--color-grey-ultralight); +} + +.button-margin-link { + margin-right: 20px; +} diff --git a/website/src/assets/scss/base/_typography.scss b/website/src/assets/scss/base/_typography.scss new file mode 100644 index 0000000..e8e9f57 --- /dev/null +++ b/website/src/assets/scss/base/_typography.scss @@ -0,0 +1,32 @@ +p, +.p-like { + font-weight: 400; + font-size: 18px; + line-height: 26px; +} +@mixin p-like { + @extend .p-like; +} + +.w600 { + font-weight: 600; +} + +p.headline { + font-size: 24px; + font-weight: 500; + line-height: 32px; + + span.special { + color: var(--color-blue-petrol); + } + + @media screen and (min-width: 1024px) { + font-size: 28px; + line-height: 40px; + } +} + +span.special { + color: var(--color-blue-petrol); +} diff --git a/website/src/assets/scss/base/_variables.scss b/website/src/assets/scss/base/_variables.scss new file mode 100644 index 0000000..01d76d1 --- /dev/null +++ b/website/src/assets/scss/base/_variables.scss @@ -0,0 +1,68 @@ +:root { + // Default breakpoints + $breakpoints: ( + sm: 576px, + md: 768px, + l: 1024px, + xl: 1440px, + ) !default; + + // Font + --font-family: 'sofia-pro', sans-serif; + + // Colors + --color-green-leaf: #01bf7d; + --color-green-leaf-dark: #00a76e; + --color-blue-petrol: #464cd0; + --color-blue-petrol-dark: #3a3e96; + --color-yellow-sun: #ffd314; + --color-yellow-sun-dark: #ffc714; + --color-red-error: #e35447; + --color-dark: #363757; + --color-grey-dark: #74747f; + --color-grey-mid: #adadb5; + --color-grey-light: #d5d6df; + --color-grey-ultralight: #f7f7f9; + --color-white: #ffffff; + + // Transition + --transition: 0.2s ease-in-out all; + + // Border radius + --radius-s: 3px; + --radius-m: 27px; + --radius-l: 34px; + --radius-circle: 50%; + + // Spacing for devices until 1024px + --spacing-xxs: 8px; + --spacing-xs: 12px; + --spacing-s: 24px; + --spacing-m: 36px; + --spacing-l: 48px; + --spacing-xl: 80px; + + // Large devices (desktops, 1024px and up) + @media screen and (min-width: 1024px) { + --spacing-xxs: 12px; + --spacing-xs: 20px; + --spacing-s: 40px; + --spacing-m: 60px; + --spacing-l: 80px; + --spacing-xl: 120px; + } +} + +// Toast Variable styling is here +$rt-namespace: 'Toastify'; +$rt-color-progress-default: linear-gradient( + to right, + #4cd964, + #5ac8fa, + #007aff, + #34aadc, + #5856d6, + #ff2d55 +) !default; +$rt-color-progress-dark: #bb86fc !default; +$rt-z-index: 9999 !default; diff --git a/website/src/assets/scss/base/normalize.css b/website/src/assets/scss/base/normalize.css new file mode 100644 index 0000000..a885ea6 --- /dev/null +++ b/website/src/assets/scss/base/normalize.css @@ -0,0 +1,341 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type='button']::-moz-focus-inner, +[type='reset']::-moz-focus-inner, +[type='submit']::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type='button']:-moz-focusring, +[type='reset']:-moz-focusring, +[type='submit']:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type='checkbox'], +[type='radio'] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type='number']::-webkit-inner-spin-button, +[type='number']::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type='search']::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/website/src/assets/scss/components/_benefit-card.scss b/website/src/assets/scss/components/_benefit-card.scss new file mode 100644 index 0000000..efb5e22 --- /dev/null +++ b/website/src/assets/scss/components/_benefit-card.scss @@ -0,0 +1,97 @@ +.mcm-benefit-card { + display: flex; + flex-direction: column; + align-items: center; + border-radius: 8px; + margin-bottom: 36px; + padding: 40px 30px 35px; + background-color: var(--color-white); + border: 1px solid var(--color-grey-light); + + @media screen and (min-width: 1024px) { + padding: 45px 30px 40px; + } + + &__title { + margin-bottom: var(--spacing-s); + } + &__desc { + text-align: center; + margin-bottom: 12px; + } + &__action { + margin-top: 12px; + + @media screen and (min-width: 1024px) { + padding-top: 24px; + } + } +} + +.mcm-benefits { + margin: 36px 0; + position: relative; + z-index: 0; + + @media screen and (min-width: 1024px) { + // Following styling is alternative of "display: subgrid" css property. + margin: 80px 0; + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(4, auto); + grid-template-columns: repeat(auto-fit, minmax(242px, 1fr)); + grid-column-gap: 24px; + + &::before { + content: ''; + position: absolute; + background: url('../svg/letter-m-blue.svg') no-repeat; + top: -130px; + width: 382px; + height: 213px; + z-index: -1; + right: 82px; + } + .mcm-benefit-card { + display: contents; + + &__title, + &__desc, + &__action, + &__list { + background-color: var(--color-white); + } + &__title { + margin-bottom: 0; + padding: 40px 30px var(--spacing-s); + border: 1px solid var(--color-grey-light); + border-bottom: 0; + border-radius: 8px 8px 0 0; + } + + &__title, + &__action { + text-align: center; + } + + &__desc { + margin-bottom: 0; + } + + &__desc, + &__list { + border-left: 1px solid var(--color-grey-light); + border-right: 1px solid var(--color-grey-light); + padding: 0 30px; + } + + &__action { + margin-top: 0; + padding: 0 30px var(--spacing-s); + border: 1px solid var(--color-grey-light); + border-top: 0; + border-radius: 0 0 8px 8px; + } + } + } +} diff --git a/website/src/assets/scss/components/_closeButton.scss b/website/src/assets/scss/components/_closeButton.scss new file mode 100644 index 0000000..4f83727 --- /dev/null +++ b/website/src/assets/scss/components/_closeButton.scss @@ -0,0 +1,26 @@ +.#{$rt-namespace}__close-button { + color: #fff; + background: transparent; + outline: none; + border: none; + padding: 0; + cursor: pointer; + opacity: 0.7; + transition: 0.3s ease; + align-self: flex-start; + + &--default { + color: #000; + opacity: 0.3; + } + + & > svg { + fill: currentColor; + height: 16px; + width: 14px; + } + + &:hover, &:focus { + opacity: 1; + } +} diff --git a/website/src/assets/scss/components/_heading.scss b/website/src/assets/scss/components/_heading.scss new file mode 100644 index 0000000..124857e --- /dev/null +++ b/website/src/assets/scss/components/_heading.scss @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------------- +// This file contains all styles related to heading component. +// ----------------------------------------------------------------------------- +.heading { + display: flex; + align-items: center; + flex-wrap: wrap; + transition: font-size 200ms; + > img { + display: inline-block; + margin-right: 34px; + + @media screen and (max-width: 767px) { + width: 78px; + } + + @media screen and (min-width: 768px) { + margin-right: 43px; + } + } +} diff --git a/website/src/assets/scss/components/_progressBar.scss b/website/src/assets/scss/components/_progressBar.scss new file mode 100644 index 0000000..aa8b67a --- /dev/null +++ b/website/src/assets/scss/components/_progressBar.scss @@ -0,0 +1,47 @@ +@keyframes #{$rt-namespace}__trackProgress { + 0%{ + transform: scaleX(1); + } + 100%{ + transform: scaleX(0); + } +} + +.#{$rt-namespace}__progress-bar { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 5px; + z-index: $rt-z-index; + opacity: 0.7; + background-color: rgba(255,255,255,.7); + transform-origin: left; + + &--animated { + animation: #{$rt-namespace}__trackProgress linear 1 forwards; + } + + &--controlled { + transition: transform .2s; + } + + &--rtl { + right: 0; + left: initial; + transform-origin: right; + } + + &--default{ + background: $rt-color-progress-default; + } + + &--dark{ + background: $rt-color-progress-dark; + } + + &--info{} + &--success{} + &--warning{} + &--error{} +} diff --git a/website/src/assets/scss/components/_section-with-support.scss b/website/src/assets/scss/components/_section-with-support.scss new file mode 100644 index 0000000..efb781b --- /dev/null +++ b/website/src/assets/scss/components/_section-with-support.scss @@ -0,0 +1,90 @@ +.mcm-section-with-support--iphone { + .mob-pattern { + margin-top: 60px; + } + @media screen and (min-width: 1024px) { + display: grid; + align-items: center; + grid-template-rows: 1fr; + grid-template-columns: auto 425px repeat(15, minmax(0, 1fr)); + + grid-gap: 0 12px; + padding-bottom: 42px; + margin-bottom: 120px; + } + + &__image { + display: none; + + @media screen and (min-width: 1024px) { + display: block; + grid-column: 2 / span 1; + grid-row: 1; + } + } + &__body { + text-align: left; + + @media screen and (min-width: 1024px) { + grid-column: 6 / span 10; + grid-row: 1; + } + } + @media screen and (min-width: 1024px) { + .mob-pattern { + z-index: -1; + grid-column: 1 / span 4; + align-self: end; + margin-bottom: -50px; + margin-right: 22px; + grid-row: 1; + + &__svg { + left: unset; + background-position: right; + width: 50vw; + right: 0; + } + } + } +} + +.mcm-section-with-support--mac { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + margin-bottom: 60px; + @media screen and (min-width: 1024px) { + margin-bottom: -40px; + margin-top: -130px; + } + + &__image { + display: none; + @media screen and (min-width: 1024px) { + display: block; + margin-right: 124px; + position: relative; + max-width: 460px; + height: 906px; + flex: 0 0 auto; + width: 40%; + z-index: -1; + + .img-wrapper { + position: absolute; + right: -253px; + width: 1449px; + height: auto; + } + } + } + &__body { + text-align: left; + + @media screen and (min-width: 1024px) { + margin-right: 84px; + } + } +} diff --git a/website/src/assets/scss/main.scss b/website/src/assets/scss/main.scss new file mode 100644 index 0000000..745ba5d --- /dev/null +++ b/website/src/assets/scss/main.scss @@ -0,0 +1,43 @@ +// Sofia Pro, Adobe Font +@import url('https://use.typekit.net/lpm1lxz.css'); +// External stylesheets +@import 'base/normalize.css'; + +// Base +@import 'base/base'; +@import 'base/variables'; +@import 'base/typography'; + +// Components +@import 'components/section-with-support'; +@import 'components/benefit-card'; +@import 'components/heading'; + +// Pages +@import 'pages/index-page'; +@import 'pages/signup-page'; +@import 'pages/components-page'; +@import 'pages/connexion-inscription'; +@import 'pages/recherche'; +@import 'pages/recherche/aides-recherche'; +@import 'pages/admin/admin-aides'; +@import 'pages/aide-page'; +@import 'pages/mentions-legales-cgu'; +@import 'pages/politique-gestion-cookies'; +@import 'pages/charte-protection-donnees-personnelles'; +@import 'pages/projet'; +@import 'pages/contact'; +@import 'pages/mon-profil'; + +// Toast variables +@charset "UTF-8"; + +@import 'base/variables'; +@import 'components/closeButton'; +@import 'components/progressBar'; + +// entrance and exit animations +@import 'animations/bounce.scss'; +@import 'animations/zoom.scss'; +@import 'animations/flip.scss'; +@import 'animations/slide.scss'; diff --git a/website/src/assets/scss/minimal.scss b/website/src/assets/scss/minimal.scss new file mode 100644 index 0000000..7560b55 --- /dev/null +++ b/website/src/assets/scss/minimal.scss @@ -0,0 +1,16 @@ +@charset "UTF-8"; + +@import "variables"; + +@keyframes #{$rt-namespace}__trackProgress { + 0%{ + transform: scaleX(1); + } + 100%{ + transform: scaleX(0); + } +} + +.#{$rt-namespace}__progress-bar { + animation: #{$rt-namespace}__trackProgress linear 1 forwards; +} diff --git a/website/src/assets/scss/pages/_aide-page.scss b/website/src/assets/scss/pages/_aide-page.scss new file mode 100644 index 0000000..2099cd2 --- /dev/null +++ b/website/src/assets/scss/pages/_aide-page.scss @@ -0,0 +1,89 @@ +.mcm-aide { + &__header { + display: flex; + + .header-body { + max-width: 670px; + + h1 { + margin-bottom: 24px; + } + + .h1 { + margin-bottom: 60px; + } + + p { + margin-bottom: 40px; + } + + button { + width: 100%; + text-transform: uppercase; + @media screen and (min-width: 585px) { + width: auto; + } + } + } + + .header-img { + flex: 1; + position: relative; + height: 450px; + + &__wrapper { + display: none; + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 450px; + > div { + position: static !important; + } + @media screen and (min-width: 1024px) { + display: block; + left: 40%; + width: 700px; + } + //@media screen and (min-width: 1300px) { + // left: auto; + // right: calc(100% - 50vw); + //} + } + } + } + + .mcm-tags { + display: flex; + flex-wrap: wrap; + margin-bottom: 40px; + + &__item { + border-radius: 12px; + background-color: var(--color-grey-ultralight); + font-size: 14px; + padding: 4px 12px; + margin-bottom: 16px; + white-space: nowrap; + &:not(:last-child) { + margin-right: 12px; + } + } + } + + .mcm-tabs__content.has-show-more.has-info { + > .page-container { + display: flex; + } + } + + @media screen and (max-width: 576px) { + .mcm-tabs__content.has-show-more.has-info { + > .page-container { + display: flex; + flex-direction: column; + } + } + } +} diff --git a/website/src/assets/scss/pages/_charte-protection-donnees-personnelles.scss b/website/src/assets/scss/pages/_charte-protection-donnees-personnelles.scss new file mode 100644 index 0000000..2d37219 --- /dev/null +++ b/website/src/assets/scss/pages/_charte-protection-donnees-personnelles.scss @@ -0,0 +1,12 @@ +.mcm-personal-data { + ul { + font-size: 18px; + margin-bottom: var(--spacing-xxs); + margin-left: var(--spacing-m); + width: 95%; + li { + list-style: outside; + padding: var(--spacing-xxs); + } + } +} diff --git a/website/src/assets/scss/pages/_components-page.scss b/website/src/assets/scss/pages/_components-page.scss new file mode 100644 index 0000000..a1a1a4b --- /dev/null +++ b/website/src/assets/scss/pages/_components-page.scss @@ -0,0 +1,82 @@ +.components-page { + section.component { + &:not(:last-child) { + margin-bottom: var(--spacing-xs); + padding-bottom: var(--spacing-s); + border-bottom: 1px solid var(--color-grey-light); + } + h2.title { + margin-bottom: var(--spacing-xs); + color: var(--color-blue-petrol-dark); + } + button + button { + margin-left: var(--spacing-xs); + } + &.icons { + svg { + max-width: 50px; + max-height: 30px; + } + } + &.colors { + .color { + width: 100px; + height: 100px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + justify-content: center; + position: relative; + span { + text-align: center; + position: absolute; + top: 105px; + } + + &:not(:last-child) { + margin-right: 10px; + margin-bottom: 50px; + } + } + .color-green-leaf { + background-color: var(--color-green-leaf); + } + .color-green-leaf-dark { + background-color: var(--color-green-leaf-dark); + } + .color-blue-petrol { + background-color: var(--color-blue-petrol); + } + .color-blue-petrol-dark { + background-color: var(--color-blue-petrol-dark); + } + .color-yellow-sun { + background-color: var(--color-yellow-sun); + } + .color-yellow-sun-dark { + background-color: var(--color-yellow-sun-dark); + } + .color-red-error { + background-color: var(--color-red-error); + } + .color-dark { + background-color: var(--color-dark); + } + .color-grey-dark { + background-color: var(--color-grey-dark); + } + .color-grey-mid { + background-color: var(--color-grey-mid); + } + .color-grey-light { + background-color: var(--color-grey-light); + } + .color-grey-ultralight { + background-color: var(--color-grey-ultralight); + } + .color-white { + background-color: var(--color-white); + } + } + } +} diff --git a/website/src/assets/scss/pages/_connexion-inscription.scss b/website/src/assets/scss/pages/_connexion-inscription.scss new file mode 100644 index 0000000..91fe9ba --- /dev/null +++ b/website/src/assets/scss/pages/_connexion-inscription.scss @@ -0,0 +1,340 @@ +.connexion-inscription { + margin-top: var(--spacing-xs); + margin-bottom: var(--spacing-s); + + @media screen and (min-width: 1024px) { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-gap: 0 24px; + } + + &__title { + margin-top: var(--spacing-s); + } + + &__first { + @media screen and (min-width: 1024px) { + // block starts at column 2 / must be 4 columns wide + grid-column: 2 / span 4; + } + } + + &__second { + @media screen and (min-width: 1024px) { + // block starts at column 8 / must be 3 columns wide + grid-column: 8 / span 4; + } + @media screen and (min-width: 1366px) { + grid-column: 8 / span 3; + } + @media screen and (min-width: 1700px) { + grid-column: 8 / span 4; + } + } + + &__image { + position: absolute; + right: calc(34% - 50vw); + + .img-rounded-left { + overflow: hidden; + border-top-left-radius: var(--radius-circle); + border-bottom-left-radius: var(--radius-circle); + } + } + + // Separator between left / right sections + &::after { + content: ''; + grid-row: 1; + grid-column: 6; + border-right: 1px solid var(--color-grey-light); + height: 100%; + } + + &--login { + @media screen and (min-width: 1024px) { + display: grid; + } + display: flex; + flex-direction: column-reverse; + position: relative; + + .connexion-inscription__second { + @media screen and (max-width: 1023px) { + display: flex; + flex-direction: column; + margin-bottom: var(--spacing-l); + } + @media screen and (max-width: 520px) { + h2, + p { + width: 73%; + } + p { + min-height: 116px; + } + } + } + + .connexion-inscription__first { + button { + @media screen and (max-width: 1023px) { + width: 100%; + } + } + } + + .connexion-inscription__image { + height: 200px; + width: 200px; + @media screen and (max-width: 520px) { + display: block; + top: 0; + right: -124px; + } + + @media screen and (min-width: 521px) { + display: none; + } + + @media screen and (min-width: 1440px) { + display: block; + height: 450px; + width: 450px; + } + + .img-rounded-left { + width: 100%; + height: 100%; + max-width: 450px; + max-height: 450px; + } + } + } + + &--question, + &--confirmation { + position: relative; + + .connexion-inscription__first { + @media screen and (max-width: 520px) { + h1, + .headline { + width: 75%; + } + } + } + + .connexion-inscription__second { + @media screen and (min-width: 1024px) { + min-height: 500px; + } + } + + .connexion-inscription__image { + right: calc(50% - 50vw); + height: 34vw; + width: 34vw; + max-height: 450px; + max-width: 450px; + @media screen and (max-width: 1024px) { + display: none; + } + + .img-rounded-left { + height: 100%; + width: 100%; + } + + .svg-letter-m { + position: absolute; + left: -5.21vw; + bottom: -2.08vw; + width: 17.91vw; + max-width: 344px; + @media screen and (max-width: 520px) { + display: none; + } + } + + @media screen and (min-width: 1024px) { + display: block; + } + } + } + + &--question { + .connexion-inscription__first { + display: flex; + flex-direction: column; + + @media screen and (min-width: 1024px) { + grid-column: 1 / span 6; + display: block; + } + + button.button--secondary { + margin-top: var(--spacing-s); + order: 4; + @media screen and (min-width: 1024px) { + margin-bottom: 0; + margin-right: var(--spacing-xs); + } + } + } + + &::after { + content: none; + } + } + + &--form { + &::after { + content: none; + } + + .connexion-inscription__first { + grid-column: 1 / span 5; + } + + .connexion-inscription__second { + grid-column: 6 / span 7; + @media screen and (min-width: 1260px) { + grid-column: 7 / span 6; + } + + .signup-form { + button.button { + @media screen and (max-width: 1023px) { + width: 100%; + } + } + } + } + } + + &--confirmation { + &::after { + content: none; + } + } + + &:not(.connexion-inscription--question):not(.connexion-inscription--confirmation) { + .connexion-inscription__image { + .svg-letter-m { + display: none; + } + } + } + + .signup-form { + fieldset.fieldset { + padding: 0; + margin-bottom: var(--spacing-m); + + .form__fields { + display: grid; + grid-template-columns: 1fr; + grid-gap: var(--spacing-xs) 24px; + padding: 0; + @media screen and (min-width: 1024px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + legend.form-section-title { + margin-bottom: var(--spacing-xs); + font-size: 20px; + font-weight: 500; + line-height: 28px; + color: var(--color-blue-petrol); + display: inline-flex; + align-items: center; + @media screen and (min-width: 1024px) { + margin-bottom: 20px; + } + } + + &--large-spacing { + legend.form-section-title { + margin-bottom: var(--spacing-s); + } + + .form__fields { + grid-gap: var(--spacing-s) 24px; + } + } + } + } +} + +.btn-FC { + align-self: center; + justify-self: flex-start; + justify-content: flex-start; + @media screen and (max-width: 1024px) { + align-self: flex-start; + } +} + +.fc_buttons_content { + display: flex; + flex: 1; + flex-direction: column; + + @media screen and (max-width: 1024px) { + margin-block: 30px; + } + + a { + font-size: 15px; + color: blue; + text-decoration-line: none !important; + } + + a:hover { + text-decoration-line: underline !important; + } + + a:visited { + color: purple; + } +} + +.fc_link { + background: url('../../assets/svg/franceconnect-btn.svg') no-repeat; + height: 70px; + width: 216px; +} + +.fc_link:hover { + background: url('../../assets/svg/franceconnect-btn-hover.svg') no-repeat left top !important; +} + +.separ-sections { + position: relative; + height: 2px; + margin: 40px 0 40px 0; + justify-content: center; + align-items: center; + background-color: var(--color-grey-light); +} + +.separ-sections .label { + background-color: var(--color-white); + position: absolute; + left: 45.5%; + top: 50%; + transform: translate(-50%, -50%); + width: 55px; + height: 25px; + line-height: 23px; + text-align: center; + @media screen and (max-width: 1260px) { + left: 41%; + } + @media screen and (max-width: 1024px) { + left: 50%; + } +} diff --git a/website/src/assets/scss/pages/_contact.scss b/website/src/assets/scss/pages/_contact.scss new file mode 100644 index 0000000..de0657e --- /dev/null +++ b/website/src/assets/scss/pages/_contact.scss @@ -0,0 +1,74 @@ +.mcm-contact { + .mcm-section-with-image { + align-items: flex-start; + + &__body { + padding-top: 80px; + } + } + + .contact-form { + margin-top: var(--spacing-xl); + + fieldset.fieldset { + padding: 0; + margin-bottom: var(--spacing-m); + + .form__fields { + display: grid; + grid-template-columns: 1fr; + grid-gap: var(--spacing-xs) 24px; + padding: 0; + @media screen and (min-width: 1024px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + legend.form-section-title { + margin-top: var(--spacing-m); + font-size: 20px; + font-weight: 500; + line-height: 28px; + color: var(--color-blue-petrol); + display: inline-flex; + align-items: center; + } + + &--large-spacing { + legend.form-section-title { + margin-bottom: var(--spacing-s); + } + + .form__fields { + grid-gap: var(--spacing-s) 24px; + } + } + + .form-btn-radio { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + @media screen and (max-width: 576px) { + flex-direction: column; + } + + .checkbox-radio { + flex: 50%; + } + } + + .contact-message { + margin-top: var(--spacing-s); + width: 100%; + border: 1px solid var(--color-grey-light); + border-radius: 2px; + height: 200px; + padding: 18px; + &:focus { + outline: none !important; + border: 1px solid var(--color-grey-dark); + } + } + } + } +} diff --git a/website/src/assets/scss/pages/_index-page.scss b/website/src/assets/scss/pages/_index-page.scss new file mode 100644 index 0000000..89e4b20 --- /dev/null +++ b/website/src/assets/scss/pages/_index-page.scss @@ -0,0 +1,243 @@ +.mcm-home { + .mcm-hero { + position: relative; + margin-top: 30px; + h1 { + color: white; + } + &__image { + position: relative; + > div { + height: 100%; + } + @media screen and (max-width: 1024px) { + height: 500px; + } + } + &__actions { + position: absolute; + z-index: 1; + } + } + .mcm-container__main { + @media screen and (min-width: 1024px) { + box-sizing: border-box; + } + text-align: center; + margin-bottom: var(--spacing-m); + + &__with-margin { + margin-top: var(--spacing-l); + + @media screen and (min-width: 1024px) { + margin-top: var(--spacing-xl); + } + } + .display { + &__item:not(:last-child) { + margin-right: 0px; + margin-bottom: 10px; + display: inline-block; + + @media screen and (min-width: 432px) { + margin-right: 20px; + margin-bottom: 0px; + } + } + } + } + + .mcm-steps { + margin-top: 24px; + margin-bottom: 60px; + + @media screen and (min-width: 768px) { + margin-top: -80px; + } + @media screen and (min-width: 1024px) { + margin-bottom: 120px; + } + } +} + +.mcm-home__header { + .page-container { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-gap: 0 24px; + text-align: center; + padding-bottom: 60px; + @media screen and (min-width: 1024px) { + padding-bottom: 120px; + } + } + h1 { + grid-column: 1 / span 12; + @media screen and (min-width: 1024px) { + grid-column: 2 / span 10; + } + } + a { + grid-column: 1 / span 12; + display: flex; + justify-content: center; + } + .m-bg-wrapper { + &::after { + content: ''; + z-index: -1; + transition: all 500ms ease-out; + @media screen and (max-width: 600px) { + bottom: -39px; + right: -15vw; + width: 229px; + height: 127px; + } + @media screen and (min-width: 601px) and (max-width: 1023px) { + bottom: -50px; + right: -8vw; + width: 300px; + height: 180px; + } + @media screen and (min-width: 1024px) { + bottom: -52px; + right: -13vw; + width: 382px; + height: 212px; + } + } + } +} + +.mcm-section-with-image { + margin-bottom: 60px; + position: relative; + padding-top: calc(200px + var(--spacing-m)); + @media screen and (min-width: 1024px) { + margin-bottom: 120px; + padding-top: 0; + display: flex; + align-items: center; + flex-direction: row-reverse; + } + &__image { + position: absolute; + height: 200px; + width: 400px; + top: 0; + right: -135px; + border-radius: 100px; + overflow: hidden; + @media screen and (min-width: 1024px) { + position: relative; + height: 500px; + margin-left: 60px; + min-width: 50%; + overflow: visible; + right: auto; + left: auto; + .img-wrapper { + position: absolute; + height: 100%; + width: 1000px; + right: auto; + border-radius: 500px; + overflow: hidden; + } + } + @media screen and (min-width: 1440px) { + min-width: 33.33%; + height: 600px; + margin-left: 120px; + } + } + &--image-left { + @media screen and (min-width: 1024px) { + flex-direction: row; + } + .mcm-section-with-image__image { + right: auto; + left: -135px; + @media screen and (min-width: 1024px) { + left: auto; + margin-right: 60px; + margin-left: 0; + .img-wrapper { + right: 0; + } + } + @media screen and (min-width: 1440px) { + margin-right: 120px; + } + } + } + &__body { + text-align: left; + .button--secondary { + margin-top: 16px; + @media screen and (min-width: 768px) { + margin-top: unset; + } + } + button.button { + width: 100%; + margin-right: 20px; + @media screen and (min-width: 768px) { + width: auto; + } + & + button { + margin-top: 20px; + } + } + p { + font-weight: 400; + } + } +} + +@media screen and (min-width: 1024px) { + .section--over-width { + .img-wrapper { + width: 1190px; + } + } +} + +.mob-pattern { + height: 170px; + position: relative; + margin-bottom: 60px; + @media screen and (min-width: 1024px) { + height: 264px; + margin-bottom: 120px; + } + &__svg { + height: 100%; + width: 200vw; + left: calc(45% - 50vw); + position: absolute; + background: url('../svg/mob-pattern.svg') repeat-x; + background-size: contain; + + @media screen and (min-width: 1024px) { + width: 115vw; + } + } +} + +.mcm-home--employeur, +.mcm-home--collectivite, +.mcm-home--operateur { + .mcm-hero { + margin-top: 0; + } +} + +.mcm-home--operateur { + .mcm-hero { + margin-bottom: 60px; + @media screen and (min-width: 1024px) { + margin-bottom: 120px; + } + } +} diff --git a/website/src/assets/scss/pages/_mentions-legales-cgu.scss b/website/src/assets/scss/pages/_mentions-legales-cgu.scss new file mode 100644 index 0000000..f65ff38 --- /dev/null +++ b/website/src/assets/scss/pages/_mentions-legales-cgu.scss @@ -0,0 +1,23 @@ +.mcm-CGU { + ul { + font-size: 18px; + margin-bottom: var(--spacing-xxs); + margin-left: var(--spacing-m); + width: 95%; + + li { + list-style: outside; + padding: var(--spacing-xxs); + } + } + + .list-style>li { + list-style: none; + } + + .list-style>li:before { + content: '\2014'; + position: absolute; + margin-left: -2em; + } +} diff --git a/website/src/assets/scss/pages/_mon-profil.scss b/website/src/assets/scss/pages/_mon-profil.scss new file mode 100644 index 0000000..5f7925c --- /dev/null +++ b/website/src/assets/scss/pages/_mon-profil.scss @@ -0,0 +1,42 @@ +$laptop-breakpoint: 1024px; + +.mcm-mon-profil { + h1 { + padding-top: var(--spacing-s); + margin-bottom: var(--spacing-xl); + font-weight: bold; + } + h2 { + margin-top: var(--spacing-s); + margin-bottom: var(--spacing-m); + } +} +.profile-width { + width: 80% !important; +} +.horizontal { + margin-top: 40px; + display: flex; + h2 { + margin-top: 0; + min-width: fit-content; + } +} +.profile-icon { + padding-top: 13px; + padding-left: 10px; + padding-right: 20px; + min-width: fit-content; + margin-left: var(--spacing-xxs); + @media screen and (max-width: $laptop-breakpoint) { + padding-top: 10px; + } +} +.statut-icon { + padding-top: 9px !important; +} +@media screen and (max-width: 900px) { + .profile-width { + width: 90% !important; + } +} diff --git a/website/src/assets/scss/pages/_politique-gestion-cookies.scss b/website/src/assets/scss/pages/_politique-gestion-cookies.scss new file mode 100644 index 0000000..7a368ef --- /dev/null +++ b/website/src/assets/scss/pages/_politique-gestion-cookies.scss @@ -0,0 +1,27 @@ +.mcm-cookies-infos { + h1 { + margin-bottom: var(--spacing-m); + } + .sub-title { + margin-top: var(--spacing-s); + font-weight: bold; + } + .cookies-table { + margin: var(--spacing-xs) auto; + thead { + tr { + vertical-align: top; + background-color: var(--color-grey-ultralight); + font-weight: 900; + } + } + td { + min-width: 180px; + padding: var(--spacing-xs); + vertical-align: top; + } + tr { + border-bottom: 1.5px solid var(--color-grey-ultralight); + } + } +} diff --git a/website/src/assets/scss/pages/_projet.scss b/website/src/assets/scss/pages/_projet.scss new file mode 100644 index 0000000..5fe44e0 --- /dev/null +++ b/website/src/assets/scss/pages/_projet.scss @@ -0,0 +1,78 @@ +.mcm-projet-page { + .breadcrumb { + // need negative margin to align breadcrumb with right picture. + margin-bottom: -13px; + } + .section-title { + @media screen and (max-width: 1023px) { + padding-top: 0; + margin-top: 38px; + .mcm-section-with-image__image { + display: none; + } + } + @media screen and (min-width: 1024px) { + margin-bottom: 60px; + + &::before { + top: -12px; + } + } + } + .mob-pattern { + margin-top: 0; + } +} +.mcm-panel { + padding-top: 48px; + padding-bottom: 24px; + + @media screen and (min-width: 1024px) { + padding-top: 80px; + } + + .heading { + > img { + margin-left: 18px; + } + > .sectionContent { + margin-top: 24px; + grid-column: 3 / span 9; + } + @media screen and (min-width: 768px) { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-column-gap: 24px; + font-size: 46px; + line-height: 46px; + + > img { + grid-column: 1 / span 2; + justify-self: center; + margin: 0; + } + > .sectionContent { + grid-column: 3 / span 9; + } + } + } + + &--resources { + .mcm-list { + @media screen and (min-width: 768px) { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-column-gap: 24px; + + &__item { + &:nth-child(2n + 1) { + grid-column: 3 / span 5; + } + &:nth-child(2n + 0) { + grid-column: 8 / span 5; + } + } + } + } + } +} diff --git a/website/src/assets/scss/pages/_recherche.scss b/website/src/assets/scss/pages/_recherche.scss new file mode 100644 index 0000000..844d6d2 --- /dev/null +++ b/website/src/assets/scss/pages/_recherche.scss @@ -0,0 +1,58 @@ +.mcm-search { + display: flex; + flex-direction: column; + align-items: center; + h1 { + text-align: center; + } + p { + text-align: center; + max-width: 550px; + } + &__actions { + @media screen and (min-width: 768px) { + display: flex; + margin-bottom: 60px; + } + .mcm-search-btn { + border-radius: 15px; + background-color: var(--color-grey-ultralight); + padding: 30px 26px; + display: flex; + flex-direction: column; + align-items: center; + max-width: 550px; + border: 2px solid var(--color-grey-ultralight); + margin-bottom: 36px; + &:hover, + &:focus { + border-color: var(--color-grey-light); + box-shadow: 0 0 19px 12px var(--color-grey-ultralight); + } + &:focus { + outline: 0; + } + + @media screen and (min-width: 768px) { + margin-bottom: 0; + flex: 1; + &:first-child { + margin-right: 36px; + max-width: none; + } + } + + @media screen and (min-width: 1024px) { + padding: 60px 75px; + &:first-child { + margin-right: 56px; + margin-bottom: 0; + } + } + h2 { + text-align: center; + margin-bottom: 40px; + } + } + } +} diff --git a/website/src/assets/scss/pages/_signup-page.scss b/website/src/assets/scss/pages/_signup-page.scss new file mode 100644 index 0000000..d6e05cc --- /dev/null +++ b/website/src/assets/scss/pages/_signup-page.scss @@ -0,0 +1,17 @@ +.check-tos { + display: flex; + .checkbox-radio { + margin-top: 2px; + } + p { + font-size: 14px; + line-height: 18px; + font-weight: 400; + a { + color: var(--color-blue-petrol); + &:hover { + text-decoration: underline; + } + } + } +} \ No newline at end of file diff --git a/website/src/assets/scss/pages/admin/_admin-aides.scss b/website/src/assets/scss/pages/admin/_admin-aides.scss new file mode 100644 index 0000000..4096b15 --- /dev/null +++ b/website/src/assets/scss/pages/admin/_admin-aides.scss @@ -0,0 +1,48 @@ +.admin-aides { + .mcm-links-nav .nav-links__item { + padding: 10px 20px 12px 20px !important; + } + + @media screen and (max-width: 500px) { + button { + width: 100%; + } + } + > div { + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .title-aides { + display: flex; + justify-content: center; + text-align: center; + margin: 20px auto; + + .admin-btn { + margin-left: var(--spacing-m); + } + .admin-logout { + background-color: var(--color-red-error); + border: none; + &:hover { + color: var(--color-red-error); + background-color: var(--color-grey-ultralight); + border: solid 1px var(--color-red-error); + } + } + + @media screen and (max-width: 760px) { + flex-direction: column; + align-items: center; + + .admin-btn { + margin-top: var(--spacing-s); + margin-left: 0; + } + } + } + } +} diff --git a/website/src/assets/scss/pages/recherche/_aides-recherche.scss b/website/src/assets/scss/pages/recherche/_aides-recherche.scss new file mode 100644 index 0000000..418f790 --- /dev/null +++ b/website/src/assets/scss/pages/recherche/_aides-recherche.scss @@ -0,0 +1,169 @@ +.mcm-aides { + &__header { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 0 24px; + + h1 { + text-align: center; + max-width: 650px; + } + + p { + text-align: center; + max-width: 650px; + } + } + + &__body { + background: var(--color-grey-ultralight); + + .mcm-dispositifs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(295px, 1fr)); + grid-gap: 24px; + padding: 30px 0 var(--spacing-xl); + } + } + + .search-section { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + + ::placeholder { + color: var(--color-green-leaf); + } + + .mcm-tab__list { + font-size: 16px; + } + + .mcm-search-form { + max-width: 800px; + + @media screen and (max-width: 1024px) { + max-width: unset; + } + } + + .search-filters { + display: flex; + flex-direction: column; + width: 100%; + + .mcm-filters__dropdown { + margin-top: var(--spacing-xxs); + } + + @media screen and (min-width: 768px) { + flex-direction: row; + + .mcm-filter-localisation { + margin-right: 20px; + } + } + + @media screen and (min-width: 1024px) { + width: auto; + } + } + + @media screen and (min-width: 1024px) { + flex-direction: row; + justify-content: center; + + .mcm-filter-transports { + margin-right: 20px; + width: 250px; + max-width: 250px; + } + + .mcm-filter-localisation { + width: 250px; + max-width: 250px; + } + } + } +} + +.mcm-filters { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + padding-top: var(--spacing-s); + + .mcm-tab-container { + width: 776px; + margin: 0px; + padding-left: 0px; + flex-grow: 1; + } + + .mcm-tab__list { + font-size: 20px; + font-weight: 500; + } + + @media screen and (max-width: 768px) { + display: block; + + .mcm-tab__list>.active-tabs::after { + display: none; + } + + .mcm-tab-container { + padding: 13px 24px; + width: 100%; + padding-left: 0px; + } + + .active-tabs::after { + display: none; + } + + .mcm-tab__list { + font-size: 16px; + } + + } + + &__dropdown { + flex-grow: 1 !important; + + .field.field__select>div>div { + margin-top: 0; + } + + .checkbox-radio { + margin-top: 0; + } + + .field__label { + font-weight: 600; + font-size: 16px; + } + } +} + +.load-more { + display: flex; + align-items: center; + justify-content: center; + margin-top: calc(var(--spacing-xl) * -1); + padding: var(--spacing-m) 0; +} + +.mcm-aides { + #search-subtitle-authenticated-text { + font-weight: bolder; + } +} + +ul> :first-child { + margin-left: 0px; +} diff --git a/website/src/assets/svg/aide-multiple.svg b/website/src/assets/svg/aide-multiple.svg new file mode 100644 index 0000000..ed62e3f --- /dev/null +++ b/website/src/assets/svg/aide-multiple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/arrow-right.svg b/website/src/assets/svg/arrow-right.svg new file mode 100644 index 0000000..58fd5c1 --- /dev/null +++ b/website/src/assets/svg/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/autopartage.svg b/website/src/assets/svg/autopartage.svg new file mode 100644 index 0000000..bb638f3 --- /dev/null +++ b/website/src/assets/svg/autopartage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/big-profile.svg b/website/src/assets/svg/big-profile.svg new file mode 100644 index 0000000..8319f0d --- /dev/null +++ b/website/src/assets/svg/big-profile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/close.svg b/website/src/assets/svg/close.svg new file mode 100644 index 0000000..941d080 --- /dev/null +++ b/website/src/assets/svg/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/covoiturage.svg b/website/src/assets/svg/covoiturage.svg new file mode 100644 index 0000000..b098fae --- /dev/null +++ b/website/src/assets/svg/covoiturage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/delete.svg b/website/src/assets/svg/delete.svg new file mode 100644 index 0000000..eef893b --- /dev/null +++ b/website/src/assets/svg/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/docs.svg b/website/src/assets/svg/docs.svg new file mode 100644 index 0000000..fd3fdfe --- /dev/null +++ b/website/src/assets/svg/docs.svg @@ -0,0 +1,25 @@ + + Illus/XL/Docs + + + Layer 1 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/download-xlsx.svg b/website/src/assets/svg/download-xlsx.svg new file mode 100644 index 0000000..d89e8c9 --- /dev/null +++ b/website/src/assets/svg/download-xlsx.svg @@ -0,0 +1,27 @@ + + + + + + + diff --git a/website/src/assets/svg/download.svg b/website/src/assets/svg/download.svg new file mode 100644 index 0000000..4dad21b --- /dev/null +++ b/website/src/assets/svg/download.svg @@ -0,0 +1,8 @@ + + + Illus/Small/Bon a savoir + + + + + diff --git a/website/src/assets/svg/edit.svg b/website/src/assets/svg/edit.svg new file mode 100644 index 0000000..973a8a0 --- /dev/null +++ b/website/src/assets/svg/edit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/website/src/assets/svg/electrique.svg b/website/src/assets/svg/electrique.svg new file mode 100644 index 0000000..50fa060 --- /dev/null +++ b/website/src/assets/svg/electrique.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/error.svg b/website/src/assets/svg/error.svg new file mode 100644 index 0000000..46cde67 --- /dev/null +++ b/website/src/assets/svg/error.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/assets/svg/franceconnect-btn-hover.svg b/website/src/assets/svg/franceconnect-btn-hover.svg new file mode 100644 index 0000000..3383a49 --- /dev/null +++ b/website/src/assets/svg/franceconnect-btn-hover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/website/src/assets/svg/franceconnect-btn.svg b/website/src/assets/svg/franceconnect-btn.svg new file mode 100644 index 0000000..2e46e60 --- /dev/null +++ b/website/src/assets/svg/franceconnect-btn.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/website/src/assets/svg/illus-graph.svg b/website/src/assets/svg/illus-graph.svg new file mode 100644 index 0000000..660e60e --- /dev/null +++ b/website/src/assets/svg/illus-graph.svg @@ -0,0 +1,14 @@ + + + Illus/Big/Graph + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/illus-ipad.svg b/website/src/assets/svg/illus-ipad.svg new file mode 100644 index 0000000..e195475 --- /dev/null +++ b/website/src/assets/svg/illus-ipad.svg @@ -0,0 +1,14 @@ + + + Illus/Big/ipad + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/illus-mobile.svg b/website/src/assets/svg/illus-mobile.svg new file mode 100644 index 0000000..549d15d --- /dev/null +++ b/website/src/assets/svg/illus-mobile.svg @@ -0,0 +1,15 @@ + + + Illus/Big/Mobile + + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/illus-money.svg b/website/src/assets/svg/illus-money.svg new file mode 100644 index 0000000..94e72a7 --- /dev/null +++ b/website/src/assets/svg/illus-money.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/illus-profile-blue.svg b/website/src/assets/svg/illus-profile-blue.svg new file mode 100644 index 0000000..140954e --- /dev/null +++ b/website/src/assets/svg/illus-profile-blue.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/illus-profile.svg b/website/src/assets/svg/illus-profile.svg new file mode 100644 index 0000000..01ec036 --- /dev/null +++ b/website/src/assets/svg/illus-profile.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/illus-tree.svg b/website/src/assets/svg/illus-tree.svg new file mode 100644 index 0000000..79ab2a2 --- /dev/null +++ b/website/src/assets/svg/illus-tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/info.svg b/website/src/assets/svg/info.svg new file mode 100644 index 0000000..823c893 --- /dev/null +++ b/website/src/assets/svg/info.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/assets/svg/information.svg b/website/src/assets/svg/information.svg new file mode 100644 index 0000000..2c24b20 --- /dev/null +++ b/website/src/assets/svg/information.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/letter-m-blue.svg b/website/src/assets/svg/letter-m-blue.svg new file mode 100644 index 0000000..f638f84 --- /dev/null +++ b/website/src/assets/svg/letter-m-blue.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/letter-m.svg b/website/src/assets/svg/letter-m.svg new file mode 100644 index 0000000..8113e40 --- /dev/null +++ b/website/src/assets/svg/letter-m.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/letter-o-blue.svg b/website/src/assets/svg/letter-o-blue.svg new file mode 100644 index 0000000..c57508a --- /dev/null +++ b/website/src/assets/svg/letter-o-blue.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/src/assets/svg/letter-o.svg b/website/src/assets/svg/letter-o.svg new file mode 100644 index 0000000..e1d3e51 --- /dev/null +++ b/website/src/assets/svg/letter-o.svg @@ -0,0 +1,2 @@ + + diff --git a/website/src/assets/svg/libreService.svg b/website/src/assets/svg/libreService.svg new file mode 100644 index 0000000..1dd5ff5 --- /dev/null +++ b/website/src/assets/svg/libreService.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/logo-ademe.svg b/website/src/assets/svg/logo-ademe.svg new file mode 100644 index 0000000..f5895e1 --- /dev/null +++ b/website/src/assets/svg/logo-ademe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/logo-baseline.svg b/website/src/assets/svg/logo-baseline.svg new file mode 100644 index 0000000..8b05b93 --- /dev/null +++ b/website/src/assets/svg/logo-baseline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/website/src/assets/svg/logo-capgemini.svg b/website/src/assets/svg/logo-capgemini.svg new file mode 100644 index 0000000..95bc38f --- /dev/null +++ b/website/src/assets/svg/logo-capgemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git "a/website/src/assets/svg/logo-certificats-\303\251conomies-\303\251nergie.png" "b/website/src/assets/svg/logo-certificats-\303\251conomies-\303\251nergie.png" new file mode 100644 index 0000000..ab5a5e2 Binary files /dev/null and "b/website/src/assets/svg/logo-certificats-\303\251conomies-\303\251nergie.png" differ diff --git a/website/src/assets/svg/logo-francemob.svg b/website/src/assets/svg/logo-francemob.svg new file mode 100644 index 0000000..7c28365 --- /dev/null +++ b/website/src/assets/svg/logo-francemob.svg @@ -0,0 +1,23 @@ + + + Logos/Partners/FranceMob@2x + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/logo-igart.svg b/website/src/assets/svg/logo-igart.svg new file mode 100644 index 0000000..b9c19b7 --- /dev/null +++ b/website/src/assets/svg/logo-igart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/logo-ministere.svg b/website/src/assets/svg/logo-ministere.svg new file mode 100644 index 0000000..8c430a0 --- /dev/null +++ b/website/src/assets/svg/logo-ministere.svg @@ -0,0 +1,139 @@ + + + Logos/Partners/Ministere@2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/logo-mobile.svg b/website/src/assets/svg/logo-mobile.svg new file mode 100644 index 0000000..dbd406d --- /dev/null +++ b/website/src/assets/svg/logo-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/logo-with-baseline.svg b/website/src/assets/svg/logo-with-baseline.svg new file mode 100644 index 0000000..b2ada22 --- /dev/null +++ b/website/src/assets/svg/logo-with-baseline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/menu.svg b/website/src/assets/svg/menu.svg new file mode 100644 index 0000000..f2a87c4 --- /dev/null +++ b/website/src/assets/svg/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/mob-favicon.svg b/website/src/assets/svg/mob-favicon.svg new file mode 100644 index 0000000..ce42712 --- /dev/null +++ b/website/src/assets/svg/mob-favicon.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/mob-footer.svg b/website/src/assets/svg/mob-footer.svg new file mode 100644 index 0000000..364a607 --- /dev/null +++ b/website/src/assets/svg/mob-footer.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/mob-pattern.svg b/website/src/assets/svg/mob-pattern.svg new file mode 100644 index 0000000..dcccc14 --- /dev/null +++ b/website/src/assets/svg/mob-pattern.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/no-image.svg b/website/src/assets/svg/no-image.svg new file mode 100644 index 0000000..4c55217 --- /dev/null +++ b/website/src/assets/svg/no-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/pdf.svg b/website/src/assets/svg/pdf.svg new file mode 100644 index 0000000..53d9ac6 --- /dev/null +++ b/website/src/assets/svg/pdf.svg @@ -0,0 +1,7 @@ + + Illus/Small/Bon a savoir + + + + + diff --git a/website/src/assets/svg/play-green.svg b/website/src/assets/svg/play-green.svg new file mode 100644 index 0000000..6a7b93e --- /dev/null +++ b/website/src/assets/svg/play-green.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/play.svg b/website/src/assets/svg/play.svg new file mode 100644 index 0000000..a6ca041 --- /dev/null +++ b/website/src/assets/svg/play.svg @@ -0,0 +1 @@ + diff --git a/website/src/assets/svg/profil-card.svg b/website/src/assets/svg/profil-card.svg new file mode 100644 index 0000000..cc15659 --- /dev/null +++ b/website/src/assets/svg/profil-card.svg @@ -0,0 +1,14 @@ + + + Illus/Big/Profile + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/profile.svg b/website/src/assets/svg/profile.svg new file mode 100644 index 0000000..ab8d5ea --- /dev/null +++ b/website/src/assets/svg/profile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/search-nation.svg b/website/src/assets/svg/search-nation.svg new file mode 100644 index 0000000..f0d8550 --- /dev/null +++ b/website/src/assets/svg/search-nation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/search-region.svg b/website/src/assets/svg/search-region.svg new file mode 100644 index 0000000..19a1188 --- /dev/null +++ b/website/src/assets/svg/search-region.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/search.svg b/website/src/assets/svg/search.svg new file mode 100644 index 0000000..bf37cac --- /dev/null +++ b/website/src/assets/svg/search.svg @@ -0,0 +1,4 @@ + + + diff --git a/website/src/assets/svg/social.svg b/website/src/assets/svg/social.svg new file mode 100644 index 0000000..4d637d3 --- /dev/null +++ b/website/src/assets/svg/social.svg @@ -0,0 +1,25 @@ + + Illus/XL/Social + + + Layer 1 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/spinner.svg b/website/src/assets/svg/spinner.svg new file mode 100644 index 0000000..a0f3eff --- /dev/null +++ b/website/src/assets/svg/spinner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/sprite/sprite.svg b/website/src/assets/svg/sprite/sprite.svg new file mode 100644 index 0000000..d2980e6 --- /dev/null +++ b/website/src/assets/svg/sprite/sprite.svg @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/src/assets/svg/success-versed.svg b/website/src/assets/svg/success-versed.svg new file mode 100644 index 0000000..23614f6 --- /dev/null +++ b/website/src/assets/svg/success-versed.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/assets/svg/success.svg b/website/src/assets/svg/success.svg new file mode 100644 index 0000000..94e2842 --- /dev/null +++ b/website/src/assets/svg/success.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/assets/svg/transportsCommun.svg b/website/src/assets/svg/transportsCommun.svg new file mode 100644 index 0000000..f9fb2b3 --- /dev/null +++ b/website/src/assets/svg/transportsCommun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/triangle-down.svg b/website/src/assets/svg/triangle-down.svg new file mode 100644 index 0000000..370efa0 --- /dev/null +++ b/website/src/assets/svg/triangle-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/triangle-up.svg b/website/src/assets/svg/triangle-up.svg new file mode 100644 index 0000000..fc532d7 --- /dev/null +++ b/website/src/assets/svg/triangle-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/trotti.svg b/website/src/assets/svg/trotti.svg new file mode 100644 index 0000000..1dd5ff5 --- /dev/null +++ b/website/src/assets/svg/trotti.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/valid.svg b/website/src/assets/svg/valid.svg new file mode 100644 index 0000000..d04e2c1 --- /dev/null +++ b/website/src/assets/svg/valid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/velo.svg b/website/src/assets/svg/velo.svg new file mode 100644 index 0000000..14cfb09 --- /dev/null +++ b/website/src/assets/svg/velo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/visible.svg b/website/src/assets/svg/visible.svg new file mode 100644 index 0000000..d4d257e --- /dev/null +++ b/website/src/assets/svg/visible.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/src/assets/svg/voiture.svg b/website/src/assets/svg/voiture.svg new file mode 100644 index 0000000..7d07aaf --- /dev/null +++ b/website/src/assets/svg/voiture.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/warning-versed.svg b/website/src/assets/svg/warning-versed.svg new file mode 100644 index 0000000..71aa3f3 --- /dev/null +++ b/website/src/assets/svg/warning-versed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/assets/svg/warning.svg b/website/src/assets/svg/warning.svg new file mode 100644 index 0000000..9a9e817 --- /dev/null +++ b/website/src/assets/svg/warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/src/cms/cms.js b/website/src/cms/cms.js new file mode 100644 index 0000000..7e7932a --- /dev/null +++ b/website/src/cms/cms.js @@ -0,0 +1,6 @@ +import CMS from 'netlify-cms-app'; + +CMS.registerMediaLibrary({ + name: 'disabled', + init: () => ({ show: () => undefined, enableStandalone: () => false }), +}); diff --git a/website/src/components/AideSearch/AideSearchGreenCard/AideSearchGreenCard.test.tsx b/website/src/components/AideSearch/AideSearchGreenCard/AideSearchGreenCard.test.tsx new file mode 100644 index 0000000..593ac1f --- /dev/null +++ b/website/src/components/AideSearch/AideSearchGreenCard/AideSearchGreenCard.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { navigate } from 'gatsby'; +import '@testing-library/jest-dom'; + +import AideSearchGreenCard from './AideSearchGreenCard'; +import { mockUseKeycloak } from '@utils/mockKeycloak'; + +jest.mock('../../../context', () => { + return { + useSession: () => mockUseKeycloak, + useUser: () => mockUseKeycloak, + }; +}); + +beforeEach(() => cleanup()); + +describe('', () => { + test('Display correct green Card', () => { + const { getByText } = render(); + expect( + getByText( + 'Découvrez les aides proposées par votre employeur en créant votre compte.' + ).closest('.mcm-card--green') + ).toBeInTheDocument(); + }); + + test('Click on green card', async () => { + const { getByText } = render(); + expect(getByText('Créer mon compte')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(getByText('Créer mon compte')); + expect(navigate).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/website/src/components/AideSearch/AideSearchGreenCard/AideSearchGreenCard.tsx b/website/src/components/AideSearch/AideSearchGreenCard/AideSearchGreenCard.tsx new file mode 100644 index 0000000..19cc889 --- /dev/null +++ b/website/src/components/AideSearch/AideSearchGreenCard/AideSearchGreenCard.tsx @@ -0,0 +1,56 @@ +import React, { FC } from 'react'; + +import ArrowLink from '@components/ArrowLink/ArrowLink'; +import Card from '@components/Card/Card'; + +import { AffiliationStatus } from '../../../../src/constants'; +import { useSession, useUser } from '../../../context'; +import { navigate } from '@reach/router'; + +import Strings from './locale/fr.json'; + +const AideSearchGreenCard: FC = () => { + /** + * APP CONTEXT + * + */ + const { citizen, authenticated } = useUser(); + const { keycloak } = useSession(); + + /** + * + * isAffiliated condition + */ + const isAffiliated: boolean = + authenticated && + citizen?.affiliation?.affiliationStatus !== AffiliationStatus.AFFILIATED; + + return ( + + isAffiliated + ? navigate('/mon-profil/', { replace: true }) + : keycloak.login({ + redirectUri: `${window.location.origin}/redirection/`, + }) + } + title={ + isAffiliated ? Strings['card.employee.title'] : Strings['card.title'] + } + footerElement={ + + } + classNames="mcm-card--green mcm-card--pointer" + /> + ); +}; + +export default AideSearchGreenCard; diff --git a/website/src/components/AideSearch/AideSearchGreenCard/locale/fr.json b/website/src/components/AideSearch/AideSearchGreenCard/locale/fr.json new file mode 100644 index 0000000..6a53843 --- /dev/null +++ b/website/src/components/AideSearch/AideSearchGreenCard/locale/fr.json @@ -0,0 +1,6 @@ +{ + "card.title": "Découvrez les aides proposées par votre employeur en créant votre compte.", + "card.employee.title": "Affiliez-vous à votre employeur depuis votre profil afin d'accéder aux aides employeurs.", + "icon.label": "Créer mon compte", + "icon.employee.label": "Accéder à mon profil" +} diff --git a/website/src/components/AideSearch/AideSearchGreenCard/locale/fr.json.d.ts b/website/src/components/AideSearch/AideSearchGreenCard/locale/fr.json.d.ts new file mode 100644 index 0000000..8b8ead1 --- /dev/null +++ b/website/src/components/AideSearch/AideSearchGreenCard/locale/fr.json.d.ts @@ -0,0 +1,8 @@ +interface Fr { + 'card.title': string; + 'icon.label': string; + 'card.employee.title': string; + 'icon.employee.label': string; +} +declare const value: Fr; +export = value; diff --git a/website/src/components/AideSearch/AideSearchList/AideSearchList.test.tsx b/website/src/components/AideSearch/AideSearchList/AideSearchList.test.tsx new file mode 100644 index 0000000..d27febc --- /dev/null +++ b/website/src/components/AideSearch/AideSearchList/AideSearchList.test.tsx @@ -0,0 +1,461 @@ +import React from 'react'; +import * as Gatsby from 'gatsby'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { act } from 'react-dom/test-utils'; + +import AideSearchList from './AideSearchList'; +import { Incentive } from '../../../utils/aides'; +import { mockUseKeycloak } from '@utils/mockKeycloak'; + +jest.mock('../../../context', () => { + return { + useSession: () => mockUseKeycloak, + useUser: () => mockUseKeycloak, + }; +}); + +const aideList: Incentive[] = [ + { + id: '0', + title: 'aide 1', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '1', + title: 'aide 2', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '250', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, +]; + +const useStaticQuery = jest.spyOn(Gatsby, 'useStaticQuery'); +useStaticQuery.mockImplementation(() => ({ + data: { + edges: [ + { + node: { + relativePath: 'velo.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'voiture.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + ], + }, +})); + +describe('', () => { + test('Check presents of green card', async () => { + const { getByText } = render(); + expect( + getByText( + 'Découvrez les aides proposées par votre employeur en créant votre compte.' + ).closest('.mcm-card--green') + ).toBeInTheDocument(); + }); + test('Render list of two elements', async () => { + const { getByText } = render(); + expect(getByText('aide 1')).toBeInTheDocument(); + expect(getByText('aide 2')).toBeInTheDocument(); + }); + test('Render list of one elements', async () => { + const { getByText } = render( + + ); + expect(getByText('aide 1')).toBeInTheDocument(); + }); + test('Render list of two elements without green card', async () => { + const { queryByText } = render(); + expect( + queryByText( + 'Découvrez les aides proposées par votre employeur en créant votre compte.' + ) + ).toBeNull(); + }); + test('Render more aides if click on "Afficher plus de résultats"', async () => { + const { getByText, queryByText } = render( + + ); + expect(queryByText('aide 14')).not.toBeInTheDocument(); + await act(async () => { + fireEvent.click(getByText('Afficher plus de résultats')); + }); + expect(queryByText('aide 14')).toBeInTheDocument(); + }); +}); + +const aideListX15: Incentive[] = [ + { + id: '1', + title: 'aide 1', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [{ id: '1', transport: 'voiture' }], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '2', + title: 'aide 2', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [{ id: '1', transport: 'voiture' }], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '3', + title: 'aide 3', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [{ id: '1', transport: 'voiture' }], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '4', + title: 'aide 4', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '5', + title: 'aide 5', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '6', + title: 'aide 6', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '7', + title: 'aide 7', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '8', + title: 'aide 8', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '9', + title: 'aide 9', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '10', + title: 'aide 10', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '11', + title: 'aide 11', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '12', + title: 'aide 12', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '13', + title: 'aide 13', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '14', + title: 'aide 14', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, + { + id: '15', + title: 'aide 15', + description: 'my description', + territoryName: 'territoryName', + funderName: 'funderName', + conditions: 'conditions', + paymentMethod: 'paymentMethod', + allocatedAmount: 'allocatedAmount', + additionalInfos: 'additionalInfos', + contact: 'contact', + validityDuration: 'validityDuration', + validityDate: 'validityDate', + minAmount: '50', + incentiveType: 'AideEmployeur', + transportList: [ + { id: '0', transport: 'velo' }, + { id: '1', transport: 'voiture' }, + ], + createdAt: 'now', + updatedAt: 'now', + }, +]; diff --git a/website/src/components/AideSearch/AideSearchList/AideSearchList.tsx b/website/src/components/AideSearch/AideSearchList/AideSearchList.tsx new file mode 100644 index 0000000..d91c1e1 --- /dev/null +++ b/website/src/components/AideSearch/AideSearchList/AideSearchList.tsx @@ -0,0 +1,126 @@ +import React, { FC, ReactNode, useEffect, useState } from 'react'; + +import Button from '@components/Button/Button'; +import Card from '@components/Card/Card'; +import AideSearchGreenCard from '@components/AideSearch/AideSearchGreenCard/AideSearchGreenCard'; + +import { getDispositifImgFilename } from '@utils/getDispositifImage'; +import { Incentive, aidesMapping } from '@utils/aides'; +import { flattenTransportList } from '@utils/helpers'; + +import Strings from './locale/fr.json'; + +/** + * INTERFACES + * + * + * + * + */ +interface Props { + items: Incentive[]; + greenCard?: boolean; +} + +/** + * dispositif list length + */ +const dispositifListLength = 12; + +/** + * @name AideSearchList + * @description This component is for listing the incentives + */ +const AideSearchList: FC = ({ items, greenCard }) => { + /** + * COMPONENT STATES + * + * + * + * + * only the items currently displayed on the page + */ + const [showedDispositifList, setShowedDispositifList] = useState( + [] + ); + + /** + * get the incentive card + * @returns JSX node + */ + const renderDispositifs = (): ReactNode => { + if (showedDispositifList && showedDispositifList.length > 0) { + return showedDispositifList.map( + ( + { id, title, minAmount, incentiveType, transportList, funderName }, + index + ) => { + const uniqueKey = `card-${index}`; + return ( + + ); + } + ); + } + return null; + }; + + /** + * displays the 12 next items in the list + */ + const renderNextItems = () => { + const newItems = items.slice( + showedDispositifList.length, + showedDispositifList.length + dispositifListLength + ); + setShowedDispositifList([...showedDispositifList, ...newItems]); + }; + + /** + * USE EFFECTS + * + * + * + * + */ + useEffect(() => { + setShowedDispositifList(items.slice(0, 11)); + }, [items]); + + /** + * RENDER + * + * + * + * + */ + return ( + <> +

+ {greenCard && } + {renderDispositifs()} +
+ {showedDispositifList.length < items.length && ( +
+ +
+ )} + + ); +}; + +export default AideSearchList; diff --git a/website/src/components/AideSearch/AideSearchList/locale/fr.json b/website/src/components/AideSearch/AideSearchList/locale/fr.json new file mode 100644 index 0000000..1f8899e --- /dev/null +++ b/website/src/components/AideSearch/AideSearchList/locale/fr.json @@ -0,0 +1,3 @@ +{ + "show.result": "Afficher plus de résultats" +} diff --git a/website/src/components/AideSearch/AideSearchList/locale/fr.json.d.ts b/website/src/components/AideSearch/AideSearchList/locale/fr.json.d.ts new file mode 100644 index 0000000..fb003bf --- /dev/null +++ b/website/src/components/AideSearch/AideSearchList/locale/fr.json.d.ts @@ -0,0 +1,5 @@ +interface Fr { + 'show.result': string; +} +declare const value: Fr; +export = value; diff --git a/website/src/components/ArrowLink/ArrowLink.test.tsx b/website/src/components/ArrowLink/ArrowLink.test.tsx new file mode 100644 index 0000000..cca3edb --- /dev/null +++ b/website/src/components/ArrowLink/ArrowLink.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ArrowLink from './ArrowLink'; + +describe('', () => { + test('ArrowLink renders with correct text and href', () => { + const { getByText } = render( + + ); + expect(getByText('Cliquez ici')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/ArrowLink/ArrowLink.tsx b/website/src/components/ArrowLink/ArrowLink.tsx new file mode 100644 index 0000000..0d08edc --- /dev/null +++ b/website/src/components/ArrowLink/ArrowLink.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; +import SVG from '../SVG/SVG'; +import './_arrow-link.scss'; + +interface ArrowLinkProps { + label?: string; +} + +const ArrowLink: FC = ({ label }) => { + return ( +
+ {label && {label}} +
+ +
+
+ ); +}; + +export default ArrowLink; diff --git a/website/src/components/ArrowLink/_arrow-link.scss b/website/src/components/ArrowLink/_arrow-link.scss new file mode 100644 index 0000000..3d7bf78 --- /dev/null +++ b/website/src/components/ArrowLink/_arrow-link.scss @@ -0,0 +1,38 @@ +.mcm-arrow-link { + display: flex; + align-items: center; + + &__label { + margin-right: 20px; + padding-bottom: 4px; + } + &__icon { + padding: 8px 14px; + border: 1px solid white; + border-radius: 15px; + svg { + fill: white; + width: 22px; + height: 13px; + } + } + &:hover { + .mcm-arrow-link__icon { + animation-duration: 0.5s; + animation-name: bounce; + } + } +} + +@keyframes bounce { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(15px); + } + + 100% { + transform: translateX(0); + } +} diff --git a/website/src/components/Button/Button.test.tsx b/website/src/components/Button/Button.test.tsx new file mode 100644 index 0000000..6f04868 --- /dev/null +++ b/website/src/components/Button/Button.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Button from './Button'; + +describe('); + expect(queryByText('Submit')).toBeTruthy(); + + // Change props + rerender(); + expect(queryByText('Cancel')).toBeTruthy(); + }); + + test('Calls correct function on click', () => { + const onClick = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('Submit')); + expect(onClick).toHaveBeenCalled(); + }); + + test('Props disabled', () => { + const { getByText } = render(); + expect(getByText('Submit')).toHaveAttribute('disabled'); + }); + + test('Props submit', () => { + const { getByText, rerender } = render(); + expect(getByText('Submit')).toHaveAttribute('type', 'submit'); + + // Change value props submit + rerender(); + expect(getByText('Submit')).toHaveAttribute('type', 'button'); + }); + + test('Props className', () => { + const { getByText, rerender } = render(); + expect(getByText('Submit')).toHaveClass('button--secondary'); + + rerender(); + expect(getByText('Submit')).toHaveClass('button--inverted'); + + rerender(); + expect(getByText('Submit')).toHaveClass('button btn-connexion'); + }); + + test('Button with icon', () => { + const { getByText, getByTestId } = render( + + ); + expect(getByTestId('svg-icon')).toBeInTheDocument(); + expect(getByText('Submit')).toHaveClass('button--icon'); + }); +}); diff --git a/website/src/components/Button/Button.tsx b/website/src/components/Button/Button.tsx new file mode 100644 index 0000000..39ff354 --- /dev/null +++ b/website/src/components/Button/Button.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import classNames from 'classnames'; +import SVG, { SVGIcons } from '../SVG/SVG'; + +import './_button.scss'; + +interface ButtonProps { + /** Primary content. */ + children?: React.ReactNode; + classnames?: string; + disabled?: boolean; + icon?: SVGIcons; + inverted?: boolean; + onClick?: React.MouseEventHandler; + secondary?: boolean; + submit?: boolean; + basic?: boolean; +} + +/** + * Renders primary or secondary UI button + * @param children + * @param classnames + * @param disabled + * @param icon + * @param onClick + * @param secondary + * @param submit + * @param basic + + * @constructor + */ +const Button: React.FC = ({ + children, + classnames, + disabled = false, + icon, + inverted, + onClick, + secondary = false, + submit = false, + basic = false, +}) => { + const CSSClass = classNames('button', classnames, { + 'button--secondary': secondary, + 'button--icon': icon, + 'button--inverted': inverted, + }); + + return ( + + ); +}; + +export default Button; diff --git a/website/src/components/Button/_button.scss b/website/src/components/Button/_button.scss new file mode 100644 index 0000000..583a5d1 --- /dev/null +++ b/website/src/components/Button/_button.scss @@ -0,0 +1,87 @@ +.button { + border-radius: 27px; + background-color: var(--color-green-leaf); + color: var(--color-white); + font-family: var(--font-family); + font-size: 16px; + font-weight: 600; + line-height: 1.375; + padding: 14px 36px 16px; + border: 1px solid var(--color-green-leaf); + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease-in-out; + > svg { + display: inline-block; + vertical-align: bottom; + width: 18px; + height: 20px; + margin-right: 20px; + fill: var(--color-white); + } + &:hover { + color: var(--color-dark); + background-color: transparent; + > svg { + fill: var(--color-dark); + } + } + &:disabled { + color: var(--color-white); + background-color: var(--color-grey-light); + border-color: var(--color-grey-light); + cursor: not-allowed; + } + &:active { + color: var(--color-green-leaf); + background-color: transparent; + transition: color 0s; + > svg { + fill: var(--color-green-leaf); + } + } + &:focus { + box-shadow: 0 0 0 3px var(--color-white), 0 0 0 5px var(--color-green-leaf); + outline: 0; + } + &--inverted { + &:hover { + color: var(--color-white); + } + } + &--secondary { + background-color: transparent; + color: var(--color-dark); + border-color: var(--color-grey-mid); + > svg { + fill: var(--color-dark); + } + &:hover { + border-color: var(--color-green-leaf); + } + &:focus { + box-shadow: 0 0 0 3px var(--color-white), 0 0 0 5px var(--color-grey-mid); + } + &.button--inverted { + border-color: var(--color-white); + background-color: transparent; + color: var(--color-white); + &:hover { + border-color: var(--color-green-leaf); + > svg { + fill: var(--color-white); + } + } + &:active { + color: var(--color-green-leaf); + border-color: var(--color-green-leaf); + } + > svg { + fill: var(--color-green-leaf); + } + } + } + &--icon { + padding-left: 22px; + } +} diff --git a/website/src/components/Card/Card.test.tsx b/website/src/components/Card/Card.test.tsx new file mode 100644 index 0000000..73e513a --- /dev/null +++ b/website/src/components/Card/Card.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Card from './Card'; +import { RenderTags } from './Card'; + +jest.mock('../Image/Image.tsx'); + +describe('Card component', () => { + it('should render correctly with required props', () => { + const { getByText } = render( + + ); + expect(getByText('Title of card').closest('.mcm-card')).toBeInTheDocument(); + expect(getByText('Title of card').closest('a')).toHaveAttribute( + 'href', + '/slug' + ); + }); + + it('Should display the tags passed', () => { + const { getByText, rerender } = render( + + ); + expect(getByText('Tag 1')).toBeInTheDocument(); + + rerender(); + expect(getByText('Tag 1')).toBeInTheDocument(); + expect(getByText('Tag 2')).toBeInTheDocument(); + }); + + it('Should display the value passed', () => { + const { getByText } = render( + + ); + expect(getByText('500')).toBeInTheDocument(); + }); + + it('Should display the element passed in footerElement prop and not display tags', () => { + const { getByText } = render( + Hello

} + href="#" + tags={[]} + /> + ); + expect(getByText('Hello')).toBeInTheDocument(); + expect(document.querySelector('.card-body-tags')).toBeEmptyDOMElement(); + }); + + it('Should display the tags elements', () => { + const { getByTestId } = render(); + expect(getByTestId('tagComponent')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/Card/Card.tsx b/website/src/components/Card/Card.tsx new file mode 100644 index 0000000..478d9bf --- /dev/null +++ b/website/src/components/Card/Card.tsx @@ -0,0 +1,112 @@ +import React, { FC } from 'react'; +import { Link } from 'gatsby'; + +import Image from '../Image/Image'; +import classnames from 'classnames'; + +import Heading from '../Heading/Heading'; + +import Strings from './locale/fr.json'; +import './_card.scss'; + +interface CardProps { + theId?: string; + href?: string; + onClick?: any; + imageFilename?: string; + title: string; + funderName?: string; + value?: string; + tags?: string[]; + footerElement?: JSX.Element; + classNames?: string; + buttonMode?: boolean; +} + +interface TagsProps { + tags?: string[]; +} + +export const RenderTags: FC = ({ tags }) => { + return ( + <> + {tags && + tags.map((tag, index) => { + return ( + + {tag} + + ); + })} + + ); +}; + +/** + * Generic component used to render a card with the following attributes. + * @param theId + * @param href + * @param onClick + * @param imageFilename + * @param title + * @param funderName + * @param value + * @param tags + * @param footerElement + * @param classNames + * @param buttonMode + * @constructor + */ +const Card: FC = ({ + theId, + href = '#', + onClick, + imageFilename, + title, + funderName, + value, + tags, + footerElement = {Strings['show.detail']}, + classNames, + buttonMode = false, +}) => { + const CommonComponent = ( + <> +
+ {imageFilename && ( +
+ +
+ )} +
+ {title} + {funderName &&

{funderName}

} +
+
+
+ {value} +
+ +
+
+
{footerElement}
+ + ); + + const CSSClass = classnames('mcm-card', classNames); + return buttonMode ? ( + onClick()}> + {CommonComponent} + + ) : ( + + {CommonComponent} + + ); +}; + +export default Card; diff --git a/website/src/components/Card/_card.scss b/website/src/components/Card/_card.scss new file mode 100644 index 0000000..d3c5197 --- /dev/null +++ b/website/src/components/Card/_card.scss @@ -0,0 +1,107 @@ +.mcm-card { + display: flex; + flex-direction: column; + border-radius: 8px; + background-color: #ffffff; + padding: 30px; + min-height: 365px; + + &__header { + display: flex; + padding-bottom: 32px; + border-bottom: 1px solid var(--color-grey-light); + + .card-header-img { + margin-right: 32px; + width: 50px; + } + + h3 { + flex: 1; + } + .mcm-card__title { + width: fit-content; + + .funder_name:first-letter { + text-transform: uppercase; + } + } + } + + &__body { + padding-top: 20px; + + .body-title { + display: inline-block; + font-size: 20px; + line-height: 28px; + font-weight: 600; + margin-bottom: 30px; + } + + .no-body-title { + display: inline-block; + font-size: 14px; + line-height: 18px; + margin-bottom: 24px; + } + + .card-body-tags { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 24px; + + &__tag { + border-radius: 12px; + background-color: var(--color-grey-ultralight); + font-size: 14px; + padding: 4px 12px; + + &:not(:last-child) { + margin-bottom: 12px; + } + } + } + } + + &__footer { + margin-top: auto; + + span { + font-size: 16px; + font-weight: 600; + color: var(--color-green-leaf); + } + } + + &--green { + background-color: var(--color-green-leaf); + color: white; + @media screen and (max-width: 661px) { + min-height: 225px; + } + + .mcm-card__header { + border-bottom: none; + + h3 { + font-weight: 400; + } + } + + .mcm-card__body { + display: none; + } + + .mcm-card__footer { + span { + color: white; + } + } + } + + &--pointer { + cursor: pointer; + } +} diff --git a/website/src/components/Card/locale/fr.json b/website/src/components/Card/locale/fr.json new file mode 100644 index 0000000..0f107de --- /dev/null +++ b/website/src/components/Card/locale/fr.json @@ -0,0 +1,3 @@ +{ + "show.detail": "Voir le détail et souscrire" +} diff --git a/website/src/components/Card/locale/fr.json.d.ts b/website/src/components/Card/locale/fr.json.d.ts new file mode 100644 index 0000000..ed7cca5 --- /dev/null +++ b/website/src/components/Card/locale/fr.json.d.ts @@ -0,0 +1,5 @@ +interface Fr { + 'show.detail': string; +} +declare const value: Fr; +export = value; diff --git a/website/src/components/CardLine/CardLine.test.tsx b/website/src/components/CardLine/CardLine.test.tsx new file mode 100644 index 0000000..cbc4436 --- /dev/null +++ b/website/src/components/CardLine/CardLine.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CardLine from './CardLine'; + +describe('', () => { + test('Display empty card', () => { + const { container } = render(); + expect(container.getElementsByClassName('card-line').length).toBe(1); + expect(container.getElementsByTagName('div').length).toBe(1); + }); + + test('Custom classnames does not override default classes', () => { + const { container } = render(); + expect(container.getElementsByClassName('card-line').length).toBe(1); + expect(container.getElementsByClassName('custom-class').length).toBe(1); + }); + + test('Display children element', () => { + const { getByText } = render(Children String); + expect(getByText('Children String')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/CardLine/CardLine.tsx b/website/src/components/CardLine/CardLine.tsx new file mode 100644 index 0000000..0655ac5 --- /dev/null +++ b/website/src/components/CardLine/CardLine.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import './_card-line.scss'; + +interface Props { + /** Primary content. */ + children?: React.ReactNode; + + /** Additional classes. */ + classnames?: string; +} + +const CardLine: FC = ({ children, classnames }) => { + const CSSClass = classNames('card-line', classnames); + return
{children}
; +}; + +export default CardLine; diff --git a/website/src/components/CardLine/CardLineColumn.test.tsx b/website/src/components/CardLine/CardLineColumn.test.tsx new file mode 100644 index 0000000..58c0740 --- /dev/null +++ b/website/src/components/CardLine/CardLineColumn.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CardLineColumn from './CardLineColumn'; + +describe('', () => { + test('Display empty column', () => { + const { container } = render(); + expect(container.getElementsByClassName('card-line__column').length).toBe( + 1 + ); + expect(container.getElementsByTagName('div').length).toBe(1); + }); + test('custom classnames does not override default classes', () => { + const { container } = render(); + expect(container.getElementsByClassName('card-line__column').length).toBe( + 1 + ); + expect(container.getElementsByClassName('custom-class').length).toBe(1); + }); + test('Display children element', () => { + const { getByText } = render( + Children String + ); + expect(getByText('Children String')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/CardLine/CardLineColumn.tsx b/website/src/components/CardLine/CardLineColumn.tsx new file mode 100644 index 0000000..48ce1da --- /dev/null +++ b/website/src/components/CardLine/CardLineColumn.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; + +interface Props { + /** Primary content. */ + children?: React.ReactNode; + + /** Additional classes. */ + classnames?: string; +} + +const CardLineColumn: FC = ({ children, classnames }) => { + const CSSClass = classNames('card-line__column', classnames); + + return
{children}
; +}; + +export default CardLineColumn; diff --git a/website/src/components/CardLine/CardLineContent.test.tsx b/website/src/components/CardLine/CardLineContent.test.tsx new file mode 100644 index 0000000..2587f03 --- /dev/null +++ b/website/src/components/CardLine/CardLineContent.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CardLineContent from './CardLineContent'; + +describe('', () => { + test('Display empty card', () => { + const { container } = render(); + expect(container.getElementsByClassName('card-line__content').length).toBe( + 1 + ); + expect(container.getElementsByTagName('div').length).toBe(1); + }); + test('custom classnames does not override default classes', () => { + const { container } = render(); + expect(container.getElementsByClassName('card-line__content').length).toBe( + 1 + ); + expect(container.getElementsByClassName('custom-class').length).toBe(1); + }); + test('Display children element', () => { + const { getByText } = render( + Children String + ); + expect(getByText('Children String')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/CardLine/CardLineContent.tsx b/website/src/components/CardLine/CardLineContent.tsx new file mode 100644 index 0000000..bb14cb8 --- /dev/null +++ b/website/src/components/CardLine/CardLineContent.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; + +interface Props { + /** Primary content. */ + children?: React.ReactNode; + + /** Additional classes. */ + classnames?: string; +} + +const CardLineContent: FC = ({ children, classnames }) => { + const CSSClass = classNames('card-line__content', classnames); + + return
{children}
; +}; + +export default CardLineContent; diff --git a/website/src/components/CardLine/_card-line.scss b/website/src/components/CardLine/_card-line.scss new file mode 100644 index 0000000..c667e58 --- /dev/null +++ b/website/src/components/CardLine/_card-line.scss @@ -0,0 +1,44 @@ +/** TODO: tous les espacements seront à revoir une fois qu'on aura les accès à Invision en mode inspection. */ +$breakpoint-fluid: 1024px; + +.card-line { + background-color: white; + margin-bottom: 24px; + border-radius: 8px; +} + +.card-line__content { + padding: 24px 0; + margin: 0 24px; + border-top: 1px solid var(--color-grey-light); + + &:first-child { + border-top: 0; + } + + @media screen and (min-width: $breakpoint-fluid) { + display: flex; + align-items: center; + justify-content: flex-start; // rendre configurable avec props + flex-wrap: nowrap; + } +} + +.card-line__column { + margin-top: 24px; + @media screen and (min-width: $breakpoint-fluid) { + margin: 0 12px; + + &:first-child { + margin-left: 0; + } + &:last-child { + margin-right: 0; + } + } + &:first-child { + margin-top: 0; + overflow: hidden; + flex-wrap: wrap; + } +} diff --git a/website/src/components/CardRequest/CardRequest.test.tsx b/website/src/components/CardRequest/CardRequest.test.tsx new file mode 100644 index 0000000..d40b76e --- /dev/null +++ b/website/src/components/CardRequest/CardRequest.test.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import * as Gatsby from 'gatsby'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CardRequest from './CardRequest'; +import { + Subscription, + FREQUENCY_VALUE, + INCENTIVE_TYPE, + REASON_REJECT_LABEL, + REASON_REJECT_VALUE, + MultiplePayment, + PAYMENT_VALUE, + SinglePayment, + STATUS, +} from '@utils/demandes'; +import { transportMapping } from '@utils/aides'; +import { format } from 'date-fns'; + +import { BreakpointProvider, QueriesObject } from 'gatsby-plugin-breakpoints'; + +const useStaticQuery = jest.spyOn(Gatsby, 'useStaticQuery'); +useStaticQuery.mockImplementation(() => ({ + data: { + edges: [ + { + node: { + relativePath: 'aide-multiple.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'transportsCommun.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'velo.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'voiture.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'libreService.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'electrique.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'autopartage.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'covoiturage.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + ], + }, +})); + +describe('', () => { + const request: Subscription = { + id: '615c5273d58eff5f6e994e04', + incentiveId: '615c5272d58eff37df994e03', + funderName: 'Mulhouse', + incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE, + incentiveTitle: "Bonus Ecologique pour l'achat d'un vélo électrique", + incentiveTransportList: ['voiture', 'libreService'], + citizenId: '76a598e5-0e65-4383-8c06-4890f9f05a00', + lastName: 'Rasovsky', + firstName: 'Bob', + email: 'bob.rasovsky@yopmail.com', + status: STATUS.TO_PROCESS, + createdAt: '2021-10-05T13:26:11.084Z', + updatedAt: '2021-10-05T13:26:11.084Z', + specificFields: { + Commentaire: 'Je veux souscrire à cette aide immédiatement NOW', + Date: '15/06/1992', + 'Salaire annuel brut': 32000, + 'Situation maritale': 'Marié.e', + }, + subscriptionValidation: { + mode: PAYMENT_VALUE.NONE, + }, + city: 'Paris', + postcode: '75000', + birthdate: '1970-01-01T00:00:00.000Z', + consent: true, + subscriptionRejection: { type: REASON_REJECT_VALUE.CONDITION }, + }; + + const queries: QueriesObject = { + sm: '(min-width: 576px)', + md: '(min-width: 768px)', + l: '(min-width: 1024px)', + xl: '(min-width: 1440px)', + portrait: '(orientation: portrait)', + }; + + test('Check presence of elements that must appear for any request status.', () => { + const { getByText } = render(); + expect(getByText(request.firstName)).toBeInTheDocument(); + expect(getByText(request.lastName.toUpperCase())).toBeInTheDocument(); + expect(getByText(request.incentiveTitle)).toBeInTheDocument(); + }); + + test('Check all alt image', () => { + const { rerender, getByAltText } = render( + + ); + expect(getByAltText('Aide de type multiple')).toBeInTheDocument(); + + for (const [key, value] of Object.entries(transportMapping)) { + const lRequest = request; + lRequest.incentiveTransportList = [key]; + rerender(); + expect(getByAltText(`Aide de type ${value}`)).toBeInTheDocument(); + } + }); + + test('Displays card of a request to be processed', () => { + const { getByText } = render(); + expect( + getByText( + `Le ${format(new Date(request.updatedAt!), "dd/MM/yyyy à H'h'mm")}` + ) + ).toBeInTheDocument(); + expect(getByText('Traiter la demande')).toBeInTheDocument(); + expect(getByText('Traiter la demande').closest('a')).toHaveAttribute( + 'href', + '/administrer-demandes/' + request.id + ); + }); + test('Displays card of a validated request', () => { + const lRequest = request; + lRequest.status = STATUS.VALIDATED; + const { rerender, getByText } = render(); + expect( + getByText( + `Validée le ${format( + new Date(lRequest.updatedAt!), + "dd/MM/yyyy à H'h'mm" + )}` + ) + ).toBeInTheDocument(); + expect(getByText('Aucun versement')).toBeInTheDocument(); + lRequest.subscriptionValidation = { + mode: PAYMENT_VALUE.SINGLE, + amount: 999, + } as SinglePayment; + rerender(); + expect(getByText('Financement unique')).toBeInTheDocument(); + lRequest.subscriptionValidation = { + mode: PAYMENT_VALUE.MULTIPLE, + frequency: FREQUENCY_VALUE.MONTHLY, + amount: 999, + lastPayment: '2021-12-31', + } as MultiplePayment; + rerender(); + expect( + getByText( + `Fin du financement le ${format( + new Date( + (lRequest.subscriptionValidation as MultiplePayment).lastPayment + ), + 'dd/MM/yyyy' + )}` + ) + ).toBeInTheDocument(); + }); + test('Displays card of a denied request', () => { + const lRequest = request; + lRequest.status = STATUS.REJECTED; + const { rerender, getByText } = render( + + + + ); + expect( + getByText( + 'Demandée le ' + format(new Date(lRequest.createdAt), 'dd/MM/yyyy') + ) + ).toBeInTheDocument(); + expect( + getByText( + `Rejetée le ${format( + new Date(lRequest.updatedAt!), + "dd/MM/yyyy à H'h'mm" + )}` + ) + ).toBeInTheDocument(); + expect( + getByText(REASON_REJECT_LABEL[lRequest.subscriptionRejection!.type]) + ).toBeInTheDocument(); + lRequest.subscriptionRejection!.type = REASON_REJECT_VALUE.MISSING_PROOF; + rerender( + + + + ); + expect( + getByText(REASON_REJECT_LABEL[lRequest.subscriptionRejection!.type]) + ).toBeInTheDocument(); + lRequest.subscriptionRejection!.type = REASON_REJECT_VALUE.INVALID_PROOF; + rerender( + + + + ); + expect( + getByText(REASON_REJECT_LABEL[lRequest.subscriptionRejection!.type]) + ).toBeInTheDocument(); + lRequest.subscriptionRejection = { + type: REASON_REJECT_VALUE.OTHER, + other: 'the reason', + }; + rerender( + + + + ); + expect(getByText('Autre - the reason')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/CardRequest/CardRequest.tsx b/website/src/components/CardRequest/CardRequest.tsx new file mode 100644 index 0000000..e3a81f0 --- /dev/null +++ b/website/src/components/CardRequest/CardRequest.tsx @@ -0,0 +1,216 @@ +import React, { FC, ReactNode } from 'react'; +import { format } from 'date-fns'; +import LinesEllipsis from 'react-lines-ellipsis'; +import { useBreakpoint } from 'gatsby-plugin-breakpoints'; +import classNames from 'classnames'; + +import { + Subscription, + SubscriptionRejection, + STATUS, + PAYMENT_VALUE, + REASON_REJECT_VALUE, + REASON_REJECT_LABEL, + MultiplePayment, +} from '@utils/demandes'; +import { transportMapping } from '@utils/aides'; +import { getDispositifImgFilename } from '@utils/getDispositifImage'; +import { firstCharUpper } from '@utils/helpers'; + +import Image from '../Image/Image'; +import Heading from '../Heading/Heading'; +import ArrowLink from '../ArrowLink/ArrowLink'; +import CardLine from '../CardLine/CardLine'; +import CardLineColumn from '../CardLine/CardLineColumn'; +import CardLineContent from '../CardLine/CardLineContent'; +import TooltipInfoIcon from '../TooltipInfoIcon/TooltipInfoIcon'; + +import Strings from './locale/fr.json'; + +import './_card-request.scss'; + +interface Props { + /** Entire object of request */ + request: Subscription; + isSubscriptionHistory: boolean; +} + +/** + * @name CardRequest + * @description A card request displays a short information of request. + */ +const CardRequest: FC = ({ request, isSubscriptionHistory }) => { + /** + * Returns the correct image filename depending on the transport type. + * Returns specific filename if there are more than 2 transport types. + * @param transportList + */ + const getDemandeImgFilename = (transportList: string[]): string => { + return getDispositifImgFilename(transportList); + }; + + const getImageAltFilename = (transportList: string[]): string => { + return `Aide de type ${ + transportList.length > 1 + ? 'multiple' + : transportMapping[request?.incentiveTransportList[0]] + }`; + }; + + const CSSClass = classNames( + request?.status === STATUS.REJECTED + ? 'card-request__reason' + : 'card-request__action' + ); + + const renderVersement = (): ReactNode => { + const { subscriptionValidation } = request; + + switch (subscriptionValidation.mode) { + case PAYMENT_VALUE.MULTIPLE: + return `${Strings['end.funding']} ${format( + new Date((subscriptionValidation as MultiplePayment).lastPayment), + 'dd/MM/yyyy' + )}`; + case PAYMENT_VALUE.SINGLE: + return Strings['unique.funding']; + case PAYMENT_VALUE.NONE: + return Strings['no.payment']; + default: + return null; + } + }; + + const renderMotif = ( + subscriptionRejection: SubscriptionRejection + ): ReactNode => { + const breakpoints = useBreakpoint(); + + if (subscriptionRejection.type !== REASON_REJECT_VALUE.OTHER) { + return REASON_REJECT_LABEL[subscriptionRejection.type]; + } + // Enables ellipsis behavior only for large and more breakpoints. + if (breakpoints.l && subscriptionRejection.other) { + return ( + + ); + } + return `${Strings['other.label']} - ${subscriptionRejection.other}`; + }; + + const renderColumnDate = (): ReactNode => { + const { status, createdAt, updatedAt } = request; + + switch (status) { + case STATUS.TO_PROCESS: + return ( +
+ {`${Strings['the.pronoun']} ${format( + new Date(createdAt), + "dd/MM/yyyy à H'h'mm" + )}`} +
+ ); + case STATUS.VALIDATED: + return ( + <> +
+ {updatedAt && + `${Strings['validated.at']} ${format( + new Date(updatedAt), + "dd/MM/yyyy à H'h'mm" + )}`} +
+
{renderVersement()}
+ + ); + case STATUS.REJECTED: + return ( + <> +
+ {updatedAt && + `${Strings['rejected.at']} ${format( + new Date(updatedAt), + "dd/MM/yyyy à H'h'mm" + )}`} +
+
+ {`${Strings['requested.at']} ${format( + new Date(createdAt), + 'dd/MM/yyyy' + )}`} +
+ + ); + default: + return null; + } + }; + + const renderColumnAction = (): ReactNode => { + const { status, subscriptionRejection } = request; + + switch (status) { + case STATUS.TO_PROCESS: + return ( + + + + ); + case STATUS.VALIDATED: + return null; + + case STATUS.REJECTED: + return subscriptionRejection && renderMotif(subscriptionRejection); + default: + return null; + } + }; + + return ( + + + {!isSubscriptionHistory && ( + + {request.isCitizenDeleted && ( + + )} + {firstCharUpper(request?.firstName)}{' '} + + {request?.lastName.toUpperCase()} + + + )} + + {request?.incentiveTransportList?.length && ( + {getImageAltFilename(request?.incentiveTransportList)} + )} + {request?.incentiveTitle} + + + {renderColumnDate()} + + + {renderColumnAction()} + + + + ); +}; + +export default CardRequest; diff --git a/website/src/components/CardRequest/_card-request.scss b/website/src/components/CardRequest/_card-request.scss new file mode 100644 index 0000000..86a580a --- /dev/null +++ b/website/src/components/CardRequest/_card-request.scss @@ -0,0 +1,74 @@ +/** TODO: tous les espacements seront à revoir une fois qu'on aura les accès à Invision en mode inspection. */ +$breakpoint-fluid: 1024px; +$breakpoint-sm: 576px; + +.card-request { + &__name { + display: flex; + font-weight: 500; + font-size: 20px; + line-height: 28px; + margin-bottom: var(--spacing-xxs); + + span { + margin-left: 3px; + } + + @media screen and (min-width: $breakpoint-fluid) { + margin-bottom: 0; + width: 20%; + } + } + &__lastname { + @media screen and (min-width: $breakpoint-fluid) { + display: block; + } + } + &__title { + display: flex; + flex-direction: row; + align-items: start; + + @media screen and (min-width: $breakpoint-fluid) { + align-items: center; + width: 40%; + + > h3 { + margin-bottom: 0; + } + } + + > img { + width: 48px; + margin-right: 24px; + } + } + &__date { + @media screen and (min-width: $breakpoint-fluid) { + width: 15%; + } + } + &__reason { + @media screen and (min-width: $breakpoint-fluid) { + width: 20%; + display: flex; + } + } + &__action { + @media screen and (min-width: $breakpoint-fluid) { + width: 25%; + display: flex; + justify-content: flex-end; + } + } + &__status-date { + font-size: 14px; + font-weight: 600; + } + &__payment { + font-size: 12px; + font-weight: 500; + color: var(--color-grey-dark); + margin-top: 12px; + } +} diff --git a/website/src/components/CardRequest/locale/fr.json b/website/src/components/CardRequest/locale/fr.json new file mode 100644 index 0000000..bc51822 --- /dev/null +++ b/website/src/components/CardRequest/locale/fr.json @@ -0,0 +1,12 @@ +{ + "the.pronoun": "Le", + "validated.at": "Validée le", + "rejected.at": "Rejetée le", + "requested.at": "Demandée le", + "process.subscription": "Traiter la demande", + "end.funding": "Fin du financement le", + "unique.funding": "Financement unique", + "no.payment": "Aucun versement", + "other.label": "Autre", + "citizen.tooltip.deleted.account": "le citoyen est supprimé" +} diff --git a/website/src/components/CardRequest/locale/fr.json.d.ts b/website/src/components/CardRequest/locale/fr.json.d.ts new file mode 100644 index 0000000..0b8f3d0 --- /dev/null +++ b/website/src/components/CardRequest/locale/fr.json.d.ts @@ -0,0 +1,15 @@ +interface Fr { + 'the.pronoun': string; + 'validated.at': string; + 'rejected.at': string; + 'requested.at': string; + 'process.subscription': string; + 'end.funding': string; + 'unique.funding': string; + 'no.payment': string; + 'other.label': string; + 'citizen.tooltip.deleted.account': string; +} + +declare const value: Fr; +export = value; diff --git a/website/src/components/Checkbox/Checkbox.test.tsx b/website/src/components/Checkbox/Checkbox.test.tsx new file mode 100644 index 0000000..be0158f --- /dev/null +++ b/website/src/components/Checkbox/Checkbox.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Checkbox from './Checkbox'; + +describe('', () => { + const errors = { + login: { + message: 'Attention, ce champ est obligatoire', + type: 'required', + }, + }; + + test('Div with correct className when there is an error and small size', () => { + const { getByText, rerender } = render( + + ); + const label = getByText('Identifiant'); + + expect(label.closest('div')).toHaveAttribute( + 'class', + 'checkbox-radio checkbox--error checkbox--small' + ); + // with no error and medium size + rerender( + + ); + expect(label.closest('div')).toHaveAttribute('class', 'checkbox-radio'); + + // with no error and no size + rerender( + + ); + expect(label.closest('div')).toHaveAttribute('class', 'checkbox-radio'); + }); + + test('Label render with correct text and the attributes of label', () => { + const { queryByText, getByText, rerender } = render( + + ); + const label = getByText('Identifiant'); + + // text + expect(queryByText('Identifiant')).toBeTruthy(); + // attributes + expect(label).toHaveAttribute('class', 'field__label'); + expect(label).toHaveAttribute('for', 'login'); + + //Correct className if size = small + rerender( + + ); + expect(label).toHaveAttribute('class', 'field__label field__label--small'); + }); + + test('The attributes of input', () => { + const { getByText } = render( + + ); + + const label = getByText('Identifiant'); + const input = label.previousElementSibling; + + expect(input).toHaveAttribute('type', 'radio'); + expect(input).toHaveAttribute('id', 'login'); + expect(input).toHaveAttribute('name', 'login'); + expect(input).toHaveAttribute('disabled'); + }); + + test('The attributes of input while checkbox and children', () => { + const message = + 'En cochant cette case je reconnais avoir pris connaissance, et accepter'; + + const { getByText } = render( + {message}

} + /> + ); + const label = getByText((content) => { + return content.includes(message); + }); + + expect(label.firstChild.textContent).toEqual(message); + }); +}); diff --git a/website/src/components/Checkbox/Checkbox.tsx b/website/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..a4f4acb --- /dev/null +++ b/website/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import classNames from 'classnames'; +import './_checkbox.scss'; +import { ErrorMessage } from '@hookform/error-message'; +import { FieldErrors } from 'react-hook-form'; +import SVG from '../SVG/SVG'; + +interface Props { + id: string; + label?: string; + name: string; + size?: 'medium' | 'small'; + type: 'checkbox' | 'radio'; + value?: string; + checked?: boolean; + disabled?: boolean; + errors?: FieldErrors; + children?: React.ReactNode; + onBlur?: React.FocusEventHandler; + onChange?: React.ChangeEventHandler; +} + +/** + * A checkbox element allows user to select value in form or elsewhere. + */ +const Checkbox = React.forwardRef( + ( + { + id, + label, + name, + size = 'medium', + type, + value, + checked = false, + disabled = false, + errors, + children, + onBlur, + onChange, + }, + ref + ) => { + const CSSClass = classNames('checkbox-radio', { + 'checkbox--error': errors && errors[name], + 'checkbox--small': size === 'small', + }); + + const CSSLabel = classNames('field__label', { + 'field__label--small': size === 'small', + }); + + return ( +
+ + + {errors && ( + { + if (process.env.NODE_ENV !== 'development' && messages) { + console.log(messages); + } + if (messages) { + return Object.entries(messages).map(([errorType, message]) => ( + + + {message} + + )); + } + return null; + }} + /> + )} +
+ ); + } +); + +export default Checkbox; diff --git a/website/src/components/Checkbox/_checkbox.scss b/website/src/components/Checkbox/_checkbox.scss new file mode 100644 index 0000000..2d42c0a --- /dev/null +++ b/website/src/components/Checkbox/_checkbox.scss @@ -0,0 +1,115 @@ +.checkbox-radio { + position: relative; + margin-top: var(--spacing-m); + + [type='checkbox']:not(:checked), + [type='checkbox']:checked { + height: 20px; + width: 20px; + position: absolute; + left: 0; + opacity: 0.01; + + & + .field__label { + cursor: pointer; + padding-left: calc(20px + var(--spacing-xs)); + } + } + + /* Aspect de la case */ + [type='checkbox']:not(:checked) + label::before, + [type='checkbox']:checked + label::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 20px; + height: 20px; + border: 1px solid var(--color-grey-light); + background: #fff; + border-radius: var(--radius-s); + transition: all 0.275s; + } + + /* Aspect de la coche */ + [type='checkbox']:not(:checked) + label::after, + [type='checkbox']:checked + label::after { + content: ''; + background-image: url('data:image/svg+xml,%3Csvg width="40" height="40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M29.42 13.185a1 1 0 011.257 1.55l-12.5 11.5-.031.025-.033.03a1 1 0 01-.015.011l-.019.014a.998.998 0 01-1.192-.025l-.012-.01a1.008 1.008 0 01-.11-.103l.059.058-.031-.028-6-6a1 1 0 011.32-1.497l.094.083 5.322 5.321 11.794-10.85z" fill="%2301BF7D" fill-rule="evenodd"/%3E%3C/svg%3E'); + background-size: 40px 40px; + background-repeat: no-repeat; + background-position: -8px -11px; + position: absolute; + left: 0; + top: 0; + width: 30px; + height: 20px; + transition: all 0.2s; + } + + /* Aspect non cochée */ + [type='checkbox']:not(:checked) + label::after { + opacity: 0; + transform: scale(0); + } + + /* Aspect cochée */ + [type='checkbox']:checked + label::after { + opacity: 1; + transform: scale(1); + } + + // RadioBox style + [type='radio']:not(:checked), + [type='radio']:checked { + height: 20px; + width: 20px; + position: absolute; + left: 0; + opacity: 0.01; + + & + .field__label { + cursor: pointer; + padding-left: calc(20px + var(--spacing-xs)); + } + } + + [type='radio']:not(:checked) + label::before, + [type='radio']:checked + label::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 20px; + height: 20px; + border: 1px solid var(--color-grey-light); + background: #fff; + border-radius: var(--radius-circle); + transition: all 0.275s; + } + + [type='radio']:not(:checked) + label::after, + [type='radio']:checked + label::after { + content: ''; + background-color: var(--color-green-leaf); + border-radius: var(--radius-circle); + position: absolute; + left: 4px; + top: 4px; + width: 12px; + height: 12px; + transition: all 0.2s; + } + + /* Aspect non cochée */ + [type='radio']:not(:checked) + label::after { + opacity: 0; + transform: scale(0); + } + + /* Aspect cochée */ + [type='radio']:checked + label::after { + opacity: 1; + transform: scale(1); + } +} diff --git a/website/src/components/CircleProgressBar/CircleProgressBar.test.tsx b/website/src/components/CircleProgressBar/CircleProgressBar.test.tsx new file mode 100644 index 0000000..3ba10df --- /dev/null +++ b/website/src/components/CircleProgressBar/CircleProgressBar.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import CircleProgressBar from './CircleProgressBar'; + +describe('', () => { + test('renders circle progress bar with 25 as value', () => { + const max = 50; + const value = 25; + const text = 'text'; + const { getByTestId, getByText } = render( + + ); + expect(getByTestId('svg')).toHaveAttribute('viewBox', '0 0 180 180'); + expect(getByTestId('svg-circle-front')).toHaveAttribute('stroke-dasharray', '534.0707511102648'); + expect(getByTestId('svg-circle-front')).toHaveAttribute('stroke-dashoffset', '267.0353755551324') + expect(getByText(text)).toBeInTheDocument(); + expect(getByText(value)).toBeInTheDocument(); + }); + + test('renders circle progress bar with 0 as value', () => { + const max = 50; + const value = 0; + const text = 'text'; + const { getByTestId, getByText } = render( + + ); + expect(getByTestId('svg')).toHaveAttribute('viewBox', '0 0 180 180'); + expect(getByTestId('svg-circle-front')).toHaveAttribute('stroke-dasharray', '534.0707511102648'); + expect(getByTestId('svg-circle-front')).toHaveAttribute('stroke-dashoffset', '534.0707511102648') + expect(getByText(text)).toBeInTheDocument(); + expect(getByText(value)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/website/src/components/CircleProgressBar/CircleProgressBar.tsx b/website/src/components/CircleProgressBar/CircleProgressBar.tsx new file mode 100644 index 0000000..345077f --- /dev/null +++ b/website/src/components/CircleProgressBar/CircleProgressBar.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react'; +import classnames from 'classnames'; +import './_circle-progress-bar.scss'; + +interface CircleProgressBarProps { + classNames?: string; + max: number; + value: number; + text: string; +} + +const CircleProgressBar: FC = ({ + classNames, + value, + max, + text, +}) => { + const size = 180; + const strokeWidth = 10; + const radius = (size - strokeWidth) / 2; + const dashArray = radius * Math.PI * 2; + const dashOffset = + max !== 0 ? dashArray - (dashArray * value) / max : dashArray; + const viewBox = `0 0 ${size} ${size}`; + + const CSSClassCircle = + max === 0 + ? classnames('mcm-front-circle') + : classnames('mcm-front-circle', classNames); + const CSSClassValue = classnames('mcm-value', classNames); + return ( + + + + + {`${value}`} + + + {text} + + + ); +}; + +export default CircleProgressBar; diff --git a/website/src/components/CircleProgressBar/_circle-progress-bar.scss b/website/src/components/CircleProgressBar/_circle-progress-bar.scss new file mode 100644 index 0000000..605135d --- /dev/null +++ b/website/src/components/CircleProgressBar/_circle-progress-bar.scss @@ -0,0 +1,42 @@ +.mcm-background-circle { + stroke: var(--color-grey-light); + fill: none; +} + +.mcm-front-circle { + fill: none; +} + +.mcm-value { + font-weight: 600; + font-size: 26px; + line-height: 32px; + @media screen and (min-width: 1024px) { + font-size: 32px; + line-height: 40px; + } +} + +.mcm-front-circle.mcm-green-leaf { + stroke: var(--color-green-leaf); +} + +.mcm-value.mcm-green-leaf { + fill: var(--color-green-leaf); +} + +.mcm-front-circle.mcm-red { + stroke: var(--color-red-error); +} + +.mcm-value.mcm-red { + fill: var(--color-red-error); +} + +.mcm-front-circle.mcm-blue-petrol { + stroke: var(--color-blue-petrol); +} + +.mcm-value.mcm-blue-petrol { + fill: var(--color-blue-petrol); +} diff --git a/website/src/components/CollapsableBlock/CollapsableBlock.test.tsx b/website/src/components/CollapsableBlock/CollapsableBlock.test.tsx new file mode 100644 index 0000000..533f89f --- /dev/null +++ b/website/src/components/CollapsableBlock/CollapsableBlock.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import CollapsableBlock from './CollapsableBlock'; + +describe('', () => { + test('renders CollapsableBlock with content', async () => { + const { findByText, getByText } = render( + + ); + expect(getByText('Commentaires')).toBeInTheDocument(); + const showCommentBtn = await findByText('Commentaires'); + fireEvent.click(showCommentBtn); + expect(await findByText('Ceci est un commentaire')).toBeInTheDocument(); + }); + test('renders CollapsableBlock without content', async () => { + const { findByText, getByText } = render( + + ); + expect(getByText('Commentaires')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/CollapsableBlock/CollapsableBlock.tsx b/website/src/components/CollapsableBlock/CollapsableBlock.tsx new file mode 100644 index 0000000..c2118d1 --- /dev/null +++ b/website/src/components/CollapsableBlock/CollapsableBlock.tsx @@ -0,0 +1,30 @@ +import React, { FC, useState } from 'react'; +import SVG from '../SVG/SVG'; + +import './_collapsable-block.scss'; + +const CollapsableBlock: FC<{ title: string; content: string }> = ({ + title, + content, +}) => { + const [showContent, setShowContent] = useState(false); + + const handleClickComments = () => { + setShowContent(!showContent); + }; + + return ( +
+
+
+ {title} + + + +
+ {showContent &&
{content}
} +
+ ); +}; + +export default CollapsableBlock; diff --git a/website/src/components/CollapsableBlock/_collapsable-block.scss b/website/src/components/CollapsableBlock/_collapsable-block.scss new file mode 100644 index 0000000..b194fd8 --- /dev/null +++ b/website/src/components/CollapsableBlock/_collapsable-block.scss @@ -0,0 +1,44 @@ +.collapsable-block-container.hidden { + display: none; +} + +.collapsable-block-container { + display: block; + width: 100%; + > hr { + border: none; + border-top: 1px solid lightgray; + margin: 30px 0 20px 0; + } + > div { + margin: 0 63px; + } + > .title { + display: flex; + width: -webkit-max-content; + width: max-content; + cursor: pointer; + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + justify-content: flex-start; + > .title-icon { + > .mcm-image.min { + max-width: 25px; + } + } + } + > .description { + margin-top: 10px; + font-size: 14px; + } + +} + +@media screen and (max-width: 768px) { + .collapsable-block-container { + > div { + margin: 0 10px; + } + } +} diff --git a/website/src/components/Content.tsx b/website/src/components/Content.tsx new file mode 100644 index 0000000..fef22f4 --- /dev/null +++ b/website/src/components/Content.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; + +interface ContentProps { + content: string; + className: string; +} + +export const HTMLContent: FC = ({ content, className }) => ( +
+); + +const Content: FC = ({ content, className }) => ( +
{content}
+); + +export default Content; diff --git a/website/src/components/DashboardFilters/DashboardFilters.test.tsx b/website/src/components/DashboardFilters/DashboardFilters.test.tsx new file mode 100644 index 0000000..5cfb11f --- /dev/null +++ b/website/src/components/DashboardFilters/DashboardFilters.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import DashboardFilters from './DashboardFilters'; + +describe('DashboardFilters Component', () => { + const onFiltersChanges = jest.fn(); + test('DashboardFilters component renders with correct placeholder', () => { + const currentYear: string = new Date().getFullYear().toString(); + const { getByText } = render(); + + expect(getByText(`Année (${currentYear})`)).toBeInTheDocument(); + expect(getByText('Semestre (1 & 2)')).toBeInTheDocument(); + }); + + test('DashboardFilters component renders 2 filters fields', () => { + const { getAllByTestId } = render(); + + expect(getAllByTestId('svg-icon')).toHaveLength(2); + }); +}); diff --git a/website/src/components/DashboardFilters/DashboardFilters.tsx b/website/src/components/DashboardFilters/DashboardFilters.tsx new file mode 100644 index 0000000..9bfb762 --- /dev/null +++ b/website/src/components/DashboardFilters/DashboardFilters.tsx @@ -0,0 +1,125 @@ +import React, { FC, useState, useCallback, useEffect } from 'react'; +import { setYear, eachYearOfInterval } from 'date-fns'; +import Strings from './locale/fr.json'; + +import FilterSelect, { + OptionType, +} from '@components/FiltersSelect/FilterSelect'; + +/** + * INTERFACE + * + * + * + * + */ +interface DashboardFiltersProps { + filtersChanges: (value1: string, value2: string) => void, +} + +/** + * Generic component used to render the both filters fro years and semesters + * @constructor + */ +const DashboardFilters: FC = ({ filtersChanges }) => { + /** + * VARIABLES + * + * + * + * + */ + const BEGIN_YEAR = 2020; + const yearInterval = eachYearOfInterval({ + start: setYear(new Date(), BEGIN_YEAR), + end: new Date(), + }); + + const yearOptionList: OptionType[] = yearInterval.reverse().map((year) => { + return { + label: year.getFullYear().toString(), + value: year.getFullYear().toString(), + }; + }); + + const semesterOptionList: OptionType[] = [ + { label: '1 & 2', value: 'all' }, + { label: '1', value: '1' }, + { label: '2', value: '2' }, + ]; + + /** + * STATES + * + * + * + * + */ + const [yearSelected, setSelectedYear] = useState( + yearOptionList[0] + ); + const [semesterSelected, setSelectedSemester] = useState( + semesterOptionList[0] + ); + const [isLoaded, setIsLoaded] = useState(false); + + /** + * FUNCTIONS + * + * + * + * + */ + const onSelectYearChanged = useCallback((option: OptionType) => { + setSelectedYear(option); + }, []); + + const onSelectSemesterChanged = useCallback((option: OptionType) => { + setSelectedSemester(option); + }, []); + + /** + * USE EFFECTS + * + * + * + * + */ + useEffect(() => { + if (isLoaded) { + filtersChanges(yearSelected.value, semesterSelected.value); + } + setIsLoaded(true); + }, [yearSelected, semesterSelected, isLoaded]); + + /** + * RENDER + * + * + * + * + */ + return ( + <> + + + + + ); +}; + +export default DashboardFilters; diff --git a/website/src/components/DashboardFilters/locale/fr.json b/website/src/components/DashboardFilters/locale/fr.json new file mode 100644 index 0000000..5b721fe --- /dev/null +++ b/website/src/components/DashboardFilters/locale/fr.json @@ -0,0 +1,4 @@ +{ + "ph.year": "Année", + "ph.semester": "Semestre" +} diff --git a/website/src/components/DashboardFilters/locale/fr.json.d.ts b/website/src/components/DashboardFilters/locale/fr.json.d.ts new file mode 100644 index 0000000..6246b80 --- /dev/null +++ b/website/src/components/DashboardFilters/locale/fr.json.d.ts @@ -0,0 +1,6 @@ +interface Fr { + 'ph.year': string; + 'ph.semester': string; +} +declare const value: Fr; +export = value; diff --git a/website/src/components/DatePicker/DatePicker.test.tsx b/website/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 0000000..9451b64 --- /dev/null +++ b/website/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DatePickerComponent from './DatePicker'; +import { useForm } from 'react-hook-form'; +import { renderHook } from '@testing-library/react-hooks'; + +describe('', () => { + const setValue = jest.fn(); + const getDateErrors = jest.fn(); + + const errors = { + birthdate: { + message: 'Merci de renseigner votre date de naissance', + type: 'required', + types: { required: 'Merci de renseigner votre date de naissance' }, + }, + }; + + const { result } = renderHook(() => useForm()); + + test('renders datePicker', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('datePicker')).toBeInTheDocument(); + expect(getByTestId('datePicker')).toHaveAttribute('class', 'field'); + }); + test('renders datePicker with error', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('error-datePicker')).toBeInTheDocument(); + }); + + test('renders datePicker with younger date', async () => { + render( + + ); + + expect(screen.getByTestId('error-datePicker')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/DatePicker/DatePicker.tsx b/website/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..9bf8608 --- /dev/null +++ b/website/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from 'react'; +import { Controller, Control } from 'react-hook-form'; +import DatePicker from 'react-date-picker'; +import { subYears, differenceInMinutes, isBefore, format } from 'date-fns'; +import { FieldErrors } from 'react-hook-form'; +import classNames from 'classnames'; + +import './_datePicker.scss'; + +import SVG from '../SVG/SVG'; + +import Strings from './locale/fr.json'; + +interface DatePickerProps { + name: string; + label?: string; + required?: boolean; + hasAgeCheck: boolean; + control: Control; + setValue(arg1: string, arg2: string): void; + errors: FieldErrors; + getDateErrors: (arg: boolean) => void; + readOnly?: boolean; + defaultValue?: Date | undefined; +} + +const ELIGIBLE_DATE = 16; +const MIN_DATE = '01/01/1900'; + +const DatePickerComponent = React.forwardRef( + ( + { + name, + label, + required = false, + hasAgeCheck = false, + readOnly = false, + control = {}, + setValue, + errors, + getDateErrors, + defaultValue, + }, + ref + ) => { + const [dateValue, setDateValue] = useState(); + const [dateErrors, setDateErrors] = useState({}); + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + /** + * Set component error to Yup error + */ + useEffect(() => { + if (errors) { + setDateErrors({ [name]: errors?.message }); + getDateErrors(true); + } + }, [errors]); + + /** + * Check if date is younger than 16 years + */ + const checkAgeCitizen = (value: Date) => { + const olderThan = subYears(new Date(), ELIGIBLE_DATE); + return differenceInMinutes(olderThan, value) > 0; + }; + + /** + * Handle component errors + * @param date input value + */ + const handleChange = (date: Date) => { + if (date === null && required) { + setDateErrors({ + ...dateErrors, + [name]: Strings['citizens.error.date.required'], + }); + getDateErrors(true); + } else if (hasAgeCheck && !checkAgeCitizen(date)) { + setDateErrors({ + ...dateErrors, + [name]: Strings['citizens.error.birthdate.age'], + }); + getDateErrors(true); + } else if (isBefore(date, new Date(MIN_DATE))) { + setDateErrors({ + ...dateErrors, + [name]: Strings['citizens.error.date.format'], + }); + getDateErrors(true); + } else { + const newDateErrors = { ...dateErrors }; + delete newDateErrors[name]; + setDateErrors(newDateErrors); + getDateErrors(Object.keys(newDateErrors).length === 0 ? false : true); + } + setDateValue(date); + setValue(name, format(new Date(date), 'dd/MM/yyyy')); + }; + + /** + * set date value + */ + useEffect(() => { + if (readOnly) { + handleChange(defaultValue); + } + }, []); + /** + * Display bloc error + */ + const displayErrors = () => { + if (Object.keys(dateErrors).length) { + return ( + + + {dateErrors[Object.keys(dateErrors)[0]]} + + ); + } + return null; + }; + + /** + * On calendar Open + */ + const handleOpenCanlendar = () => { + setIsCalendarOpen(!isCalendarOpen); + }; + + const CSSClass = classNames('field', { + 'field--error': Object.keys(dateErrors).length, + }); + + return ( +
+ {label && ( + + {label} + {required && } + + )} + ( + handleChange(date)} + value={readOnly ? defaultValue : dateValue} + ref={ref} + format="dd/MM/yyyy" + onCalendarOpen={() => handleOpenCanlendar()} + showLeadingZeros + disabled={readOnly} + /> + )} + /> + {displayErrors()} +
+ ); + } +); + +export default DatePickerComponent; diff --git a/website/src/components/DatePicker/_datePicker.scss b/website/src/components/DatePicker/_datePicker.scss new file mode 100644 index 0000000..8ebcdfe --- /dev/null +++ b/website/src/components/DatePicker/_datePicker.scss @@ -0,0 +1,29 @@ +.field { + .react-date-picker { + font-family: var(--font-family); + padding: 0; + border: 1px solid var(--color-grey-light); + border-radius: var(--radius-l); + width: 100%; + margin-top: var(--spacing-xxs); + + .react-date-picker__wrapper { + padding: 18px 32px 22px; + border: none; + } + + .react-date-picker__calendar { + inset: 100% auto auto 0px !important; + } + } + + .react-date-picker--disabled { + background: var(--color-grey-ultralight); + } +} + +.field--error { + .react-date-picker { + border-color: var(--color-red-error); + } +} diff --git a/website/src/components/DatePicker/locale/fr.json b/website/src/components/DatePicker/locale/fr.json new file mode 100644 index 0000000..1a9091f --- /dev/null +++ b/website/src/components/DatePicker/locale/fr.json @@ -0,0 +1,6 @@ +{ + "citizens.error.date.required": "Attention, ce champ est obligatoire", + "citizens.error.birthdate.required": "Merci de renseigner votre date de naissance", + "citizens.error.birthdate.age": "Il est encore un peu trop tôt ! Mon Compte Mobilité est accessible à partir de 16 ans", + "citizens.error.date.format": "La date renseignée est incorrecte" +} diff --git a/website/src/components/DatePicker/locale/fr.json.d.ts b/website/src/components/DatePicker/locale/fr.json.d.ts new file mode 100644 index 0000000..c1bae83 --- /dev/null +++ b/website/src/components/DatePicker/locale/fr.json.d.ts @@ -0,0 +1,7 @@ +interface Fr { + 'citizens.error.date.required': string; + 'citizens.error.birthdate.age': string; + 'citizens.error.date.format': string; +} +declare const value: Fr; +export = value; diff --git a/website/src/components/DemandeSearch/DemandeSearchList/DemandeSearchList.test.tsx b/website/src/components/DemandeSearch/DemandeSearchList/DemandeSearchList.test.tsx new file mode 100644 index 0000000..1946718 --- /dev/null +++ b/website/src/components/DemandeSearch/DemandeSearchList/DemandeSearchList.test.tsx @@ -0,0 +1,296 @@ +import React from 'react'; +import * as Gatsby from 'gatsby'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { act } from 'react-dom/test-utils'; +import DemandeSearchList from './DemandeSearchList'; +import { Subscription } from '@utils/demandes'; + +const demandeList: Subscription[] = [ + { + id: '1', + incentiveId: '1', + incentiveTitle: 'aide 1', + incentiveTransportList: ['velo'], + citizenId: '1', + lastName: 'lastName 1', + firstName: 'firstName 1', + email: 'user@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '2', + incentiveId: '2', + incentiveTitle: 'aide 2', + incentiveTransportList: ['voiture'], + citizenId: '2', + lastName: 'lastName 2', + firstName: 'firstName 2', + email: 'user2@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, +]; + +const useStaticQuery = jest.spyOn(Gatsby, 'useStaticQuery'); +useStaticQuery.mockImplementation(() => ({ + data: { + edges: [ + { + node: { + relativePath: 'velo.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + { + node: { + relativePath: 'voiture.svg', + childImageSharp: { + fluid: { + aspectRatio: 1, + src: 'src', + srcSet: 'srcSet', + sizes: 'sizes', + }, + }, + }, + }, + ], + }, +})); + +describe('', () => { + test('Render list of two elements', async () => { + const { getByText } = render(); + expect(getByText('aide 1')).toBeInTheDocument(); + expect(getByText('aide 2')).toBeInTheDocument(); + }); + test('Render list of one elements', async () => { + const { getByText } = render( + + ); + expect(getByText('aide 1')).toBeInTheDocument(); + }); + test('Render list of two elements', async () => { + const { queryByText } = render(); + expect(queryByText('aide 2')).toBeInTheDocument(); + }); + test('Render more aides if click on "Afficher plus de demandes"', async () => { + const { getByText, queryByText } = render( + + ); + expect(queryByText('aide 11')).not.toBeInTheDocument(); + await act(async () => { + fireEvent.click(getByText('Afficher plus de demandes')); + }); + expect(queryByText('aide 11')).toBeInTheDocument(); + }); +}); + +const demandeListX: Subscription[] = [ + { + id: '1', + incentiveId: '1', + incentiveTitle: 'aide 1', + incentiveTransportList: ['velo'], + citizenId: '1', + lastName: 'lastName 1', + firstName: 'firstName 1', + email: 'user@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '2', + incentiveId: '2', + incentiveTitle: 'aide 2', + incentiveTransportList: ['voiture'], + citizenId: '2', + lastName: 'lastName 2', + firstName: 'firstName 2', + email: 'user2@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '3', + incentiveId: '3', + incentiveTitle: 'aide 3', + incentiveTransportList: ['velo'], + citizenId: '3', + lastName: 'lastName 3', + firstName: 'firstName 3', + email: 'user3@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '4', + incentiveId: '4', + incentiveTitle: 'aide 4', + incentiveTransportList: ['voiture'], + citizenId: '4', + lastName: 'lastName 4', + firstName: 'firstName 4', + email: '4@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '5', + incentiveId: '5', + incentiveTitle: 'aide 5', + incentiveTransportList: ['velo'], + citizenId: '5', + lastName: 'lastName 5', + firstName: 'firstName 5', + email: 'user5@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '6', + incentiveId: '6', + incentiveTitle: 'aide 6', + incentiveTransportList: ['voiture'], + citizenId: '6', + lastName: 'lastName 6', + firstName: 'firstName 6', + email: 'user6@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '7', + incentiveId: '7', + incentiveTitle: 'aide 7', + incentiveTransportList: ['velo'], + citizenId: '7', + lastName: 'lastName 7', + firstName: 'firstName 7', + email: 'user7@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '8', + incentiveId: '8', + incentiveTitle: 'aide 8', + incentiveTransportList: ['voiture'], + citizenId: '8', + lastName: 'lastName 8', + firstName: 'firstName 8', + email: 'user8@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '9', + incentiveId: '9', + incentiveTitle: 'aide 9', + incentiveTransportList: ['velo'], + citizenId: '9', + lastName: 'lastName 9', + firstName: 'firstName 9', + email: 'user9@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '10', + incentiveId: '10', + incentiveTitle: 'aide 10', + incentiveTransportList: ['voiture'], + citizenId: '10', + lastName: 'lastName 10', + firstName: 'firstName 10', + email: 'user10@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, + { + id: '11', + incentiveId: '11', + incentiveTitle: 'aide 11', + incentiveTransportList: ['voiture'], + citizenId: '11', + lastName: 'lastName 11', + firstName: 'firstName 11', + email: 'user11@example.com', + consent: true, + status: 'A_TRAITER', + attachments: ['RB'], + createdAt: '2021-02-02', + updatedAt: '2021-02-02', + funderName: 'Capgemini', + incentiveType: 'AideEmployeur', + }, +]; diff --git a/website/src/components/DemandeSearch/DemandeSearchList/DemandeSearchList.tsx b/website/src/components/DemandeSearch/DemandeSearchList/DemandeSearchList.tsx new file mode 100644 index 0000000..a969a7d --- /dev/null +++ b/website/src/components/DemandeSearch/DemandeSearchList/DemandeSearchList.tsx @@ -0,0 +1,57 @@ +import React, { FC, ReactNode, useEffect, useState } from 'react'; + +import { Subscription } from '@utils/demandes'; +import CardRequest from '../../CardRequest/CardRequest'; +import Button from '../../Button/Button'; + +import Strings from './locale/fr.json'; +import './_demande-search-list.scss'; + +interface Props { + items: Subscription[]; +} + +const DemandeSearchList: FC = ({ items }) => { + // only the items currently displayed on the page + const [showedDemandeList, setshowedDemandeList] = useState( + [] + ); + + useEffect(() => { + setshowedDemandeList(items.slice(0, 10)); + }, [items]); + + const renderDispositifs = (): ReactNode => { + if (showedDemandeList && showedDemandeList.length > 0) { + return showedDemandeList.map((demande, index) => { + const uniqueKey = `card-${index}`; + return ; + }); + } + return null; + }; + + // Displays the 10 next items in the list + const renderNextItems = () => { + const newItems = items.slice( + showedDemandeList.length, + showedDemandeList.length + 10 + ); + setshowedDemandeList([...showedDemandeList, ...newItems]); + }; + + return ( + <> +
{items && renderDispositifs()}
+ {showedDemandeList.length < items.length && ( +
+ +
+ )} + + ); +}; + +export default DemandeSearchList; diff --git a/website/src/components/DemandeSearch/DemandeSearchList/_demande-search-list.scss b/website/src/components/DemandeSearch/DemandeSearchList/_demande-search-list.scss new file mode 100644 index 0000000..e829674 --- /dev/null +++ b/website/src/components/DemandeSearch/DemandeSearchList/_demande-search-list.scss @@ -0,0 +1,25 @@ +$tablet-breakpoint: 810px; +$laptop-breakpoint: 1024px; + +.mcm-dispositifs { + padding-top: 60px; + padding-bottom: 60px; +} +.mcm-arrow-link__icon svg { + fill: var(--color-green-leaf); +} +.mcm-arrow-link__icon { + padding: 8px 14px; + border: 1px solid var(--color-green-leaf); + border-radius: 15px; +} +.fit-img { + margin-right: 32px; + width: 50px; +} +.mcm-block-mobile { + display: contents; + @media screen and (max-width: $laptop-breakpoint - 1) { + display: flex; + } +} diff --git a/website/src/components/DemandeSearch/DemandeSearchList/locale/fr.json b/website/src/components/DemandeSearch/DemandeSearchList/locale/fr.json new file mode 100644 index 0000000..72325e9 --- /dev/null +++ b/website/src/components/DemandeSearch/DemandeSearchList/locale/fr.json @@ -0,0 +1,3 @@ +{ + "employees.button.load.more": "Afficher plus de demandes" +} diff --git a/website/src/components/DemandeSearch/DemandeSearchList/locale/fr.json.d.ts b/website/src/components/DemandeSearch/DemandeSearchList/locale/fr.json.d.ts new file mode 100644 index 0000000..42212cf --- /dev/null +++ b/website/src/components/DemandeSearch/DemandeSearchList/locale/fr.json.d.ts @@ -0,0 +1,6 @@ +interface Fr { + 'employees.button.load.more': string; +} + +declare const value: Fr; +export = value; diff --git a/website/src/components/DocumentBloc/DocumentBloc.tsx b/website/src/components/DocumentBloc/DocumentBloc.tsx new file mode 100644 index 0000000..65ac880 --- /dev/null +++ b/website/src/components/DocumentBloc/DocumentBloc.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; + +import SVG from '@components/SVG/SVG'; +import Heading from '@components/Heading/Heading'; + +import './_documentBloc.scss'; +import Strings from './locale/fr.json'; + +interface Props { + name: string; + deleteFile(arg: number): void; + index: number; + withAddedDoc: boolean; +} + +const DocumentBloc: FC = ({ + name, + deleteFile, + index, + withAddedDoc = false, +}) => { + return ( +
+
+ + {name} + {withAddedDoc && {Strings['added.justif']}} +
+ +
+ ); +}; + +export default DocumentBloc; diff --git a/website/src/components/DocumentBloc/_documentBloc.scss b/website/src/components/DocumentBloc/_documentBloc.scss new file mode 100644 index 0000000..db97541 --- /dev/null +++ b/website/src/components/DocumentBloc/_documentBloc.scss @@ -0,0 +1,34 @@ +.file_bloc { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) 0; + padding-right: var(--spacing-xs); + border-bottom: 1px solid var(--color-grey-light); + &:last-child { + border-bottom: unset; + } + div { + display: flex; + align-items: center; + } + > div { + display: grid; + column-gap: var(--spacing-xs); + + margin-right: var(--spacing-xs); + + p { + grid-column-start: 2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + &:last-child { + font-size: 14px; + } + } + } + button { + color: var(--color-green-leaf); + } +} diff --git a/website/src/components/DocumentBloc/locale/fr.json b/website/src/components/DocumentBloc/locale/fr.json new file mode 100644 index 0000000..0b552fa --- /dev/null +++ b/website/src/components/DocumentBloc/locale/fr.json @@ -0,0 +1,4 @@ +{ + "delete.btn": "Supprimer", + "added.justif": "Document ajouté" +} diff --git a/website/src/components/DocumentBloc/locale/fr.json.d.ts b/website/src/components/DocumentBloc/locale/fr.json.d.ts new file mode 100644 index 0000000..5d3e3a5 --- /dev/null +++ b/website/src/components/DocumentBloc/locale/fr.json.d.ts @@ -0,0 +1,7 @@ +interface Fr { + 'delete.btn': string; + 'added.justif': string; +} + +declare const value: Fr; +export = value; diff --git a/website/src/components/DropZone/DropZone.test.tsx b/website/src/components/DropZone/DropZone.test.tsx new file mode 100644 index 0000000..03b1898 --- /dev/null +++ b/website/src/components/DropZone/DropZone.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import DropZone from './DropZoneComponent'; + +jest.mock('../Image/Image.tsx'); + +describe('', () => { + const value = 'ou glisser / déposer vos documents ici'; + + test('renders children text', () => { + const { getByText } = render( + {}}>{value} + ); + expect(getByText(value)).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/DropZone/DropZoneComponent.tsx b/website/src/components/DropZone/DropZoneComponent.tsx new file mode 100644 index 0000000..3c07402 --- /dev/null +++ b/website/src/components/DropZone/DropZoneComponent.tsx @@ -0,0 +1,63 @@ +import React, { FC } from 'react'; +import Dropzone from 'react-dropzone'; +import classNames from 'classnames'; + +import SVG from '@components/SVG/SVG'; + +import Strings from './locale/fr.json'; + +import './_dropZone.scss'; + +interface Props { + isDisabled?: boolean; + maxFiles?: number; + multiple?: boolean; + dropFileAction(): void; +} + +const DropZoneComponent: FC = ({ + dropFileAction, + multiple, + maxFiles, + isDisabled, +}) => { + const onDropFile = (files) => { + dropFileAction(files); + }; + return ( + + {({ getRootProps, getInputProps, isDragActive }) => { + return ( +
+
+ +
+ +
+ +
+ {`${Strings['dropZone.import']}`} + {`${Strings['dropZone.slide']} / ${Strings['dropZone.drop.document']}`} +
+
+
+ ); + }} +
+ ); +}; + +export default DropZoneComponent; diff --git a/website/src/components/DropZone/_dropZone.scss b/website/src/components/DropZone/_dropZone.scss new file mode 100644 index 0000000..e5c916f --- /dev/null +++ b/website/src/components/DropZone/_dropZone.scss @@ -0,0 +1,60 @@ +.container { + height: 100%; + margin-left: 0px; + cursor: pointer; + box-sizing: border-box; + padding: 10px; + .border.dz_content { + border-radius: 8px; + border: var(--color-grey-mid) 2px dashed; + width: 100%; + height: 250px; + display: flex; + align-items: center; + padding: unset; + } +} +.dragEffect { + background-color: var(--color-green-leaf); + color: var(--color-white); + .text_wrapper { + .import-label { + color: var(--color-white); + } + } +} + +.icon_wrapper { + height: 100%; + width: fit-content; + padding: 60px 24px 60px 66px; + + svg { + width: auto; + height: 100%; + } +} + +.text_wrapper { + display: flex; + span { + display: block; + } + .import-label { + color: var(--color-green-leaf); + text-decoration: underline; + margin-right: 5px; + } +} + +@media only screen and (min-width: 768px) { + .container { + max-width: 100%; + } +} + +@media only screen and (max-width: 600px) { + .icon_wrapper { + padding: 100px 15px 99px 10px; + } +} diff --git a/website/src/components/DropZone/locale/fr.json b/website/src/components/DropZone/locale/fr.json new file mode 100644 index 0000000..2934f40 --- /dev/null +++ b/website/src/components/DropZone/locale/fr.json @@ -0,0 +1,5 @@ +{ + "dropZone.import": "Importer ", + "dropZone.slide": " ou glisser", + "dropZone.drop.document": "déposer vos documents ici" +} diff --git a/website/src/components/DropZone/locale/fr.json.d.ts b/website/src/components/DropZone/locale/fr.json.d.ts new file mode 100644 index 0000000..6128937 --- /dev/null +++ b/website/src/components/DropZone/locale/fr.json.d.ts @@ -0,0 +1,8 @@ +interface Fr { + 'dropZone.import': string; + 'dropZone.slide': string; + 'dropZone.drop.document': string; +} + +declare const value: Fr; +export = value; diff --git a/website/src/components/Faq/FaqBloc/FaqBloc.test.tsx b/website/src/components/Faq/FaqBloc/FaqBloc.test.tsx new file mode 100644 index 0000000..ebd28ba --- /dev/null +++ b/website/src/components/Faq/FaqBloc/FaqBloc.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import FaqBloc from './FaqBloc'; + +describe('', () => { + + + const blocTitle = "blocTitle"; + const questions = [{ title: "question", answer: "answer" }]; + + test('renders FaqBloc with content', async () => { + const { findByText, getByText } = render( + + ); + expect(getByText("blocTitle")).toBeInTheDocument(); + const showBlocBtn = await findByText('blocTitle'); + fireEvent.click(showBlocBtn); + expect(await findByText('blocTitle')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/Faq/FaqBloc/FaqBloc.tsx b/website/src/components/Faq/FaqBloc/FaqBloc.tsx new file mode 100644 index 0000000..7fc8c6e --- /dev/null +++ b/website/src/components/Faq/FaqBloc/FaqBloc.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import FaqCollapse from '../FaqCollapse/FaqCollapse'; +import { Question, Bloc } from '@utils/faq'; + +import './_faq-bloc.scss'; + +const FaqBloc: FC = ({ blocTitle, questions }) => { + return ( +
+
+

{blocTitle}

+
+ {questions?.length > 0 && ( + <> + {questions?.map((question: Question) => ( + + ))} + + )} +
+ ); +}; + +export default FaqBloc; diff --git a/website/src/components/Faq/FaqBloc/_faq-bloc.scss b/website/src/components/Faq/FaqBloc/_faq-bloc.scss new file mode 100644 index 0000000..17e172c --- /dev/null +++ b/website/src/components/Faq/FaqBloc/_faq-bloc.scss @@ -0,0 +1,3 @@ +.blocTitle { + margin: 0 0 60px 0; +} \ No newline at end of file diff --git a/website/src/components/Faq/FaqCategory/FaqCategory.test.tsx b/website/src/components/Faq/FaqCategory/FaqCategory.test.tsx new file mode 100644 index 0000000..00affe8 --- /dev/null +++ b/website/src/components/Faq/FaqCategory/FaqCategory.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import FaqCategory from './FaqCategory'; + +describe('', () => { + + + const categoryTitle = "test"; + const bloc = {blocTitle: "test", questions:[{ title: "test", answer: "test" }]}; + + test('renders FaqCategory with content', async () => { + const { findByText, getByText } = render( + + ); + expect(getByText('test')).toBeInTheDocument(); + const showCategorytBtn = await findByText('test'); + fireEvent.click(showCategorytBtn); + expect(await findByText('test')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/Faq/FaqCategory/FaqCategory.tsx b/website/src/components/Faq/FaqCategory/FaqCategory.tsx new file mode 100644 index 0000000..c96831c --- /dev/null +++ b/website/src/components/Faq/FaqCategory/FaqCategory.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import FaqBloc from '../FaqBloc/FaqBloc'; +import { Bloc, Category } from '@utils/faq'; + +import './_faq-category.scss'; + +const FaqCategory: FC = ({ categoryTitle, bloc }) => { + return ( +
+
+

{categoryTitle}

+
+ {bloc?.length > 0 && ( + <> + {bloc?.map((bloc: Bloc) => ( + + ))} + + )} +
+ ); +}; + +export default FaqCategory; diff --git a/website/src/components/Faq/FaqCategory/_faq-category.scss b/website/src/components/Faq/FaqCategory/_faq-category.scss new file mode 100644 index 0000000..97ec39a --- /dev/null +++ b/website/src/components/Faq/FaqCategory/_faq-category.scss @@ -0,0 +1,11 @@ +.category-title h2 { + font-weight: bold; + font-size: 45px; +} + + +@media screen and (max-width: 1023px) { + .category-title h2 { + font-size: 32px; + } +} \ No newline at end of file diff --git a/website/src/components/Faq/FaqCollapse/FaqCollapse.test.tsx b/website/src/components/Faq/FaqCollapse/FaqCollapse.test.tsx new file mode 100644 index 0000000..91e03ea --- /dev/null +++ b/website/src/components/Faq/FaqCollapse/FaqCollapse.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import FaqCollapse from './FaqCollapse'; + +describe('', () => { + test('renders FaqCollapse with content', async () => { + const { findByText, getByText } = render( + + ); + expect(getByText('Question')).toBeInTheDocument(); + const showAnswerBtn = await findByText('Question'); + fireEvent.click(showAnswerBtn); + expect(await findByText('Ceci est la réponse à la question')).toBeInTheDocument(); + }); + test('renders FaqCollapse without content', async () => { + const { getByText } = render( + + ); + expect(getByText('Question')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/Faq/FaqCollapse/FaqCollapse.tsx b/website/src/components/Faq/FaqCollapse/FaqCollapse.tsx new file mode 100644 index 0000000..294db71 --- /dev/null +++ b/website/src/components/Faq/FaqCollapse/FaqCollapse.tsx @@ -0,0 +1,36 @@ +import React, { FC, useState } from 'react'; +import SVG from '../../SVG/SVG'; +import { Question } from '@utils/faq'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import './_faq-collapse.scss'; + +const FaqCollapse: FC = ({ title, answer }) => { + const [showAnswer, setShowAnswer] = useState(false); + + const handleClickComments = () => { + setShowAnswer(!showAnswer); + }; + + return ( +
+
+

{title}

+ + + +
+ {showAnswer && ( + + )} +
+
+ ); +}; + +export default FaqCollapse; diff --git a/website/src/components/Faq/FaqCollapse/_faq-collapse.scss b/website/src/components/Faq/FaqCollapse/_faq-collapse.scss new file mode 100644 index 0000000..336cc7a --- /dev/null +++ b/website/src/components/Faq/FaqCollapse/_faq-collapse.scss @@ -0,0 +1,71 @@ +.question-title { + margin: 0 0 20px 0 !important; +} + +.faq-collapse-container { + display: block; + width: 100%; + + .title-icon { + margin-left: auto; + } + + >hr { + border: none; + border-top: 1px solid var(--color-grey-light); + margin: 30px 0 20px 0; + } + + >.title { + display: flex; + cursor: pointer; + flex-direction: row; + justify-content: space-between; + + >.title-icon { + >.mcm-image.min { + max-width: 25px; + } + } + } + + >.description { + margin-top: 10px; + font-size: 18px; + width: 100%; + padding-right: 100px; + + p { + line-height: 1.8; + text-align: justify; + } + + a { + color: var(--color-blue-petrol); + } + + a:hover { + text-decoration: underline; + } + + ol, + ul { + list-style: decimal; + + li { + line-height: 1.8; + margin-left: 23px; + } + } + + + } + + @media screen and (max-width: 768px) { + .faq-collapse-container { + >div { + margin: 0 10px; + } + } + } +} diff --git a/website/src/components/FiltersSelect/FilterSelect.test.tsx b/website/src/components/FiltersSelect/FilterSelect.test.tsx new file mode 100644 index 0000000..5e1e06d --- /dev/null +++ b/website/src/components/FiltersSelect/FilterSelect.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import FilterSelect from './FilterSelect'; + +describe('FilterSelect component', () => { + const transportsOptions = [ + { value: 'transportsCommun', label: 'Transports en commun' }, + { value: 'velo', label: 'Vélo' }, + { value: 'voiture', label: 'Voiture' }, + { value: 'libreService', label: '2 ou 3 roues en libre-service' }, + { value: 'electrique', label: '2 ou 3 roues électrique' }, + { value: 'autopartage', label: 'Autopartage' }, + { value: 'covoiturage', label: 'Covoiturage' }, + ]; + const onSelectChange = jest.fn(); + test('FilterSelect component renders with correct placeholder', () => { + const { getByText } = render( + + ); + expect(getByText('Modes de transport')).toBeInTheDocument(); + }); +}); diff --git a/website/src/components/FiltersSelect/FilterSelect.tsx b/website/src/components/FiltersSelect/FilterSelect.tsx new file mode 100644 index 0000000..ef98802 --- /dev/null +++ b/website/src/components/FiltersSelect/FilterSelect.tsx @@ -0,0 +1,135 @@ +import React, { FC, useState } from 'react'; +import Select, { components, ControlProps, OptionProps } from 'react-select'; + +export interface OptionType { + label: string; + value: string; +} + +interface FiltersSelectProps { + options: OptionType[]; + isMulti: boolean; + onSelectChange: (event: any) => void; + placeholder: string; + defaultValue?: OptionType; + showSelectedValue?: boolean; +} + +// Dropdown indicator for react-select +const DropdownIndicator = (props: any) => { + return ( + + + + + + ); +}; + +// Control for react-select +const Control = (props: ControlProps) => ( +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + +
+); + +// ValueContainer for react-select +const Placeholder = ({ children, ...props }: any) => { + const { getValue, hasValue, selectProps } = props; + const nbValues = getValue().length; + if (!hasValue) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + {children} + ); + } + if (selectProps.showSelectedValue) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {`${children} (${selectProps.value.label})`} + + ); + } + return ( + + {`${children} (${nbValues})`} + + ); +}; + +// Option for react-select +const Option = (props: OptionProps) => { + const { label, isSelected } = props; + return ( +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + +
+ + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+
+
+ ); +}; +/** + * Dropdown with checkboxes allowing to filter results + * @param options + * @param isMulti + * @param onSelectChange + * @param placeholder + * @param showSelectedValue + * @param defaultValue + * @constructor + */ +const FilterSelect: FC = ({ + options, + isMulti, + onSelectChange, + placeholder, + showSelectedValue = false, + defaultValue = null, +}) => { + const [selectedOption, setSelectedOption] = useState(defaultValue); + + const handleChange = (option: React.SetStateAction) => { + setSelectedOption(option); + return onSelectChange(option); + }; + // TODO : Fix l'auto-focus sur la 1ère option après un clic sur une option. En attente de : https://github.com/JedWatson/react-select/issues/4370 + return ( + + +
    +
  • + {Strings['legal.notice']} +
  • +
  • + + {Strings['cookie.policy.and.management']} + +
  • +
  • + + {Strings['privacy.policy']} + +
  • +
  • + +

    {Strings['app.version']}

    +
    +
  • +
+
+
+ + +
    +
  • + + {Strings['contact.us']} + +
  • +
  • + + Linkedin + +
  • +
  • + + {Strings['faq']} + +
  • +
+
+
+ + ); +}; + +export default Footer; diff --git a/website/src/components/Footer/_footer.scss b/website/src/components/Footer/_footer.scss new file mode 100644 index 0000000..35fd76b --- /dev/null +++ b/website/src/components/Footer/_footer.scss @@ -0,0 +1,168 @@ +.mcm-footer { + background: var(--color-blue-petrol); + color: var(--color-white); + padding: var(--spacing-m) 0; + margin-top: auto; + .mcm-container__main { + margin: 0 auto; + + @media screen and (min-width: 1024px) { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-gap: 0 24px; + } + } + &--with-image { + padding-top: 0; + .mcm-container__main { + margin-top: var(--spacing-m); + } + .footer-image { + display: none; + @media screen and (min-width: 1024px) { + display: block; + height: 500px; + } + + @media screen and (min-width: 1440px) { + height: 600px; + } + &__wrapper { + height: 100%; + > div { + height: 100%; + } + } + } + } + &--with-image-mobile { + .footer-image { + display: block; + @media screen and (max-width: 767px) { + max-height: 400px; + } + } + } +} + +.mcm-footer-logo { + align-self: center; + margin-bottom: var(--spacing-s); + width: 120px; + grid-column: span 3; + + @media screen and (min-width: 1024px) { + width: auto; + margin-bottom: 0; + } +} + +.version-tooltip { + left: 250%; + align-items: center; + border-radius: 10px; + color: white; + display: flex; + font-size: 16px; + font-weight: 400; + line-height: 20px; + max-width: 330px; + opacity: 1; + padding: 10px 10px 14px 10px; + position: relative; + text-align: center; +} + +.mcm-footer-links { + position: relative; + margin-bottom: calc(var(--spacing-xs) / 2); + grid-column: 5 / span 4; + + @media screen and (min-width: 1366px) { + grid-column: 6 / span 5; + } + + &:last-child { + grid-column: 10 / span 3; + + @media screen and (min-width: 1366px) { + grid-column: 11 / span 3; + } + } + + &__title { + display: flex; + align-items: center; + font-size: 16px; + line-height: 22px; + font-weight: 600; + cursor: pointer; + + @media screen and (min-width: 1024px) { + cursor: default; + margin-bottom: var(--spacing-s); + font-size: 20px; + line-height: 28px; + font-weight: 500; + } + + &:after { + content: ''; + background-image: url('data:image/svg+xml,%3Csvg width="40" height="40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M20 25l5-10H15z" fill="%23fff" fill-rule="evenodd"/%3E%3C/svg%3E'); + fill: var(--color-white); + background-size: 40px 40px; + background-repeat: no-repeat; + height: 40px; + width: 40px; + margin-left: auto; + @media screen and (min-width: 1024px) { + display: none; + } + } + } + + & input[type='checkbox'], + & input[type='radio'] { + background-color: transparent; + border: 1px solid transparent; + height: 1px; + position: absolute; + width: 1px; + margin: 4px 0 0 0; + outline: none; + display: none; + &:checked { + & + label:after { + transform: rotate(180deg); + } + & + label + ul { + display: block; + height: auto; + } + } + } + + ul { + display: none; + height: 0; + overflow: hidden; + margin-top: var(--spacing-xs); + padding-left: var(--spacing-xs); + + @media screen and (min-width: 1024px) { + display: initial; + margin-top: 0; + padding-left: 0; + } + + li { + line-height: 22px; + &:not(:last-child) { + margin-bottom: var(--spacing-xs); + } + a:hover { + text-decoration: underline; + } + } + } +} diff --git a/website/src/components/Footer/locale/fr.json b/website/src/components/Footer/locale/fr.json new file mode 100644 index 0000000..18d1609 --- /dev/null +++ b/website/src/components/Footer/locale/fr.json @@ -0,0 +1,10 @@ +{ + "legal.information": "Informations légales", + "legal.notice": "Mentions légales et Conditions Générales d’utilisation", + "cookie.policy.and.management": "Politique et gestion des cookies", + "privacy.policy": "Charte de Protection des Données Personnelles", + "helps.accessibility": "Aides et accessibilité", + "contact.us": "Nous contacter", + "faq" : "FAQ", + "app.version": "Version" +} diff --git a/website/src/components/Footer/locale/fr.json.d.ts b/website/src/components/Footer/locale/fr.json.d.ts new file mode 100644 index 0000000..3c2c49a --- /dev/null +++ b/website/src/components/Footer/locale/fr.json.d.ts @@ -0,0 +1,12 @@ +interface Fr { + 'legal.information': string; + 'legal.notice': string; + 'cookie.policy.and.management': string; + 'privacy.policy': string; + 'helps.accessibility': string; + 'contact.us': string; + 'faq': string; + 'app.version': string; +} +declare const value: Fr; +export = value; diff --git a/website/src/components/Form/ContactForm/ContactForm.test.tsx b/website/src/components/Form/ContactForm/ContactForm.test.tsx new file mode 100644 index 0000000..bff9bac --- /dev/null +++ b/website/src/components/Form/ContactForm/ContactForm.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { act } from 'react-dom/test-utils'; +import ContactForm from './ContactForm'; +import axios from 'axios'; +import { screen } from '@testing-library/dom'; + +jest.unmock('axios'); + +afterEach(cleanup); + +const contactMock = { + firstName: 'Roger', + lastName: 'Dupond', + userType: 'citoyen', + birthdate: '2000-12-12', + email: 'roger.dupond@tintin.fr', + postcode: '68100', + message: 'Voici mon message', + tos: true, +}; + +const dataError = { + errors: { + id: 'contact.error.email.format', + message: "L'email n'est pas au bon format", + code: 20007, + }, +}; + +jest.mock('axios', () => { + const mAxiosInstance = { + post: jest + .fn() + .mockReturnValueOnce( + // test 1 + Promise.resolve({ + data: contactMock, + }) + ) + .mockReturnValueOnce( + // test 2 + Promise.resolve({ + data: dataError, + }) + ) + .mockRejectedValueOnce( + // test 3 + Promise.resolve({ + data: contactMock, + }) + ), + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { + create: jest.fn(() => mAxiosInstance), + }; +}); + +describe('', () => { + test('Submit form with contactMock', async () => { + const { getByText, getByLabelText, getByTestId } = render(); + + // fill the form + await act(async () => { + fireEvent.click(getByLabelText('Un.e citoyen.ne')); + }); + await act(async () => { + fireEvent.change(getByLabelText('Nom *'), { + target: { value: contactMock.lastName }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Prénom *'), { + target: { value: contactMock.firstName }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Email *'), { + target: { value: contactMock.email }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Code postal *'), { + target: { value: contactMock.postcode }, + }); + }); + await act(async () => { + fireEvent.click(getByTestId('checkbox-test').querySelector('#tos')!); + }); + // submit + await act(async () => { + fireEvent.click(getByText('Envoyer')); + }); + + expect(axios.create).toHaveBeenCalledTimes(1); + }); + + test('Submit form with return error', async () => { + const { getByText, getByLabelText, getByTestId } = render(); + + // fill the form + await act(async () => { + fireEvent.click(getByLabelText('Un.e citoyen.ne')); + }); + await act(async () => { + fireEvent.change(getByLabelText('Nom *'), { + target: { value: contactMock.lastName }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Prénom *'), { + target: { value: contactMock.firstName }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Email *'), { + target: { value: contactMock.email }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Code postal *'), { + target: { value: contactMock.postcode }, + }); + }); + await act(async () => { + fireEvent.click(getByTestId('checkbox-test').querySelector('#tos')!); + }); + // submit + await act(async () => { + fireEvent.click(getByText('Envoyer')); + }); + + expect(axios.create).toHaveBeenCalledTimes(1); + }); + + test('Submit form with return error', async () => { + const { getByText, getByLabelText, getByTestId } = render(); + + // fill the form + await act(async () => { + fireEvent.click(getByLabelText('Un.e citoyen.ne')); + }); + await act(async () => { + fireEvent.change(getByLabelText('Nom *'), { + target: { value: contactMock.lastName }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Prénom *'), { + target: { value: contactMock.firstName }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Email *'), { + target: { value: contactMock.email }, + }); + }); + await act(async () => { + fireEvent.change(getByLabelText('Code postal *'), { + target: { value: contactMock.postcode }, + }); + }); + await act(async () => { + fireEvent.click(getByTestId('checkbox-test').querySelector('#tos')!); + }); + // submit + await act(async () => { + fireEvent.click(getByText('Envoyer')); + }); + + expect(axios.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/website/src/components/Form/ContactForm/ContactForm.tsx b/website/src/components/Form/ContactForm/ContactForm.tsx new file mode 100644 index 0000000..a18f2ff --- /dev/null +++ b/website/src/components/Form/ContactForm/ContactForm.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import toast from 'react-hot-toast'; + +import Button from '../../Button/Button'; +import TextField from '../../TextField/TextField'; +import FormSection from '../../FormSection/FormSection'; +import Checkbox from '../../Checkbox/Checkbox'; +import schema from './schema'; +import { Contact, send } from '@api/ContactService'; +import { computeServerErrors } from '../../../utils/form'; +import Strings from './locale/fr.json'; + +/** + * Form used for contact process + */ +const ContactForm: React.FC = () => { + const { + register, + handleSubmit, + reset, + setError, + formState: { errors, isSubmitSuccessful }, + } = useForm({ + criteriaMode: 'all', + resolver: yupResolver(schema), + }); + const [disabled, setDisabled] = useState(false); + + const onSubmit = async (contactData: Contact): Promise => { + setDisabled(true); + send(contactData) + .then((contact) => { + if (contact.errors) { + computeServerErrors(contact.errors, setError, Strings); + } else { + toast.success(Strings['success.message']); + } + }) + .catch((error) => { + console.log(error); + }) + .finally(() => { + setDisabled(false); + }); + }; + + useEffect(() => { + // Make sure that form was successfully submitted and active in same time. + if (isSubmitSuccessful && !disabled) { + reset(); + } + }, [disabled]); + + return ( +
+
+ {Strings['you.are']} +
+ + + + +
+
+ + + + + + + +
+ + {Strings['your.message']} + +