diff --git a/package-lock.json b/package-lock.json index 1fcd87dd..d971d25a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2657,10 +2657,11 @@ } }, "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } @@ -10695,6 +10696,7 @@ "@gouvfr-lasuite/proconnect.core": "^0.4.0", "@gouvfr-lasuite/proconnect.devtools.typescript": "0.0.0", "@gouvfr-lasuite/proconnect.insee": "^0.3.2", + "@sinonjs/fake-timers": "^14.0.0", "@types/mocha": "^10.0.10", "@types/node": "^22.10.2", "await-to-js": "^3.0.0", @@ -10705,6 +10707,16 @@ "tsx": "^4.19.2" } }, + "packages/identite/node_modules/@sinonjs/fake-timers": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-14.0.0.tgz", + "integrity": "sha512-QfoXRaUTjMVVn/ZbnD4LS3TPtqOkOdKIYCKldIVPnuClcwRKat6LI2mRZ2s5qiBfO6Fy03An35dSls/2/FEc0Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "packages/identity-repository": { "name": "@gouvfr-lasuite/proconnect.identity-repository", "version": "0.2.0", diff --git a/packages/identite/package.json b/packages/identite/package.json index 6576124d..f4497c59 100644 --- a/packages/identite/package.json +++ b/packages/identite/package.json @@ -54,6 +54,7 @@ "@gouvfr-lasuite/proconnect.core": "^0.4.0", "@gouvfr-lasuite/proconnect.devtools.typescript": "0.0.0", "@gouvfr-lasuite/proconnect.insee": "^0.3.2", + "@sinonjs/fake-timers": "^14.0.0", "@types/mocha": "^10.0.10", "@types/node": "^22.10.2", "await-to-js": "^3.0.0", diff --git a/packages/identite/src/errors/index.ts b/packages/identite/src/errors/index.ts new file mode 100644 index 00000000..0e1f90f3 --- /dev/null +++ b/packages/identite/src/errors/index.ts @@ -0,0 +1 @@ +export class NotFoundError extends Error {} diff --git a/packages/identite/src/managers/organization/force-join-organization.test.ts b/packages/identite/src/managers/organization/force-join-organization.test.ts new file mode 100644 index 00000000..4ca42341 --- /dev/null +++ b/packages/identite/src/managers/organization/force-join-organization.test.ts @@ -0,0 +1,65 @@ +import { NotFoundError } from "#src/errors"; +import type { Organization, User } from "#src/types"; +import * as chai from "chai"; +import "chai-as-promised"; +import chaiAsPromised from "chai-as-promised"; +import { describe } from "mocha"; +import { forceJoinOrganizationFactory } from "./force-join-organization.js"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +describe(forceJoinOrganizationFactory.name, () => { + it("should update the organization user link ", async () => { + const forceJoinOrganization = forceJoinOrganizationFactory({ + findById: () => Promise.resolve({ id: 42 } as Organization), + findEmailDomainsByOrganizationId: () => Promise.resolve([]), + findUserById: () => + Promise.resolve({ email: "lion.eljonson@darkangels.world" } as User), + linkUserToOrganization: (values) => Promise.resolve(values as any), + }); + + await expect( + forceJoinOrganization({ + organization_id: 42, + user_id: 42, + }), + ).eventually.deep.equal({ + is_external: false, + organization_id: 42, + user_id: 42, + verification_type: "no_validation_means_available", + }); + }); + + it("❎ throws NotFoundError for unknown organization", async () => { + const forceJoinOrganization = forceJoinOrganizationFactory({ + findById: () => Promise.resolve(undefined), + findEmailDomainsByOrganizationId: () => Promise.resolve([]), + findUserById: () => Promise.resolve({ id: 42 } as User), + linkUserToOrganization: () => Promise.reject(), + }); + + await expect( + forceJoinOrganization({ + organization_id: 42, + user_id: 42, + }), + ).rejectedWith(NotFoundError); + }); + + it("❎ throws NotFoundError for unknown user", async () => { + const forceJoinOrganization = forceJoinOrganizationFactory({ + findById: () => Promise.resolve({ id: 42 } as Organization), + findEmailDomainsByOrganizationId: () => Promise.resolve([]), + findUserById: () => Promise.resolve(undefined), + linkUserToOrganization: () => Promise.reject(), + }); + + await expect( + forceJoinOrganization({ + organization_id: 42, + user_id: 42, + }), + ).rejectedWith(NotFoundError); + }); +}); diff --git a/packages/identite/src/managers/organization/force-join-organization.ts b/packages/identite/src/managers/organization/force-join-organization.ts new file mode 100644 index 00000000..48fb1376 --- /dev/null +++ b/packages/identite/src/managers/organization/force-join-organization.ts @@ -0,0 +1,74 @@ +// + +import { NotFoundError } from "#src/errors"; +import type { FindEmailDomainsByOrganizationIdHandler } from "#src/repositories/email-domain"; +import type { + FindByIdHandler as FindOrganizationByIdHandler, + LinkUserToOrganizationHandler, +} from "#src/repositories/organization"; +import type { FindByIdHandler as FindUserByIdHandler } from "#src/repositories/user"; +import type { BaseUserOrganizationLink } from "#src/types"; +import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; +import { isEmpty, some } from "lodash-es"; + +// + +type FactoryDependencies = { + findById: FindOrganizationByIdHandler; + findEmailDomainsByOrganizationId: FindEmailDomainsByOrganizationIdHandler; + findUserById: FindUserByIdHandler; + linkUserToOrganization: LinkUserToOrganizationHandler; +}; + +// + +export function forceJoinOrganizationFactory({ + findById, + findEmailDomainsByOrganizationId, + findUserById, + linkUserToOrganization, +}: FactoryDependencies) { + return async function forceJoinOrganization({ + organization_id, + user_id, + is_external = false, + }: { + organization_id: number; + user_id: number; + is_external?: boolean; + }) { + const user = await findUserById(user_id); + const organization = await findById(organization_id); + if (isEmpty(user) || isEmpty(organization)) { + throw new NotFoundError(); + } + const { email } = user; + const domain = getEmailDomain(email); + const organizationEmailDomains = + await findEmailDomainsByOrganizationId(organization_id); + + let link_verification_type: BaseUserOrganizationLink["verification_type"]; + if ( + some(organizationEmailDomains, { + domain, + verification_type: "verified", + }) || + some(organizationEmailDomains, { + domain, + verification_type: "trackdechets_postal_mail", + }) || + some(organizationEmailDomains, { domain, verification_type: "external" }) + ) { + link_verification_type = "domain"; + } else { + link_verification_type = "no_validation_means_available"; + } + + return await linkUserToOrganization({ + organization_id, + user_id, + is_external, + verification_type: link_verification_type, + }); + }; +} diff --git a/packages/identite/src/managers/organization/index.ts b/packages/identite/src/managers/organization/index.ts index a13e1ebe..1106d2fc 100644 --- a/packages/identite/src/managers/organization/index.ts +++ b/packages/identite/src/managers/organization/index.ts @@ -1,4 +1,5 @@ // +export * from "./force-join-organization.js"; export * from "./get-organization-info.js"; export * from "./mark-domain-as-verified.js"; diff --git a/packages/identite/src/repositories/organization/index.ts b/packages/identite/src/repositories/organization/index.ts index 7f11f890..fa220070 100644 --- a/packages/identite/src/repositories/organization/index.ts +++ b/packages/identite/src/repositories/organization/index.ts @@ -2,4 +2,5 @@ export * from "./find-by-id.js"; export * from "./get-users-by-organization.js"; +export * from "./link-user-to-organization.js"; export * from "./upsert.js"; diff --git a/packages/identite/src/repositories/organization/link-user-to-organization.test.ts b/packages/identite/src/repositories/organization/link-user-to-organization.test.ts new file mode 100644 index 00000000..bf6b40e2 --- /dev/null +++ b/packages/identite/src/repositories/organization/link-user-to-organization.test.ts @@ -0,0 +1,56 @@ +import { emptyDatabase, migrate, pg } from "#testing"; +import FakeTimers from "@sinonjs/fake-timers"; +import * as chai from "chai"; +import "chai-as-promised"; +import chaiAsPromised from "chai-as-promised"; +import { describe } from "mocha"; +import { linkUserToOrganizationFactory } from "./link-user-to-organization.js"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +// + +const linkUserToOrganization = linkUserToOrganizationFactory({ pg: pg as any }); + +describe(linkUserToOrganizationFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should link user to organization", async () => { + await pg.sql` + INSERT INTO organizations + (cached_libelle, cached_nom_complet, id, siret, created_at, updated_at) + VALUES + ('Necron', 'Necrontyr', 1, '⚰️', '1967-12-19', '1967-12-19') + ; + `; + 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') + ; + `; + FakeTimers.install({ now: new Date("4444-04-04") }); + + const userOrganizationLink = await linkUserToOrganization({ + organization_id: 1, + user_id: 1, + verification_type: "bypassed", + }); + + expect(userOrganizationLink).to.deep.equal({ + created_at: new Date("4444-04-04"), + has_been_greeted: false, + is_external: false, + needs_official_contact_email_verification: false, + official_contact_email_verification_sent_at: null, + official_contact_email_verification_token: null, + organization_id: 1, + updated_at: new Date("4444-04-04"), + user_id: 1, + verification_type: "bypassed", + verified_at: null, + }); + }); +}); diff --git a/packages/identite/src/repositories/organization/link-user-to-organization.ts b/packages/identite/src/repositories/organization/link-user-to-organization.ts new file mode 100644 index 00000000..b5875192 --- /dev/null +++ b/packages/identite/src/repositories/organization/link-user-to-organization.ts @@ -0,0 +1,49 @@ +import type { DatabaseContext, UserOrganizationLink } from "#src/types"; +import type { QueryResult } from "pg"; + +export function linkUserToOrganizationFactory({ pg }: DatabaseContext) { + return async function linkUserToOrganization({ + organization_id, + user_id, + is_external = false, + verification_type, + needs_official_contact_email_verification = false, + }: { + organization_id: number; + user_id: number; + is_external?: boolean; + verification_type: UserOrganizationLink["verification_type"]; + needs_official_contact_email_verification?: UserOrganizationLink["needs_official_contact_email_verification"]; + }) { + const { rows }: QueryResult = await pg.query( + ` + INSERT INTO users_organizations + (user_id, + organization_id, + is_external, + verification_type, + needs_official_contact_email_verification, + updated_at, + created_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, + [ + user_id, + organization_id, + is_external, + verification_type, + needs_official_contact_email_verification, + new Date(), + new Date(), + ], + ); + + return rows.shift()! as UserOrganizationLink; + }; +} + +export type LinkUserToOrganizationHandler = ReturnType< + typeof linkUserToOrganizationFactory +>; diff --git a/packages/identite/src/repositories/organization/upsert.ts b/packages/identite/src/repositories/organization/upsert.ts index a42c8054..cc530194 100644 --- a/packages/identite/src/repositories/organization/upsert.ts +++ b/packages/identite/src/repositories/organization/upsert.ts @@ -138,3 +138,5 @@ export function upsertFactory({ pg }: DatabaseContext) { return rows.shift()!; }; } + +export type UpsertHandler = ReturnType; diff --git a/packages/identite/src/repositories/user/find-by-id.ts b/packages/identite/src/repositories/user/find-by-id.ts index 8b34c299..3a61c968 100644 --- a/packages/identite/src/repositories/user/find-by-id.ts +++ b/packages/identite/src/repositories/user/find-by-id.ts @@ -19,3 +19,5 @@ export function findByIdFactory({ pg }: DatabaseContext) { return rows.shift(); }; } + +export type FindByIdHandler = ReturnType; diff --git a/src/config/errors.ts b/src/config/errors.ts index 694606d7..22571d99 100644 --- a/src/config/errors.ts +++ b/src/config/errors.ts @@ -17,8 +17,6 @@ export class InseeNotActiveError extends Error {} export class UserNotFoundError extends Error {} -export class NotFoundError extends Error {} - export class ForbiddenError extends Error {} export class UnableToAutoJoinOrganizationError extends Error { diff --git a/src/controllers/api.ts b/src/controllers/api.ts index 0aafb198..1acb0c84 100644 --- a/src/controllers/api.ts +++ b/src/controllers/api.ts @@ -1,11 +1,8 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { NextFunction, Request, Response } from "express"; import HttpErrors from "http-errors"; import { z, ZodError } from "zod"; -import { - InseeConnectionError, - InseeNotFoundError, - NotFoundError, -} from "../config/errors"; +import { InseeConnectionError, InseeNotFoundError } from "../config/errors"; import notificationMessages from "../config/notification-messages"; import { getOrganizationInfo } from "../connectors/api-sirene"; import { sendModerationProcessedEmail } from "../managers/moderation"; diff --git a/src/controllers/organization.ts b/src/controllers/organization.ts index e7d69be6..67013142 100644 --- a/src/controllers/organization.ts +++ b/src/controllers/organization.ts @@ -1,4 +1,5 @@ import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { NextFunction, Request, Response } from "express"; import HttpErrors from "http-errors"; import { isEmpty } from "lodash-es"; @@ -7,7 +8,6 @@ import { InseeConnectionError, InseeNotActiveError, InvalidSiretError, - NotFoundError, UnableToAutoJoinOrganizationError, UserAlreadyAskedToJoinOrganizationError, UserInOrganizationAlreadyError, diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index 0de40536..016c4dac 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -1,6 +1,7 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { NextFunction, Request, Response } from "express"; import { z } from "zod"; -import { InvalidTotpTokenError, NotFoundError } from "../config/errors"; +import { InvalidTotpTokenError } from "../config/errors"; import { addAuthenticationMethodReferenceInSession, getUserFromAuthenticatedSession, diff --git a/src/controllers/user/edit-moderation.ts b/src/controllers/user/edit-moderation.ts index 72948763..e414920a 100644 --- a/src/controllers/user/edit-moderation.ts +++ b/src/controllers/user/edit-moderation.ts @@ -1,6 +1,6 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { NextFunction, Request, Response } from "express"; import { z } from "zod"; -import { NotFoundError } from "../../config/errors"; import { cancelModeration } from "../../managers/moderation"; import { getUserFromAuthenticatedSession } from "../../managers/session/authenticated"; import { idSchema } from "../../services/custom-zod-schemas"; diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 18cbe9c0..e7a290a2 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -1,3 +1,4 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { AuthenticationResponseJSON, RegistrationResponseJSON, @@ -6,7 +7,6 @@ import type { NextFunction, Request, Response } from "express"; import HttpErrors from "http-errors"; import { z, ZodError } from "zod"; import { - NotFoundError, UserNotLoggedInError, WebauthnAuthenticationFailedError, WebauthnRegistrationFailedError, diff --git a/src/managers/moderation.ts b/src/managers/moderation.ts index 239add3e..3759b018 100644 --- a/src/managers/moderation.ts +++ b/src/managers/moderation.ts @@ -1,8 +1,9 @@ import { ModerationProcessed } from "@gouvfr-lasuite/proconnect.email"; +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; 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"; +import { ForbiddenError } from "../config/errors"; import { sendMail } from "../connectors/mail"; import { deleteModeration, diff --git a/src/managers/oidc-client.ts b/src/managers/oidc-client.ts index d6b2e2d9..5c6c986e 100644 --- a/src/managers/oidc-client.ts +++ b/src/managers/oidc-client.ts @@ -1,7 +1,7 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import * as Sentry from "@sentry/node"; import { isEmpty, isString } from "lodash-es"; import type { KoaContextWithOIDC } from "oidc-provider"; -import { NotFoundError } from "../config/errors"; import { addConnection, findByClientId } from "../repositories/oidc-client"; import { getSelectedOrganizationId } from "../repositories/redis/selected-organization"; import { logger } from "../services/log"; diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index 5c19badb..9262d118 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -1,8 +1,9 @@ import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import { Welcome } from "@gouvfr-lasuite/proconnect.email"; +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; +import { forceJoinOrganizationFactory } from "@gouvfr-lasuite/proconnect.identite/managers/organization"; import type { - BaseUserOrganizationLink, Organization, OrganizationInfo, UserOrganizationLink, @@ -19,7 +20,6 @@ import { InseeConnectionError, InseeNotActiveError, InvalidSiretError, - NotFoundError, UnableToAutoJoinOrganizationError, UserAlreadyAskedToJoinOrganizationError, UserInOrganizationAlreadyError, @@ -361,46 +361,13 @@ export const joinOrganization = async ({ throw new UnableToAutoJoinOrganizationError(moderation_id); }; -export const forceJoinOrganization = async ({ - organization_id, - user_id, - is_external = false, -}: { - organization_id: number; - user_id: number; - is_external?: boolean; -}) => { - const user = await findUserById(user_id); - const organization = await findById(organization_id); - if (isEmpty(user) || isEmpty(organization)) { - throw new NotFoundError(); - } - const { email } = user; - const domain = getEmailDomain(email); - const organizationEmailDomains = - await findEmailDomainsByOrganizationId(organization_id); - let link_verification_type: BaseUserOrganizationLink["verification_type"]; - if ( - some(organizationEmailDomains, { domain, verification_type: "verified" }) || - some(organizationEmailDomains, { - domain, - verification_type: "trackdechets_postal_mail", - }) || - some(organizationEmailDomains, { domain, verification_type: "external" }) - ) { - link_verification_type = "domain"; - } else { - link_verification_type = "no_validation_means_available"; - } - - return await linkUserToOrganization({ - organization_id, - user_id, - is_external, - verification_type: link_verification_type, - }); -}; +export const forceJoinOrganization = forceJoinOrganizationFactory({ + findById, + findEmailDomainsByOrganizationId, + findUserById, + linkUserToOrganization, +}); export const greetForJoiningOrganization = async ({ user_id, diff --git a/src/managers/organization/main.ts b/src/managers/organization/main.ts index 5f9eef92..b511b602 100644 --- a/src/managers/organization/main.ts +++ b/src/managers/organization/main.ts @@ -1,7 +1,7 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import { markDomainAsVerifiedFactory } from "@gouvfr-lasuite/proconnect.identite/managers/organization"; import type { Organization } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty } from "lodash-es"; -import { NotFoundError } from "../../config/errors"; import { addDomain, findEmailDomainsByOrganizationId, diff --git a/src/managers/organization/official-contact-email-verification.ts b/src/managers/organization/official-contact-email-verification.ts index a6857be2..9d68499b 100644 --- a/src/managers/organization/official-contact-email-verification.ts +++ b/src/managers/organization/official-contact-email-verification.ts @@ -1,12 +1,12 @@ import { generateDicewarePassword } from "@gouvfr-lasuite/proconnect.core/security"; import { OfficialContactEmailVerification } from "@gouvfr-lasuite/proconnect.email"; +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { UserOrganizationLink } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty } from "lodash-es"; import { HOST } from "../../config/env"; import { ApiAnnuaireError, InvalidTokenError, - NotFoundError, OfficialContactEmailVerificationNotNeededError, } from "../../config/errors"; import { getAnnuaireEducationNationaleContactEmail } from "../../connectors/api-annuaire-education-nationale"; diff --git a/src/managers/user.ts b/src/managers/user.ts index cc3406d4..f2042954 100644 --- a/src/managers/user.ts +++ b/src/managers/user.ts @@ -18,6 +18,7 @@ import { UpdatePersonalDataMail, VerifyEmail, } from "@gouvfr-lasuite/proconnect.email"; +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty } from "lodash-es"; import { @@ -35,7 +36,6 @@ import { InvalidTokenError, LeakedPasswordError, NoNeedVerifyEmailAddressError, - NotFoundError, UserNotFoundError, WeakPasswordError, } from "../config/errors"; diff --git a/src/managers/webauthn.ts b/src/managers/webauthn.ts index 790b371d..ddf5554c 100644 --- a/src/managers/webauthn.ts +++ b/src/managers/webauthn.ts @@ -1,3 +1,4 @@ +import { NotFoundError } from "@gouvfr-lasuite/proconnect.identite/errors"; import { generateAuthenticationOptions, generateRegistrationOptions, @@ -15,7 +16,6 @@ import moment from "moment"; import "moment-timezone"; import { APPLICATION_NAME, HOST, WEBSITE_IDENTIFIER } from "../config/env"; import { - NotFoundError, UserNotFoundError, WebauthnAuthenticationFailedError, WebauthnRegistrationFailedError,