From c9c7e6e732a3f374e93236b054cc1b9f16d07e35 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Thu, 12 Dec 2024 18:05:09 +0100 Subject: [PATCH 1/6] refactor(core): move security checks to core --- .changeset/smooth-waves-complain.md | 7 + package-lock.json | 157 ++++++++++++------ package.json | 16 +- packages/core/package.json | 27 +-- .../src}/data/diceware-wordlist-fr-alt.ts | 0 .../generate-diceware-password.test.ts | 26 +++ .../security/generate-diceware-password.ts | 25 +++ .../src/security/generate-pin-token.test.ts | 19 +++ .../core/src/security/generate-pin-token.ts | 13 ++ .../core/src/security/generate-token.test.ts | 13 ++ packages/core/src/security/generate-token.ts | 9 + packages/core/src/security/index.ts | 10 ++ .../core/src/security/is-domain-valid.test.ts | 27 +++ packages/core/src/security/is-domain-valid.ts | 28 ++++ .../core/src/security/is-email-valid.test.ts | 70 ++++++++ packages/core/src/security/is-email-valid.ts | 44 +++++ .../core/src/security/is-name-valid.test.ts | 61 +++++++ packages/core/src/security/is-name-valid.ts | 5 + .../security/is-phone-number-valid.test.ts | 44 +++++ .../src/security/is-phone-number-valid.ts | 19 +++ .../core/src/security/is-siret-valid.test.ts | 36 ++++ packages/core/src/security/is-siret-valid.ts | 15 ++ packages/core/src/services/email/index.ts | 3 + .../core/src/services/suggestion/index.ts | 3 + packages/core/tsconfig.json | 2 +- packages/core/tsconfig.lib.json | 13 +- packages/core/types/index.d.ts | 5 +- .../types/is-disposable-email-domain.d.ts | 5 + packages/core/types/tld-extract.d.ts | 5 + scripts/import-accounts-coop.ts | 12 +- scripts/import-accounts.ts | 10 +- scripts/import-domains.ts | 5 +- .../api-annuaire-education-nationale.ts | 2 +- src/connectors/api-annuaire-service-public.ts | 2 +- src/managers/organization/join.ts | 2 +- .../official-contact-email-verification.ts | 5 +- src/managers/user.ts | 27 +-- src/services/custom-zod-schemas.ts | 9 +- src/services/email.ts | 2 +- src/services/organization.ts | 2 +- src/services/security.ts | 110 ------------ src/types/is-disposable-email-domain.d.ts | 1 - test/security.test.ts | 154 ----------------- 43 files changed, 660 insertions(+), 390 deletions(-) create mode 100644 .changeset/smooth-waves-complain.md rename {src => packages/core/src}/data/diceware-wordlist-fr-alt.ts (100%) create mode 100644 packages/core/src/security/generate-diceware-password.test.ts create mode 100644 packages/core/src/security/generate-diceware-password.ts create mode 100644 packages/core/src/security/generate-pin-token.test.ts create mode 100644 packages/core/src/security/generate-pin-token.ts create mode 100644 packages/core/src/security/generate-token.test.ts create mode 100644 packages/core/src/security/generate-token.ts create mode 100644 packages/core/src/security/index.ts create mode 100644 packages/core/src/security/is-domain-valid.test.ts create mode 100644 packages/core/src/security/is-domain-valid.ts create mode 100644 packages/core/src/security/is-email-valid.test.ts create mode 100644 packages/core/src/security/is-email-valid.ts create mode 100644 packages/core/src/security/is-name-valid.test.ts create mode 100644 packages/core/src/security/is-name-valid.ts create mode 100644 packages/core/src/security/is-phone-number-valid.test.ts create mode 100644 packages/core/src/security/is-phone-number-valid.ts create mode 100644 packages/core/src/security/is-siret-valid.test.ts create mode 100644 packages/core/src/security/is-siret-valid.ts create mode 100644 packages/core/src/services/email/index.ts create mode 100644 packages/core/src/services/suggestion/index.ts create mode 100644 packages/core/types/is-disposable-email-domain.d.ts create mode 100644 packages/core/types/tld-extract.d.ts delete mode 100644 src/types/is-disposable-email-domain.d.ts diff --git a/.changeset/smooth-waves-complain.md b/.changeset/smooth-waves-complain.md new file mode 100644 index 00000000..100acbb0 --- /dev/null +++ b/.changeset/smooth-waves-complain.md @@ -0,0 +1,7 @@ +--- +"@gouvfr-lasuite/proconnect.core": minor +--- + +👮 Accueillons l'équipe de sécurité de ProConnect + +Dans le cadres la migration du script d'import de comptes coop, une partie des fonctions de validation sont déplacées dans le package `@gouvfr-lasuite/proconnect.core/security` pour permettre leur réutilisation dans Hyyypertool. diff --git a/package-lock.json b/package-lock.json index 1cffaff0..1e45c348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,13 +51,11 @@ "html-to-text": "^9.0.5", "http-errors": "^2.0.0", "ioredis": "^5.4.1", - "is-disposable-email-domain": "^1.0.7", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.45", "morgan": "^1.10.0", - "nanoid": "^3.3.6", "nocache": "^4.0.0", "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.15", @@ -67,7 +65,7 @@ "qrcode": "^1.5.4", "rate-limiter-flexible": "^2.4.2", "tld-extract": "^2.1.0", - "tsx": "^4.17.0", + "tsx": "^4.19.2", "typescript": "^5.5.4", "vite": "^5.2.14", "zod": "^3.23.8", @@ -78,28 +76,28 @@ "@changesets/cli": "^2.27.10", "@simplewebauthn/types": "^10.0.0", "@sinonjs/fake-timers": "^11.2.2", - "@types/chai": "^5.0.0", + "@types/chai": "^5.0.1", "@types/chai-as-promised": "^7.1.8", "@types/html-to-text": "^9.0.4", "@types/http-errors": "^2.0.4", "@types/lodash": "^4.17.10", "@types/lodash-es": "^4.17.12", - "@types/mocha": "^10.0.7", - "@types/node": "^22.1.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", "@types/nodemailer": "^6.4.16", "@types/oidc-provider": "^8.5.2", "@types/qrcode": "^1.5.5", "@types/sinonjs__fake-timers": "^8.1.5", "axe-core": "^4.8.4", - "chai": "^5.1.1", - "chai-as-promised": "^8.0.0", + "chai": "^5.1.2", + "chai-as-promised": "^8.0.1", "concurrently": "^9.0.1", "copy-and-watch": "^0.1.6", "csv": "^6.3.9", "cypress": "^13.15.2", "cypress-axe": "^1.5.0", "cypress-maildev": "^1.3.2", - "mocha": "^10.7.3", + "mocha": "^11.0.1", "nock": "^13.5.4", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0" @@ -2397,11 +2395,14 @@ } }, "node_modules/@types/chai": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.0.tgz", - "integrity": "sha512-+DwhEHAaFPPdJ2ral3kNHFQXnTfscEEFsUxzD+d7nlcLrFK23JtNjH71RGasTcHb88b4vVi4mTyfpf8u2L8bdA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz", + "integrity": "sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } }, "node_modules/@types/chai-as-promised": { "version": "7.1.8", @@ -2469,6 +2470,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ejs": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", @@ -2585,9 +2593,9 @@ "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==" }, "node_modules/@types/mocha": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz", - "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true, "license": "MIT" }, @@ -2600,12 +2608,12 @@ } }, "node_modules/@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.20.0" } }, "node_modules/@types/nodemailer": { @@ -3495,9 +3503,9 @@ } }, "node_modules/chai": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", - "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", "dev": true, "license": "MIT", "dependencies": { @@ -3512,11 +3520,11 @@ } }, "node_modules/chai-as-promised": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.0.tgz", - "integrity": "sha512-sMsGXTrS3FunP/wbqh/KxM8Kj/aLPXQGkNtvE5wPfSToq8wkkvBpTZo1LIiEVmC4BwkKpag+l5h/20lBMk6nUg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.1.tgz", + "integrity": "sha512-OIEJtOL8xxJSH8JJWbIoRjybbzR52iFuDHuF8eb+nTPD6tgXLjRqsgnUGqQfFODxYvq5QdirT0pN9dZ0+Gz6rA==", "dev": true, - "license": "WTFPL", + "license": "MIT", "dependencies": { "check-error": "^2.0.0" }, @@ -6893,9 +6901,9 @@ "peer": true }, "node_modules/mocha": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", - "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.0.1.tgz", + "integrity": "sha512-+3GkODfsDG71KSCQhc4IekSW+ItCK/kiez1Z28ksWvYhKXV/syxMlerR/sC7whDp7IyreZ4YxceMLdTs5hQE8A==", "dev": true, "license": "MIT", "dependencies": { @@ -6906,7 +6914,7 @@ "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", @@ -6925,7 +6933,7 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/brace-expansion": { @@ -6933,6 +6941,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -6963,19 +6972,37 @@ "license": "MIT" }, "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9410,9 +9437,9 @@ } }, "node_modules/tsx": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.17.0.tgz", - "integrity": "sha512-eN4mnDA5UMKDt4YZixo9tBioibaMBpoxBkD+rIPAjVmYERSG0/dWEY1CEFuV89CgASlKL499q8AhmkMnnjtOJg==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", "license": "MIT", "dependencies": { "esbuild": "~0.23.0", @@ -9548,9 +9575,9 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, "node_modules/universalify": { @@ -9582,7 +9609,8 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -10365,14 +10393,37 @@ "license": "MIT", "dependencies": { "@zootools/email-spell-checker": "^1.12.0", - "is-disposable-email-domain": "^1.0.7" + "is-disposable-email-domain": "^1.0.7", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "tld-extract": "^2.1.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.0", - "@types/mocha": "^10.0.7", - "chai": "^5.1.1", - "mocha": "^10.7.3", - "tsx": "^4.17.0" + "@types/lodash-es": "^4.17.12", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "tsx": "^4.19.2" + } + }, + "packages/core/node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" } }, "packages/email": { diff --git a/package.json b/package.json index 3353955c..2e12560b 100644 --- a/package.json +++ b/package.json @@ -85,13 +85,11 @@ "html-to-text": "^9.0.5", "http-errors": "^2.0.0", "ioredis": "^5.4.1", - "is-disposable-email-domain": "^1.0.7", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.45", "morgan": "^1.10.0", - "nanoid": "^3.3.6", "nocache": "^4.0.0", "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.15", @@ -101,7 +99,7 @@ "qrcode": "^1.5.4", "rate-limiter-flexible": "^2.4.2", "tld-extract": "^2.1.0", - "tsx": "^4.17.0", + "tsx": "^4.19.2", "typescript": "^5.5.4", "vite": "^5.2.14", "zod": "^3.23.8", @@ -112,28 +110,28 @@ "@changesets/cli": "^2.27.10", "@simplewebauthn/types": "^10.0.0", "@sinonjs/fake-timers": "^11.2.2", - "@types/chai": "^5.0.0", + "@types/chai": "^5.0.1", "@types/chai-as-promised": "^7.1.8", "@types/html-to-text": "^9.0.4", "@types/http-errors": "^2.0.4", "@types/lodash": "^4.17.10", "@types/lodash-es": "^4.17.12", - "@types/mocha": "^10.0.7", - "@types/node": "^22.1.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", "@types/nodemailer": "^6.4.16", "@types/oidc-provider": "^8.5.2", "@types/qrcode": "^1.5.5", "@types/sinonjs__fake-timers": "^8.1.5", "axe-core": "^4.8.4", - "chai": "^5.1.1", - "chai-as-promised": "^8.0.0", + "chai": "^5.1.2", + "chai-as-promised": "^8.0.1", "concurrently": "^9.0.1", "copy-and-watch": "^0.1.6", "csv": "^6.3.9", "cypress": "^13.15.2", "cypress-axe": "^1.5.0", "cypress-maildev": "^1.3.2", - "mocha": "^10.7.3", + "mocha": "^11.0.1", "nock": "^13.5.4", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0" diff --git a/packages/core/package.json b/packages/core/package.json index e953d5d7..18c28781 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,15 +20,15 @@ "exports": { "./*": { "require": { - "types": "./dist/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" }, "import": { - "types": "./dist/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" }, - "types": "./dist/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" } }, "scripts": { @@ -46,14 +46,19 @@ }, "dependencies": { "@zootools/email-spell-checker": "^1.12.0", - "is-disposable-email-domain": "^1.0.7" + "is-disposable-email-domain": "^1.0.7", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "tld-extract": "^2.1.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.0", - "@types/mocha": "^10.0.7", - "chai": "^5.1.1", - "mocha": "^10.7.3", - "tsx": "^4.17.0" + "@types/lodash-es": "^4.17.12", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "tsx": "^4.19.2" }, "publishConfig": { "access": "public", diff --git a/src/data/diceware-wordlist-fr-alt.ts b/packages/core/src/data/diceware-wordlist-fr-alt.ts similarity index 100% rename from src/data/diceware-wordlist-fr-alt.ts rename to packages/core/src/data/diceware-wordlist-fr-alt.ts diff --git a/packages/core/src/security/generate-diceware-password.test.ts b/packages/core/src/security/generate-diceware-password.test.ts new file mode 100644 index 00000000..436b022d --- /dev/null +++ b/packages/core/src/security/generate-diceware-password.test.ts @@ -0,0 +1,26 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { GenerarateDicewarePassword } from "./generate-diceware-password.js"; + +// + +describe("GenerarateDicewarePassword", () => { + it("should generate two words", () => { + const generatePassword = GenerarateDicewarePassword([ + () => "11111", + () => "22222", + ]); + assert.equal(generatePassword(), "abandon-cible"); + }); + + it("should generate three words", () => { + const generatePassword = GenerarateDicewarePassword([ + () => "11111", + () => "22222", + () => "33333", + ]); + assert.equal(generatePassword(), "abandon-cible-gastrique"); + }); +}); diff --git a/packages/core/src/security/generate-diceware-password.ts b/packages/core/src/security/generate-diceware-password.ts new file mode 100644 index 00000000..de36950d --- /dev/null +++ b/packages/core/src/security/generate-diceware-password.ts @@ -0,0 +1,25 @@ +// + +import { customAlphabet } from "nanoid"; +import dicewareWordlistFrAlt from "../data/diceware-wordlist-fr-alt.js"; + +// + +type dice = "1" | "2" | "3" | "4" | "5" | "6"; +type fiveDices = `${dice}${dice}${dice}${dice}${dice}`; +const nanoidFiveDices = customAlphabet("123456", 5); +const createFiveDices = () => nanoidFiveDices() as fiveDices; + +export function GenerarateDicewarePassword( + generators: Array, +) { + return function generatePassword() { + return generators + .map((generator) => dicewareWordlistFrAlt[generator()]) + .join("-"); + }; +} + +export const generateDicewarePassword = GenerarateDicewarePassword( + Array(2).fill(createFiveDices), +); diff --git a/packages/core/src/security/generate-pin-token.test.ts b/packages/core/src/security/generate-pin-token.test.ts new file mode 100644 index 00000000..b90f8bef --- /dev/null +++ b/packages/core/src/security/generate-pin-token.test.ts @@ -0,0 +1,19 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { generatePinToken } from "./generate-pin-token.js"; + +// + +describe("generatePinToken", () => { + it("should use digits only", () => { + const token = generatePinToken(); + assert.match(token, /^[0-9]{10}$/); + }); + + it("should be 10 characters long", () => { + const token = generatePinToken(); + assert.lengthOf(token, 10); + }); +}); diff --git a/packages/core/src/security/generate-pin-token.ts b/packages/core/src/security/generate-pin-token.ts new file mode 100644 index 00000000..a050152a --- /dev/null +++ b/packages/core/src/security/generate-pin-token.ts @@ -0,0 +1,13 @@ +// + +import { customAlphabet } from "nanoid"; + +// + +const nanoidPin = customAlphabet("0123456789", 10); + +// + +export function generatePinToken() { + return nanoidPin(); +} diff --git a/packages/core/src/security/generate-token.test.ts b/packages/core/src/security/generate-token.test.ts new file mode 100644 index 00000000..bced2de4 --- /dev/null +++ b/packages/core/src/security/generate-token.test.ts @@ -0,0 +1,13 @@ +// + +import { assert } from "chai"; +import { generateToken } from "./generate-token.js"; + +// + +describe("generateToken", () => { + it("should be 64 characters long", () => { + const token = generateToken(); + assert.lengthOf(token, 64); + }); +}); diff --git a/packages/core/src/security/generate-token.ts b/packages/core/src/security/generate-token.ts new file mode 100644 index 00000000..207c3db2 --- /dev/null +++ b/packages/core/src/security/generate-token.ts @@ -0,0 +1,9 @@ +// + +import { nanoid } from "nanoid"; + +// + +export function generateToken() { + return nanoid(64); +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts new file mode 100644 index 00000000..3f24ca5b --- /dev/null +++ b/packages/core/src/security/index.ts @@ -0,0 +1,10 @@ +// + +export * from "./generate-diceware-password.js"; +export * from "./generate-pin-token.js"; +export * from "./generate-token.js"; +export * from "./is-domain-valid.js"; +export * from "./is-email-valid.js"; +export * from "./is-name-valid.js"; +export * from "./is-phone-number-valid.js"; +export * from "./is-siret-valid.js"; diff --git a/packages/core/src/security/is-domain-valid.test.ts b/packages/core/src/security/is-domain-valid.test.ts new file mode 100644 index 00000000..ea5f4b2b --- /dev/null +++ b/packages/core/src/security/is-domain-valid.test.ts @@ -0,0 +1,27 @@ +// + +import { assert } from "chai"; +import { isDomainValid } from "./is-domain-valid.js"; + +// + +describe("isDomainValid", () => { + it("should return false for undefined value", () => { + assert.equal(isDomainValid(undefined), false); + }); + + it("should return false for empty string", () => { + assert.equal(isDomainValid(""), false); + }); + + it("should return false if contains characters other than number and letters", () => { + assert.equal(isDomainValid("héééééé"), false); + }); + + it("should allow dot less tld", () => { + assert.equal(isDomainValid("co.uk"), true); + }); + it("should allow gouv.fr", () => { + assert.equal(isDomainValid("gouv.fr"), true); + }); +}); diff --git a/packages/core/src/security/is-domain-valid.ts b/packages/core/src/security/is-domain-valid.ts new file mode 100644 index 00000000..1c084e3a --- /dev/null +++ b/packages/core/src/security/is-domain-valid.ts @@ -0,0 +1,28 @@ +// + +import { isEmpty, isString } from "lodash-es"; +import { parse_host } from "tld-extract"; + +// + +/* + * specifications of these functions can be found at + * https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html#email-address-validation + */ +export function isDomainValid(domain: unknown): domain is string { + if (!isString(domain) || isEmpty(domain)) { + return false; + } + + if (domain.match(/^[a-zA-Z0-9.-]*$/) === null) { + return false; + } + + try { + parse_host(domain, { allowDotlessTLD: true }); + } catch { + return false; + } + + return true; +} diff --git a/packages/core/src/security/is-email-valid.test.ts b/packages/core/src/security/is-email-valid.test.ts new file mode 100644 index 00000000..c1e74f73 --- /dev/null +++ b/packages/core/src/security/is-email-valid.test.ts @@ -0,0 +1,70 @@ +import { assert } from "chai"; +import { isEmailValid } from "./is-email-valid.js"; + +describe("isEmailValid", () => { + it("should return false for undefined value", () => { + assert.equal(isEmailValid(undefined), false); + }); + + it("should return false for empty string", () => { + assert.equal(isEmailValid(""), false); + }); + + it("should return false if no @ is present", () => { + assert.equal(isEmailValid("test"), false); + }); + + it("should return false if no domain is present", () => { + assert.equal(isEmailValid("test@"), false); + }); + + it("should return false if two @ are present", () => { + assert.equal(isEmailValid("test@test@test"), false); + }); + + it("should return false if domains contain other than letters, numbers, hyphens (-) and periods (.)", () => { + assert.equal(isEmailValid("test@test_test"), false); + }); + + it("should return false if tld has the wrong case", () => { + assert.equal(isEmailValid("jean@wanadoo.Fr"), false); + }); + + it("should return false if local part is longer than 63 characters", () => { + assert.equal( + isEmailValid( + "1234567890123456789012345678901234567890123456789012345678901234@test", + ), + false, + ); + }); + + it("should return false if total length is longer than 254 characters", () => { + assert.equal( + isEmailValid( + "test@1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", + ), + false, + ); + }); + + // this test cases have been taken from + // https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript/32686261#32686261 + const validEmailAddresses = [ + "prettyandsimple@example.com", + "very.common@example.com", + "disposable.style.email.with+symbol@example.com", + "other.email-with-dash@example.com", + "#!$%&'*+-/=?^_`{}|~@example.org", + '"()[]:,;\\"!#$%&\'*+-/=?^_`{}| ~.a"@example.org', + '" "@example.org', // space between the quotes + "üñîçøðé@example.com", // Unicode characters in local part + "Pelé@example.com", // Latin + ]; + + validEmailAddresses.forEach((validEmailAddress) => { + it("should return true for valid email address", () => { + assert.equal(isEmailValid(validEmailAddress), true); + }); + }); +}); diff --git a/packages/core/src/security/is-email-valid.ts b/packages/core/src/security/is-email-valid.ts new file mode 100644 index 00000000..c09e4ef5 --- /dev/null +++ b/packages/core/src/security/is-email-valid.ts @@ -0,0 +1,44 @@ +// + +import { isEmpty, isString } from "lodash-es"; +import { Buffer } from "node:buffer"; +import { isDomainValid } from "./is-domain-valid.js"; + +// + +export function isEmailValid(email: unknown): email is string { + if (!isString(email) || isEmpty(email)) { + return false; + } + + const parts = email.split("@").filter((part) => part); + + // The email address contains two parts, separated with an @ symbol. + // => these parts are non-empty strings + // => there are two and only two parts + if (parts.length !== 2) { + return false; + } + + // The email address does not contain dangerous characters + // => the postgres connector is taking care of this + + // The domain part contains only letters, numbers, hyphens (-) and periods (.) + const domain = parts[1]; + if (!isDomainValid(domain)) { + return false; + } + + // The local part (before the @) should be no more than 63 characters. + const localPart = parts[0]; + if (Buffer.from(localPart).length > 63) { + return false; + } + + // The total length should be no more than 254 characters. + if (Buffer.from(email).length > 254) { + return false; + } + + return true; +} diff --git a/packages/core/src/security/is-name-valid.test.ts b/packages/core/src/security/is-name-valid.test.ts new file mode 100644 index 00000000..3a9fef27 --- /dev/null +++ b/packages/core/src/security/is-name-valid.test.ts @@ -0,0 +1,61 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { isNameValid } from "./is-name-valid.js"; + +// + +describe("isNameValid", () => { + const invalidNames = [ + "jean@domaine.fr", + "dsi_etudes_applications", + "R2 - Sebastien", + "0000", + "CCTV70", + "0623456789", + ";GOUZE", + "Agathe/Carine", + ``, + "SG/PAFF/DDTM06", + "Jean*Robert", + "Jose_luis", + "MME.", + "Sabrina.b", + "M.Christine", + "Bousbecque59098*", + "vAL2RIE", + "Ch.", + "YOANNI TH.", + "M. le Président", + ]; + + invalidNames.forEach((invalidName) => { + it(`should return false for invalid names: ${invalidName}`, () => { + assert.equal(isNameValid(invalidName), false); + }); + }); + + const validNames = [ + "Jean", + "Jean-Jean", + "TAREK WAJDI", + " Tania", + "Надежда", + "沃德天·", + "อาทิตย์ นาถมทอง", + "俊宇", + "Doğan", + "Hanåğğne", + "سليمان خالد", + "marcn bh", + "THỊ PHƯƠNG HỒNG", + "Yamina⁵", + ]; + + validNames.forEach((validName) => { + it(`should return true for valid names: ${validName}`, () => { + assert.equal(isNameValid(validName), true); + }); + }); +}); diff --git a/packages/core/src/security/is-name-valid.ts b/packages/core/src/security/is-name-valid.ts new file mode 100644 index 00000000..09a61077 --- /dev/null +++ b/packages/core/src/security/is-name-valid.ts @@ -0,0 +1,5 @@ +// + +export function isNameValid(name: string) { + return !!name.match(/^[^$&+:;=?@#|<>.^*()%!\d_\[\]{}\\\/"`~]*$/); +} diff --git a/packages/core/src/security/is-phone-number-valid.test.ts b/packages/core/src/security/is-phone-number-valid.test.ts new file mode 100644 index 00000000..4e7f2bc0 --- /dev/null +++ b/packages/core/src/security/is-phone-number-valid.test.ts @@ -0,0 +1,44 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { isPhoneNumberValid } from "./is-phone-number-valid.js"; + +// + +describe("isPhoneNumberValid", () => { + [ + undefined, + null, + 0, + true, + "📞", + "FR", + "Jean Michel", + "$", + "&", + "(", + ")", + "+33210", + ].forEach((name) => { + it(`should return false for "${name}"`, () => { + assert.equal(isPhoneNumberValid(name), false); + }); + }); + + it("should return true for '0123456789'", () => { + assert.equal(isPhoneNumberValid("0123456789"), true); + }); + + it("should return true for '0-1-2-3-4-5-6-7-8-9'", () => { + assert.equal(isPhoneNumberValid("0-1-2-3-4-5-6-7-8-9"), true); + }); + + it("should return true for '+00123456'", () => { + assert.equal(isPhoneNumberValid("+00123456"), true); + }); + + it("should return true for '+33123456789'", () => { + assert.equal(isPhoneNumberValid("+33123456789"), true); + }); +}); diff --git a/packages/core/src/security/is-phone-number-valid.ts b/packages/core/src/security/is-phone-number-valid.ts new file mode 100644 index 00000000..e7532f26 --- /dev/null +++ b/packages/core/src/security/is-phone-number-valid.ts @@ -0,0 +1,19 @@ +// + +import { isEmpty, isString } from "lodash-es"; + +// + +export function isPhoneNumberValid( + phoneNumber: unknown, +): phoneNumber is string { + if (!isString(phoneNumber) || isEmpty(phoneNumber)) { + return false; + } + + if (!phoneNumber.match(/^\+?(?:[0-9][ -]?){6,14}[0-9]$/)) { + return false; + } + + return true; +} diff --git a/packages/core/src/security/is-siret-valid.test.ts b/packages/core/src/security/is-siret-valid.test.ts new file mode 100644 index 00000000..2aa9b48b --- /dev/null +++ b/packages/core/src/security/is-siret-valid.test.ts @@ -0,0 +1,36 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { isSiretValid } from "./is-siret-valid.js"; + +// + +describe("isSiretValid", () => { + it("should return false for undefined value", () => { + assert.equal(isSiretValid(undefined), false); + }); + + it("should return false for empty string", () => { + assert.equal(isSiretValid(""), false); + }); + + it("should return false if it contains characters other than number", () => { + assert.equal(isSiretValid("a2345678901234"), false); + }); + it("should return false if it contains more that 14 numbers", () => { + assert.equal(isSiretValid("123456789012345"), false); + }); + + it("should return false if it contains less that 14 numbers", () => { + assert.equal(isSiretValid("1234567890123"), false); + }); + + it("should return true if it contains exactly 14 numbers", () => { + assert.equal(isSiretValid("12345678901234"), true); + }); + + it("should return true if it contains exactly 14 numbers with spaces", () => { + assert.equal(isSiretValid(" 123 456 789\n\r01234 \n"), true); + }); +}); diff --git a/packages/core/src/security/is-siret-valid.ts b/packages/core/src/security/is-siret-valid.ts new file mode 100644 index 00000000..106cbf97 --- /dev/null +++ b/packages/core/src/security/is-siret-valid.ts @@ -0,0 +1,15 @@ +// + +import { isEmpty, isString } from "lodash-es"; + +// + +export function isSiretValid(siret: unknown): siret is string { + if (!isString(siret) || isEmpty(siret)) { + return false; + } + + const siretNoSpaces = siret.replace(/\s/g, ""); + + return /^\d{14}$/.test(siretNoSpaces); +} diff --git a/packages/core/src/services/email/index.ts b/packages/core/src/services/email/index.ts new file mode 100644 index 00000000..da7061c2 --- /dev/null +++ b/packages/core/src/services/email/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./isAFreeDomain.js"; diff --git a/packages/core/src/services/suggestion/index.ts b/packages/core/src/services/suggestion/index.ts new file mode 100644 index 00000000..1352d188 --- /dev/null +++ b/packages/core/src/services/suggestion/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./did-you-mean.js"; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 1c2a9acc..23b7c277 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -5,7 +5,7 @@ "declarationMap": true, "outDir": "./dist", "rootDir": "src", - "types": ["./types"], + "types": ["node", "./types"], "module": "NodeNext", "moduleResolution": "nodenext", "verbatimModuleSyntax": true, diff --git a/packages/core/tsconfig.lib.json b/packages/core/tsconfig.lib.json index c3d239e6..3ba35441 100644 --- a/packages/core/tsconfig.lib.json +++ b/packages/core/tsconfig.lib.json @@ -1,16 +1,9 @@ { "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", - "types": [ - "./types" - ] + "rootDir": "./src" }, - "exclude": [ - "src/**/*.test.ts" - ], + "exclude": ["src/**/*.test.ts"], "extends": "./tsconfig.json", - "include": [ - "src" - ] + "include": ["src"] } diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 644b727e..a8c6994f 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -1,5 +1,4 @@ // -declare module "is-disposable-email-domain" { - function isFree(email: string): boolean; -} +/// +/// diff --git a/packages/core/types/is-disposable-email-domain.d.ts b/packages/core/types/is-disposable-email-domain.d.ts new file mode 100644 index 00000000..644b727e --- /dev/null +++ b/packages/core/types/is-disposable-email-domain.d.ts @@ -0,0 +1,5 @@ +// + +declare module "is-disposable-email-domain" { + function isFree(email: string): boolean; +} diff --git a/packages/core/types/tld-extract.d.ts b/packages/core/types/tld-extract.d.ts new file mode 100644 index 00000000..0211b457 --- /dev/null +++ b/packages/core/types/tld-extract.d.ts @@ -0,0 +1,5 @@ +// + +declare module "tld-extract" { + function parse_host(domain: string, { allowDotlessTLD: boolean }): boolean; +} diff --git a/scripts/import-accounts-coop.ts b/scripts/import-accounts-coop.ts index 33f107ee..ab284b79 100644 --- a/scripts/import-accounts-coop.ts +++ b/scripts/import-accounts-coop.ts @@ -1,4 +1,10 @@ // src https://stackoverflow.com/questions/40994095/pipe-streams-to-edit-csv-file-in-node-js +import { + isEmailValid, + isNameValid, + isPhoneNumberValid, + isSiretValid, +} from "@gouvfr-lasuite/proconnect.core/security"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; @@ -22,12 +28,6 @@ import { startDurationMesure, throttleApiCall, } from "../src/services/script-helpers"; -import { - isEmailValid, - isNameValid, - isPhoneNumberValid, - isSiretValid, -} from "../src/services/security"; const { INPUT_FILE, OUTPUT_FILE } = z .object({ diff --git a/scripts/import-accounts.ts b/scripts/import-accounts.ts index 04e02c62..ebb913b1 100644 --- a/scripts/import-accounts.ts +++ b/scripts/import-accounts.ts @@ -1,4 +1,9 @@ // src https://stackoverflow.com/questions/40994095/pipe-streams-to-edit-csv-file-in-node-js +import { + isEmailValid, + isNameValid, + isSiretValid, +} from "@gouvfr-lasuite/proconnect.core/security"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; @@ -22,11 +27,6 @@ import { startDurationMesure, throttleApiCall, } from "../src/services/script-helpers"; -import { - isEmailValid, - isNameValid, - isSiretValid, -} from "../src/services/security"; const { INPUT_FILE, OUTPUT_FILE } = z .object({ diff --git a/scripts/import-domains.ts b/scripts/import-domains.ts index 19560844..e967b79f 100644 --- a/scripts/import-domains.ts +++ b/scripts/import-domains.ts @@ -1,4 +1,8 @@ // src https://stackoverflow.com/questions/40994095/pipe-streams-to-edit-csv-file-in-node-js +import { + isDomainValid, + isSiretValid, +} from "@gouvfr-lasuite/proconnect.core/security"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; @@ -23,7 +27,6 @@ import { startDurationMesure, throttleApiCall, } from "../src/services/script-helpers"; -import { isDomainValid, isSiretValid } from "../src/services/security"; const { INPUT_FILE, OUTPUT_FILE } = z .object({ diff --git a/src/connectors/api-annuaire-education-nationale.ts b/src/connectors/api-annuaire-education-nationale.ts index e66373bb..9f026750 100644 --- a/src/connectors/api-annuaire-education-nationale.ts +++ b/src/connectors/api-annuaire-education-nationale.ts @@ -1,3 +1,4 @@ +import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import axios, { AxiosError, type AxiosResponse } from "axios"; import { isEmpty, isString } from "lodash-es"; import { @@ -11,7 +12,6 @@ import { ApiAnnuaireNotFoundError, } from "../config/errors"; import { logger } from "../services/log"; -import { isEmailValid } from "../services/security"; type ApiAnnuaireEducationNationaleReponse = { total_count: number; diff --git a/src/connectors/api-annuaire-service-public.ts b/src/connectors/api-annuaire-service-public.ts index e9812df0..d5ac87a8 100644 --- a/src/connectors/api-annuaire-service-public.ts +++ b/src/connectors/api-annuaire-service-public.ts @@ -1,3 +1,4 @@ +import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import axios, { AxiosError, type AxiosResponse } from "axios"; import { isEmpty, isString } from "lodash-es"; import { @@ -12,7 +13,6 @@ import { ApiAnnuaireTooManyResultsError, } from "../config/errors"; import { logger } from "../services/log"; -import { isEmailValid } from "../services/security"; // more info at https://api-lannuaire.service-public.fr/api/explore/v2.1/console diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index beb00441..6a41394f 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -1,3 +1,4 @@ +import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import { Welcome } from "@gouvfr-lasuite/proconnect.email"; import * as Sentry from "@sentry/node"; import { isEmpty, some } from "lodash-es"; @@ -53,7 +54,6 @@ import { isEtablissementScolaireDuPremierEtSecondDegre, isSmallAssociation, } from "../../services/organization"; -import { isEmailValid } from "../../services/security"; import { unableToAutoJoinOrganizationMd } from "../../views/mails/unable-to-auto-join-organization"; import { getOrganizationsByUserId, markDomainAsVerified } from "./main"; diff --git a/src/managers/organization/official-contact-email-verification.ts b/src/managers/organization/official-contact-email-verification.ts index dfc5faf6..76a75f71 100644 --- a/src/managers/organization/official-contact-email-verification.ts +++ b/src/managers/organization/official-contact-email-verification.ts @@ -1,3 +1,4 @@ +import { generateDicewarePassword } from "@gouvfr-lasuite/proconnect.core/security"; import { OfficialContactEmailVerification } from "@gouvfr-lasuite/proconnect.email"; import { isEmpty } from "lodash-es"; import { HOST } from "../../config/env"; @@ -20,7 +21,6 @@ import { isCommune, isEtablissementScolaireDuPremierEtSecondDegre, } from "../../services/organization"; -import { generateDicewarePassword } from "../../services/security"; const OFFICIAL_CONTACT_EMAIL_VERIFICATION_TOKEN_EXPIRATION_DURATION_IN_MINUTES = 60; @@ -94,8 +94,7 @@ export const sendOfficialContactEmailVerificationEmail = async ({ return { codeSent: false, contactEmail, libelle }; } - const official_contact_email_verification_token = - await generateDicewarePassword(); + const official_contact_email_verification_token = generateDicewarePassword(); await updateUserOrganizationLink(organization_id, user_id, { official_contact_email_verification_token, diff --git a/src/managers/user.ts b/src/managers/user.ts index f26d107e..82102488 100644 --- a/src/managers/user.ts +++ b/src/managers/user.ts @@ -1,3 +1,8 @@ +import { + generatePinToken, + generateToken, +} from "@gouvfr-lasuite/proconnect.core/security"; +import { getDidYouMeanSuggestion } from "@gouvfr-lasuite/proconnect.core/services/suggestion"; import { Add2fa, AddAccessKey, @@ -12,6 +17,13 @@ import { VerifyEmail, } from "@gouvfr-lasuite/proconnect.email"; import { isEmpty } from "lodash-es"; +import { + HOST, + MAGIC_LINK_TOKEN_EXPIRATION_DURATION_IN_MINUTES, + MAX_DURATION_BETWEEN_TWO_EMAIL_ADDRESS_VERIFICATION_IN_MINUTES, + RESET_PASSWORD_TOKEN_EXPIRATION_DURATION_IN_MINUTES, + VERIFY_EMAIL_TOKEN_EXPIRATION_DURATION_IN_MINUTES, +} from "../config/env"; import { EmailUnavailableError, InvalidCredentialsError, @@ -26,15 +38,6 @@ import { } from "../config/errors"; import { isEmailSafeToSendTransactional } from "../connectors/debounce"; import { sendMail } from "../connectors/mail"; - -import { getDidYouMeanSuggestion } from "@gouvfr-lasuite/proconnect.core/services/suggestion/did-you-mean.js"; -import { - HOST, - MAGIC_LINK_TOKEN_EXPIRATION_DURATION_IN_MINUTES, - MAX_DURATION_BETWEEN_TWO_EMAIL_ADDRESS_VERIFICATION_IN_MINUTES, - RESET_PASSWORD_TOKEN_EXPIRATION_DURATION_IN_MINUTES, - VERIFY_EMAIL_TOKEN_EXPIRATION_DURATION_IN_MINUTES, -} from "../config/env"; import { hasPasswordBeenPwned } from "../connectors/pwnedpasswords"; import { create, @@ -46,8 +49,6 @@ import { } from "../repositories/user"; import { isExpired } from "../services/is-expired"; import { - generatePinToken, - generateToken, hashPassword, isPasswordSecure, validatePassword, @@ -182,7 +183,7 @@ export const sendEmailAddressVerificationEmail = async ({ return { codeSent: false, updatedUser: user }; } - const verify_email_token = await generatePinToken(); + const verify_email_token = generatePinToken(); const updatedUser = await update(user.id, { verify_email_token, @@ -472,7 +473,7 @@ export const sendSendMagicLinkEmail = async ( }); } - const magicLinkToken = await generateToken(); + const magicLinkToken = generateToken(); await update(user.id, { magic_link_token: magicLinkToken, diff --git a/src/services/custom-zod-schemas.ts b/src/services/custom-zod-schemas.ts index 0300bb86..4fc4f3fc 100644 --- a/src/services/custom-zod-schemas.ts +++ b/src/services/custom-zod-schemas.ts @@ -1,13 +1,12 @@ -import { z } from "zod"; -import { normalizeOfficialContactEmailVerificationToken } from "./normalize-official-contact-email-verification-token"; import { isEmailValid, isNameValid, - isNotificationLabelValid, isPhoneNumberValid, isSiretValid, - isVisibleString, -} from "./security"; +} from "@gouvfr-lasuite/proconnect.core/security"; +import { z } from "zod"; +import { normalizeOfficialContactEmailVerificationToken } from "./normalize-official-contact-email-verification-token"; +import { isNotificationLabelValid, isVisibleString } from "./security"; export const siretSchema = () => z diff --git a/src/services/email.ts b/src/services/email.ts index dfd01b9e..14093989 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -1,6 +1,6 @@ // -import { isAFreeDomain } from "@gouvfr-lasuite/proconnect.core/services/email/isAFreeDomain.js"; +import { isAFreeDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import { parse_host } from "tld-extract"; import { FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_FREE, diff --git a/src/services/organization.ts b/src/services/organization.ts index ded90c16..668ecacb 100644 --- a/src/services/organization.ts +++ b/src/services/organization.ts @@ -1,4 +1,4 @@ -import { isDomainValid } from "./security"; +import { isDomainValid } from "@gouvfr-lasuite/proconnect.core/security"; /** * These fonctions return approximate results. As the data tranche effectifs is diff --git a/src/services/security.ts b/src/services/security.ts index 3d604e9b..4ac6e93c 100644 --- a/src/services/security.ts +++ b/src/services/security.ts @@ -1,10 +1,7 @@ import bcrypt from "bcryptjs"; import { hasIn, isEmpty, isString } from "lodash-es"; -import { customAlphabet, nanoid } from "nanoid/async"; -import { parse_host } from "tld-extract"; import { HOST } from "../config/env"; import notificationMessages from "../config/notification-messages"; -import dicewareWordlistFrAlt from "../data/diceware-wordlist-fr-alt"; import type { AmrValue } from "../types/express-session"; import { owaspPasswordStrengthTest } from "./owasp-password-strength-tester"; @@ -52,119 +49,12 @@ export const isPasswordSecure = (plainPassword: string, email: string) => { return !containsBlacklistedWord && strong; }; -/* - * specifications of these functions can be found at - * https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html#email-address-validation - */ -export const isDomainValid = (domain: unknown): domain is string => { - if (!isString(domain) || isEmpty(domain)) { - return false; - } - - if (domain.match(/^[a-zA-Z0-9.-]*$/) === null) { - return false; - } - - try { - parse_host(domain, { allowDotlessTLD: true }); - } catch (error) { - return false; - } - - return true; -}; -export const isEmailValid = (email: unknown): email is string => { - if (!isString(email) || isEmpty(email)) { - return false; - } - - const parts = email.split("@").filter((part) => part); - - // The email address contains two parts, separated with an @ symbol. - // => these parts are non-empty strings - // => there are two and only two parts - if (parts.length !== 2) { - return false; - } - - // The email address does not contain dangerous characters - // => the postgres connector is taking care of this - - // The domain part contains only letters, numbers, hyphens (-) and periods (.) - const domain = parts[1]; - if (!isDomainValid(domain)) { - return false; - } - - // The local part (before the @) should be no more than 63 characters. - const localPart = parts[0]; - if (Buffer.from(localPart).length > 63) { - return false; - } - - // The total length should be no more than 254 characters. - if (Buffer.from(email).length > 254) { - return false; - } - - return true; -}; - -export const isPhoneNumberValid = ( - phoneNumber: unknown, -): phoneNumber is string => { - if (!isString(phoneNumber) || isEmpty(phoneNumber)) { - return false; - } - - if (!phoneNumber.match(/^\+?(?:[0-9][ -]?){6,14}[0-9]$/)) { - return false; - } - - return true; -}; - export const isVisibleString = (input: string) => { const visibleCharRegex = /[^\s\p{Cf}\p{Cc}\p{Zl}\p{Zp}]/u; return visibleCharRegex.test(input); }; -export const isNameValid = (name: string) => { - return !!name.match(/^[^$&+:;=?@#|<>.^*()%!\d_\[\]{}\\\/"`~]*$/); -}; - -const nanoidPin = customAlphabet("0123456789", 10); - -export const generatePinToken = async () => { - return await nanoidPin(); -}; - -export const generateToken = async () => { - return await nanoid(64); -}; - -type dice = "1" | "2" | "3" | "4" | "5" | "6"; -type fiveDices = `${dice}${dice}${dice}${dice}${dice}`; -const nanoidFiveDices = customAlphabet("123456", 5); - -export const generateDicewarePassword = async () => { - const firstFiveDices = (await nanoidFiveDices()) as fiveDices; - const secondFiveDices = (await nanoidFiveDices()) as fiveDices; - - return `${dicewareWordlistFrAlt[firstFiveDices]}-${dicewareWordlistFrAlt[secondFiveDices]}`; -}; - -export const isSiretValid = (siret: unknown): siret is string => { - if (!isString(siret) || isEmpty(siret)) { - return false; - } - - const siretNoSpaces = siret.replace(/\s/g, ""); - - return /^\d{14}$/.test(siretNoSpaces); -}; - export const getTrustedReferrerPath = (referrer: unknown): string | null => { if (!isString(referrer) || isEmpty(referrer)) { return null; diff --git a/src/types/is-disposable-email-domain.d.ts b/src/types/is-disposable-email-domain.d.ts deleted file mode 100644 index 1b3715e5..00000000 --- a/src/types/is-disposable-email-domain.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "is-disposable-email-domain"; diff --git a/test/security.test.ts b/test/security.test.ts index 851c3749..af2016b3 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -2,110 +2,10 @@ import { assert } from "chai"; import { HOST } from "../src/config/env"; import { getTrustedReferrerPath, - isEmailValid, - isNameValid, isPasswordSecure, - isSiretValid, isVisibleString, } from "../src/services/security"; -describe("isEmailValid", () => { - it("should return false for undefined value", () => { - assert.equal(isEmailValid(undefined), false); - }); - - it("should return false for empty string", () => { - assert.equal(isEmailValid(""), false); - }); - - it("should return false if no @ is present", () => { - assert.equal(isEmailValid("test"), false); - }); - - it("should return false if no domain is present", () => { - assert.equal(isEmailValid("test@"), false); - }); - - it("should return false if two @ are present", () => { - assert.equal(isEmailValid("test@test@test"), false); - }); - - it("should return false if domains contain other than letters, numbers, hyphens (-) and periods (.)", () => { - assert.equal(isEmailValid("test@test_test"), false); - }); - - it("should return false if tld has the wrong case", () => { - assert.equal(isEmailValid("jean@wanadoo.Fr"), false); - }); - - it("should return false if local part is longer than 63 characters", () => { - assert.equal( - isEmailValid( - "1234567890123456789012345678901234567890123456789012345678901234@test", - ), - false, - ); - }); - - it("should return false if total length is longer than 254 characters", () => { - assert.equal( - isEmailValid( - "test@1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", - ), - false, - ); - }); - - // this test cases have been taken from - // https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript/32686261#32686261 - const validEmailAddresses = [ - "prettyandsimple@example.com", - "very.common@example.com", - "disposable.style.email.with+symbol@example.com", - "other.email-with-dash@example.com", - "#!$%&'*+-/=?^_`{}|~@example.org", - '"()[]:,;\\"!#$%&\'*+-/=?^_`{}| ~.a"@example.org', - '" "@example.org', // space between the quotes - "üñîçøðé@example.com", // Unicode characters in local part - "Pelé@example.com", // Latin - ]; - - validEmailAddresses.forEach((validEmailAddress) => { - it("should return true for valid email address", () => { - assert.equal(isEmailValid(validEmailAddress), true); - }); - }); -}); - -describe("isSiretValid", () => { - it("should return false for undefined value", () => { - assert.equal(isSiretValid(undefined), false); - }); - - it("should return false for empty string", () => { - assert.equal(isSiretValid(""), false); - }); - - it("should return false if it contains characters other than number", () => { - assert.equal(isSiretValid("a2345678901234"), false); - }); - it("should return false if it contains more that 14 numbers", () => { - assert.equal(isSiretValid("123456789012345"), false); - }); - - it("should return false if it contains less that 14 numbers", () => { - assert.equal(isSiretValid("1234567890123"), false); - }); - - it("should return true if it contains exactly 14 numbers", () => { - assert.equal(isSiretValid("12345678901234"), true); - }); - - it("should return true if it contains exactly 14 numbers with spaces", () => { - assert.equal(isSiretValid(" 123 456 789\n\r01234 \n"), true); - }); -}); - describe("isVisibleString", () => { const nonVisibleStrings = [ "", @@ -131,60 +31,6 @@ describe("isVisibleString", () => { }); }); -describe("isNameValid", () => { - const invalidNames = [ - "jean@domaine.fr", - "dsi_etudes_applications", - "R2 - Sebastien", - "0000", - "CCTV70", - "0623456789", - ";GOUZE", - "Agathe/Carine", - ``, - "SG/PAFF/DDTM06", - "Jean*Robert", - "Jose_luis", - "MME.", - "Sabrina.b", - "M.Christine", - "Bousbecque59098*", - "vAL2RIE", - "Ch.", - "YOANNI TH.", - "M. le Président", - ]; - - invalidNames.forEach((invalidName) => { - it(`should return false for invalid names: ${invalidName}`, () => { - assert.equal(isNameValid(invalidName), false); - }); - }); - - const validNames = [ - "Jean", - "Jean-Jean", - "TAREK WAJDI", - " Tania", - "Надежда", - "沃德天·", - "อาทิตย์ นาถมทอง", - "俊宇", - "Doğan", - "Hanåğğne", - "سليمان خالد", - "marcn bh", - "THỊ PHƯƠNG HỒNG", - "Yamina⁵", - ]; - - validNames.forEach((validName) => { - it(`should return true for valid names: ${validName}`, () => { - assert.equal(isNameValid(validName), true); - }); - }); -}); - describe("isUrlTrusted", () => { it("should not trust null url", () => { assert.equal(getTrustedReferrerPath(null), null); From fdb40c6610195c30599f18919088b211ae2f65a1 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Mon, 16 Dec 2024 09:04:16 +0100 Subject: [PATCH 2/6] refactor(core): extract insee connector to core --- package-lock.json | 1 + packages/core/package.json | 1 + .../connectors/get-insee-access-token.test.ts | 43 +++++++++++++ .../src/connectors/get-insee-access-token.ts | 0 packages/core/src/connectors/index.ts | 3 + .../connectors/insee/get-access-token.test.ts | 63 +++++++++++++++++++ .../src/connectors/insee/get-access-token.ts | 43 +++++++++++++ packages/core/src/connectors/insee/index.ts | 3 + 8 files changed, 157 insertions(+) create mode 100644 packages/core/src/connectors/get-insee-access-token.test.ts create mode 100644 packages/core/src/connectors/get-insee-access-token.ts create mode 100644 packages/core/src/connectors/index.ts create mode 100644 packages/core/src/connectors/insee/get-access-token.test.ts create mode 100644 packages/core/src/connectors/insee/get-access-token.ts create mode 100644 packages/core/src/connectors/insee/index.ts diff --git a/package-lock.json b/package-lock.json index 1e45c348..1f718415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10393,6 +10393,7 @@ "license": "MIT", "dependencies": { "@zootools/email-spell-checker": "^1.12.0", + "axios": "^1.7.7", "is-disposable-email-domain": "^1.0.7", "lodash-es": "^4.17.21", "nanoid": "^5.0.9", diff --git a/packages/core/package.json b/packages/core/package.json index 18c28781..c28a2a0f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@zootools/email-spell-checker": "^1.12.0", + "axios": "^1.7.7", "is-disposable-email-domain": "^1.0.7", "lodash-es": "^4.17.21", "nanoid": "^5.0.9", diff --git a/packages/core/src/connectors/get-insee-access-token.test.ts b/packages/core/src/connectors/get-insee-access-token.test.ts new file mode 100644 index 00000000..80727d02 --- /dev/null +++ b/packages/core/src/connectors/get-insee-access-token.test.ts @@ -0,0 +1,43 @@ +// + +import axios, { type AxiosResponse } from "axios"; + +// + +type GetInseeAccessTokenCradle = { + fetch: typeof globalThis.fetch; + username: string; + password: string; + timeout: number; +}; + +type GetTokenReponse = { + access_token: string; + scope: "am_application_scope default"; + token_type: "Bearer"; + expires_in: number; +}; + +// + +export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { + const { username, password, timeout } = cradle; + return async function getInseeAccessToken() { + const { + data: { access_token }, + }: AxiosResponse = await axios.post( + "https://api.insee.fr/token", + "grant_type=client_credentials", + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + auth: { + username, + password, + }, + timeout, + }, + ); + + return access_token; + }; +} diff --git a/packages/core/src/connectors/get-insee-access-token.ts b/packages/core/src/connectors/get-insee-access-token.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts new file mode 100644 index 00000000..ca94dd9a --- /dev/null +++ b/packages/core/src/connectors/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./get-insee-access-token.js"; diff --git a/packages/core/src/connectors/insee/get-access-token.test.ts b/packages/core/src/connectors/insee/get-access-token.test.ts new file mode 100644 index 00000000..bab11728 --- /dev/null +++ b/packages/core/src/connectors/insee/get-access-token.test.ts @@ -0,0 +1,63 @@ +// + +import axios, { type AxiosResponse } from "axios"; + +// + +type GetInseeAccessTokenCradle = { + fetch: typeof globalThis.fetch; + username: string; + password: string; + timeout: number; +}; + +type GetTokenReponse = { + access_token: string; + scope: "am_application_scope default"; + token_type: "Bearer"; + expires_in: number; +}; + +// + +type Credentials = { + username: string; + password: string; + timeout: number; +}; + +function createInseeTokenRequest(credentials: Credentials) { + const { username, password, timeout } = credentials; + + return { + url: "https://api.insee.fr/token", + body: "grant_type=client_credentials", + config: { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + auth: { username, password }, + timeout, + }, + }; +} + +export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { + const { username, password, timeout } = cradle; + return async function getInseeAccessToken() { + const { + data: { access_token }, + }: AxiosResponse = await axios.post( + "https://api.insee.fr/token", + "grant_type=client_credentials", + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + auth: { + username, + password, + }, + timeout, + }, + ); + + return access_token; + }; +} diff --git a/packages/core/src/connectors/insee/get-access-token.ts b/packages/core/src/connectors/insee/get-access-token.ts new file mode 100644 index 00000000..80727d02 --- /dev/null +++ b/packages/core/src/connectors/insee/get-access-token.ts @@ -0,0 +1,43 @@ +// + +import axios, { type AxiosResponse } from "axios"; + +// + +type GetInseeAccessTokenCradle = { + fetch: typeof globalThis.fetch; + username: string; + password: string; + timeout: number; +}; + +type GetTokenReponse = { + access_token: string; + scope: "am_application_scope default"; + token_type: "Bearer"; + expires_in: number; +}; + +// + +export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { + const { username, password, timeout } = cradle; + return async function getInseeAccessToken() { + const { + data: { access_token }, + }: AxiosResponse = await axios.post( + "https://api.insee.fr/token", + "grant_type=client_credentials", + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + auth: { + username, + password, + }, + timeout, + }, + ); + + return access_token; + }; +} diff --git a/packages/core/src/connectors/insee/index.ts b/packages/core/src/connectors/insee/index.ts new file mode 100644 index 00000000..6497cf60 --- /dev/null +++ b/packages/core/src/connectors/insee/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./get-access-token.js"; From ac7b135fef6a1e515d68fc60ca84101f94377492 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Mon, 16 Dec 2024 20:10:51 +0100 Subject: [PATCH 3/6] refactor(insee): extract insee connector to individual pkg --- .../1545154190802_create-users-table.cjs | 8 +- .../1623752868625_add-moderation-table.cjs | 2 + .../1633705667117_add-new-moderation-type.cjs | 2 + migrations/1702600151114_add-webauthn.cjs | 3 + package-lock.json | 74 +++++++++++++++++-- package.json | 6 +- .../src/connectors/get-insee-access-token.ts | 0 .../connectors/insee/get-access-token.test.ts | 63 ---------------- .../src/connectors/insee/get-access-token.ts | 43 ----------- packages/core/src/connectors/insee/index.ts | 3 - packages/identite/package.json | 64 ++++++++++++++++ packages/identite/src/types/contexts.ts | 9 +++ packages/identite/src/types/index.ts | 4 + .../identite/src/types/user.ts | 40 +++++----- .../identite/src/user/find-by-email.test.ts | 69 +++++++++++++++++ packages/identite/src/user/find-by-email.ts | 20 +++++ packages/identite/src/user/index.ts | 3 + packages/identite/tsconfig.json | 18 +++++ packages/identite/tsconfig.lib.json | 9 +++ packages/insee/package.json | 63 ++++++++++++++++ .../src/token/get-insee-access-token.test.ts | 26 +++++++ .../src/token/get-insee-access-token.ts} | 25 +++---- .../connectors => insee/src/token}/index.ts | 0 packages/insee/tsconfig.json | 18 +++++ packages/insee/tsconfig.lib.json | 9 +++ scripts/import-accounts-coop.ts | 32 ++++++-- src/connectors/api-sirene/index.ts | 35 +++------ src/managers/moderation.ts | 1 + src/managers/session/authenticated.ts | 2 +- src/managers/user.ts | 1 + src/repositories/organization/getters.ts | 1 + src/repositories/organization/setters.ts | 1 + src/repositories/user.ts | 2 +- 33 files changed, 470 insertions(+), 186 deletions(-) delete mode 100644 packages/core/src/connectors/get-insee-access-token.ts delete mode 100644 packages/core/src/connectors/insee/get-access-token.test.ts delete mode 100644 packages/core/src/connectors/insee/get-access-token.ts delete mode 100644 packages/core/src/connectors/insee/index.ts create mode 100644 packages/identite/package.json create mode 100644 packages/identite/src/types/contexts.ts create mode 100644 packages/identite/src/types/index.ts rename src/types/user.d.ts => packages/identite/src/types/user.ts (96%) create mode 100644 packages/identite/src/user/find-by-email.test.ts create mode 100644 packages/identite/src/user/find-by-email.ts create mode 100644 packages/identite/src/user/index.ts create mode 100644 packages/identite/tsconfig.json create mode 100644 packages/identite/tsconfig.lib.json create mode 100644 packages/insee/package.json create mode 100644 packages/insee/src/token/get-insee-access-token.test.ts rename packages/{core/src/connectors/get-insee-access-token.test.ts => insee/src/token/get-insee-access-token.ts} (55%) rename packages/{core/src/connectors => insee/src/token}/index.ts (100%) create mode 100644 packages/insee/tsconfig.json create mode 100644 packages/insee/tsconfig.lib.json diff --git a/migrations/1545154190802_create-users-table.cjs b/migrations/1545154190802_create-users-table.cjs index 4c7f756c..fcdf8410 100644 --- a/migrations/1545154190802_create-users-table.cjs +++ b/migrations/1545154190802_create-users-table.cjs @@ -1,7 +1,7 @@ exports.shorthands = undefined; exports.up = async (pgm) => { - await pgm.db.query(` + await pgm.db.query(` CREATE TABLE users ( id serial NOT NULL, email character varying DEFAULT ''::character varying NOT NULL, @@ -21,16 +21,16 @@ CREATE TABLE users ( type character varying );`); - await pgm.db.query(` + await pgm.db.query(` ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); `); await pgm.db.query(` -CREATE UNIQUE INDEX index_users_on_email ON users USING btree (email); +CREATE UNIQUE INDEX index_users_on_email ON users USING btree (email); `); - await pgm.db.query(` + await pgm.db.query(` CREATE UNIQUE INDEX index_users_on_reset_password_token ON users USING btree (reset_password_token); `); diff --git a/migrations/1623752868625_add-moderation-table.cjs b/migrations/1623752868625_add-moderation-table.cjs index 2b84c5de..18e3b1e1 100644 --- a/migrations/1623752868625_add-moderation-table.cjs +++ b/migrations/1623752868625_add-moderation-table.cjs @@ -3,6 +3,8 @@ exports.shorthands = undefined; exports.up = async (pgm) => { await pgm.db.query(` CREATE TYPE moderation_type AS ENUM('organization_join_block'); + `); + await pgm.db.query(` CREATE TABLE moderations ( id serial, user_id int NOT NULL, diff --git a/migrations/1633705667117_add-new-moderation-type.cjs b/migrations/1633705667117_add-new-moderation-type.cjs index a73141af..ed060d6c 100644 --- a/migrations/1633705667117_add-new-moderation-type.cjs +++ b/migrations/1633705667117_add-new-moderation-type.cjs @@ -4,6 +4,8 @@ exports.up = async (pgm) => { await pgm.db.query(` ALTER TABLE moderations ALTER COLUMN type TYPE character varying; + `); + await pgm.db.query(` DROP TYPE moderation_type; `); }; diff --git a/migrations/1702600151114_add-webauthn.cjs b/migrations/1702600151114_add-webauthn.cjs index ee963c71..52881469 100644 --- a/migrations/1702600151114_add-webauthn.cjs +++ b/migrations/1702600151114_add-webauthn.cjs @@ -16,6 +16,9 @@ exports.up = async (pgm) => { REFERENCES users (id) ON DELETE CASCADE ); + `); + + await pgm.db.query(` CREATE UNIQUE INDEX index_authenticators_on_credential_id ON authenticators USING btree (credential_id); `); diff --git a/package-lock.json b/package-lock.json index 1f718415..a1c5b096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@gouvfr-lasuite/crisp": "https://github.com/douglasduteil/crisp/releases/download/v1.6.1/douglasduteil-crisp-1.6.1.tgz", "@gouvfr-lasuite/proconnect.core": "workspace:*", "@gouvfr-lasuite/proconnect.email": "workspace:*", + "@gouvfr-lasuite/proconnect.identite": "workspace:*", + "@gouvfr-lasuite/proconnect.insee": "workspace:*", "@gouvfr/dsfr": "^1.12.1", "@kitajs/html": "^4.2.4", "@kitajs/ts-html-plugin": "^4.1.0", @@ -877,6 +879,13 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.2.15.tgz", + "integrity": "sha512-Jiq31Dnk+rg8rMhcSxs4lQvHTyizNo5b269c1gCC3ldQ0sCLrNVPGzy+KnmonKy1ZArTUuXZf23/UamzFMKVaA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -1288,6 +1297,14 @@ "resolved": "packages/email", "link": true }, + "node_modules/@gouvfr-lasuite/proconnect.identite": { + "resolved": "packages/identite", + "link": true + }, + "node_modules/@gouvfr-lasuite/proconnect.insee": { + "resolved": "packages/insee", + "link": true + }, "node_modules/@gouvfr/dsfr": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@gouvfr/dsfr/-/dsfr-1.12.1.tgz", @@ -7138,10 +7155,11 @@ } }, "node_modules/nock": { - "version": "13.5.4", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.4.tgz", - "integrity": "sha512-yAyTfdeNJGGBFxWdzSKCBYxs5FxLbCg5X5Q4ets974hcQzG1+qCxvIyOo4j2Ry6MUlhWVMX4OoYDefAIIwupjw==", + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", @@ -10441,15 +10459,55 @@ "vite": "^5.4.8" } }, - "packages/free-email": { - "name": "@gouvfr-lasuite/moncomptepro.free-email", - "version": "0.0.0", + "packages/identite": { + "name": "@gouvfr-lasuite/proconnect.identite", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "@electric-sql/pglite": "^0.2.15", + "@gouvfr-lasuite/proconnect.core": "^0.2.0", + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "node-pg-migrate": "^7.6.1", + "pg": "^8.13.0", + "tsx": "^4.19.2" + } + }, + "packages/identity-repository": { + "name": "@gouvfr-lasuite/proconnect.identity-repository", + "version": "0.2.0", "extraneous": true, + "license": "MIT", + "devDependencies": { + "@electric-sql/pglite": "^0.2.15", + "@gouvfr-lasuite/proconnect.core": "^0.2.0", + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "pg": "^8.13.0", + "tsx": "^4.19.2" + } + }, + "packages/insee": { + "name": "@gouvfr-lasuite/proconnect.insee", + "version": "0.2.0", + "license": "MIT", "dependencies": { - "is-disposable-email-domain": "^1.0.7" + "axios": "^1.7.7" }, "devDependencies": { - "@tsconfig/node22": "^22.0.0" + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "nock": "^13.5.6", + "tsx": "^4.19.2" } } } diff --git a/package.json b/package.json index 2e12560b..0c70c809 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "watch:js": "tsc --watch --preserveWatchOutput", "watch:node": "tsx --watch src/index.ts", "watch:workspaces:core": "npm run dev --if-present --workspace=@gouvfr-lasuite/proconnect.core", - "watch:workspaces:email": "npm run dev --if-present --workspace=@gouvfr-lasuite/proconnect.email" + "watch:workspaces:email": "npm run dev --if-present --workspace=@gouvfr-lasuite/proconnect.email", + "watch:workspaces:insee": "npm run dev --if-present --workspace=@gouvfr-lasuite/proconnect.insee", + "watch:workspaces:identite": "npm run dev --if-present --workspace=@gouvfr-lasuite/proconnect.identite" }, "prettier": { "plugins": [ @@ -51,6 +53,8 @@ "@gouvfr-lasuite/crisp": "https://github.com/douglasduteil/crisp/releases/download/v1.6.1/douglasduteil-crisp-1.6.1.tgz", "@gouvfr-lasuite/proconnect.core": "workspace:*", "@gouvfr-lasuite/proconnect.email": "workspace:*", + "@gouvfr-lasuite/proconnect.identite": "workspace:*", + "@gouvfr-lasuite/proconnect.insee": "workspace:*", "@gouvfr/dsfr": "^1.12.1", "@kitajs/html": "^4.2.4", "@kitajs/ts-html-plugin": "^4.1.0", diff --git a/packages/core/src/connectors/get-insee-access-token.ts b/packages/core/src/connectors/get-insee-access-token.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/core/src/connectors/insee/get-access-token.test.ts b/packages/core/src/connectors/insee/get-access-token.test.ts deleted file mode 100644 index bab11728..00000000 --- a/packages/core/src/connectors/insee/get-access-token.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// - -import axios, { type AxiosResponse } from "axios"; - -// - -type GetInseeAccessTokenCradle = { - fetch: typeof globalThis.fetch; - username: string; - password: string; - timeout: number; -}; - -type GetTokenReponse = { - access_token: string; - scope: "am_application_scope default"; - token_type: "Bearer"; - expires_in: number; -}; - -// - -type Credentials = { - username: string; - password: string; - timeout: number; -}; - -function createInseeTokenRequest(credentials: Credentials) { - const { username, password, timeout } = credentials; - - return { - url: "https://api.insee.fr/token", - body: "grant_type=client_credentials", - config: { - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - auth: { username, password }, - timeout, - }, - }; -} - -export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { - const { username, password, timeout } = cradle; - return async function getInseeAccessToken() { - const { - data: { access_token }, - }: AxiosResponse = await axios.post( - "https://api.insee.fr/token", - "grant_type=client_credentials", - { - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - auth: { - username, - password, - }, - timeout, - }, - ); - - return access_token; - }; -} diff --git a/packages/core/src/connectors/insee/get-access-token.ts b/packages/core/src/connectors/insee/get-access-token.ts deleted file mode 100644 index 80727d02..00000000 --- a/packages/core/src/connectors/insee/get-access-token.ts +++ /dev/null @@ -1,43 +0,0 @@ -// - -import axios, { type AxiosResponse } from "axios"; - -// - -type GetInseeAccessTokenCradle = { - fetch: typeof globalThis.fetch; - username: string; - password: string; - timeout: number; -}; - -type GetTokenReponse = { - access_token: string; - scope: "am_application_scope default"; - token_type: "Bearer"; - expires_in: number; -}; - -// - -export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { - const { username, password, timeout } = cradle; - return async function getInseeAccessToken() { - const { - data: { access_token }, - }: AxiosResponse = await axios.post( - "https://api.insee.fr/token", - "grant_type=client_credentials", - { - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - auth: { - username, - password, - }, - timeout, - }, - ); - - return access_token; - }; -} diff --git a/packages/core/src/connectors/insee/index.ts b/packages/core/src/connectors/insee/index.ts deleted file mode 100644 index 6497cf60..00000000 --- a/packages/core/src/connectors/insee/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// - -export * from "./get-access-token.js"; diff --git a/packages/identite/package.json b/packages/identite/package.json new file mode 100644 index 00000000..e13963a4 --- /dev/null +++ b/packages/identite/package.json @@ -0,0 +1,64 @@ +{ + "name": "@gouvfr-lasuite/proconnect.identite", + "version": "0.2.0", + "homepage": "https://github.com/numerique-gouv/moncomptepro/tree/master/packages/identite#readme", + "bugs": "https://github.com/numerique-gouv/moncomptepro/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/numerique-gouv/moncomptepro.git", + "directory": "packages/identite" + }, + "license": "MIT", + "sideEffects": false, + "type": "module", + "imports": { + "#src/*": { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + } + }, + "exports": { + "./*": { + "require": { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + "import": { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.lib.json", + "check": "npm run build -- --noEmit", + "dev": "npm run build -- --watch --preserveWatchOutput", + "test": "mocha" + }, + "mocha": { + "reporter": "spec", + "require": [ + "tsx" + ], + "spec": "src/**/*.test.ts" + }, + "dependencies": {}, + "devDependencies": { + "@electric-sql/pglite": "^0.2.15", + "@gouvfr-lasuite/proconnect.core": "^0.2.0", + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "node-pg-migrate": "^7.6.1", + "pg": "^8.13.0", + "tsx": "^4.19.2" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/identite/src/types/contexts.ts b/packages/identite/src/types/contexts.ts new file mode 100644 index 00000000..c55b4067 --- /dev/null +++ b/packages/identite/src/types/contexts.ts @@ -0,0 +1,9 @@ +// + +import Pg from "pg"; + +// + +export type DatabaseContext = { + pg: Pg.Pool; +}; diff --git a/packages/identite/src/types/index.ts b/packages/identite/src/types/index.ts new file mode 100644 index 00000000..3ff3a81a --- /dev/null +++ b/packages/identite/src/types/index.ts @@ -0,0 +1,4 @@ +// + +export * from "./contexts.js"; +export * from "./user.js"; diff --git a/src/types/user.d.ts b/packages/identite/src/types/user.ts similarity index 96% rename from src/types/user.d.ts rename to packages/identite/src/types/user.ts index f927ce2c..683b91f1 100644 --- a/src/types/user.d.ts +++ b/packages/identite/src/types/user.ts @@ -1,27 +1,29 @@ -interface User { - id: number; - email: string; - encrypted_password: string | null; - reset_password_token: string | null; - reset_password_sent_at: Date | null; - sign_in_count: number; - last_sign_in_at: Date | null; +// + +export interface User { created_at: Date; - updated_at: Date; + current_challenge: string | null; + email_verified_at: Date | null; email_verified: boolean; - verify_email_token: string | null; - verify_email_sent_at: Date | null; - given_name: string | null; + email: string; + encrypted_password: string | null; + encrypted_totp_key: string | null; family_name: string | null; - phone_number: string | null; + force_2fa: boolean; + given_name: string | null; + id: number; job: string | null; - magic_link_token: string | null; + last_sign_in_at: Date | null; magic_link_sent_at: Date | null; - email_verified_at: Date | null; - current_challenge: string | null; - needs_inclusionconnect_welcome_page: boolean; + magic_link_token: string | null; needs_inclusionconnect_onboarding_help: boolean; - encrypted_totp_key: string | null; + needs_inclusionconnect_welcome_page: boolean; + phone_number: string | null; + reset_password_sent_at: Date | null; + reset_password_token: string | null; + sign_in_count: number; totp_key_verified_at: Date | null; - force_2fa: boolean; + updated_at: Date; + verify_email_sent_at: Date | null; + verify_email_token: string | null; } diff --git a/packages/identite/src/user/find-by-email.test.ts b/packages/identite/src/user/find-by-email.test.ts new file mode 100644 index 00000000..8e449a9e --- /dev/null +++ b/packages/identite/src/user/find-by-email.test.ts @@ -0,0 +1,69 @@ +// + +import { PGlite } from "@electric-sql/pglite"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; +import { findByEmailFactory } from "./find-by-email.js"; + +// + +const pg = new PGlite(); +const findByEmail = findByEmailFactory({ pg: pg as any }); + +before(async function migrate() { + await runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../../migrations"), + direction: "up", + migrationsTable: "pg-migrate", + }); +}); + +describe("FindByEmail", () => { + it("should find a user by email", async () => { + await pg.sql`INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'I', 'Primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'IV', 'Primarque'); + `; + + const user = await findByEmail("lion.eljonson@darkangels.world"); + + expect(user).to.deep.equal({ + created_at: new Date("4444-04-04T00:00:00.000Z"), + current_challenge: null, + email_verified_at: null, + email_verified: false, + email: "lion.eljonson@darkangels.world", + encrypted_password: "", + encrypted_totp_key: null, + family_name: "El'Jonson", + force_2fa: false, + given_name: "Lion", + id: 1, + job: "Primarque", + last_sign_in_at: null, + magic_link_sent_at: null, + magic_link_token: null, + needs_inclusionconnect_onboarding_help: false, + needs_inclusionconnect_welcome_page: false, + phone_number: "I", + reset_password_sent_at: null, + reset_password_token: null, + sign_in_count: 0, + totp_key_verified_at: null, + updated_at: new Date("4444-04-04T00:00:00.000Z"), + verify_email_sent_at: null, + verify_email_token: null, + }); + }); + + it("❎ fail to find the God-Emperor of Mankind", async () => { + const user = await findByEmail("the God-Emperor of Mankind"); + + expect(user).to.be.undefined; + }); +}); diff --git a/packages/identite/src/user/find-by-email.ts b/packages/identite/src/user/find-by-email.ts new file mode 100644 index 00000000..75ba3e85 --- /dev/null +++ b/packages/identite/src/user/find-by-email.ts @@ -0,0 +1,20 @@ +// + +import type { DatabaseContext, User } from "#src/types"; +import { type QueryResult } from "pg"; + +// + +export function findByEmailFactory({ pg }: DatabaseContext) { + return async function findByEmail(email: string) { + const { rows }: QueryResult = await pg.query( + ` + SELECT * + FROM users WHERE email = $1 + `, + [email], + ); + + return rows.shift(); + }; +} diff --git a/packages/identite/src/user/index.ts b/packages/identite/src/user/index.ts new file mode 100644 index 00000000..843716a0 --- /dev/null +++ b/packages/identite/src/user/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./find-by-email.js"; diff --git a/packages/identite/tsconfig.json b/packages/identite/tsconfig.json new file mode 100644 index 00000000..ec2a6345 --- /dev/null +++ b/packages/identite/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "src", + "types": ["node"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "verbatimModuleSyntax": true, + "paths": { + "#src/*": ["./src/*"] + } + }, + "extends": "@tsconfig/node22/tsconfig.json", + "references": [] +} diff --git a/packages/identite/tsconfig.lib.json b/packages/identite/tsconfig.lib.json new file mode 100644 index 00000000..3ba35441 --- /dev/null +++ b/packages/identite/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["src/**/*.test.ts"], + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/packages/insee/package.json b/packages/insee/package.json new file mode 100644 index 00000000..dc9dbf98 --- /dev/null +++ b/packages/insee/package.json @@ -0,0 +1,63 @@ +{ + "name": "@gouvfr-lasuite/proconnect.insee", + "version": "0.2.0", + "homepage": "https://github.com/numerique-gouv/moncomptepro/tree/master/packages/insee#readme", + "bugs": "https://github.com/numerique-gouv/moncomptepro/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/numerique-gouv/moncomptepro.git", + "directory": "packages/insee" + }, + "license": "MIT", + "sideEffects": false, + "type": "module", + "imports": { + "#src/*": { + "types": "./src/*", + "default": "./dist/*" + } + }, + "exports": { + "./*": { + "require": { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + "import": { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.lib.json", + "check": "npm run build -- --noEmit", + "dev": "npm run build -- --watch --preserveWatchOutput", + "test": "mocha" + }, + "mocha": { + "reporter": "spec", + "require": [ + "tsx" + ], + "spec": "src/**/*.test.ts" + }, + "dependencies": { + "axios": "^1.7.7" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "nock": "^13.5.6", + "tsx": "^4.19.2" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/insee/src/token/get-insee-access-token.test.ts b/packages/insee/src/token/get-insee-access-token.test.ts new file mode 100644 index 00000000..c1b42aa7 --- /dev/null +++ b/packages/insee/src/token/get-insee-access-token.test.ts @@ -0,0 +1,26 @@ +// + +import { expect } from "chai"; +import { describe, it } from "mocha"; +import nock from "nock"; +import { getInseeAccessTokenFactory } from "./get-insee-access-token.js"; + +// + +const getInseeAccessToken = getInseeAccessTokenFactory({ + consumerKey: "🔑", + consumerSecret: "㊙️", +}); + +describe("GetInseeAccessToken", () => { + it("should return 🛂 access token", async () => { + nock("https://api.insee.fr").post("/token").reply(200, { + access_token: "🛂", + scope: "am_application_scope default", + token_type: "Bearer", + expires_in: 123456, + }); + const access_token = await getInseeAccessToken(); + expect(access_token).to.be.equal("🛂"); + }); +}); diff --git a/packages/core/src/connectors/get-insee-access-token.test.ts b/packages/insee/src/token/get-insee-access-token.ts similarity index 55% rename from packages/core/src/connectors/get-insee-access-token.test.ts rename to packages/insee/src/token/get-insee-access-token.ts index 80727d02..6f4ec0ff 100644 --- a/packages/core/src/connectors/get-insee-access-token.test.ts +++ b/packages/insee/src/token/get-insee-access-token.ts @@ -1,17 +1,14 @@ // -import axios, { type AxiosResponse } from "axios"; +import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; // -type GetInseeAccessTokenCradle = { - fetch: typeof globalThis.fetch; - username: string; - password: string; - timeout: number; +export type InseeCredentials = { + consumerKey: string; + consumerSecret: string; }; - -type GetTokenReponse = { +export type GetTokenReponse = { access_token: string; scope: "am_application_scope default"; token_type: "Bearer"; @@ -20,8 +17,10 @@ type GetTokenReponse = { // -export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { - const { username, password, timeout } = cradle; +export function getInseeAccessTokenFactory( + credentials: InseeCredentials, + config?: AxiosRequestConfig, +) { return async function getInseeAccessToken() { const { data: { access_token }, @@ -31,10 +30,10 @@ export function GetInseeAccessToken(cradle: GetInseeAccessTokenCradle) { { headers: { "Content-Type": "application/x-www-form-urlencoded" }, auth: { - username, - password, + username: credentials.consumerKey, + password: credentials.consumerSecret, }, - timeout, + ...config, }, ); diff --git a/packages/core/src/connectors/index.ts b/packages/insee/src/token/index.ts similarity index 100% rename from packages/core/src/connectors/index.ts rename to packages/insee/src/token/index.ts diff --git a/packages/insee/tsconfig.json b/packages/insee/tsconfig.json new file mode 100644 index 00000000..ec2a6345 --- /dev/null +++ b/packages/insee/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "src", + "types": ["node"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "verbatimModuleSyntax": true, + "paths": { + "#src/*": ["./src/*"] + } + }, + "extends": "@tsconfig/node22/tsconfig.json", + "references": [] +} diff --git a/packages/insee/tsconfig.lib.json b/packages/insee/tsconfig.lib.json new file mode 100644 index 00000000..3ba35441 --- /dev/null +++ b/packages/insee/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["src/**/*.test.ts"], + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/scripts/import-accounts-coop.ts b/scripts/import-accounts-coop.ts index ab284b79..5b0988bf 100644 --- a/scripts/import-accounts-coop.ts +++ b/scripts/import-accounts-coop.ts @@ -5,21 +5,26 @@ import { isPhoneNumberValid, isSiretValid, } from "@gouvfr-lasuite/proconnect.core/security"; +import { findByEmailFactory } from "@gouvfr-lasuite/proconnect.identite/user"; +import { getInseeAccessTokenFactory } from "@gouvfr-lasuite/proconnect.insee/token"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; import { isEmpty, isString, some, toInteger } from "lodash-es"; import { z } from "zod"; import { - getInseeAccessToken, - getOrganizationInfo, -} from "../src/connectors/api-sirene"; + HTTP_CLIENT_TIMEOUT, + INSEE_CONSUMER_KEY, + INSEE_CONSUMER_SECRET, +} from "../src/config/env"; +import { getOrganizationInfo } from "../src/connectors/api-sirene"; +import { getDatabaseConnection } from "../src/connectors/postgres"; import { findByUserId } from "../src/repositories/organization/getters"; import { linkUserToOrganization, upsert, } from "../src/repositories/organization/setters"; -import { create, findByEmail, update } from "../src/repositories/user"; +import { create, update } from "../src/repositories/user"; import { logger } from "../src/services/log"; import { getNumberOfLineInFile, @@ -29,6 +34,23 @@ import { throttleApiCall, } from "../src/services/script-helpers"; +// + +const getInseeAccessToken = getInseeAccessTokenFactory( + { + consumerKey: INSEE_CONSUMER_KEY, + consumerSecret: INSEE_CONSUMER_SECRET, + }, + { + timeout: HTTP_CLIENT_TIMEOUT, + }, +); + +const pg = getDatabaseConnection(); +const findByEmail = findByEmailFactory({ pg }); + +// + const { INPUT_FILE, OUTPUT_FILE } = z .object({ INPUT_FILE: z.string().default("./input.csv"), @@ -107,10 +129,10 @@ const maxInseeCallRateInMs = rateInMsFromArgs !== 0 ? rateInMsFromArgs : 125; const start = startDurationMesure(); try { const { + coordinateur, prenom: given_name, nom: family_name, téléphone: phone_number, - coordinateur, "email professionnel secondaire": professional_email, "SIRET structure": siret, } = data; diff --git a/src/connectors/api-sirene/index.ts b/src/connectors/api-sirene/index.ts index a2539e05..1e357591 100644 --- a/src/connectors/api-sirene/index.ts +++ b/src/connectors/api-sirene/index.ts @@ -1,3 +1,4 @@ +import { getInseeAccessTokenFactory } from "@gouvfr-lasuite/proconnect.insee/token"; import axios, { AxiosError, type AxiosResponse } from "axios"; import { cloneDeep, set } from "lodash-es"; import { @@ -168,13 +169,6 @@ type EtablissementSearchResponse = { etablissements: InseeEtablissement[]; }; -type GetTokenReponse = { - access_token: string; - scope: "am_application_scope default"; - token_type: "Bearer"; - expires_in: number; -}; - const hideNonDiffusibleData = ( etablissement: EtablissementSearchBySiretResponse["etablissement"], ): EtablissementSearchBySiretResponse["etablissement"] => { @@ -264,24 +258,15 @@ const hideNonDiffusibleData = ( return hiddenEtablissement; }; -export const getInseeAccessToken = async () => { - const { - data: { access_token }, - }: AxiosResponse = await axios.post( - "https://api.insee.fr/token", - "grant_type=client_credentials", - { - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - auth: { - username: INSEE_CONSUMER_KEY!, - password: INSEE_CONSUMER_SECRET!, - }, - timeout: HTTP_CLIENT_TIMEOUT, - }, - ); - - return access_token; -}; +export const getInseeAccessToken = getInseeAccessTokenFactory( + { + consumerKey: INSEE_CONSUMER_KEY, + consumerSecret: INSEE_CONSUMER_SECRET, + }, + { + timeout: HTTP_CLIENT_TIMEOUT, + }, +); export const getOrganizationInfo = async ( siretOrSiren: string, diff --git a/src/managers/moderation.ts b/src/managers/moderation.ts index ff898428..239add3e 100644 --- a/src/managers/moderation.ts +++ b/src/managers/moderation.ts @@ -1,4 +1,5 @@ import { ModerationProcessed } from "@gouvfr-lasuite/proconnect.email"; +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty } from "lodash-es"; import { HOST } from "../config/env"; import { ForbiddenError, NotFoundError } from "../config/errors"; diff --git a/src/managers/session/authenticated.ts b/src/managers/session/authenticated.ts index 13062eb0..cac74906 100644 --- a/src/managers/session/authenticated.ts +++ b/src/managers/session/authenticated.ts @@ -1,3 +1,4 @@ +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import * as Sentry from "@sentry/node"; import type { Request, Response } from "express"; import { Session, type SessionData } from "express-session"; @@ -24,7 +25,6 @@ import { setBrowserAsTrustedForUser, setIsTrustedBrowserFromLoggedInSession, } from "../browser-authentication"; - export const isWithinAuthenticatedSession = ( session: Session & Partial, ): session is Session & Partial & AuthenticatedSessionData => { diff --git a/src/managers/user.ts b/src/managers/user.ts index 82102488..91865ef5 100644 --- a/src/managers/user.ts +++ b/src/managers/user.ts @@ -16,6 +16,7 @@ import { UpdateTotpApplication, VerifyEmail, } from "@gouvfr-lasuite/proconnect.email"; +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty } from "lodash-es"; import { HOST, diff --git a/src/repositories/organization/getters.ts b/src/repositories/organization/getters.ts index e7a9abb3..fbb2c8df 100644 --- a/src/repositories/organization/getters.ts +++ b/src/repositories/organization/getters.ts @@ -1,3 +1,4 @@ +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../../connectors/postgres"; diff --git a/src/repositories/organization/setters.ts b/src/repositories/organization/setters.ts index 9c5daf97..88b90631 100644 --- a/src/repositories/organization/setters.ts +++ b/src/repositories/organization/setters.ts @@ -1,3 +1,4 @@ +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../../connectors/postgres"; import { hashToPostgresParams } from "../../services/hash-to-postgres-params"; diff --git a/src/repositories/user.ts b/src/repositories/user.ts index 55254512..78a6a8f8 100644 --- a/src/repositories/user.ts +++ b/src/repositories/user.ts @@ -1,7 +1,7 @@ +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../connectors/postgres"; import { hashToPostgresParams } from "../services/hash-to-postgres-params"; - export const findById = async (id: number) => { const connection = getDatabaseConnection(); From afbb33f8a7716d586cc0c4036834e98680377200 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Wed, 18 Dec 2024 15:54:17 +0100 Subject: [PATCH 4/6] SAVE --- package-lock.json | 12 ++ packages/identite/package.json | 5 +- packages/identite/src/organization/index.ts | 3 + packages/identite/src/organization/upsert.ts | 140 ++++++++++++++++++ .../identite/src/organization/upset.test.ts | 54 +++++++ .../src/services/hash-to-postgres-params.ts | 34 +++++ packages/identite/src/services/index.ts | 3 + packages/identite/src/types/index.ts | 3 + .../identite/src/types/organization-info.ts | 26 ++++ packages/identite/src/types/organization.ts | 30 ++++ .../identite/src/types/tranche-effectifs.ts | 23 +-- packages/identite/src/user/create.test.ts | 31 ++++ packages/identite/src/user/create.ts | 42 ++++++ .../identite/src/user/find-by-email.test.ts | 38 +---- packages/identite/src/user/index.ts | 2 + packages/identite/src/user/update.test.ts | 36 +++++ packages/identite/src/user/update.ts | 29 ++++ scripts/import-accounts-coop.ts | 9 +- scripts/import-domains.ts | 1 + src/connectors/api-sirene/codes-effectifs.ts | 2 + src/connectors/api-sirene/index.ts | 4 + src/managers/organization/join.ts | 4 + src/managers/organization/main.ts | 1 + src/repositories/organization/getters.ts | 5 +- src/repositories/organization/setters.ts | 131 +--------------- src/repositories/user.ts | 78 +--------- src/services/organization.ts | 1 + src/services/script-helpers.ts | 1 + src/types/organization.d.ts | 24 --- test/organization.test.ts | 1 + 30 files changed, 493 insertions(+), 280 deletions(-) create mode 100644 packages/identite/src/organization/index.ts create mode 100644 packages/identite/src/organization/upsert.ts create mode 100644 packages/identite/src/organization/upset.test.ts create mode 100644 packages/identite/src/services/hash-to-postgres-params.ts create mode 100644 packages/identite/src/services/index.ts create mode 100644 packages/identite/src/types/organization-info.ts create mode 100644 packages/identite/src/types/organization.ts rename src/types/organization-info.d.ts => packages/identite/src/types/tranche-effectifs.ts (62%) create mode 100644 packages/identite/src/user/create.test.ts create mode 100644 packages/identite/src/user/create.ts create mode 100644 packages/identite/src/user/update.test.ts create mode 100644 packages/identite/src/user/update.ts diff --git a/package-lock.json b/package-lock.json index a1c5b096..f6acfb32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9158,6 +9158,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sql-template-tag": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/sql-template-tag/-/sql-template-tag-5.2.1.tgz", + "integrity": "sha512-lFdvXCOqWhV40A7w4oQVDyuaNFb5yO+dhsHStZzOdtDJWCBWYv4+hhATK5nPpY5v/T1OMVcLMPeN4519qIyb9Q==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -10463,6 +10472,9 @@ "name": "@gouvfr-lasuite/proconnect.identite", "version": "0.2.0", "license": "MIT", + "dependencies": { + "sql-template-tag": "^5.2.1" + }, "devDependencies": { "@electric-sql/pglite": "^0.2.15", "@gouvfr-lasuite/proconnect.core": "^0.2.0", diff --git a/packages/identite/package.json b/packages/identite/package.json index e13963a4..076fcc36 100644 --- a/packages/identite/package.json +++ b/packages/identite/package.json @@ -33,6 +33,7 @@ }, "scripts": { "build": "tsc --project tsconfig.lib.json", + "clean": "rm -rf dist tsconfig*.tsbuildinfo", "check": "npm run build -- --noEmit", "dev": "npm run build -- --watch --preserveWatchOutput", "test": "mocha" @@ -44,7 +45,9 @@ ], "spec": "src/**/*.test.ts" }, - "dependencies": {}, + "dependencies": { + "sql-template-tag": "^5.2.1" + }, "devDependencies": { "@electric-sql/pglite": "^0.2.15", "@gouvfr-lasuite/proconnect.core": "^0.2.0", diff --git a/packages/identite/src/organization/index.ts b/packages/identite/src/organization/index.ts new file mode 100644 index 00000000..8ed86e54 --- /dev/null +++ b/packages/identite/src/organization/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./upsert.js"; diff --git a/packages/identite/src/organization/upsert.ts b/packages/identite/src/organization/upsert.ts new file mode 100644 index 00000000..a42c8054 --- /dev/null +++ b/packages/identite/src/organization/upsert.ts @@ -0,0 +1,140 @@ +// + +import type { + DatabaseContext, + Organization, + OrganizationInfo, +} from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function upsertFactory({ pg }: DatabaseContext) { + return async function upsert({ + siret, + organizationInfo: { + libelle: cached_libelle, + nomComplet: cached_nom_complet, + enseigne: cached_enseigne, + trancheEffectifs: cached_tranche_effectifs, + trancheEffectifsUniteLegale: cached_tranche_effectifs_unite_legale, + libelleTrancheEffectif: cached_libelle_tranche_effectif, + etatAdministratif: cached_etat_administratif, + estActive: cached_est_active, + statutDiffusion: cached_statut_diffusion, + estDiffusible: cached_est_diffusible, + adresse: cached_adresse, + codePostal: cached_code_postal, + codeOfficielGeographique: cached_code_officiel_geographique, + activitePrincipale: cached_activite_principale, + libelleActivitePrincipale: cached_libelle_activite_principale, + categorieJuridique: cached_categorie_juridique, + libelleCategorieJuridique: cached_libelle_categorie_juridique, + }, + }: { + siret: string; + organizationInfo: OrganizationInfo; + }) { + const { rows }: QueryResult = await pg.query( + ` + INSERT INTO organizations + ( + siret, + cached_libelle, + cached_nom_complet, + cached_enseigne, + cached_tranche_effectifs, + cached_tranche_effectifs_unite_legale, + cached_libelle_tranche_effectif, + cached_etat_administratif, + cached_est_active, + cached_statut_diffusion, + cached_est_diffusible, + cached_adresse, + cached_code_postal, + cached_code_officiel_geographique, + cached_activite_principale, + cached_libelle_activite_principale, + cached_categorie_juridique, + cached_libelle_categorie_juridique, + organization_info_fetched_at, + updated_at, + created_at + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + ON CONFLICT (siret) + DO UPDATE + SET ( + siret, + cached_libelle, + cached_nom_complet, + cached_enseigne, + cached_tranche_effectifs, + cached_tranche_effectifs_unite_legale, + cached_libelle_tranche_effectif, + cached_etat_administratif, + cached_est_active, + cached_statut_diffusion, + cached_est_diffusible, + cached_adresse, + cached_code_postal, + cached_code_officiel_geographique, + cached_activite_principale, + cached_libelle_activite_principale, + cached_categorie_juridique, + cached_libelle_categorie_juridique, + organization_info_fetched_at, + updated_at + ) = ( + EXCLUDED.siret, + EXCLUDED.cached_libelle, + EXCLUDED.cached_nom_complet, + EXCLUDED.cached_enseigne, + EXCLUDED.cached_tranche_effectifs, + EXCLUDED.cached_tranche_effectifs_unite_legale, + EXCLUDED.cached_libelle_tranche_effectif, + EXCLUDED.cached_etat_administratif, + EXCLUDED.cached_est_active, + EXCLUDED.cached_statut_diffusion, + EXCLUDED.cached_est_diffusible, + EXCLUDED.cached_adresse, + EXCLUDED.cached_code_postal, + EXCLUDED.cached_code_officiel_geographique, + EXCLUDED.cached_activite_principale, + EXCLUDED.cached_libelle_activite_principale, + EXCLUDED.cached_categorie_juridique, + EXCLUDED.cached_libelle_categorie_juridique, + EXCLUDED.organization_info_fetched_at, + EXCLUDED.updated_at + ) + RETURNING * + `, + [ + siret, + cached_libelle, + cached_nom_complet, + cached_enseigne, + cached_tranche_effectifs, + cached_tranche_effectifs_unite_legale, + cached_libelle_tranche_effectif, + cached_etat_administratif, + cached_est_active, + cached_statut_diffusion, + cached_est_diffusible, + cached_adresse, + cached_code_postal, + cached_code_officiel_geographique, + cached_activite_principale, + cached_libelle_activite_principale, + cached_categorie_juridique, + cached_libelle_categorie_juridique, + new Date(), + new Date(), + new Date(), + ], + ); + + return rows.shift()!; + }; +} diff --git a/packages/identite/src/organization/upset.test.ts b/packages/identite/src/organization/upset.test.ts new file mode 100644 index 00000000..c440652b --- /dev/null +++ b/packages/identite/src/organization/upset.test.ts @@ -0,0 +1,54 @@ +// + +import { PGlite } from "@electric-sql/pglite"; +import { expect } from "chai"; +import { noop } from "lodash-es"; +import { before, describe, it } from "mocha"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; +import { upsertFactory } from "./upsert.js"; + +// + +const pg = new PGlite(); +const upset = upsertFactory({ pg: pg as any }); + +before(async function migrate() { + await runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../../migrations"), + direction: "up", + migrationsTable: "pg-migrate", + log: noop, + }); +}); + +describe("upset", () => { + it("should create the Tau Empire organization", async () => { + const organization = await upset({ + organizationInfo: { + libelle: "Tau Empire", + nomComplet: "Tau Empire", + } as any, + siret: "👽️", + }); + expect(organization.created_at).to.deep.equal(organization.updated_at); + }); + + it("should update the Necron organization", async () => { + await pg.sql`insert into organizations + (siret, created_at, updated_at) + VALUES + ('⚰️', '1967-12-19', '1967-12-19'); + `; + const organization = await upset({ + organizationInfo: { + libelle: "Necron", + nomComplet: "Necrontyr", + } as any, + siret: "⚰️", + }); + expect(organization.created_at).to.not.deep.equal(organization.updated_at); + expect(organization.cached_libelle).to.equal("Necron"); + }); +}); diff --git a/packages/identite/src/services/hash-to-postgres-params.ts b/packages/identite/src/services/hash-to-postgres-params.ts new file mode 100644 index 00000000..d3f6d570 --- /dev/null +++ b/packages/identite/src/services/hash-to-postgres-params.ts @@ -0,0 +1,34 @@ +// + +import { chain } from "lodash-es"; + +// + +export function hashToPostgresParams(fieldsToUpdate: Partial): { + // postgres column-list syntax + paramsString: string; + // postgres column-list syntax for prepared statement + valuesString: string; + values: any[]; +} { + const paramsString = "(" + Object.keys(fieldsToUpdate).join(", ") + ")"; + // 'email, encrypted_password' + + const valuesString = + "(" + + chain(fieldsToUpdate) + // { email: 'email@xy.z', encrypted_password: 'hash' } + .toPairs() + // [[ 'email', 'email@xy.z'], ['encrypted_password', 'hash' ]] + .map((_value, index) => `$${index + 1}`) + // [ '$1', '$2' ] + .join(", ") + // '$1, $2' + .value() + + ")"; + + const values = Object.values(fieldsToUpdate); + // [ 'email@xy.z', 'hash' ] + + return { paramsString, valuesString, values }; +} diff --git a/packages/identite/src/services/index.ts b/packages/identite/src/services/index.ts new file mode 100644 index 00000000..8d832022 --- /dev/null +++ b/packages/identite/src/services/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./hash-to-postgres-params.js"; diff --git a/packages/identite/src/types/index.ts b/packages/identite/src/types/index.ts index 3ff3a81a..68505bb0 100644 --- a/packages/identite/src/types/index.ts +++ b/packages/identite/src/types/index.ts @@ -1,4 +1,7 @@ // export * from "./contexts.js"; +export * from "./organization-info.js"; +export * from "./organization.js"; +export * from "./tranche-effectifs.js"; export * from "./user.js"; diff --git a/packages/identite/src/types/organization-info.ts b/packages/identite/src/types/organization-info.ts new file mode 100644 index 00000000..4ee941a7 --- /dev/null +++ b/packages/identite/src/types/organization-info.ts @@ -0,0 +1,26 @@ +// + +import type { TrancheEffectifs } from "./tranche-effectifs.js"; + +// + +export interface OrganizationInfo { + siret: string; + libelle: string; + nomComplet: string; + enseigne: string; + trancheEffectifs: TrancheEffectifs; + trancheEffectifsUniteLegale: TrancheEffectifs; + libelleTrancheEffectif: string; + etatAdministratif: string; + estActive: boolean; + statutDiffusion: string; + estDiffusible: boolean; + adresse: string; + codePostal: string; + codeOfficielGeographique: string; + activitePrincipale: string; + libelleActivitePrincipale: string; + categorieJuridique: string; + libelleCategorieJuridique: string; +} diff --git a/packages/identite/src/types/organization.ts b/packages/identite/src/types/organization.ts new file mode 100644 index 00000000..2b21b95f --- /dev/null +++ b/packages/identite/src/types/organization.ts @@ -0,0 +1,30 @@ +// + +import type { TrancheEffectifs } from "./tranche-effectifs.js"; + +// + +export interface Organization { + id: number; + siret: string; + created_at: Date; + updated_at: Date; + cached_libelle: string | null; + cached_nom_complet: string | null; + cached_enseigne: string | null; + cached_tranche_effectifs: TrancheEffectifs; + cached_tranche_effectifs_unite_legale: string | null; + cached_libelle_tranche_effectif: string | null; + cached_etat_administratif: string | null; + cached_est_active: string | null; + cached_statut_diffusion: string | null; + cached_est_diffusible: string | null; + cached_adresse: string | null; + cached_code_postal: string | null; + cached_code_officiel_geographique: string | null; + cached_activite_principale: string | null; + cached_libelle_activite_principale: string | null; + cached_categorie_juridique: string | null; + cached_libelle_categorie_juridique: string | null; + organization_info_fetched_at: Date | null; +} diff --git a/src/types/organization-info.d.ts b/packages/identite/src/types/tranche-effectifs.ts similarity index 62% rename from src/types/organization-info.d.ts rename to packages/identite/src/types/tranche-effectifs.ts index 83eadaa5..5994996a 100644 --- a/src/types/organization-info.d.ts +++ b/packages/identite/src/types/tranche-effectifs.ts @@ -1,5 +1,5 @@ // source : https://www.sirene.fr/sirene/public/variable/trancheEffectifsEtablissement -type TrancheEffectifs = +export type TrancheEffectifs = // le champ peut être null bien que la documentation ne spécifie pas à quoi correspond ce cas | null // Etablissement non employeur (pas de salarié au cours de l'année de référence et pas d'effectif au 31/12)NN @@ -34,24 +34,3 @@ type TrancheEffectifs = | "52" // 10 000 salariés et plus | "53"; - -interface OrganizationInfo { - siret: string; - libelle: string; - nomComplet: string; - enseigne: string; - trancheEffectifs: TrancheEffectifs; - trancheEffectifsUniteLegale: TrancheEffectifs; - libelleTrancheEffectif: string; - etatAdministratif: string; - estActive: boolean; - statutDiffusion: string; - estDiffusible: boolean; - adresse: string; - codePostal: string; - codeOfficielGeographique: string; - activitePrincipale: string; - libelleActivitePrincipale: string; - categorieJuridique: string; - libelleCategorieJuridique: string; -} diff --git a/packages/identite/src/user/create.test.ts b/packages/identite/src/user/create.test.ts new file mode 100644 index 00000000..0396e2b2 --- /dev/null +++ b/packages/identite/src/user/create.test.ts @@ -0,0 +1,31 @@ +// + +import { PGlite } from "@electric-sql/pglite"; +import { expect } from "chai"; +import { noop } from "lodash-es"; +import { before, describe, it } from "mocha"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; +import { createUserFactory } from "./create.js"; + +// + +const pg = new PGlite(); +const createUser = createUserFactory({ pg: pg as any }); + +before(async function migrate() { + await runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../../migrations"), + direction: "up", + log: noop, + migrationsTable: "pg-migrate", + }); +}); + +describe("CreateUser", () => { + it("should create the god-emperor of mankind", async () => { + const user = await createUser({ email: "god-emperor@mankind" }); + expect(user.email).to.equal("god-emperor@mankind"); + }); +}); diff --git a/packages/identite/src/user/create.ts b/packages/identite/src/user/create.ts new file mode 100644 index 00000000..d7a6ea6b --- /dev/null +++ b/packages/identite/src/user/create.ts @@ -0,0 +1,42 @@ +// + +import { hashToPostgresParams } from "#src/services"; +import type { DatabaseContext, User } from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function createUserFactory({ pg }: DatabaseContext) { + return async function createUser({ + email, + encrypted_password = null, + }: { + email: string; + encrypted_password?: string | null; + }) { + const userWithTimestamps = { + email, + email_verified: false, + verify_email_token: null, + verify_email_sent_at: null, + encrypted_password, + magic_link_token: null, + magic_link_sent_at: null, + reset_password_token: null, + reset_password_sent_at: null, + sign_in_count: 0, + last_sign_in_at: null, + created_at: new Date(), + updated_at: new Date(), + }; + + const { paramsString, valuesString, values } = + hashToPostgresParams(userWithTimestamps); + + const { rows }: QueryResult = await pg.query( + `INSERT INTO users ${paramsString} VALUES ${valuesString} RETURNING *;`, + values, + ); + return rows.shift()!; + }; +} diff --git a/packages/identite/src/user/find-by-email.test.ts b/packages/identite/src/user/find-by-email.test.ts index 8e449a9e..03872c22 100644 --- a/packages/identite/src/user/find-by-email.test.ts +++ b/packages/identite/src/user/find-by-email.test.ts @@ -2,6 +2,7 @@ import { PGlite } from "@electric-sql/pglite"; import { expect } from "chai"; +import { noop } from "lodash-es"; import { before, describe, it } from "mocha"; import { runner } from "node-pg-migrate"; import { join } from "path"; @@ -18,47 +19,22 @@ before(async function migrate() { dir: join(import.meta.dirname, "../../../../migrations"), direction: "up", migrationsTable: "pg-migrate", + log: noop, }); }); describe("FindByEmail", () => { it("should find a user by email", async () => { - await pg.sql`INSERT INTO users + await pg.sql`insert into users (id, email, created_at, updated_at, given_name, family_name, phone_number, job) - VALUES - (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'I', 'Primarque'), - (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'IV', 'Primarque'); + values + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque'); `; const user = await findByEmail("lion.eljonson@darkangels.world"); - expect(user).to.deep.equal({ - created_at: new Date("4444-04-04T00:00:00.000Z"), - current_challenge: null, - email_verified_at: null, - email_verified: false, - email: "lion.eljonson@darkangels.world", - encrypted_password: "", - encrypted_totp_key: null, - family_name: "El'Jonson", - force_2fa: false, - given_name: "Lion", - id: 1, - job: "Primarque", - last_sign_in_at: null, - magic_link_sent_at: null, - magic_link_token: null, - needs_inclusionconnect_onboarding_help: false, - needs_inclusionconnect_welcome_page: false, - phone_number: "I", - reset_password_sent_at: null, - reset_password_token: null, - sign_in_count: 0, - totp_key_verified_at: null, - updated_at: new Date("4444-04-04T00:00:00.000Z"), - verify_email_sent_at: null, - verify_email_token: null, - }); + expect(user?.email).to.equal("lion.eljonson@darkangels.world"); }); it("❎ fail to find the God-Emperor of Mankind", async () => { diff --git a/packages/identite/src/user/index.ts b/packages/identite/src/user/index.ts index 843716a0..466f4bce 100644 --- a/packages/identite/src/user/index.ts +++ b/packages/identite/src/user/index.ts @@ -1,3 +1,5 @@ // +export * from "./create.js"; export * from "./find-by-email.js"; +export * from "./update.js"; diff --git a/packages/identite/src/user/update.test.ts b/packages/identite/src/user/update.test.ts new file mode 100644 index 00000000..5bba1028 --- /dev/null +++ b/packages/identite/src/user/update.test.ts @@ -0,0 +1,36 @@ +// + +import { PGlite } from "@electric-sql/pglite"; +import { expect } from "chai"; +import { noop } from "lodash-es"; +import { before, describe, it } from "mocha"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; +import { updateUserFactory } from "./update.js"; + +// + +const pg = new PGlite(); +const updateUser = updateUserFactory({ pg: pg as any }); + +before(async function migrate() { + await runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../../migrations"), + direction: "up", + log: noop, + migrationsTable: "pg-migrate", + }); +}); + +describe("UpdateUser", () => { + it("should update the user job", async () => { + await pg.sql`INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'I', 'Primarque'); + `; + const user = await updateUser(1, { job: "Chevalier de l'Ordre" }); + expect(user.job).to.equal("Chevalier de l'Ordre"); + }); +}); diff --git a/packages/identite/src/user/update.ts b/packages/identite/src/user/update.ts new file mode 100644 index 00000000..e1299fbe --- /dev/null +++ b/packages/identite/src/user/update.ts @@ -0,0 +1,29 @@ +// + +import { hashToPostgresParams } from "#src/services"; +import type { DatabaseContext, User } from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function updateUserFactory({ pg }: DatabaseContext) { + return async function updateUser(id: number, fieldsToUpdate: Partial) { + const fieldsToUpdateWithTimestamps = { + ...fieldsToUpdate, + updated_at: new Date(), + }; + + const { paramsString, valuesString, values } = hashToPostgresParams( + fieldsToUpdateWithTimestamps, + ); + + const { rows }: QueryResult = await pg.query( + `UPDATE users SET ${paramsString} = ${valuesString} WHERE id = $${ + values.length + 1 + } RETURNING *`, + [...values, id], + ); + + return rows.shift()!; + }; +} diff --git a/scripts/import-accounts-coop.ts b/scripts/import-accounts-coop.ts index 5b0988bf..19c0bdd4 100644 --- a/scripts/import-accounts-coop.ts +++ b/scripts/import-accounts-coop.ts @@ -5,7 +5,11 @@ import { isPhoneNumberValid, isSiretValid, } from "@gouvfr-lasuite/proconnect.core/security"; -import { findByEmailFactory } from "@gouvfr-lasuite/proconnect.identite/user"; +import { + createUserFactory, + findByEmailFactory, + updateUserFactory, +} from "@gouvfr-lasuite/proconnect.identite/user"; import { getInseeAccessTokenFactory } from "@gouvfr-lasuite/proconnect.insee/token"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; @@ -24,7 +28,6 @@ import { linkUserToOrganization, upsert, } from "../src/repositories/organization/setters"; -import { create, update } from "../src/repositories/user"; import { logger } from "../src/services/log"; import { getNumberOfLineInFile, @@ -48,6 +51,8 @@ const getInseeAccessToken = getInseeAccessTokenFactory( const pg = getDatabaseConnection(); const findByEmail = findByEmailFactory({ pg }); +const create = createUserFactory({ pg }); +const update = updateUserFactory({ pg }); // diff --git a/scripts/import-domains.ts b/scripts/import-domains.ts index e967b79f..68fad590 100644 --- a/scripts/import-domains.ts +++ b/scripts/import-domains.ts @@ -3,6 +3,7 @@ import { isDomainValid, isSiretValid, } from "@gouvfr-lasuite/proconnect.core/security"; +import type { Organization } from "@gouvfr-lasuite/proconnect.identite/types"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; diff --git a/src/connectors/api-sirene/codes-effectifs.ts b/src/connectors/api-sirene/codes-effectifs.ts index 6caa1719..3d4b00cb 100644 --- a/src/connectors/api-sirene/codes-effectifs.ts +++ b/src/connectors/api-sirene/codes-effectifs.ts @@ -1,3 +1,5 @@ +import type { TrancheEffectifs } from "@gouvfr-lasuite/proconnect.identite/types"; + export const codesEffectifs: { [K in NonNullable]: string } = { NN: "Unité non employeuse (pas de salarié au cours de l'année de référence et pas d'effectif au 31/12)", diff --git a/src/connectors/api-sirene/index.ts b/src/connectors/api-sirene/index.ts index 1e357591..659d798e 100644 --- a/src/connectors/api-sirene/index.ts +++ b/src/connectors/api-sirene/index.ts @@ -1,3 +1,7 @@ +import type { + OrganizationInfo, + TrancheEffectifs, +} from "@gouvfr-lasuite/proconnect.identite/types"; import { getInseeAccessTokenFactory } from "@gouvfr-lasuite/proconnect.insee/token"; import axios, { AxiosError, type AxiosResponse } from "axios"; import { cloneDeep, set } from "lodash-es"; diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index 6a41394f..2c76ffa5 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -1,5 +1,9 @@ import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import { Welcome } from "@gouvfr-lasuite/proconnect.email"; +import type { + Organization, + OrganizationInfo, +} from "@gouvfr-lasuite/proconnect.identite/types"; import * as Sentry from "@sentry/node"; import { isEmpty, some } from "lodash-es"; import { diff --git a/src/managers/organization/main.ts b/src/managers/organization/main.ts index a3ae80da..6a676aa7 100644 --- a/src/managers/organization/main.ts +++ b/src/managers/organization/main.ts @@ -1,3 +1,4 @@ +import type { Organization } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty, some } from "lodash-es"; import { NotFoundError } from "../../config/errors"; import { diff --git a/src/repositories/organization/getters.ts b/src/repositories/organization/getters.ts index fbb2c8df..7d66fa2e 100644 --- a/src/repositories/organization/getters.ts +++ b/src/repositories/organization/getters.ts @@ -1,4 +1,7 @@ -import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; +import type { + Organization, + User, +} from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../../connectors/postgres"; diff --git a/src/repositories/organization/setters.ts b/src/repositories/organization/setters.ts index 88b90631..3429b39d 100644 --- a/src/repositories/organization/setters.ts +++ b/src/repositories/organization/setters.ts @@ -1,137 +1,10 @@ +import { upsertFactory } from "@gouvfr-lasuite/proconnect.identite/organization"; import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../../connectors/postgres"; import { hashToPostgresParams } from "../../services/hash-to-postgres-params"; -export const upsert = async ({ - siret, - organizationInfo: { - libelle: cached_libelle, - nomComplet: cached_nom_complet, - enseigne: cached_enseigne, - trancheEffectifs: cached_tranche_effectifs, - trancheEffectifsUniteLegale: cached_tranche_effectifs_unite_legale, - libelleTrancheEffectif: cached_libelle_tranche_effectif, - etatAdministratif: cached_etat_administratif, - estActive: cached_est_active, - statutDiffusion: cached_statut_diffusion, - estDiffusible: cached_est_diffusible, - adresse: cached_adresse, - codePostal: cached_code_postal, - codeOfficielGeographique: cached_code_officiel_geographique, - activitePrincipale: cached_activite_principale, - libelleActivitePrincipale: cached_libelle_activite_principale, - categorieJuridique: cached_categorie_juridique, - libelleCategorieJuridique: cached_libelle_categorie_juridique, - }, -}: { - siret: string; - organizationInfo: OrganizationInfo; -}) => { - const connection = getDatabaseConnection(); - - const { rows }: QueryResult = await connection.query( - ` -INSERT INTO organizations - ( - siret, - cached_libelle, - cached_nom_complet, - cached_enseigne, - cached_tranche_effectifs, - cached_tranche_effectifs_unite_legale, - cached_libelle_tranche_effectif, - cached_etat_administratif, - cached_est_active, - cached_statut_diffusion, - cached_est_diffusible, - cached_adresse, - cached_code_postal, - cached_code_officiel_geographique, - cached_activite_principale, - cached_libelle_activite_principale, - cached_categorie_juridique, - cached_libelle_categorie_juridique, - organization_info_fetched_at, - updated_at, - created_at - ) -VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) -ON CONFLICT (siret) -DO UPDATE - SET ( - siret, - cached_libelle, - cached_nom_complet, - cached_enseigne, - cached_tranche_effectifs, - cached_tranche_effectifs_unite_legale, - cached_libelle_tranche_effectif, - cached_etat_administratif, - cached_est_active, - cached_statut_diffusion, - cached_est_diffusible, - cached_adresse, - cached_code_postal, - cached_code_officiel_geographique, - cached_activite_principale, - cached_libelle_activite_principale, - cached_categorie_juridique, - cached_libelle_categorie_juridique, - organization_info_fetched_at, - updated_at - ) = ( - EXCLUDED.siret, - EXCLUDED.cached_libelle, - EXCLUDED.cached_nom_complet, - EXCLUDED.cached_enseigne, - EXCLUDED.cached_tranche_effectifs, - EXCLUDED.cached_tranche_effectifs_unite_legale, - EXCLUDED.cached_libelle_tranche_effectif, - EXCLUDED.cached_etat_administratif, - EXCLUDED.cached_est_active, - EXCLUDED.cached_statut_diffusion, - EXCLUDED.cached_est_diffusible, - EXCLUDED.cached_adresse, - EXCLUDED.cached_code_postal, - EXCLUDED.cached_code_officiel_geographique, - EXCLUDED.cached_activite_principale, - EXCLUDED.cached_libelle_activite_principale, - EXCLUDED.cached_categorie_juridique, - EXCLUDED.cached_libelle_categorie_juridique, - EXCLUDED.organization_info_fetched_at, - EXCLUDED.updated_at - ) -RETURNING * -`, - [ - siret, - cached_libelle, - cached_nom_complet, - cached_enseigne, - cached_tranche_effectifs, - cached_tranche_effectifs_unite_legale, - cached_libelle_tranche_effectif, - cached_etat_administratif, - cached_est_active, - cached_statut_diffusion, - cached_est_diffusible, - cached_adresse, - cached_code_postal, - cached_code_officiel_geographique, - cached_activite_principale, - cached_libelle_activite_principale, - cached_categorie_juridique, - cached_libelle_categorie_juridique, - new Date(), - new Date(), - new Date(), - ], - ); - - return rows.shift()!; -}; +export const upsert = upsertFactory({ pg: getDatabaseConnection() }); export const linkUserToOrganization = async ({ organization_id, diff --git a/src/repositories/user.ts b/src/repositories/user.ts index 78a6a8f8..75783d27 100644 --- a/src/repositories/user.ts +++ b/src/repositories/user.ts @@ -1,7 +1,11 @@ import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; +import { + createUserFactory, + findByEmailFactory, + updateUserFactory, +} from "@gouvfr-lasuite/proconnect.identite/user"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../connectors/postgres"; -import { hashToPostgresParams } from "../services/hash-to-postgres-params"; export const findById = async (id: number) => { const connection = getDatabaseConnection(); @@ -16,19 +20,7 @@ FROM users WHERE id = $1 return rows.shift(); }; -export const findByEmail = async (email: string) => { - const connection = getDatabaseConnection(); - - const { rows }: QueryResult = await connection.query( - ` -SELECT * -FROM users WHERE email = $1 -`, - [email], - ); - - return rows.shift(); -}; +export const findByEmail = findByEmailFactory({ pg: getDatabaseConnection() }); export const findByMagicLinkToken = async (magic_link_token: string) => { const connection = getDatabaseConnection(); @@ -60,63 +52,9 @@ FROM users WHERE reset_password_token = $1 return rows.shift(); }; -export const update = async (id: number, fieldsToUpdate: Partial) => { - const connection = getDatabaseConnection(); - - const fieldsToUpdateWithTimestamps = { - ...fieldsToUpdate, - updated_at: new Date(), - }; - - const { paramsString, valuesString, values } = hashToPostgresParams( - fieldsToUpdateWithTimestamps, - ); +export const update = updateUserFactory({ pg: getDatabaseConnection() }); - const { rows }: QueryResult = await connection.query( - `UPDATE users SET ${paramsString} = ${valuesString} WHERE id = $${ - values.length + 1 - } RETURNING *`, - [...values, id], - ); - - return rows.shift()!; -}; - -export const create = async ({ - email, - encrypted_password = null, -}: { - email: string; - encrypted_password?: string | null; -}) => { - const connection = getDatabaseConnection(); - - const userWithTimestamps = { - email, - email_verified: false, - verify_email_token: null, - verify_email_sent_at: null, - encrypted_password, - magic_link_token: null, - magic_link_sent_at: null, - reset_password_token: null, - reset_password_sent_at: null, - sign_in_count: 0, - last_sign_in_at: null, - created_at: new Date(), - updated_at: new Date(), - }; - - const { paramsString, valuesString, values } = - hashToPostgresParams(userWithTimestamps); - - const { rows }: QueryResult = await connection.query( - `INSERT INTO users ${paramsString} VALUES ${valuesString} RETURNING *;`, - values, - ); - - return rows.shift()!; -}; +export const create = createUserFactory({ pg: getDatabaseConnection() }); export const deleteUser = async (id: number) => { const connection = getDatabaseConnection(); diff --git a/src/services/organization.ts b/src/services/organization.ts index 668ecacb..2eeea3f2 100644 --- a/src/services/organization.ts +++ b/src/services/organization.ts @@ -1,4 +1,5 @@ import { isDomainValid } from "@gouvfr-lasuite/proconnect.core/security"; +import type { Organization } from "@gouvfr-lasuite/proconnect.identite/types"; /** * These fonctions return approximate results. As the data tranche effectifs is diff --git a/src/services/script-helpers.ts b/src/services/script-helpers.ts index a532b106..994bd0f8 100644 --- a/src/services/script-helpers.ts +++ b/src/services/script-helpers.ts @@ -1,4 +1,5 @@ // from https://ipirozhenko.com/blog/measuring-requests-duration-nodejs-express/ +import type { OrganizationInfo } from "@gouvfr-lasuite/proconnect.identite/types"; import fs from "fs"; import { isEmpty } from "lodash-es"; diff --git a/src/types/organization.d.ts b/src/types/organization.d.ts index 2f779a1f..e69de29b 100644 --- a/src/types/organization.d.ts +++ b/src/types/organization.d.ts @@ -1,24 +0,0 @@ -interface Organization { - id: number; - siret: string; - created_at: Date; - updated_at: Date; - cached_libelle: string | null; - cached_nom_complet: string | null; - cached_enseigne: string | null; - cached_tranche_effectifs: TrancheEffectifs; - cached_tranche_effectifs_unite_legale: string | null; - cached_libelle_tranche_effectif: string | null; - cached_etat_administratif: string | null; - cached_est_active: string | null; - cached_statut_diffusion: string | null; - cached_est_diffusible: string | null; - cached_adresse: string | null; - cached_code_postal: string | null; - cached_code_officiel_geographique: string | null; - cached_activite_principale: string | null; - cached_libelle_activite_principale: string | null; - cached_categorie_juridique: string | null; - cached_libelle_categorie_juridique: string | null; - organization_info_fetched_at: Date | null; -} diff --git a/test/organization.test.ts b/test/organization.test.ts index 67eb9f15..44d73f4b 100644 --- a/test/organization.test.ts +++ b/test/organization.test.ts @@ -1,3 +1,4 @@ +import type { Organization } from "@gouvfr-lasuite/proconnect.identite/types"; import { assert } from "chai"; import { isCommune, From 26b881e554228ebb63a7651eb723434ed7990455 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Wed, 18 Dec 2024 16:07:18 +0100 Subject: [PATCH 5/6] refactor(identite): extract minimal registry access --- .github/workflows/end-to-end.yml | 1 + .github/workflows/fixtures.yml | 1 + Dockerfile | 8 ++++++-- packages/core/package.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/end-to-end.yml b/.github/workflows/end-to-end.yml index 0bfaff32..de731fd7 100644 --- a/.github/workflows/end-to-end.yml +++ b/.github/workflows/end-to-end.yml @@ -109,6 +109,7 @@ jobs: cache: "npm" node-version-file: package.json - run: npm ci --include=dev + - run: npm run build:workspaces - run: npm run migrate up - run: npm run fixtures:load-ci -- cypress/e2e/${{ matrix.e2e_test }}/fixtures.sql - run: npm run update-organization-info -- 500 diff --git a/.github/workflows/fixtures.yml b/.github/workflows/fixtures.yml index 4a10d4cd..a78154b1 100644 --- a/.github/workflows/fixtures.yml +++ b/.github/workflows/fixtures.yml @@ -38,6 +38,7 @@ jobs: cache: "npm" node-version-file: package.json - run: npm ci --omit=dev # omit dev dependencies to simulate deployed environment + - run: npm run build:workspaces - run: npm run migrate up - run: npm run fixtures:load-ci -- scripts/fixtures.sql - run: npm run update-organization-info -- 500 diff --git a/Dockerfile b/Dockerfile index 192efafe..b77e295d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,10 @@ WORKDIR /app FROM base AS prod-deps RUN --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package-lock.json,target=package-lock.json \ - --mount=type=bind,source=packages/email/package.json,target=packages/email/package.json \ --mount=type=bind,source=packages/core/package.json,target=packages/core/package.json \ + --mount=type=bind,source=packages/email/package.json,target=packages/email/package.json \ + --mount=type=bind,source=packages/identite/package.json,target=packages/identite/package.json \ + --mount=type=bind,source=packages/insee/package.json,target=packages/insee/package.json \ --mount=type=cache,target=/root/.npm \ npm ci --omit=dev @@ -14,8 +16,10 @@ FROM base AS build ENV CYPRESS_INSTALL_BINARY=0 RUN --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package-lock.json,target=package-lock.json \ - --mount=type=bind,source=packages/email/package.json,target=packages/email/package.json \ --mount=type=bind,source=packages/core/package.json,target=packages/core/package.json \ + --mount=type=bind,source=packages/email/package.json,target=packages/email/package.json \ + --mount=type=bind,source=packages/identite/package.json,target=packages/identite/package.json \ + --mount=type=bind,source=packages/insee/package.json,target=packages/insee/package.json \ --mount=type=cache,target=/root/.npm \ npm ci COPY tsconfig.json vite.config.mjs ./ diff --git a/packages/core/package.json b/packages/core/package.json index c28a2a0f..e2f3fc77 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,7 @@ "spec": "src/**/*.test.ts" }, "dependencies": { + "@types/lodash-es": "^4.17.12", "@zootools/email-spell-checker": "^1.12.0", "axios": "^1.7.7", "is-disposable-email-domain": "^1.0.7", @@ -54,7 +55,6 @@ }, "devDependencies": { "@tsconfig/node22": "^22.0.0", - "@types/lodash-es": "^4.17.12", "@types/mocha": "^10.0.10", "@types/node": "^22.10.2", "chai": "^5.1.2", From a40b972d211cb46c7ab7b346f672e8f41190efb9 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Tue, 24 Dec 2024 01:58:26 +0100 Subject: [PATCH 6/6] refactor(identite): extraxt getOrganizationInfo usecase --- package-lock.json | 5 +- packages/identite/package.json | 2 + .../get-organization-info.test.ts | 253 +++++++++++ .../src/organization/get-organization-info.ts | 256 +++++++++++ packages/identite/src/organization/index.ts | 1 + packages/identite/src/types/index.ts | 1 - .../identite/src/types/organization-info.ts | 2 +- packages/identite/src/types/organization.ts | 2 +- packages/insee/package.json | 4 +- .../insee/src/data}/categories-juridiques.ts | 3 + .../insee/src/data}/codes-effectifs.ts | 6 +- .../insee/src/data}/codes-naf.ts | 4 + .../insee/src/data}/codes-voie.ts | 3 + packages/insee/src/data/index.ts | 6 + .../insee/src/entreprises/find-by-siren.ts | 40 ++ .../insee/src/entreprises/find-by-siret.ts | 34 ++ packages/insee/src/entreprises/index.ts | 4 + packages/insee/src/errors/index.ts | 8 + .../src/formatters/adresse-etablissement.ts | 75 ++++ packages/insee/src/formatters/enseigne.ts | 8 + packages/insee/src/formatters/index.ts | 8 + .../libelle-from-categories-juridiques.ts | 9 + .../formatters/libelle-from-code-effectif.ts | 25 ++ .../src/formatters/libelle-from-code-naf.ts | 10 + packages/insee/src/formatters/nom-complet.ts | 47 ++ .../insee/src/token/get-insee-access-token.ts | 4 + packages/insee/src/types/index.ts | 159 +++++++ .../src/types/tranche-effectifs.ts | 0 src/connectors/api-sirene.ts | 44 ++ src/connectors/api-sirene/formatters.js | 134 ------ src/connectors/api-sirene/index.ts | 420 ------------------ test/api-sirene.test.ts | 108 ----- 32 files changed, 1014 insertions(+), 671 deletions(-) create mode 100644 packages/identite/src/organization/get-organization-info.test.ts create mode 100644 packages/identite/src/organization/get-organization-info.ts rename {src/connectors/api-sirene => packages/insee/src/data}/categories-juridiques.ts (99%) rename {src/connectors/api-sirene => packages/insee/src/data}/codes-effectifs.ts (90%) rename {src/connectors/api-sirene => packages/insee/src/data}/codes-naf.ts (99%) rename {src/connectors/api-sirene => packages/insee/src/data}/codes-voie.ts (95%) create mode 100644 packages/insee/src/data/index.ts create mode 100644 packages/insee/src/entreprises/find-by-siren.ts create mode 100644 packages/insee/src/entreprises/find-by-siret.ts create mode 100644 packages/insee/src/entreprises/index.ts create mode 100644 packages/insee/src/errors/index.ts create mode 100644 packages/insee/src/formatters/adresse-etablissement.ts create mode 100644 packages/insee/src/formatters/enseigne.ts create mode 100644 packages/insee/src/formatters/index.ts create mode 100644 packages/insee/src/formatters/libelle-from-categories-juridiques.ts create mode 100644 packages/insee/src/formatters/libelle-from-code-effectif.ts create mode 100644 packages/insee/src/formatters/libelle-from-code-naf.ts create mode 100644 packages/insee/src/formatters/nom-complet.ts create mode 100644 packages/insee/src/types/index.ts rename packages/{identite => insee}/src/types/tranche-effectifs.ts (100%) create mode 100644 src/connectors/api-sirene.ts delete mode 100644 src/connectors/api-sirene/formatters.js delete mode 100644 src/connectors/api-sirene/index.ts delete mode 100644 test/api-sirene.test.ts diff --git a/package-lock.json b/package-lock.json index f6acfb32..659bd5da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2592,14 +2592,12 @@ "version": "4.17.10", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/lodash-es": { "version": "4.17.12", "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, "dependencies": { "@types/lodash": "*" } @@ -10419,6 +10417,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "@types/lodash-es": "^4.17.12", "@zootools/email-spell-checker": "^1.12.0", "axios": "^1.7.7", "is-disposable-email-domain": "^1.0.7", @@ -10428,7 +10427,6 @@ }, "devDependencies": { "@tsconfig/node22": "^22.0.0", - "@types/lodash-es": "^4.17.12", "@types/mocha": "^10.0.10", "@types/node": "^22.10.2", "chai": "^5.1.2", @@ -10478,6 +10476,7 @@ "devDependencies": { "@electric-sql/pglite": "^0.2.15", "@gouvfr-lasuite/proconnect.core": "^0.2.0", + "@gouvfr-lasuite/proconnect.insee": "^0.2.0", "@tsconfig/node22": "^22.0.0", "@types/mocha": "^10.0.10", "@types/node": "^22.10.2", diff --git a/packages/identite/package.json b/packages/identite/package.json index 076fcc36..f42eecba 100644 --- a/packages/identite/package.json +++ b/packages/identite/package.json @@ -51,9 +51,11 @@ "devDependencies": { "@electric-sql/pglite": "^0.2.15", "@gouvfr-lasuite/proconnect.core": "^0.2.0", + "@gouvfr-lasuite/proconnect.insee": "^0.2.0", "@tsconfig/node22": "^22.0.0", "@types/mocha": "^10.0.10", "@types/node": "^22.10.2", + "await-to-js": "^3.0.0", "chai": "^5.1.2", "mocha": "^11.0.1", "node-pg-migrate": "^7.6.1", diff --git a/packages/identite/src/organization/get-organization-info.test.ts b/packages/identite/src/organization/get-organization-info.test.ts new file mode 100644 index 00000000..c4bb6957 --- /dev/null +++ b/packages/identite/src/organization/get-organization-info.test.ts @@ -0,0 +1,253 @@ +import { getOrganizationInfoFactory } from "@gouvfr-lasuite/proconnect.identite/organization"; +import { + InseeConnectionError, + InseeNotFoundError, +} from "@gouvfr-lasuite/proconnect.insee/errors"; +import type { InseeEtablissement } from "@gouvfr-lasuite/proconnect.insee/types"; +import { AxiosError, type AxiosResponse } from "axios"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; + +chai.use(chaiAsPromised); +const assert = chai.assert; + +describe("getOrganizationInfo", () => { + const diffusibleOrganizationInfo = { + siret: "20007184300060", + libelle: "Cc du vexin normand", + nomComplet: "Cc du vexin normand", + enseigne: "", + trancheEffectifs: "22", + trancheEffectifsUniteLegale: "22", + libelleTrancheEffectif: "100 à 199 salariés, en 2021", + etatAdministratif: "A", + estActive: true, + statutDiffusion: "O", + estDiffusible: true, + adresse: "3 rue maison de vatimesnil, 27150 Etrepagny", + codePostal: "27150", + codeOfficielGeographique: "27226", + activitePrincipale: "84.11Z", + libelleActivitePrincipale: "84.11Z - Administration publique générale", + categorieJuridique: "7346", + libelleCategorieJuridique: "Communauté de communes", + }; + + it("should return valid payload for diffusible établissement", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => + Promise.resolve({ + adresseEtablissement: { + numeroVoieEtablissement: "3", + typeVoieEtablissement: "RUE", + libelleVoieEtablissement: "MAISON DE VATIMESNIL", + codePostalEtablissement: "27150", + libelleCommuneEtablissement: "ETREPAGNY", + codeCommuneEtablissement: "27226", + }, + periodesEtablissement: [ + { + activitePrincipaleEtablissement: "84.11Z", + etatAdministratifEtablissement: "A", + }, + ], + siret: "20007184300060", + statutDiffusionEtablissement: "O", + trancheEffectifsEtablissement: "22", + anneeEffectifsEtablissement: "2021", + uniteLegale: { + categorieJuridiqueUniteLegale: 7346, + denominationUniteLegale: "Cc du vexin normand", + trancheEffectifsUniteLegale: "22", + }, + } as InseeEtablissement), + }); + + await assert.eventually.deepEqual( + getOrganizationInfo("20007184300060"), + diffusibleOrganizationInfo, + ); + }); + + it("should return valid payload for diffusible établissement", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => + Promise.resolve({ + adresseEtablissement: { + numeroVoieEtablissement: "3", + typeVoieEtablissement: "RUE", + libelleVoieEtablissement: "MAISON DE VATIMESNIL", + codePostalEtablissement: "27150", + libelleCommuneEtablissement: "ETREPAGNY", + codeCommuneEtablissement: "27226", + }, + periodesEtablissement: [ + { + activitePrincipaleEtablissement: "84.11Z", + etatAdministratifEtablissement: "A", + }, + ], + siret: "20007184300060", + statutDiffusionEtablissement: "O", + trancheEffectifsEtablissement: "22", + anneeEffectifsEtablissement: "2021", + uniteLegale: { + categorieJuridiqueUniteLegale: 7346, + denominationUniteLegale: "Cc du vexin normand", + trancheEffectifsUniteLegale: "22", + }, + } as InseeEtablissement), + findBySiret: () => Promise.reject(), + }); + + await assert.eventually.deepEqual( + getOrganizationInfo("200071843"), + diffusibleOrganizationInfo, + ); + }); + + it("should show partial data for partially non diffusible établissement", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => + Promise.resolve({ + adresseEtablissement: { + numeroVoieEtablissement: "12", + typeVoieEtablissement: "AV", + libelleVoieEtablissement: "DE LA GARE", + codePostalEtablissement: "06220", + libelleCommuneEtablissement: "VALLAURIS", + codeCommuneEtablissement: "06155", + }, + periodesEtablissement: [ + { + activitePrincipaleEtablissement: "62.02A", + etatAdministratifEtablissement: "A", + }, + ], + siret: "94957325700019", + statutDiffusionEtablissement: "P", + trancheEffectifsEtablissement: null, + uniteLegale: { + activitePrincipaleUniteLegale: "62.02A", + categorieJuridiqueUniteLegale: 1000, + trancheEffectifsUniteLegale: null, + sexeUniteLegale: "M", + nomUniteLegale: "DUBIGNY", + prenom1UniteLegale: "RAPHAËL", + prenomUsuelUniteLegale: "RAPHAËL", + }, + } as InseeEtablissement), + }); + + await assert.eventually.deepEqual(getOrganizationInfo("94957325700019"), { + siret: "94957325700019", + libelle: "Nom inconnu", + nomComplet: "Nom inconnu", + enseigne: "", + trancheEffectifs: null, + trancheEffectifsUniteLegale: null, + libelleTrancheEffectif: "", + etatAdministratif: "A", + estActive: true, + statutDiffusion: "P", + estDiffusible: false, + adresse: "06220 Vallauris", + codePostal: "06220", + codeOfficielGeographique: "06155", + activitePrincipale: "62.02A", + libelleActivitePrincipale: + "62.02A - Conseil en systèmes et logiciels informatiques", + categorieJuridique: "1000", + libelleCategorieJuridique: "Entrepreneur individuel", + }); + }); + + it("should throw for totally non diffusible établissement", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => + Promise.resolve({ + statutDiffusionEtablissement: "N", + } as InseeEtablissement), + }); + + await assert.isRejected( + getOrganizationInfo("53512638700013"), + InseeNotFoundError, + ); + }); + + it("should throw on Axios 403 Http Error", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => + Promise.reject( + new AxiosError(undefined, undefined, undefined, undefined, { + status: 403, + } as AxiosResponse), + ), + }); + + await assert.isRejected( + getOrganizationInfo("53512638700013"), + InseeNotFoundError, + ); + }); + + it("should throw on Axios 404 Http Error", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => + Promise.reject( + new AxiosError(undefined, undefined, undefined, undefined, { + status: 404, + } as AxiosResponse), + ), + findBySiret: () => Promise.reject(), + }); + + await assert.isRejected( + getOrganizationInfo("200071843"), + InseeNotFoundError, + ); + }); + + it("should throw a connecction error on Axios ECONNABORTED Error", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => + Promise.reject(new AxiosError(undefined, AxiosError.ECONNABORTED)), + }); + + await assert.isRejected( + getOrganizationInfo("53512638700013"), + InseeConnectionError, + ); + }); + + it("should throw a connecction error on Axios ERR_BAD_RESPONSE Error", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => + Promise.reject(new AxiosError(undefined, AxiosError.ERR_BAD_RESPONSE)), + }); + + await assert.isRejected( + getOrganizationInfo("53512638700013"), + InseeConnectionError, + ); + }); + + it("should throw a connecction error on Axios EAI_AGAIN Error", async () => { + const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren: () => Promise.reject(), + findBySiret: () => Promise.reject(new AxiosError(undefined, "EAI_AGAIN")), + }); + + await assert.isRejected( + getOrganizationInfo("53512638700013"), + InseeConnectionError, + ); + }); +}); diff --git a/packages/identite/src/organization/get-organization-info.ts b/packages/identite/src/organization/get-organization-info.ts new file mode 100644 index 00000000..caef96ea --- /dev/null +++ b/packages/identite/src/organization/get-organization-info.ts @@ -0,0 +1,256 @@ +// + +import type { OrganizationInfo } from "#src/types"; +import type { + FindBySirenHandler, + FindBySiretHandler, +} from "@gouvfr-lasuite/proconnect.insee/entreprises"; +import { + InseeConnectionError, + InseeNotFoundError, +} from "@gouvfr-lasuite/proconnect.insee/errors"; +import { + formatAdresseEtablissement, + formatEnseigne, + formatNomComplet, + libelleFromCategoriesJuridiques, + libelleFromCodeEffectif, + libelleFromCodeNaf, +} from "@gouvfr-lasuite/proconnect.insee/formatters"; +import type { InseeEtablissement } from "@gouvfr-lasuite/proconnect.insee/types"; +import { to } from "await-to-js"; +import { AxiosError } from "axios"; +import { cloneDeep, set } from "lodash-es"; + +// + +export class InvalidSiretError extends Error {} + +// +type FactoryDependencies = { + findBySiret: FindBySiretHandler; + findBySiren: FindBySirenHandler; +}; + +export function getOrganizationInfoFactory(ctx: FactoryDependencies) { + const { findBySiren, findBySiret } = ctx; + + const siret = [/^\d{14}$/, findBySiret] as const; + const siren = [/^\d{9}$/, findBySiren] as const; + + const strategies = [siret, siren]; + + return async function getOrganizationInfo( + siretOrSiren: string, + ): Promise { + const [_, finder] = + strategies.find(([pattern]) => pattern.test(siretOrSiren)) ?? []; + + if (!finder) { + throw new InvalidSiretError(); + } + + const [finder_error, etablissement] = await to(finder(siretOrSiren)); + if (finder_error) { + if ( + finder_error instanceof AxiosError && + finder_error.response && + [403, 404].includes(finder_error.response.status) + ) { + throw new InseeNotFoundError(); + } + + if ( + finder_error instanceof AxiosError && + (finder_error.code === "ECONNABORTED" || + finder_error.code === "ERR_BAD_RESPONSE" || + finder_error.code === "EAI_AGAIN") + ) { + throw new InseeConnectionError(); + } + + throw finder_error; + } + + const { statutDiffusionEtablissement } = etablissement; + + if (statutDiffusionEtablissement === "N") { + throw new InseeNotFoundError(); + } + + return etablissementToOrganizationInfo( + statutDiffusionEtablissement === "P" + ? hideNonDiffusibleData(etablissement) + : etablissement, + ); + }; +} + +function etablissementToOrganizationInfo( + etablissement: InseeEtablissement, +): OrganizationInfo { + const { + adresseEtablissement, + anneeEffectifsEtablissement, + periodesEtablissement, + siret: siretFromInseeApi, + statutDiffusionEtablissement, + trancheEffectifsEtablissement, + uniteLegale, + } = etablissement; + + const { + categorieJuridiqueUniteLegale, + denominationUniteLegale, + sigleUniteLegale, + nomUniteLegale, + nomUsageUniteLegale, + prenomUsuelUniteLegale, + trancheEffectifsUniteLegale, + } = uniteLegale; + + // get last period to obtain most recent data + const { + activitePrincipaleEtablissement, + enseigne1Etablissement, + enseigne2Etablissement, + enseigne3Etablissement, + etatAdministratifEtablissement, + } = periodesEtablissement[0]; + + const { codePostalEtablissement, codeCommuneEtablissement } = + adresseEtablissement; + + const enseigne = formatEnseigne( + enseigne1Etablissement, + enseigne2Etablissement, + enseigne3Etablissement, + ); + + const nomComplet = formatNomComplet({ + denominationUniteLegale, + prenomUsuelUniteLegale, + nomUniteLegale, + nomUsageUniteLegale, + sigleUniteLegale, + }); + + const organizationLabel = `${nomComplet}${enseigne ? ` - ${enseigne}` : ""}`; + + return { + siret: siretFromInseeApi, + libelle: organizationLabel, + nomComplet, + enseigne, + trancheEffectifs: trancheEffectifsEtablissement, + trancheEffectifsUniteLegale, + libelleTrancheEffectif: + libelleFromCodeEffectif( + trancheEffectifsEtablissement, + anneeEffectifsEtablissement, + ) ?? "", + etatAdministratif: etatAdministratifEtablissement, + estActive: etatAdministratifEtablissement === "A", + statutDiffusion: statutDiffusionEtablissement, + estDiffusible: statutDiffusionEtablissement === "O", + adresse: formatAdresseEtablissement(adresseEtablissement), + codePostal: codePostalEtablissement, + codeOfficielGeographique: codeCommuneEtablissement, + activitePrincipale: activitePrincipaleEtablissement, + libelleActivitePrincipale: libelleFromCodeNaf( + activitePrincipaleEtablissement, + ), + categorieJuridique: `${categorieJuridiqueUniteLegale}`, + libelleCategorieJuridique: + libelleFromCategoriesJuridiques(categorieJuridiqueUniteLegale) ?? "", + }; +} + +const hideNonDiffusibleData = ( + etablissement: InseeEtablissement, +): InseeEtablissement => { + const hiddenEtablissement = cloneDeep(etablissement); + set(hiddenEtablissement, "uniteLegale.denominationUniteLegale", null); + set(hiddenEtablissement, "uniteLegale.sigleUniteLegale", null); + set(hiddenEtablissement, "uniteLegale.denominationUsuelle1UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.denominationUsuelle2UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.denominationUsuelle3UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.sexeUniteLegale", null); + set(hiddenEtablissement, "uniteLegale.nomUniteLegale", null); + set(hiddenEtablissement, "uniteLegale.nomUsageUniteLegale", null); + set(hiddenEtablissement, "uniteLegale.prenom1UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.prenom2UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.prenom3UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.prenom4UniteLegale", null); + set(hiddenEtablissement, "uniteLegale.prenomUsuelUniteLegale", null); + set(hiddenEtablissement, "uniteLegale.pseudonymeUniteLegale", null); + set( + hiddenEtablissement, + "adresseEtablissement.complementAdresseEtablissement", + null, + ); + set( + hiddenEtablissement, + "adresseEtablissement.numeroVoieEtablissement", + null, + ); + set( + hiddenEtablissement, + "adresseEtablissement.indiceRepetitionEtablissement", + null, + ); + set(hiddenEtablissement, "adresseEtablissement.typeVoieEtablissement", null); + set( + hiddenEtablissement, + "adresseEtablissement.libelleVoieEtablissement", + null, + ); + set( + hiddenEtablissement, + "adresse2Etablissement.complementAdresse2Etablissement", + null, + ); + set( + hiddenEtablissement, + "adresse2Etablissement.numeroVoie2Etablissement", + null, + ); + set( + hiddenEtablissement, + "adresse2Etablissement.indiceRepetition2Etablissement", + null, + ); + set( + hiddenEtablissement, + "adresse2Etablissement.typeVoie2Etablissement", + null, + ); + set( + hiddenEtablissement, + "adresse2Etablissement.libelleVoie2Etablissement", + null, + ); + + set( + hiddenEtablissement, + "periodesEtablissement.0.enseigne1Etablissement", + null, + ); + set( + hiddenEtablissement, + "periodesEtablissement.0.enseigne2Etablissement", + null, + ); + set( + hiddenEtablissement, + "periodesEtablissement.0.enseigne3Etablissement", + null, + ); + set( + hiddenEtablissement, + "periodesEtablissement.0.denominationUsuelleEtablissement", + null, + ); + + return hiddenEtablissement; +}; diff --git a/packages/identite/src/organization/index.ts b/packages/identite/src/organization/index.ts index 8ed86e54..4ee2827f 100644 --- a/packages/identite/src/organization/index.ts +++ b/packages/identite/src/organization/index.ts @@ -1,3 +1,4 @@ // +export * from "./get-organization-info.js"; export * from "./upsert.js"; diff --git a/packages/identite/src/types/index.ts b/packages/identite/src/types/index.ts index 68505bb0..1c97d28b 100644 --- a/packages/identite/src/types/index.ts +++ b/packages/identite/src/types/index.ts @@ -3,5 +3,4 @@ export * from "./contexts.js"; export * from "./organization-info.js"; export * from "./organization.js"; -export * from "./tranche-effectifs.js"; export * from "./user.js"; diff --git a/packages/identite/src/types/organization-info.ts b/packages/identite/src/types/organization-info.ts index 4ee941a7..598a0b9f 100644 --- a/packages/identite/src/types/organization-info.ts +++ b/packages/identite/src/types/organization-info.ts @@ -1,6 +1,6 @@ // -import type { TrancheEffectifs } from "./tranche-effectifs.js"; +import type { TrancheEffectifs } from "@gouvfr-lasuite/proconnect.insee/types"; // diff --git a/packages/identite/src/types/organization.ts b/packages/identite/src/types/organization.ts index 2b21b95f..ded1fce8 100644 --- a/packages/identite/src/types/organization.ts +++ b/packages/identite/src/types/organization.ts @@ -1,6 +1,6 @@ // -import type { TrancheEffectifs } from "./tranche-effectifs.js"; +import type { TrancheEffectifs } from "@gouvfr-lasuite/proconnect.insee/types"; // diff --git a/packages/insee/package.json b/packages/insee/package.json index dc9dbf98..1856b893 100644 --- a/packages/insee/package.json +++ b/packages/insee/package.json @@ -13,8 +13,8 @@ "type": "module", "imports": { "#src/*": { - "types": "./src/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" } }, "exports": { diff --git a/src/connectors/api-sirene/categories-juridiques.ts b/packages/insee/src/data/categories-juridiques.ts similarity index 99% rename from src/connectors/api-sirene/categories-juridiques.ts rename to packages/insee/src/data/categories-juridiques.ts index 0d25fce5..5c7bc198 100644 --- a/src/connectors/api-sirene/categories-juridiques.ts +++ b/packages/insee/src/data/categories-juridiques.ts @@ -1,3 +1,6 @@ +// + +export type CategoriesJuridique = keyof typeof categoriesJuridiques; export const categoriesJuridiques = { 1: "Entrepreneur individuel", 2: "Groupement de droit privé non doté de la personnalité morale", diff --git a/src/connectors/api-sirene/codes-effectifs.ts b/packages/insee/src/data/codes-effectifs.ts similarity index 90% rename from src/connectors/api-sirene/codes-effectifs.ts rename to packages/insee/src/data/codes-effectifs.ts index 3d4b00cb..a4697609 100644 --- a/src/connectors/api-sirene/codes-effectifs.ts +++ b/packages/insee/src/data/codes-effectifs.ts @@ -1,4 +1,8 @@ -import type { TrancheEffectifs } from "@gouvfr-lasuite/proconnect.identite/types"; +// + +import type { TrancheEffectifs } from "#src/types/tranche-effectifs.js"; + +// export const codesEffectifs: { [K in NonNullable]: string } = { diff --git a/src/connectors/api-sirene/codes-naf.ts b/packages/insee/src/data/codes-naf.ts similarity index 99% rename from src/connectors/api-sirene/codes-naf.ts rename to packages/insee/src/data/codes-naf.ts index 7d4a326a..18229ee0 100644 --- a/src/connectors/api-sirene/codes-naf.ts +++ b/packages/insee/src/data/codes-naf.ts @@ -1,3 +1,7 @@ +// + +export type CodeNaf = keyof typeof codesNaf; + export const codesNaf = { "01.11Z": "Culture de céréales (à l’exception du riz), de légumineuses et de graines oléagineuses", diff --git a/src/connectors/api-sirene/codes-voie.ts b/packages/insee/src/data/codes-voie.ts similarity index 95% rename from src/connectors/api-sirene/codes-voie.ts rename to packages/insee/src/data/codes-voie.ts index 07a9a8a0..013db1bf 100644 --- a/src/connectors/api-sirene/codes-voie.ts +++ b/packages/insee/src/data/codes-voie.ts @@ -1,3 +1,6 @@ +// + +export type CodeVoie = keyof typeof codesVoies; export const codesVoies = { AIRE: "Aire", ALL: "Allée", diff --git a/packages/insee/src/data/index.ts b/packages/insee/src/data/index.ts new file mode 100644 index 00000000..a2b127a8 --- /dev/null +++ b/packages/insee/src/data/index.ts @@ -0,0 +1,6 @@ +// + +export * from "./categories-juridiques.js"; +export * from "./codes-effectifs.js"; +export * from "./codes-naf.js"; +export * from "./codes-voie.js"; diff --git a/packages/insee/src/entreprises/find-by-siren.ts b/packages/insee/src/entreprises/find-by-siren.ts new file mode 100644 index 00000000..b8a21365 --- /dev/null +++ b/packages/insee/src/entreprises/find-by-siren.ts @@ -0,0 +1,40 @@ +// + +import { InvalidSirenError } from "#src/errors"; +import type { GetInseeAccessTokenHandler } from "#src/token"; +import type { EtablissementSearchResponse } from "#src/types"; +import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; + +// + +type FactoryDependencies = { + getAccessToken: GetInseeAccessTokenHandler; + config?: AxiosRequestConfig; +}; + +export function findBySirenFactory({ + getAccessToken, + config, +}: FactoryDependencies) { + return async function findBySiren(siren: string) { + const token = await getAccessToken(); + const { data }: AxiosResponse = + await axios.get( + `https://api.insee.fr/entreprises/sirene/siret?q=siren:${siren} AND etablissementSiege:true`, + { + headers: { Authorization: `Bearer ${token}` }, + ...config, + }, + ); + + const etablissement = data.etablissements.at(0); + + if (!etablissement) { + throw new InvalidSirenError(); + } + + return etablissement; + }; +} + +export type FindBySirenHandler = ReturnType; diff --git a/packages/insee/src/entreprises/find-by-siret.ts b/packages/insee/src/entreprises/find-by-siret.ts new file mode 100644 index 00000000..b2012515 --- /dev/null +++ b/packages/insee/src/entreprises/find-by-siret.ts @@ -0,0 +1,34 @@ +// + +import type { GetInseeAccessTokenHandler } from "#src/token"; +import type { EtablissementSearchBySiretResponse } from "#src/types"; +import type { AxiosRequestConfig, AxiosResponse } from "axios"; +import axios from "axios"; + +// + +type FactoryDependencies = { + getAccessToken: GetInseeAccessTokenHandler; + config?: AxiosRequestConfig; +}; + +export function findBySiretFactory({ + getAccessToken, + config, +}: FactoryDependencies) { + return async function findBySiret(siret: string) { + const token = await getAccessToken(); + const { data }: AxiosResponse = + await axios.get( + `https://api.insee.fr/entreprises/sirene/siret/${siret}`, + { + headers: { Authorization: `Bearer ${token}` }, + ...config, + }, + ); + + return data.etablissement; + }; +} + +export type FindBySiretHandler = ReturnType; diff --git a/packages/insee/src/entreprises/index.ts b/packages/insee/src/entreprises/index.ts new file mode 100644 index 00000000..a70b285c --- /dev/null +++ b/packages/insee/src/entreprises/index.ts @@ -0,0 +1,4 @@ +// + +export * from "./find-by-siren.js"; +export * from "./find-by-siret.js"; diff --git a/packages/insee/src/errors/index.ts b/packages/insee/src/errors/index.ts new file mode 100644 index 00000000..6930b5e7 --- /dev/null +++ b/packages/insee/src/errors/index.ts @@ -0,0 +1,8 @@ +// + +export class InseeNotFoundError extends Error {} + +export class InvalidSiretError extends Error {} +export class InvalidSirenError extends Error {} + +export class InseeConnectionError extends Error {} diff --git a/packages/insee/src/formatters/adresse-etablissement.ts b/packages/insee/src/formatters/adresse-etablissement.ts new file mode 100644 index 00000000..94ae9f58 --- /dev/null +++ b/packages/insee/src/formatters/adresse-etablissement.ts @@ -0,0 +1,75 @@ +// + +import { codesVoies, type CodeVoie } from "#src/data"; +import type { InseeEtablissement } from "#src/types"; +import { capitalize } from "lodash-es"; + +// + +export const formatAdresseEtablissement = ({ + complementAdresseEtablissement, + numeroVoieEtablissement, + indiceRepetitionEtablissement, + typeVoieEtablissement, + libelleVoieEtablissement, + distributionSpecialeEtablissement, + codePostalEtablissement, + libelleCommuneEtablissement, + codeCedexEtablissement, + libelleCedexEtablissement, + libelleCommuneEtrangerEtablissement, + codePaysEtrangerEtablissement, + libellePaysEtrangerEtablissement, +}: InseeEtablissement["adresseEtablissement"]) => { + if ( + !complementAdresseEtablissement && + !numeroVoieEtablissement && + !typeVoieEtablissement && + !libelleCommuneEtablissement && + !distributionSpecialeEtablissement && + !codePostalEtablissement && + !codeCedexEtablissement && + !libelleVoieEtablissement && + !libelleCommuneEtrangerEtablissement && + !codePaysEtrangerEtablissement && + !libellePaysEtrangerEtablissement + ) { + return ""; + } + + const fullLibelleFromTypeVoie = libelleFromTypeVoie(typeVoieEtablissement); + + return [ + wrapWord(complementAdresseEtablissement, ", ", true), + wrapWord(numeroVoieEtablissement), + wrapWord(indiceRepetitionEtablissement), + wrapWord(fullLibelleFromTypeVoie), + wrapWord(libelleVoieEtablissement, ", "), + wrapWord(distributionSpecialeEtablissement, ", "), + wrapWord(codePostalEtablissement || codeCedexEtablissement), + wrapWord( + libelleCommuneEtablissement || + libelleCedexEtablissement || + libelleCommuneEtrangerEtablissement, + "", + true, + ), + libellePaysEtrangerEtablissement + ? `, ${wrapWord(libellePaysEtrangerEtablissement, "", true)}` + : "", + ].join(""); +}; + +const libelleFromTypeVoie = (codeVoie: CodeVoie) => { + return codesVoies[codeVoie] || codeVoie; +}; + +const wrapWord = (word: string | null, punct = " ", caps = false) => { + if (!word) { + return ""; + } + if (caps) { + return capitalize(word) + punct; + } + return word.toString().toLowerCase() + punct; +}; diff --git a/packages/insee/src/formatters/enseigne.ts b/packages/insee/src/formatters/enseigne.ts new file mode 100644 index 00000000..3f861952 --- /dev/null +++ b/packages/insee/src/formatters/enseigne.ts @@ -0,0 +1,8 @@ +// + +import { capitalize, isEmpty } from "lodash-es"; + +// + +export const formatEnseigne = (...args: (string | null)[]) => + capitalize(args.filter((e) => !isEmpty(e)).join(" ")) || ""; diff --git a/packages/insee/src/formatters/index.ts b/packages/insee/src/formatters/index.ts new file mode 100644 index 00000000..bc58830c --- /dev/null +++ b/packages/insee/src/formatters/index.ts @@ -0,0 +1,8 @@ +// + +export * from "./adresse-etablissement.js"; +export * from "./enseigne.js"; +export * from "./libelle-from-categories-juridiques.js"; +export * from "./libelle-from-code-effectif.js"; +export * from "./libelle-from-code-naf.js"; +export * from "./nom-complet.js"; diff --git a/packages/insee/src/formatters/libelle-from-categories-juridiques.ts b/packages/insee/src/formatters/libelle-from-categories-juridiques.ts new file mode 100644 index 00000000..10fec25c --- /dev/null +++ b/packages/insee/src/formatters/libelle-from-categories-juridiques.ts @@ -0,0 +1,9 @@ +// + +import { categoriesJuridiques, type CategoriesJuridique } from "#src/data"; + +// + +export const libelleFromCategoriesJuridiques = ( + categorie: CategoriesJuridique, +) => categoriesJuridiques[categorie] || null; diff --git a/packages/insee/src/formatters/libelle-from-code-effectif.ts b/packages/insee/src/formatters/libelle-from-code-effectif.ts new file mode 100644 index 00000000..b16a92f6 --- /dev/null +++ b/packages/insee/src/formatters/libelle-from-code-effectif.ts @@ -0,0 +1,25 @@ +// + +import { codesEffectifs } from "#src/data"; +import type { TrancheEffectifs } from "#src/types/tranche-effectifs.js"; + +// + +export const libelleFromCodeEffectif = ( + codeEffectif: NonNullable, + anneeEffectif: string, + characterEmployeurUniteLegale?: string, +) => { + const libelle = codesEffectifs[codeEffectif]; + + if (libelle && anneeEffectif) { + return `${libelle}, en ${anneeEffectif}`; + } + if (libelle) { + return libelle; + } + if (characterEmployeurUniteLegale === "N") { + return "Unité non employeuse"; + } + return null; +}; diff --git a/packages/insee/src/formatters/libelle-from-code-naf.ts b/packages/insee/src/formatters/libelle-from-code-naf.ts new file mode 100644 index 00000000..f445530e --- /dev/null +++ b/packages/insee/src/formatters/libelle-from-code-naf.ts @@ -0,0 +1,10 @@ +// + +import { codesNaf, type CodeNaf } from "#src/data"; + +// + +export const libelleFromCodeNaf = (codeNaf: CodeNaf, addCode = true) => { + const label = codesNaf[codeNaf] || "Activité inconnue"; + return addCode && codeNaf ? `${codeNaf} - ${label}` : label; +}; diff --git a/packages/insee/src/formatters/nom-complet.ts b/packages/insee/src/formatters/nom-complet.ts new file mode 100644 index 00000000..8a0cc8e0 --- /dev/null +++ b/packages/insee/src/formatters/nom-complet.ts @@ -0,0 +1,47 @@ +// + +import { capitalize } from "lodash-es"; + +// + +type FormatNomCompletArgs = { + denominationUniteLegale: string; + prenomUsuelUniteLegale: string | null; + nomUniteLegale: string | null; + nomUsageUniteLegale: string | null; + sigleUniteLegale: string | null; +}; + +export const formatNomComplet = ({ + denominationUniteLegale, + prenomUsuelUniteLegale, + nomUniteLegale, + nomUsageUniteLegale, + sigleUniteLegale, +}: FormatNomCompletArgs) => { + const formattedFirstName = formatFirstNames([prenomUsuelUniteLegale ?? ""]); + const formattedName = formatNameFull( + nomUniteLegale ?? "", + nomUsageUniteLegale ?? "", + ); + return `${ + capitalize(denominationUniteLegale) || + [formattedFirstName, formattedName].filter((e) => !!e).join(" ") || + "Nom inconnu" + }${sigleUniteLegale ? ` (${sigleUniteLegale})` : ""}`; +}; + +const formatFirstNames = (firstNames: string[], nameCount = 0) => { + const formatted = firstNames.map(capitalize).filter((name) => !!name); + if (nameCount > 0 && nameCount < firstNames.length) { + return formatted.slice(0, nameCount).join(", "); + } + return formatted.join(", "); +}; + +const formatNameFull = (nomPatronymique = "", nomUsage = "") => { + if (nomUsage && nomPatronymique) { + return `${capitalize(nomUsage)} (${capitalize(nomPatronymique)})`; + } + return capitalize(nomUsage || nomPatronymique || ""); +}; diff --git a/packages/insee/src/token/get-insee-access-token.ts b/packages/insee/src/token/get-insee-access-token.ts index 6f4ec0ff..381106a9 100644 --- a/packages/insee/src/token/get-insee-access-token.ts +++ b/packages/insee/src/token/get-insee-access-token.ts @@ -40,3 +40,7 @@ export function getInseeAccessTokenFactory( return access_token; }; } + +export type GetInseeAccessTokenHandler = ReturnType< + typeof getInseeAccessTokenFactory +>; diff --git a/packages/insee/src/types/index.ts b/packages/insee/src/types/index.ts new file mode 100644 index 00000000..1d4896b8 --- /dev/null +++ b/packages/insee/src/types/index.ts @@ -0,0 +1,159 @@ +// + +import type { CategoriesJuridique, CodeNaf, CodeVoie } from "#src/data"; +import type { TrancheEffectifs } from "./tranche-effectifs.js"; + +// + +export * from "./tranche-effectifs.js"; + +// + +export type EtablissementSearchBySiretResponse = { + etablissement: InseeEtablissement; +}; + +export type EtablissementSearchResponse = { + header: { + total: number; + debut: number; + nombre: number; + }; + etablissements: InseeEtablissement[]; +}; + +export type InseeEtablissement = { + // ex: '217400563' + siren: string; + // ex: '00011' + nic: string; + // ex: '21740056300011' + siret: string; + // ex: 'O' + statutDiffusionEtablissement: "O" | "P" | "N"; + // ex: '1983-03-01' + dateCreationEtablissement: string; + // ex: '32' + trancheEffectifsEtablissement: TrancheEffectifs; + // ex: '2020' + anneeEffectifsEtablissement: string; + activitePrincipaleRegistreMetiersEtablissement: string | null; + // ex: '2022-08-29T09:08:45' + dateDernierTraitementEtablissement: string; + // ex: true + etablissementSiege: boolean; + // ex: 4 + nombrePeriodesEtablissement: number; + uniteLegale: { + // ex: 'A' + etatAdministratifUniteLegale: string; + // ex: 'O' + statutDiffusionUniteLegale: "O" | "P" | "N"; + // ex: '1982-01-01' + dateCreationUniteLegale: string; + // ex: '7210' + categorieJuridiqueUniteLegale: CategoriesJuridique; + // ex: 'COMMUNE DE CHAMONIX MONT BLANC' + denominationUniteLegale: string; + sigleUniteLegale: string | null; + denominationUsuelle1UniteLegale: string | null; + denominationUsuelle2UniteLegale: string | null; + denominationUsuelle3UniteLegale: string | null; + sexeUniteLegale: string | null; + nomUniteLegale: string | null; + nomUsageUniteLegale: string | null; + prenom1UniteLegale: string | null; + prenom2UniteLegale: string | null; + prenom3UniteLegale: string | null; + prenom4UniteLegale: string | null; + prenomUsuelUniteLegale: string | null; + pseudonymeUniteLegale: string | null; + // ex: '84.11Z' + activitePrincipaleUniteLegale: string; + nomenclatureActivitePrincipaleUniteLegale: "NAFRev2"; + identifiantAssociationUniteLegale: string | null; + // ex: 'N' + economieSocialeSolidaireUniteLegale: string; + // ex: 'N' + societeMissionUniteLegale: string; + // ex: 'O' + caractereEmployeurUniteLegale: string; + // ex: '32' + trancheEffectifsUniteLegale: TrancheEffectifs; + // ex: '2020' + anneeEffectifsUniteLegale: string; + // ex: '00011' + nicSiegeUniteLegale: string; + // ex: '2023-03-01T20:13:11' + dateDernierTraitementUniteLegale: string; + // ex: 'ETI' + categorieEntreprise: string; + // ex: '2020' + anneeCategorieEntreprise: string; + }; + adresseEtablissement: { + complementAdresseEtablissement: string | null; + // ex: '38' + numeroVoieEtablissement: string; + indiceRepetitionEtablissement: string | null; + // ex: 'PL' + typeVoieEtablissement: CodeVoie; + // ex: 'DE L EGLISE' + libelleVoieEtablissement: string; + // ex: '74400' + codePostalEtablissement: string; + // ex: 'CHAMONIX-MONT-BLANC' + libelleCommuneEtablissement: string; + libelleCommuneEtrangerEtablissement: string | null; + distributionSpecialeEtablissement: string | null; + // ex: '74056' + codeCommuneEtablissement: string; + codeCedexEtablissement: string | null; + libelleCedexEtablissement: string | null; + codePaysEtrangerEtablissement: string | null; + libellePaysEtrangerEtablissement: string | null; + }; + adresse2Etablissement: { + complementAdresse2Etablissement: null; + numeroVoie2Etablissement: null; + indiceRepetition2Etablissement: null; + typeVoie2Etablissement: null; + libelleVoie2Etablissement: null; + codePostal2Etablissement: null; + libelleCommune2Etablissement: null; + libelleCommuneEtranger2Etablissement: null; + distributionSpeciale2Etablissement: null; + codeCommune2Etablissement: null; + codeCedex2Etablissement: null; + libelleCedex2Etablissement: null; + codePaysEtranger2Etablissement: null; + libellePaysEtranger2Etablissement: null; + }; + periodesEtablissement: { + dateFin: string | null; + // ex: '2008-01-01' + dateDebut: string; + // ex: 'A' + etatAdministratifEtablissement: string; + // ex: false + changementEtatAdministratifEtablissement: boolean; + // ex: 'MAIRIE CHAMONIX - ARGENTIERE' + enseigne1Etablissement: string; + enseigne2Etablissement: null; + enseigne3Etablissement: null; + // ex: false + changementEnseigneEtablissement: boolean; + denominationUsuelleEtablissement: null; + // ex: false + changementDenominationUsuelleEtablissement: boolean; + // ex: '84.11Z' + activitePrincipaleEtablissement: CodeNaf; + nomenclatureActivitePrincipaleEtablissement: "NAFRev2"; + // ex: true + changementActivitePrincipaleEtablissement: boolean; + // ex: 'O' + caractereEmployeurEtablissement: string; + // ex: false + changementCaractereEmployeurEtablissement: boolean; + }[]; +}; diff --git a/packages/identite/src/types/tranche-effectifs.ts b/packages/insee/src/types/tranche-effectifs.ts similarity index 100% rename from packages/identite/src/types/tranche-effectifs.ts rename to packages/insee/src/types/tranche-effectifs.ts diff --git a/src/connectors/api-sirene.ts b/src/connectors/api-sirene.ts new file mode 100644 index 00000000..e18cc5f1 --- /dev/null +++ b/src/connectors/api-sirene.ts @@ -0,0 +1,44 @@ +// + +import { getOrganizationInfoFactory } from "@gouvfr-lasuite/proconnect.identite/organization"; +import { + findBySirenFactory, + findBySiretFactory, +} from "@gouvfr-lasuite/proconnect.insee/entreprises"; +import { getInseeAccessTokenFactory } from "@gouvfr-lasuite/proconnect.insee/token"; +import { + HTTP_CLIENT_TIMEOUT, + INSEE_CONSUMER_KEY, + INSEE_CONSUMER_SECRET, +} from "../config/env"; + +// + +export const getAccessToken = getInseeAccessTokenFactory( + { + consumerKey: INSEE_CONSUMER_KEY, + consumerSecret: INSEE_CONSUMER_SECRET, + }, + { + timeout: HTTP_CLIENT_TIMEOUT, + }, +); + +export const findBySiret = findBySiretFactory({ + getAccessToken, + config: { + timeout: HTTP_CLIENT_TIMEOUT, + }, +}); + +export const findBySiren = findBySirenFactory({ + getAccessToken, + config: { + timeout: HTTP_CLIENT_TIMEOUT, + }, +}); + +export const getOrganizationInfo = getOrganizationInfoFactory({ + findBySiren, + findBySiret, +}); diff --git a/src/connectors/api-sirene/formatters.js b/src/connectors/api-sirene/formatters.js deleted file mode 100644 index b10cec8a..00000000 --- a/src/connectors/api-sirene/formatters.js +++ /dev/null @@ -1,134 +0,0 @@ -import { capitalize, isEmpty } from "lodash-es"; -import { categoriesJuridiques } from "./categories-juridiques"; -import { codesEffectifs } from "./codes-effectifs"; -import { codesNaf } from "./codes-naf"; -import { codesVoies } from "./codes-voie"; - -export const formatEnseigne = (...args) => - capitalize(args.filter((e) => !isEmpty(e)).join(" ")) || ""; - -export const formatNomComplet = ({ - denominationUniteLegale, - prenomUsuelUniteLegale, - nomUniteLegale, - nomUsageUniteLegale, - sigleUniteLegale, -}) => { - const formattedFirstName = formatFirstNames([prenomUsuelUniteLegale]); - const formattedName = formatNameFull(nomUniteLegale, nomUsageUniteLegale); - return `${ - capitalize(denominationUniteLegale) || - [formattedFirstName, formattedName].filter((e) => !!e).join(" ") || - "Nom inconnu" - }${sigleUniteLegale ? ` (${sigleUniteLegale})` : ""}`; -}; - -export const formatNameFull = (nomPatronymique = "", nomUsage = "") => { - if (nomUsage && nomPatronymique) { - return `${capitalize(nomUsage)} (${capitalize(nomPatronymique)})`; - } - return capitalize(nomUsage || nomPatronymique || ""); -}; - -export const formatFirstNames = (firstNames, nameCount = 0) => { - const formatted = firstNames.map(capitalize).filter((name) => !!name); - if (nameCount > 0 && nameCount < firstNames.length) { - return formatted.slice(0, nameCount).join(", "); - } - return formatted.join(", "); -}; - -const wrapWord = (word, punct = " ", caps = false) => { - if (!word) { - return ""; - } - if (caps) { - return capitalize(word) + punct; - } - return word.toString().toLowerCase() + punct; -}; - -const libelleFromTypeVoie = (codeVoie) => { - return codesVoies[codeVoie] || codeVoie; -}; - -export const formatAdresseEtablissement = ({ - complementAdresseEtablissement, - numeroVoieEtablissement, - indiceRepetitionEtablissement, - typeVoieEtablissement, - libelleVoieEtablissement, - distributionSpecialeEtablissement, - codePostalEtablissement, - libelleCommuneEtablissement, - codeCedexEtablissement, - libelleCedexEtablissement, - libelleCommuneEtrangerEtablissement, - codePaysEtrangerEtablissement, - libellePaysEtrangerEtablissement, -}) => { - if ( - !complementAdresseEtablissement && - !numeroVoieEtablissement && - !typeVoieEtablissement && - !libelleCommuneEtablissement && - !distributionSpecialeEtablissement && - !codePostalEtablissement && - !codeCedexEtablissement && - !libelleVoieEtablissement && - !libelleCommuneEtrangerEtablissement && - !codePaysEtrangerEtablissement && - !libellePaysEtrangerEtablissement - ) { - return ""; - } - - const fullLibelleFromTypeVoie = libelleFromTypeVoie(typeVoieEtablissement); - - return [ - wrapWord(complementAdresseEtablissement, ", ", true), - wrapWord(numeroVoieEtablissement), - wrapWord(indiceRepetitionEtablissement), - wrapWord(fullLibelleFromTypeVoie), - wrapWord(libelleVoieEtablissement, ", "), - wrapWord(distributionSpecialeEtablissement, ", "), - wrapWord(codePostalEtablissement || codeCedexEtablissement), - wrapWord( - libelleCommuneEtablissement || - libelleCedexEtablissement || - libelleCommuneEtrangerEtablissement, - "", - true, - ), - libellePaysEtrangerEtablissement - ? `, ${wrapWord(libellePaysEtrangerEtablissement, "", true)}` - : "", - ].join(""); -}; - -export const libelleFromCodeNaf = (codeNaf = "", addCode = true) => { - const label = codesNaf[codeNaf] || "Activité inconnue"; - return addCode && codeNaf ? `${codeNaf} - ${label}` : label; -}; - -export const libelleFromCategoriesJuridiques = (categorie) => - categoriesJuridiques[categorie] || null; - -export const libelleFromCodeEffectif = ( - codeEffectif, - anneeEffectif, - characterEmployeurUniteLegale, -) => { - const libelle = codesEffectifs[codeEffectif]; - - if (libelle && anneeEffectif) { - return `${libelle}, en ${anneeEffectif}`; - } - if (libelle) { - return libelle; - } - if (characterEmployeurUniteLegale === "N") { - return "Unité non employeuse"; - } - return null; -}; diff --git a/src/connectors/api-sirene/index.ts b/src/connectors/api-sirene/index.ts deleted file mode 100644 index 659d798e..00000000 --- a/src/connectors/api-sirene/index.ts +++ /dev/null @@ -1,420 +0,0 @@ -import type { - OrganizationInfo, - TrancheEffectifs, -} from "@gouvfr-lasuite/proconnect.identite/types"; -import { getInseeAccessTokenFactory } from "@gouvfr-lasuite/proconnect.insee/token"; -import axios, { AxiosError, type AxiosResponse } from "axios"; -import { cloneDeep, set } from "lodash-es"; -import { - HTTP_CLIENT_TIMEOUT, - INSEE_CONSUMER_KEY, - INSEE_CONSUMER_SECRET, -} from "../../config/env"; -import { - InseeConnectionError, - InseeNotFoundError, - InvalidSiretError, -} from "../../config/errors"; -import { - formatAdresseEtablissement, - formatEnseigne, - formatNomComplet, - libelleFromCategoriesJuridiques, - libelleFromCodeEffectif, - libelleFromCodeNaf, -} from "./formatters"; - -type InseeEtablissement = { - // ex: '217400563' - siren: string; - // ex: '00011' - nic: string; - // ex: '21740056300011' - siret: string; - // ex: 'O' - statutDiffusionEtablissement: "O" | "P" | "N"; - // ex: '1983-03-01' - dateCreationEtablissement: string; - // ex: '32' - trancheEffectifsEtablissement: TrancheEffectifs; - // ex: '2020' - anneeEffectifsEtablissement: string; - activitePrincipaleRegistreMetiersEtablissement: string | null; - // ex: '2022-08-29T09:08:45' - dateDernierTraitementEtablissement: string; - // ex: true - etablissementSiege: boolean; - // ex: 4 - nombrePeriodesEtablissement: number; - uniteLegale: { - // ex: 'A' - etatAdministratifUniteLegale: string; - // ex: 'O' - statutDiffusionUniteLegale: "O" | "P" | "N"; - // ex: '1982-01-01' - dateCreationUniteLegale: string; - // ex: '7210' - categorieJuridiqueUniteLegale: string; - // ex: 'COMMUNE DE CHAMONIX MONT BLANC' - denominationUniteLegale: string; - sigleUniteLegale: string | null; - denominationUsuelle1UniteLegale: string | null; - denominationUsuelle2UniteLegale: string | null; - denominationUsuelle3UniteLegale: string | null; - sexeUniteLegale: string | null; - nomUniteLegale: string | null; - nomUsageUniteLegale: string | null; - prenom1UniteLegale: string | null; - prenom2UniteLegale: string | null; - prenom3UniteLegale: string | null; - prenom4UniteLegale: string | null; - prenomUsuelUniteLegale: string | null; - pseudonymeUniteLegale: string | null; - // ex: '84.11Z' - activitePrincipaleUniteLegale: string; - nomenclatureActivitePrincipaleUniteLegale: "NAFRev2"; - identifiantAssociationUniteLegale: string | null; - // ex: 'N' - economieSocialeSolidaireUniteLegale: string; - // ex: 'N' - societeMissionUniteLegale: string; - // ex: 'O' - caractereEmployeurUniteLegale: string; - // ex: '32' - trancheEffectifsUniteLegale: TrancheEffectifs; - // ex: '2020' - anneeEffectifsUniteLegale: string; - // ex: '00011' - nicSiegeUniteLegale: string; - // ex: '2023-03-01T20:13:11' - dateDernierTraitementUniteLegale: string; - // ex: 'ETI' - categorieEntreprise: string; - // ex: '2020' - anneeCategorieEntreprise: string; - }; - adresseEtablissement: { - complementAdresseEtablissement: string | null; - // ex: '38' - numeroVoieEtablissement: string; - indiceRepetitionEtablissement: string | null; - // ex: 'PL' - typeVoieEtablissement: string; - // ex: 'DE L EGLISE' - libelleVoieEtablissement: string; - // ex: '74400' - codePostalEtablissement: string; - // ex: 'CHAMONIX-MONT-BLANC' - libelleCommuneEtablissement: string; - libelleCommuneEtrangerEtablissement: string | null; - distributionSpecialeEtablissement: string | null; - // ex: '74056' - codeCommuneEtablissement: string; - codeCedexEtablissement: string | null; - libelleCedexEtablissement: string | null; - codePaysEtrangerEtablissement: string | null; - libellePaysEtrangerEtablissement: string | null; - }; - adresse2Etablissement: { - complementAdresse2Etablissement: null; - numeroVoie2Etablissement: null; - indiceRepetition2Etablissement: null; - typeVoie2Etablissement: null; - libelleVoie2Etablissement: null; - codePostal2Etablissement: null; - libelleCommune2Etablissement: null; - libelleCommuneEtranger2Etablissement: null; - distributionSpeciale2Etablissement: null; - codeCommune2Etablissement: null; - codeCedex2Etablissement: null; - libelleCedex2Etablissement: null; - codePaysEtranger2Etablissement: null; - libellePaysEtranger2Etablissement: null; - }; - periodesEtablissement: { - dateFin: string | null; - // ex: '2008-01-01' - dateDebut: string; - // ex: 'A' - etatAdministratifEtablissement: string; - // ex: false - changementEtatAdministratifEtablissement: boolean; - // ex: 'MAIRIE CHAMONIX - ARGENTIERE' - enseigne1Etablissement: string; - enseigne2Etablissement: null; - enseigne3Etablissement: null; - // ex: false - changementEnseigneEtablissement: boolean; - denominationUsuelleEtablissement: null; - // ex: false - changementDenominationUsuelleEtablissement: boolean; - // ex: '84.11Z' - activitePrincipaleEtablissement: string; - nomenclatureActivitePrincipaleEtablissement: "NAFRev2"; - // ex: true - changementActivitePrincipaleEtablissement: boolean; - // ex: 'O' - caractereEmployeurEtablissement: string; - // ex: false - changementCaractereEmployeurEtablissement: boolean; - }[]; -}; - -type EtablissementSearchBySiretResponse = { - etablissement: InseeEtablissement; -}; - -type EtablissementSearchResponse = { - header: { - total: number; - debut: number; - nombre: number; - }; - etablissements: InseeEtablissement[]; -}; - -const hideNonDiffusibleData = ( - etablissement: EtablissementSearchBySiretResponse["etablissement"], -): EtablissementSearchBySiretResponse["etablissement"] => { - const hiddenEtablissement = cloneDeep(etablissement); - set(hiddenEtablissement, "uniteLegale.denominationUniteLegale", null); - set(hiddenEtablissement, "uniteLegale.sigleUniteLegale", null); - set(hiddenEtablissement, "uniteLegale.denominationUsuelle1UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.denominationUsuelle2UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.denominationUsuelle3UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.sexeUniteLegale", null); - set(hiddenEtablissement, "uniteLegale.nomUniteLegale", null); - set(hiddenEtablissement, "uniteLegale.nomUsageUniteLegale", null); - set(hiddenEtablissement, "uniteLegale.prenom1UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.prenom2UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.prenom3UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.prenom4UniteLegale", null); - set(hiddenEtablissement, "uniteLegale.prenomUsuelUniteLegale", null); - set(hiddenEtablissement, "uniteLegale.pseudonymeUniteLegale", null); - set( - hiddenEtablissement, - "adresseEtablissement.complementAdresseEtablissement", - null, - ); - set( - hiddenEtablissement, - "adresseEtablissement.numeroVoieEtablissement", - null, - ); - set( - hiddenEtablissement, - "adresseEtablissement.indiceRepetitionEtablissement", - null, - ); - set(hiddenEtablissement, "adresseEtablissement.typeVoieEtablissement", null); - set( - hiddenEtablissement, - "adresseEtablissement.libelleVoieEtablissement", - null, - ); - set( - hiddenEtablissement, - "adresse2Etablissement.complementAdresse2Etablissement", - null, - ); - set( - hiddenEtablissement, - "adresse2Etablissement.numeroVoie2Etablissement", - null, - ); - set( - hiddenEtablissement, - "adresse2Etablissement.indiceRepetition2Etablissement", - null, - ); - set( - hiddenEtablissement, - "adresse2Etablissement.typeVoie2Etablissement", - null, - ); - set( - hiddenEtablissement, - "adresse2Etablissement.libelleVoie2Etablissement", - null, - ); - - set( - hiddenEtablissement, - "periodesEtablissement.0.enseigne1Etablissement", - null, - ); - set( - hiddenEtablissement, - "periodesEtablissement.0.enseigne2Etablissement", - null, - ); - set( - hiddenEtablissement, - "periodesEtablissement.0.enseigne3Etablissement", - null, - ); - set( - hiddenEtablissement, - "periodesEtablissement.0.denominationUsuelleEtablissement", - null, - ); - - return hiddenEtablissement; -}; - -export const getInseeAccessToken = getInseeAccessTokenFactory( - { - consumerKey: INSEE_CONSUMER_KEY, - consumerSecret: INSEE_CONSUMER_SECRET, - }, - { - timeout: HTTP_CLIENT_TIMEOUT, - }, -); - -export const getOrganizationInfo = async ( - siretOrSiren: string, - provided_access_token?: string, -): Promise => { - try { - let access_token = provided_access_token; - if (!access_token) { - access_token = await getInseeAccessToken(); - } - - let etablissement: InseeEtablissement; - - if (siretOrSiren.match(/^\d{14}$/)) { - let { data }: AxiosResponse = - await axios.get( - `https://api.insee.fr/entreprises/sirene/siret/${siretOrSiren}`, - { - headers: { Authorization: `Bearer ${access_token}` }, - timeout: HTTP_CLIENT_TIMEOUT, - }, - ); - - etablissement = data.etablissement; - } else if (siretOrSiren.match(/^\d{9}$/)) { - // siretOrSiren is a siren, we fetch the data of the siege social - let { data }: AxiosResponse = - await axios.get( - `https://api.insee.fr/entreprises/sirene/siret?q=siren:${siretOrSiren} AND etablissementSiege:true`, - { - headers: { Authorization: `Bearer ${access_token}` }, - timeout: HTTP_CLIENT_TIMEOUT, - }, - ); - - etablissement = data.etablissements[0]; - } else { - throw new InvalidSiretError(); - } - - const { statutDiffusionEtablissement } = etablissement; - - if (statutDiffusionEtablissement === "N") { - throw new InseeNotFoundError(); - } - - if (statutDiffusionEtablissement === "P") { - etablissement = hideNonDiffusibleData(etablissement); - } - - const { - siret: siretFromInseeApi, - trancheEffectifsEtablissement, - anneeEffectifsEtablissement, - adresseEtablissement, - periodesEtablissement, - uniteLegale, - } = etablissement; - - const { - categorieJuridiqueUniteLegale, - denominationUniteLegale, - sigleUniteLegale, - nomUniteLegale, - nomUsageUniteLegale, - prenomUsuelUniteLegale, - trancheEffectifsUniteLegale, - } = uniteLegale; - - // get last period to obtain most recent data - const { - activitePrincipaleEtablissement, - enseigne1Etablissement, - enseigne2Etablissement, - enseigne3Etablissement, - etatAdministratifEtablissement, - } = periodesEtablissement[0]; - - const { codePostalEtablissement, codeCommuneEtablissement } = - adresseEtablissement; - - const enseigne = formatEnseigne( - enseigne1Etablissement, - enseigne2Etablissement, - enseigne3Etablissement, - ); - - const nomComplet = formatNomComplet({ - denominationUniteLegale, - prenomUsuelUniteLegale, - nomUniteLegale, - nomUsageUniteLegale, - sigleUniteLegale, - }); - - const organizationLabel = `${nomComplet}${ - enseigne ? ` - ${enseigne}` : "" - }`; - - return { - siret: siretFromInseeApi, - libelle: organizationLabel, - nomComplet, - enseigne, - trancheEffectifs: trancheEffectifsEtablissement, - trancheEffectifsUniteLegale, - libelleTrancheEffectif: libelleFromCodeEffectif( - trancheEffectifsEtablissement, - anneeEffectifsEtablissement, - ), - etatAdministratif: etatAdministratifEtablissement, - estActive: etatAdministratifEtablissement === "A", - statutDiffusion: statutDiffusionEtablissement, - estDiffusible: statutDiffusionEtablissement === "O", - adresse: formatAdresseEtablissement(adresseEtablissement), - codePostal: codePostalEtablissement, - codeOfficielGeographique: codeCommuneEtablissement, - activitePrincipale: activitePrincipaleEtablissement, - libelleActivitePrincipale: libelleFromCodeNaf( - activitePrincipaleEtablissement, - ), - categorieJuridique: categorieJuridiqueUniteLegale, - libelleCategorieJuridique: libelleFromCategoriesJuridiques( - categorieJuridiqueUniteLegale, - ), - }; - } catch (e) { - if ( - e instanceof AxiosError && - e.response && - [403, 404].includes(e.response.status) - ) { - throw new InseeNotFoundError(); - } - - if ( - e instanceof AxiosError && - (e.code === "ECONNABORTED" || - e.code === "ERR_BAD_RESPONSE" || - e.code === "EAI_AGAIN") - ) { - throw new InseeConnectionError(); - } - - throw e; - } -}; diff --git a/test/api-sirene.test.ts b/test/api-sirene.test.ts deleted file mode 100644 index 4189e18b..00000000 --- a/test/api-sirene.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import nock from "nock"; -import { InseeNotFoundError } from "../src/config/errors"; -import { getOrganizationInfo } from "../src/connectors/api-sirene"; -import diffusible from "./api-sirene-data/diffusible.json"; -import partiallyNonDiffusible from "./api-sirene-data/partially-non-diffusible.json"; -import searchBySiren from "./api-sirene-data/search-by-siren.json"; - -chai.use(chaiAsPromised); -const assert = chai.assert; - -describe("getOrganizationInfo", () => { - beforeEach(() => { - nock("https://api.insee.fr").post("/token").reply(200, { - access_token: "08e42802-9ac9-3403-a2a9-b5be11ce446c", - scope: "am_application_scope default", - token_type: "Bearer", - expires_in: 521596, - }); - }); - - const diffusibleOrganizationInfo = { - siret: "20007184300060", - libelle: "Cc du vexin normand", - nomComplet: "Cc du vexin normand", - enseigne: "", - trancheEffectifs: "22", - trancheEffectifsUniteLegale: "22", - libelleTrancheEffectif: "100 à 199 salariés, en 2021", - etatAdministratif: "A", - estActive: true, - statutDiffusion: "O", - estDiffusible: true, - adresse: "3 rue maison de vatimesnil, 27150 Etrepagny", - codePostal: "27150", - codeOfficielGeographique: "27226", - activitePrincipale: "84.11Z", - libelleActivitePrincipale: "84.11Z - Administration publique générale", - categorieJuridique: "7346", - libelleCategorieJuridique: "Communauté de communes", - }; - - it("should return valid payload for diffusible établissement", async () => { - nock("https://api.insee.fr") - .get("/entreprises/sirene/siret/20007184300060") - .reply(200, diffusible); - await assert.eventually.deepEqual( - getOrganizationInfo("20007184300060"), - diffusibleOrganizationInfo, - ); - }); - - it("should return valid payload for diffusible établissement", async () => { - nock("https://api.insee.fr") - .get( - "/entreprises/sirene/siret?q=siren:200071843 AND etablissementSiege:true", - ) - .reply(200, searchBySiren); - await assert.eventually.deepEqual( - getOrganizationInfo("200071843"), - diffusibleOrganizationInfo, - ); - }); - - it("should show partial data for partially non diffusible établissement", async () => { - nock("https://api.insee.fr") - .get("/entreprises/sirene/siret/94957325700019") - .reply(200, partiallyNonDiffusible); - - await assert.eventually.deepEqual(getOrganizationInfo("94957325700019"), { - siret: "94957325700019", - libelle: "Nom inconnu", - nomComplet: "Nom inconnu", - enseigne: "", - trancheEffectifs: null, - trancheEffectifsUniteLegale: null, - libelleTrancheEffectif: null, - etatAdministratif: "A", - estActive: true, - statutDiffusion: "P", - estDiffusible: false, - adresse: "06220 Vallauris", - codePostal: "06220", - codeOfficielGeographique: "06155", - activitePrincipale: "62.02A", - libelleActivitePrincipale: - "62.02A - Conseil en systèmes et logiciels informatiques", - categorieJuridique: "1000", - libelleCategorieJuridique: "Entrepreneur individuel", - }); - }); - - it("should throw for totally non diffusible établissement", async () => { - nock("https://api.insee.fr") - .get("/entreprises/sirene/siret/53512638700013") - .reply(403, { - header: { - statut: 403, - message: "Établissement non diffusable (53512638700013)", - }, - }); - await assert.isRejected( - getOrganizationInfo("53512638700013"), - InseeNotFoundError, - ); - }); -});