From ec9dbe0d8fcee5e83d5ec534a3637b60431958eb Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 11 Aug 2026 13:12:59 +0000 Subject: [PATCH] Add end-to-end tests for SaaS email triggers and user account bridges - Implemented e2e tests for email trigger inversion scenarios in `saas-email-trigger-inversion-e2e.test.ts`, covering user registration, email verification, password reset requests, and email change requests with and without the suppressEmail flag. - Created comprehensive tests for user account management in `saas-user-account-bridges-e2e.test.ts`, including user profile retrieval, password changes, name updates, settings management, 2FA enrollment and verification, and account deletion. - Ensured proper handling of microservice JWT authentication across all new tests. --- backend/src/common/data-injection.tokens.ts | 3 + ...user-in-company-and-connection-group.ds.ts | 2 + ...user-in-company-and-connection-group.ds.ts | 17 +- .../invite-user-in-company.use.case.ts | 99 +++- .../outgoing-email-payload.ds.ts | 34 ++ .../src/entities/email/email/email.service.ts | 190 +++---- ...erification-custom-repository-extension.ts | 16 +- .../data-structures/change-user-email.ds.ts | 2 + .../operation-result-message.ds.ts | 9 + .../request-email-change.ds.ts | 4 + .../request-password-reset.ds.ts | 12 + .../data-structures/usual-register-user.ds.ts | 8 + .../request-change-user-email.use.case.ts | 21 +- .../request-email-verification.use.case.ts | 24 +- .../request-reset-user-password.use.case.ts | 30 +- .../use-cases/user-use-cases.interfaces.ts | 27 +- .../verify-change-user-email.use.case.ts | 19 +- backend/src/entities/user/user.controller.ts | 11 +- backend/src/exceptions/text/messages.ts | 1 - .../agents-microservice/agents.controller.ts | 2 +- .../data-structures/agents.ds.ts | 8 + .../dto/agents-auth.dtos.ts | 18 +- .../use-cases/agents-use-cases.interface.ts | 3 +- .../use-cases/validate-user-token.use.case.ts | 17 +- .../saas-email-gateway.service.ts | 70 +++ .../saas-gateway.ts/saas-gateway.module.ts | 5 +- .../data-structures/saas-email-flows.dtos.ts | 40 +- .../data-structures/saas-otp-login.ds.ts | 6 + .../data-structures/saas-user-account.dtos.ts | 151 +++++ .../saas-microservice/saas.controller.ts | 271 ++++++++- .../saas-microservice/saas.module.ts | 75 +++ .../use-cases/saas-otp-login.use.case.ts | 73 +++ .../use-cases/saas-use-cases.interface.ts | 8 +- .../saas-usual-register-user.use.case.ts | 39 +- .../update-user-as-admin.ds.ts | 11 + .../dto/update-user-email-as-admin.dto.ts | 10 + .../dto/update-user-password-as-admin.dto.ts | 11 + .../selfhosted-use-cases.interfaces.ts | 10 + .../update-user-email-as-admin.use.case.ts | 65 +++ .../update-user-password-as-admin.use.case.ts | 54 ++ .../selfhosted-operations.controller.ts | 58 +- .../selhosted-operations.module.ts | 42 +- ...non-saas-selfhosted-user-admin-e2e.test.ts | 360 ++++++++++++ .../saas-email-trigger-inversion-e2e.test.ts | 409 ++++++++++++++ .../saas-user-account-bridges-e2e.test.ts | 521 ++++++++++++++++++ 45 files changed, 2628 insertions(+), 238 deletions(-) create mode 100644 backend/src/entities/email/application/data-structures/outgoing-email-payload.ds.ts create mode 100644 backend/src/entities/user/application/data-structures/request-password-reset.ds.ts create mode 100644 backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts create mode 100644 backend/src/microservices/saas-microservice/data-structures/saas-otp-login.ds.ts create mode 100644 backend/src/microservices/saas-microservice/data-structures/saas-user-account.dtos.ts create mode 100644 backend/src/microservices/saas-microservice/use-cases/saas-otp-login.use.case.ts create mode 100644 backend/src/selfhosted-operations/application/data-structures/update-user-as-admin.ds.ts create mode 100644 backend/src/selfhosted-operations/application/dto/update-user-email-as-admin.dto.ts create mode 100644 backend/src/selfhosted-operations/application/dto/update-user-password-as-admin.dto.ts create mode 100644 backend/src/selfhosted-operations/application/use-cases/update-user-email-as-admin.use.case.ts create mode 100644 backend/src/selfhosted-operations/application/use-cases/update-user-password-as-admin.use.case.ts create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-selfhosted-user-admin-e2e.test.ts create mode 100644 backend/test/ava-tests/saas-tests/saas-email-trigger-inversion-e2e.test.ts create mode 100644 backend/test/ava-tests/saas-tests/saas-user-account-bridges-e2e.test.ts diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index e2afdaa77..9bbc8dbe5 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -130,6 +130,7 @@ export enum UseCaseType { SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD = 'SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD', SAAS_GET_CONNECTIONS_INFO_BY_IDS = 'SAAS_GET_CONNECTIONS_INFO_BY_IDS', SAAS_GET_HOSTED_CONNECTION_CREDENTIALS = 'SAAS_GET_HOSTED_CONNECTION_CREDENTIALS', + SAAS_OTP_LOGIN = 'SAAS_OTP_LOGIN', INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP', VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP', @@ -242,6 +243,8 @@ export enum UseCaseType { IS_CONFIGURED = 'IS_CONFIGURED', CREATE_INITIAL_USER = 'CREATE_INITIAL_USER', + SELFHOSTED_UPDATE_USER_PASSWORD = 'SELFHOSTED_UPDATE_USER_PASSWORD', + SELFHOSTED_UPDATE_USER_EMAIL = 'SELFHOSTED_UPDATE_USER_EMAIL', GENERATE_SCHEMA_CHANGE = 'GENERATE_SCHEMA_CHANGE', APPROVE_SCHEMA_CHANGE = 'APPROVE_SCHEMA_CHANGE', diff --git a/backend/src/entities/company-info/application/data-structures/invite-user-in-company-and-connection-group.ds.ts b/backend/src/entities/company-info/application/data-structures/invite-user-in-company-and-connection-group.ds.ts index b71db9811..5c78ebebb 100644 --- a/backend/src/entities/company-info/application/data-structures/invite-user-in-company-and-connection-group.ds.ts +++ b/backend/src/entities/company-info/application/data-structures/invite-user-in-company-and-connection-group.ds.ts @@ -10,4 +10,6 @@ export class InviteUserInCompanyAndConnectionGroupDs { inviteLinkBase?: string; /** Satellite-provided prefix for the confirmation link the re-invite branch sends to inactive users. */ emailVerificationLinkBase?: string; + /** Bridge-only (plan 15 Phase 2): skip the send(s) and return the email payload instead. */ + suppressEmail?: boolean; } diff --git a/backend/src/entities/company-info/application/data-structures/invited-user-in-company-and-connection-group.ds.ts b/backend/src/entities/company-info/application/data-structures/invited-user-in-company-and-connection-group.ds.ts index f7ec4e775..87fbc2401 100644 --- a/backend/src/entities/company-info/application/data-structures/invited-user-in-company-and-connection-group.ds.ts +++ b/backend/src/entities/company-info/application/data-structures/invited-user-in-company-and-connection-group.ds.ts @@ -1,16 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; +import { OutgoingEmailPayloadDs } from '../../../email/application/data-structures/outgoing-email-payload.ds.js'; import { UserRoleEnum } from '../../../user/enums/user-role.enum.js'; export class InvitedUserInCompanyAndConnectionGroupDs { @ApiProperty() companyId: string; - @ApiProperty() - groupId: string; + @ApiProperty({ nullable: true, type: String }) + groupId: string | null; @ApiProperty() email: string; @ApiProperty({ enum: UserRoleEnum }) role: UserRoleEnum; + + // Present only when the /saas/* bridge caller set `suppressEmail: true` (plan 15 Phase 2). + @ApiProperty({ required: false, type: OutgoingEmailPayloadDs }) + emailPayload?: OutgoingEmailPayloadDs; + + // suppressEmail-only: the invited address belongs to an existing-but-inactive user, so no + // invitation was created — the payload above is a re-confirmation letter instead. Returned as + // a marked success (not thrown) because the global exception filter serializes a fixed shape + // and would strip the payload from an error body; the SaaS caller translates this back into + // the user-facing 400 after sending the letter. + @ApiProperty({ required: false }) + userAlreadyAddedInactive?: boolean; } diff --git a/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts b/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts index 469a4ad82..b17af94c4 100644 --- a/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts +++ b/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts @@ -5,6 +5,7 @@ import { BaseType } from '../../../common/data-injection.tokens.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; import { isTest } from '../../../helpers/app/is-test.js'; +import { Constants } from '../../../helpers/constants/constants.js'; import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; import { SaasCompanyGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-company-gateway.service.js'; import { EmailService } from '../../email/email/email.service.js'; @@ -72,28 +73,45 @@ export class InviteUserInCompanyAndConnectionGroupUseCase } if (foundInvitedUser && !foundInvitedUser.isActive) { - const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); + // Trigger inversion (plan 15 Phase 2): with `suppressEmail` (bridge-only) the re-confirmation + // letter is NOT sent here — the raw token travels back as a MARKED SUCCESS (the global + // exception filter would strip extra fields from an error body) and the SaaS caller sends + // the letter, then surfaces the user-facing 400 itself. Never log the raw token here. + if (inputData.suppressEmail) { + const { rawToken: suppressedRawToken } = + await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundInvitedUser); + return { + companyId, + groupId: groupId ?? null, + email: foundInvitedUser.email, + role: invitedUserCompanyRole, + userAlreadyAddedInactive: true, + emailPayload: { + type: 'email_confirmation', + to: foundInvitedUser.email, + rawToken: suppressedRawToken, + companyId, + }, + }; + } + const { rawToken } = await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundInvitedUser); - const sendEmailResult = await this.emailService.sendEmailConfirmation( - foundInvitedUser.email, - rawToken, - companyCustomDomain, - ValidationHelper.resolveEmailVerificationLinkBase(inputData.emailVerificationLinkBase), - ); - - if (!sendEmailResult && !isTest() && !isSaaS()) { - throw new HttpException( - { - message: Messages.EMAIL_SEND_FAILED(invitedUserEmail), - }, - HttpStatus.INTERNAL_SERVER_ERROR, + if (isSaaS()) { + const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); + await this.emailService.sendEmailConfirmation( + foundInvitedUser.email, + rawToken, + companyCustomDomain, + ValidationHelper.resolveEmailVerificationLinkBase(inputData.emailVerificationLinkBase), + ); + } else { + // Plan 15 Phase 6 (rev 5): self-hosted sends no email — the admin takes the full + // confirmation link from the server logs and hands it to the user. + this.logger.printTechString( + `Email confirmation link: ${Constants.APP_DOMAIN_ADDRESS}/external/user/email/verify/${rawToken}`, ); - } - - if (!isSaaS()) { - this.logger.printTechString(`Invitation verification string: ${rawToken}`); } throw new HttpException( { @@ -110,21 +128,46 @@ export class InviteUserInCompanyAndConnectionGroupUseCase invitedUserEmail, invitedUserCompanyRole, ); - const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); - await this.emailService.sendInvitationToCompany( - invitedUserEmail, - rawToken, - companyId, - foundCompany.name, - companyCustomDomain, - ValidationHelper.resolveEmailVerificationLinkBase(inputData.inviteLinkBase), - ); - const invitationRO: any = { + const invitationRO: InvitedUserInCompanyAndConnectionGroupDs & { verificationString?: string } = { companyId: companyId, groupId: groupId, email: invitedUserEmail, role: invitedUserCompanyRole, }; + + // Trigger inversion (plan 15 Phase 2): the SaaS caller builds the invite link and sends the + // letter itself — return the raw token instead of sending (and never log it). + if (inputData.suppressEmail) { + invitationRO.emailPayload = { + type: 'company_invite', + to: invitedUserEmail, + rawToken, + companyId, + companyName: foundCompany.name ?? null, + }; + if (isTest()) { + invitationRO.verificationString = rawToken; + } + return invitationRO; + } + + if (isSaaS()) { + const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); + await this.emailService.sendInvitationToCompany( + invitedUserEmail, + rawToken, + companyId, + foundCompany.name, + companyCustomDomain, + ValidationHelper.resolveEmailVerificationLinkBase(inputData.inviteLinkBase), + ); + } else { + // Plan 15 Phase 6 (rev 5): self-hosted sends no email — the admin takes the full + // invitation link from the server logs and hands it to the invited user. + this.logger.printTechString( + `Invitation link: ${Constants.APP_DOMAIN_ADDRESS}/company/${companyId}/verify/${rawToken}/`, + ); + } if (isTest()) { invitationRO.verificationString = rawToken; } diff --git a/backend/src/entities/email/application/data-structures/outgoing-email-payload.ds.ts b/backend/src/entities/email/application/data-structures/outgoing-email-payload.ds.ts new file mode 100644 index 000000000..8ac676439 --- /dev/null +++ b/backend/src/entities/email/application/data-structures/outgoing-email-payload.ds.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export type OutgoingEmailPayloadType = + | 'email_confirmation' + | 'password_reset_request' + | 'email_change_request' + | 'email_changed' + | 'company_invite'; + +/** + * Email context a `/saas/*` bridge returns INSTEAD of sending the letter itself when the caller + * sets `suppressEmail: true` (plan 15 Phase 2 — trigger inversion). The SaaS control plane builds + * the final link from `rawToken` and sends the email in-process. The raw token transits exactly + * one internal HTTPS hop and must never be logged on either side. + */ +export class OutgoingEmailPayloadDs { + @ApiProperty({ + enum: ['email_confirmation', 'password_reset_request', 'email_change_request', 'email_changed', 'company_invite'], + description: 'Email type discriminator (matches the SaaS EmailType catalog).', + }) + type: OutgoingEmailPayloadType; + + @ApiProperty({ description: 'Recipient address.' }) + to: string; + + @ApiProperty({ required: false, description: 'Raw verification token (absent for notice-only letters).' }) + rawToken?: string; + + @ApiProperty({ required: false }) + companyId?: string; + + @ApiProperty({ required: false, nullable: true, type: String }) + companyName?: string | null; +} diff --git a/backend/src/entities/email/email/email.service.ts b/backend/src/entities/email/email/email.service.ts index d45db6c3a..083f26e91 100644 --- a/backend/src/entities/email/email/email.service.ts +++ b/backend/src/entities/email/email/email.service.ts @@ -1,21 +1,18 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; import Mail from 'nodemailer/lib/mailer/index.js'; import SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; -import * as nunjucks from 'nunjucks'; import PQueue from 'p-queue'; -import { BaseType } from '../../../common/data-injection.tokens.js'; import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; +import { isSaaS } from '../../../helpers/app/is-saas.js'; import { isTest } from '../../../helpers/app/is-test.js'; import { Constants } from '../../../helpers/constants/constants.js'; import { getErrorMessage } from '../../../helpers/get-error-message.js'; -import { appConfig } from '../../../shared/config/app-config.js'; +import { SaasEmailGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-email-gateway.service.js'; import { WinstonLogger } from '../../logging/winston-logger.js'; import { UserInfoMessageData } from '../../table-actions/table-actions-module/table-action-activation.service.js'; import { EmailLetter } from '../email-messages/email-message.js'; -import { EMAIL_TEXT } from '../email-text/email-text.js'; import { EmailTransporterService } from '../transporter/email-transporter-service.js'; -import { escapeHtml } from '../utils/escape-html.util.js'; import { EmailGenerator } from './email.generator.js'; import { IMessage } from './email.interface.js'; @@ -25,16 +22,23 @@ export interface ICronMessagingResults { rejected?: Array; } +// Plan 15 Phase 3: the core composes no letters and transports no email in any mode. +// Every public send* method builds the letter parameters (including the legacy link +// computation with linkBase/customCompanyDomain handling) and hands them to the +// `dispatchEmail` seam, which fires the saas-side composer webhook in SaaS mode and +// suppresses the send entirely self-hosted (rev-5 decision: self-hosted sends NOTHING). +// The transporter/nunjucks path below the seam (`sendEmailToUser`/`sendMail`) is dead +// code kept only until the Phase 7 deletion. @Injectable() export class EmailService { - private readonly emailFrom = appConfig.email.from; constructor( - @Inject(BaseType.NUNJUCKS) - private readonly nunjucksEnv: nunjucks.Environment, private readonly emailTransporterService: EmailTransporterService, + private readonly saasEmailGatewayService: SaasEmailGatewayService, private readonly logger: WinstonLogger, ) {} + // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion): nothing routes letters + // through the local transporter anymore. public async sendEmailToUser(letterContent: IMessage): Promise { if (isTest()) return null; const mailResult = await this.sendEmailWithTimeout(letterContent); @@ -51,35 +55,12 @@ export class EmailService { tableName: string, primaryKeyValuesArray: Array>, ): Promise { - const currentYear = new Date().getFullYear(); - const action = - triggerOperation === TableActionEventEnum.ADD_ROW - ? 'added a row' - : triggerOperation === TableActionEventEnum.UPDATE_ROW - ? 'updated a row' - : triggerOperation === TableActionEventEnum.DELETE_ROW - ? 'deleted a row' - : 'performed an action'; - const textContent = EMAIL_TEXT.ACTION_EMAIL.EMAIL_TEXT(userInfo, action, tableName, primaryKeyValuesArray); - - const primaryKeysValuesStr = JSON.stringify(primaryKeyValuesArray); - const letterContent: IMessage = { - from: this.emailFrom, - to: userEmail, - subject: EMAIL_TEXT.ACTION_EMAIL.EMAIL_SUBJECT, - text: textContent, - html: this.nunjucksEnv.render('action-email-activation.njk', { - userInfo, - triggerOperation, - tableName, - action, - primaryKeysValuesStr, - currentYear, - textContent, - primaryKeyValuesArray, - }), - }; - return await this.sendEmailToUser(letterContent); + return await this.dispatchEmail('table_action', userEmail, { + userInfo, + triggerOperation: triggerOperation as string, + tableName, + primaryKeyValuesArray, + }); } public async sendRemindersToUsers(userEmails: Array): Promise> { @@ -90,7 +71,7 @@ export class EmailService { for (const email of userEmails) { try { const result = await queue.add(async () => { - return await this.sendReminderToUser(email); + return await this.dispatchEmail('reminder', email, {}); }); mailingResults.push(result); } catch (error) { @@ -115,7 +96,7 @@ export class EmailService { const mailingResults: Array = await Promise.all( userEmails.map(async (email: string) => { return await queue.add(async () => { - return await this.send2faEnabledInCompanyToUser(email, companyName); + return await this.dispatchEmail('company_2fa_enabled', email, { companyName }); }); }), ); @@ -127,18 +108,7 @@ export class EmailService { } public async sendInvitedInNewGroup(email: string, groupTitle: string): Promise { - const currentYear = new Date().getFullYear(); - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.INVITE_IN_GROUP.EMAIL_SUBJECT, - text: EMAIL_TEXT.INVITE_IN_GROUP.EMAIL_TEXT(groupTitle), - html: this.nunjucksEnv.render('invite-in-group-notification.njk', { - groupTitle, - currentYear, - }), - }; - return await this.sendEmailToUser(letterContent); + return await this.dispatchEmail('group_invite', email, { groupTitle }); } public async sendInvitationToCompany( @@ -149,25 +119,16 @@ export class EmailService { customCompanyDomain: string | null, verificationLinkBase: string | null = null, ): Promise { - const currentYear = new Date().getFullYear(); const domain = customCompanyDomain ? customCompanyDomain : Constants.APP_DOMAIN_ADDRESS; // A satellite-provided base already carries the company id in its path. const link = verificationLinkBase ? `${verificationLinkBase}/${verificationString}` : `${domain}/company/${companyId}/verify/${verificationString}/`; - const companyName = invitedCompanyName ? ` "${invitedCompanyName}" ` : ` `; - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.INVITE_IN_COMPANY.EMAIL_SUBJECT, - text: EMAIL_TEXT.INVITE_IN_COMPANY.EMAIL_TEXT(link, escapeHtml(companyName)), - html: this.nunjucksEnv.render('invite-in-company-notification.njk', { - linkToAccept: link, - companyName, - currentYear, - }), - }; - return await this.sendEmailToUser(letterContent); + // The saas composer adds the quotes/spacing around the name — pass the raw value or null. + return await this.dispatchEmail('company_invite', email, { + link, + companyName: invitedCompanyName ? invitedCompanyName : null, + }); } public async sendEmailConfirmation( @@ -180,27 +141,11 @@ export class EmailService { const link = verificationLinkBase ? `${verificationLinkBase}/${verificationString}` : `${domain}/external/user/email/verify/${verificationString}`; - const currentYear = new Date().getFullYear(); - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.CONFIRM_EMAIL.EMAIL_SUBJECT, - text: EMAIL_TEXT.CONFIRM_EMAIL.EMAIL_TEXT(link), - html: this.nunjucksEnv.render('confirm-email-notification.njk', { linkToConfirm: link, currentYear }), - }; - return await this.sendEmailToUser(letterContent); + return await this.dispatchEmail('email_confirmation', email, { link }); } public async sendEmailChanged(email: string): Promise { - const currentYear = new Date().getFullYear(); - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.CHANGED_EMAIL.EMAIL_SUBJECT, - text: EMAIL_TEXT.CHANGED_EMAIL.EMAIL_TEXT, - html: this.nunjucksEnv.render('changed-email-notification.njk', { currentYear }), - }; - return await this.sendEmailToUser(letterContent); + return await this.dispatchEmail('email_changed', email, {}); } public async sendEmailChangeRequest( @@ -209,19 +154,11 @@ export class EmailService { customCompanyDomain: string | null, verificationLinkBase: string | null = null, ): Promise { - const currentYear = new Date().getFullYear(); const domain = customCompanyDomain ? customCompanyDomain : Constants.APP_DOMAIN_ADDRESS; - const linkToConfirm = verificationLinkBase + const link = verificationLinkBase ? `${verificationLinkBase}/${requestString}` : `${domain}/external/user/email/change/verify/${requestString}`; - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.CHANGE_EMAIL_REQUEST.EMAIL_SUBJECT, - text: EMAIL_TEXT.CHANGE_EMAIL_REQUEST.EMAIL_TEXT(linkToConfirm), - html: this.nunjucksEnv.render('change-email-request-notification.njk', { linkToConfirm, currentYear }), - }; - return await this.sendEmailToUser(letterContent); + return await this.dispatchEmail('email_change_request', email, { link }); } public async sendPasswordResetRequest( @@ -230,21 +167,15 @@ export class EmailService { customCompanyDomain: string | null, verificationLinkBase: string | null = null, ): Promise { - const currentYear = new Date().getFullYear(); const domain = customCompanyDomain ? customCompanyDomain : Constants.APP_DOMAIN_ADDRESS; - const linkToConfirm = verificationLinkBase + const link = verificationLinkBase ? `${verificationLinkBase}/${requestString}` : `${domain}/external/user/password/reset/verify/${requestString}`; - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.RESET_PASSWORD_REQUEST.EMAIL_SUBJECT, - text: EMAIL_TEXT.RESET_PASSWORD_REQUEST.EMAIL_TEXT(linkToConfirm), - html: this.nunjucksEnv.render('reset-password-request-notification.njk', { linkToConfirm, currentYear }), - }; - return await this.sendEmailToUser(letterContent); + return await this.dispatchEmail('password_reset_request', email, { link }); } + // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion), together with the + // transporter/template-engine machinery it drives. public async sendMail(letterContent: IMessage): Promise { const testEmail = new EmailLetter({ from: letterContent.from, @@ -258,33 +189,36 @@ export class EmailService { return await this.emailTransporterService.transportEmail(emailMessage); } - private async sendReminderToUser(email: string): Promise { - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.ROCKETADMIN_REMINDER.EMAIL_SUBJECT, - text: EMAIL_TEXT.ROCKETADMIN_REMINDER.EMAIL_TEXT, - html: this.nunjucksEnv.render('rocketadmin-reminder-email.html'), - }; - return await this.sendEmailToUser(letterContent); - } - - private async send2faEnabledInCompanyToUser( - email: string, - companyName: string, + // Plan 15 Phase 3 seam — the single gate every outgoing letter passes through: + // - test mode -> no-op (unchanged semantics); + // - SaaS mode -> saas-side composer webhook, mapped onto a SentMessageInfo-compatible + // object (null on any failure — email never fails the parent operation); + // - self-hosted -> suppressed entirely (plan 15 rev 5: self-hosted sends NOTHING, ever). + private async dispatchEmail( + type: string, + to: string, + params: Record, ): Promise { - const letterContent: IMessage = { - from: this.emailFrom, - to: email, - subject: EMAIL_TEXT.COMPANY_2FA_ENABLED.EMAIL_SUBJECT, - text: EMAIL_TEXT.COMPANY_2FA_ENABLED.EMAIL_TEXT(companyName), - html: this.nunjucksEnv.render('company-2fa-enabled-notification.njk', { - companyName, - }), - }; - return await this.sendEmailToUser(letterContent); + if (isTest()) { + return null; + } + if (isSaaS()) { + const webhookResult = await this.saasEmailGatewayService.sendEmail(type, to, params); + if (!webhookResult) { + return null; + } + const sentLike: Pick = { + messageId: webhookResult.messageId ?? '', + accepted: webhookResult.accepted ?? [], + rejected: webhookResult.rejected ?? [], + }; + return sentLike as SMTPTransport.SentMessageInfo; + } + this.logger.debug(`email suppressed (self-hosted): ${type}`); + return null; } + // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion). private async sendEmailWithTimeout(letterContent: IMessage): Promise { return new Promise(async (resolve) => { setTimeout(() => { diff --git a/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts b/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts index 9731f6d0c..c6269e0d5 100644 --- a/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts +++ b/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts @@ -23,16 +23,14 @@ export const emailVerificationRepositoryExtension = { async createOrUpdateEmailVerification( user: UserEntity, ): Promise<{ entity: EmailVerificationEntity; rawToken: string }> { - if (!user.email_verification) { - const rawToken = Encryptor.generateRandomString(); - const newEmailVerification = new EmailVerificationEntity(); - newEmailVerification.verification_string = Encryptor.hashVerificationToken(rawToken); - newEmailVerification.user = user; - const entity = await this.save(newEmailVerification); - return { entity, rawToken }; + // A missing `user.email_verification` may only mean the caller loaded the + // user without that relation (the invite flow does) — always query by + // user id, or the insert below collides with the one-row-per-user unique + // constraint for any registered-but-unconfirmed user. + const foundEmailVerification = await this.findOne({ where: { user: { id: user.id } } }); + if (foundEmailVerification) { + await this.remove(foundEmailVerification); } - const foundEmailVerification = await this.findOne({ where: { id: user.email_verification.id } }); - await this.remove(foundEmailVerification); const rawToken = Encryptor.generateRandomString(); const newEmailVerification = new EmailVerificationEntity(); newEmailVerification.verification_string = Encryptor.hashVerificationToken(rawToken); diff --git a/backend/src/entities/user/application/data-structures/change-user-email.ds.ts b/backend/src/entities/user/application/data-structures/change-user-email.ds.ts index 1dfac1ad5..1d2a3aa5b 100644 --- a/backend/src/entities/user/application/data-structures/change-user-email.ds.ts +++ b/backend/src/entities/user/application/data-structures/change-user-email.ds.ts @@ -1,4 +1,6 @@ export class ChangeUserEmailDs { newEmail: string; verificationString: string; + /** Bridge-only (plan 15 Phase 2): skip the `email_changed` notice and return its payload instead. */ + suppressEmail?: boolean; } diff --git a/backend/src/entities/user/application/data-structures/operation-result-message.ds.ts b/backend/src/entities/user/application/data-structures/operation-result-message.ds.ts index bc1f8e0f0..762de5d8a 100644 --- a/backend/src/entities/user/application/data-structures/operation-result-message.ds.ts +++ b/backend/src/entities/user/application/data-structures/operation-result-message.ds.ts @@ -1,6 +1,15 @@ import { ApiProperty } from '@nestjs/swagger'; +import { OutgoingEmailPayloadDs } from '../../../email/application/data-structures/outgoing-email-payload.ds.js'; export class OperationResultMessageDs { @ApiProperty() message: string; } + +// Returned by the email-flow use cases shared between the public routes and the /saas/* bridges: +// `emailPayload` is populated only when the bridge caller set `suppressEmail: true` (plan 15 +// Phase 2) — public routes never set the flag, so their responses stay a bare `{message}`. +export class OperationResultMessageWithEmailPayloadDs extends OperationResultMessageDs { + @ApiProperty({ required: false, type: OutgoingEmailPayloadDs }) + emailPayload?: OutgoingEmailPayloadDs; +} diff --git a/backend/src/entities/user/application/data-structures/request-email-change.ds.ts b/backend/src/entities/user/application/data-structures/request-email-change.ds.ts index 68981cfe6..e6e112c51 100644 --- a/backend/src/entities/user/application/data-structures/request-email-change.ds.ts +++ b/backend/src/entities/user/application/data-structures/request-email-change.ds.ts @@ -2,10 +2,14 @@ export class RequestEmailChangeDs { userId: string; /** Satellite-provided link prefix (see ValidationHelper.resolveEmailVerificationLinkBase). */ verificationLinkBase?: string; + /** Bridge-only (plan 15 Phase 2): skip the send and return the email payload instead. */ + suppressEmail?: boolean; } export class RequestEmailVerificationDs { userId: string; /** Satellite-provided link prefix (see ValidationHelper.resolveEmailVerificationLinkBase). */ verificationLinkBase?: string; + /** Bridge-only (plan 15 Phase 2): skip the send and return the email payload instead. */ + suppressEmail?: boolean; } diff --git a/backend/src/entities/user/application/data-structures/request-password-reset.ds.ts b/backend/src/entities/user/application/data-structures/request-password-reset.ds.ts new file mode 100644 index 000000000..effafa095 --- /dev/null +++ b/backend/src/entities/user/application/data-structures/request-password-reset.ds.ts @@ -0,0 +1,12 @@ +// Input of RequestResetUserPasswordUseCase. Deliberately a DS the controllers construct field by +// field (never the raw request body): `suppressEmail` must only ever be set by the /saas/* +// microservice bridge — a public caller must not be able to smuggle it in and receive the raw +// reset token (the global ValidationPipe does not strip unknown body properties). +export class RequestPasswordResetDs { + email: string; + companyId: string; + /** Satellite-provided link prefix (see ValidationHelper.resolveEmailVerificationLinkBase). */ + verificationLinkBase?: string; + /** Bridge-only (plan 15 Phase 2): skip the send and return the email payload instead. */ + suppressEmail?: boolean; +} diff --git a/backend/src/entities/user/application/data-structures/usual-register-user.ds.ts b/backend/src/entities/user/application/data-structures/usual-register-user.ds.ts index 314bb3646..077cb2e1d 100644 --- a/backend/src/entities/user/application/data-structures/usual-register-user.ds.ts +++ b/backend/src/entities/user/application/data-structures/usual-register-user.ds.ts @@ -32,4 +32,12 @@ export class SaasUsualUserRegisterDS extends UsualRegisterUserDs { 'When omitted or not allowed, the legacy frontend link is built instead.', }) emailVerificationLinkBase?: string; + + @ApiProperty({ + required: false, + description: + 'Skip sending the confirmation email; the response instead carries `emailPayload` (raw token + context) ' + + 'so the SaaS caller sends the letter itself (plan 15 Phase 2).', + }) + suppressEmail?: boolean; } diff --git a/backend/src/entities/user/use-cases/request-change-user-email.use.case.ts b/backend/src/entities/user/use-cases/request-change-user-email.use.case.ts index 91c7a006a..64e911828 100644 --- a/backend/src/entities/user/use-cases/request-change-user-email.use.case.ts +++ b/backend/src/entities/user/use-cases/request-change-user-email.use.case.ts @@ -6,13 +6,13 @@ import { Messages } from '../../../exceptions/text/messages.js'; import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; import { SaasCompanyGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-company-gateway.service.js'; import { EmailService } from '../../email/email/email.service.js'; -import { OperationResultMessageDs } from '../application/data-structures/operation-result-message.ds.js'; +import { OperationResultMessageWithEmailPayloadDs } from '../application/data-structures/operation-result-message.ds.js'; import { RequestEmailChangeDs } from '../application/data-structures/request-email-change.ds.js'; import { IRequestEmailChange } from './user-use-cases.interfaces.js'; @Injectable() export class RequestChangeUserEmailUseCase - extends AbstractUseCase + extends AbstractUseCase implements IRequestEmailChange { constructor( @@ -24,7 +24,7 @@ export class RequestChangeUserEmailUseCase super(); } - protected async implementation(inputData: RequestEmailChangeDs): Promise { + protected async implementation(inputData: RequestEmailChangeDs): Promise { const { userId } = inputData; const foundUser = await this._dbContext.userRepository.findOneUserById(userId); if (!foundUser) { @@ -45,6 +45,21 @@ export class RequestChangeUserEmailUseCase } const { rawToken } = await this._dbContext.emailChangeRepository.createOrUpdateEmailChangeEntity(foundUser); const userCompanyInfo = await this._dbContext.companyInfoRepository.findCompanyInfoByUserId(userId); + + // Trigger inversion (plan 15 Phase 2): bridge callers send the letter themselves — return + // the raw token instead of sending (and never log it). + if (inputData.suppressEmail) { + return { + message: Messages.EMAIL_CHANGE_REQUESTED, + emailPayload: { + type: 'email_change_request', + to: foundUser.email, + rawToken, + companyId: userCompanyInfo.id, + }, + }; + } + const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(userCompanyInfo.id); const mailingResult = await this.emailService.sendEmailChangeRequest( foundUser.email, diff --git a/backend/src/entities/user/use-cases/request-email-verification.use.case.ts b/backend/src/entities/user/use-cases/request-email-verification.use.case.ts index 460c41662..6113f0f7f 100644 --- a/backend/src/entities/user/use-cases/request-email-verification.use.case.ts +++ b/backend/src/entities/user/use-cases/request-email-verification.use.case.ts @@ -6,13 +6,13 @@ import { Messages } from '../../../exceptions/text/messages.js'; import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; import { SaasCompanyGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-company-gateway.service.js'; import { EmailService } from '../../email/email/email.service.js'; -import { OperationResultMessageDs } from '../application/data-structures/operation-result-message.ds.js'; +import { OperationResultMessageWithEmailPayloadDs } from '../application/data-structures/operation-result-message.ds.js'; import { RequestEmailVerificationDs } from '../application/data-structures/request-email-change.ds.js'; import { IRequestEmailVerification } from './user-use-cases.interfaces.js'; @Injectable() export class RequestEmailVerificationUseCase - extends AbstractUseCase + extends AbstractUseCase implements IRequestEmailVerification { constructor( @@ -24,7 +24,9 @@ export class RequestEmailVerificationUseCase super(); } - protected async implementation(inputData: RequestEmailVerificationDs): Promise { + protected async implementation( + inputData: RequestEmailVerificationDs, + ): Promise { const { userId } = inputData; const foundUser = await this._dbContext.userRepository.findOneUserWithEmailVerification(userId); if (!foundUser) { @@ -44,6 +46,22 @@ export class RequestEmailVerificationUseCase ); } const foundUserCompany = await this._dbContext.companyInfoRepository.findCompanyInfoByUserId(foundUser.id); + + // Trigger inversion (plan 15 Phase 2): bridge callers send the letter themselves — return + // the raw token instead of sending (and never log it). + if (inputData.suppressEmail) { + const { rawToken } = await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundUser); + return { + message: Messages.EMAIL_VERIFICATION_REQUESTED, + emailPayload: { + type: 'email_confirmation', + to: foundUser.email, + rawToken, + companyId: foundUserCompany.id, + }, + }; + } + const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(foundUserCompany.id); const { rawToken } = await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundUser); diff --git a/backend/src/entities/user/use-cases/request-reset-user-password.use.case.ts b/backend/src/entities/user/use-cases/request-reset-user-password.use.case.ts index c5c41b589..3e8536d0f 100644 --- a/backend/src/entities/user/use-cases/request-reset-user-password.use.case.ts +++ b/backend/src/entities/user/use-cases/request-reset-user-password.use.case.ts @@ -6,12 +6,12 @@ import { Messages } from '../../../exceptions/text/messages.js'; import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; import { SaasCompanyGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-company-gateway.service.js'; import { EmailService } from '../../email/email/email.service.js'; -import { OperationResultMessageDs } from '../application/data-structures/operation-result-message.ds.js'; -import { RequestRestUserPasswordDto } from '../dto/request-rest-user-password.dto.js'; +import { OperationResultMessageWithEmailPayloadDs } from '../application/data-structures/operation-result-message.ds.js'; +import { RequestPasswordResetDs } from '../application/data-structures/request-password-reset.ds.js'; import { IRequestPasswordReset } from './user-use-cases.interfaces.js'; export class RequestResetUserPasswordUseCase - extends AbstractUseCase + extends AbstractUseCase implements IRequestPasswordReset { constructor( @@ -23,11 +23,17 @@ export class RequestResetUserPasswordUseCase super(); } - protected async implementation(emailData: RequestRestUserPasswordDto): Promise { - const { companyId } = emailData; + protected async implementation(emailData: RequestPasswordResetDs): Promise { + const { companyId, suppressEmail } = emailData; const email = emailData.email.toLowerCase(); const foundUser = await this._dbContext.userRepository.findOneUserByEmailAndCompanyId(email, companyId); if (!foundUser) { + // Trigger inversion (plan 15 Phase 2): the bridge answers the same `{message}` whether or + // not the user exists (no payload, no error) so the SaaS caller leaks nothing to the + // browser. The legacy path keeps today's behavior for old callers. + if (suppressEmail) { + return { message: Messages.PASSWORD_RESET_REQUESTED }; + } throw new HttpException( { message: Messages.USER_MISSING_EMAIL_OR_SOCIAL_REGISTERED, @@ -35,6 +41,20 @@ export class RequestResetUserPasswordUseCase HttpStatus.FORBIDDEN, ); } + + if (suppressEmail) { + const { rawToken } = await this._dbContext.passwordResetRepository.createOrUpdatePasswordResetEntity(foundUser); + return { + message: Messages.PASSWORD_RESET_REQUESTED, + emailPayload: { + type: 'password_reset_request', + to: foundUser.email, + rawToken, + companyId, + }, + }; + } + const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); const { rawToken } = await this._dbContext.passwordResetRepository.createOrUpdatePasswordResetEntity(foundUser); diff --git a/backend/src/entities/user/use-cases/user-use-cases.interfaces.ts b/backend/src/entities/user/use-cases/user-use-cases.interfaces.ts index 5e26b7a95..761c0a70e 100644 --- a/backend/src/entities/user/use-cases/user-use-cases.interfaces.ts +++ b/backend/src/entities/user/use-cases/user-use-cases.interfaces.ts @@ -5,7 +5,10 @@ import { ChangeUserNameDS } from '../application/data-structures/change-user-nam import { ChangeUsualUserPasswordDto } from '../application/data-structures/change-usual-user-password.ds.js'; import { CreateUserDs } from '../application/data-structures/create-user.ds.js'; import { FindUserDs } from '../application/data-structures/find-user.ds.js'; -import { OperationResultMessageDs } from '../application/data-structures/operation-result-message.ds.js'; +import { + OperationResultMessageDs, + OperationResultMessageWithEmailPayloadDs, +} from '../application/data-structures/operation-result-message.ds.js'; import { OtpSecretDS } from '../application/data-structures/otp-secret.ds.js'; import { OtpDisablingResultDS, @@ -16,13 +19,13 @@ import { RequestEmailChangeDs, RequestEmailVerificationDs, } from '../application/data-structures/request-email-change.ds.js'; +import { RequestPasswordResetDs } from '../application/data-structures/request-password-reset.ds.js'; import { ResetUsualUserPasswordDs } from '../application/data-structures/reset-usual-user-password.ds.js'; import { SaveUserSettingsDs } from '../application/data-structures/save-user-settings.ds.js'; import { ToggleConnectionDisplayModeDs } from '../application/data-structures/toggle-connection-display-mode.ds.js'; import { UsualLoginDs } from '../application/data-structures/usual-login.ds.js'; import { VerifyOtpDS } from '../application/data-structures/verify-otp.ds.js'; import { FoundUserDto } from '../dto/found-user.dto.js'; -import { RequestRestUserPasswordDto } from '../dto/request-rest-user-password.dto.js'; import { IToken } from '../utils/generate-gwt-token.js'; export interface IFindUserUseCase { @@ -54,19 +57,31 @@ export interface IVerifyPasswordReset { } export interface IRequestPasswordReset { - execute(emailData: RequestRestUserPasswordDto, inTransaction: InTransactionEnum): Promise; + execute( + emailData: RequestPasswordResetDs, + inTransaction: InTransactionEnum, + ): Promise; } export interface IRequestEmailChange { - execute(inputData: RequestEmailChangeDs, inTransaction: InTransactionEnum): Promise; + execute( + inputData: RequestEmailChangeDs, + inTransaction: InTransactionEnum, + ): Promise; } export interface IVerifyEmailChange { - execute(inputData: ChangeUserEmailDs, inTransaction: InTransactionEnum): Promise; + execute( + inputData: ChangeUserEmailDs, + inTransaction: InTransactionEnum, + ): Promise; } export interface IRequestEmailVerification { - execute(inputData: RequestEmailVerificationDs, inTransaction: InTransactionEnum): Promise; + execute( + inputData: RequestEmailVerificationDs, + inTransaction: InTransactionEnum, + ): Promise; } export interface IDeleteUserAccount { diff --git a/backend/src/entities/user/use-cases/verify-change-user-email.use.case.ts b/backend/src/entities/user/use-cases/verify-change-user-email.use.case.ts index a70426ac0..654978bda 100644 --- a/backend/src/entities/user/use-cases/verify-change-user-email.use.case.ts +++ b/backend/src/entities/user/use-cases/verify-change-user-email.use.case.ts @@ -6,12 +6,12 @@ import { Messages } from '../../../exceptions/text/messages.js'; import { Encryptor } from '../../../helpers/encryption/encryptor.js'; import { EmailService } from '../../email/email/email.service.js'; import { ChangeUserEmailDs } from '../application/data-structures/change-user-email.ds.js'; -import { OperationResultMessageDs } from '../application/data-structures/operation-result-message.ds.js'; +import { OperationResultMessageWithEmailPayloadDs } from '../application/data-structures/operation-result-message.ds.js'; import { IVerifyEmailChange } from './user-use-cases.interfaces.js'; @Injectable() export class VerifyChangeUserEmailUseCase - extends AbstractUseCase + extends AbstractUseCase implements IVerifyEmailChange { constructor( @@ -22,7 +22,7 @@ export class VerifyChangeUserEmailUseCase super(); } - protected async implementation(inputData: ChangeUserEmailDs): Promise { + protected async implementation(inputData: ChangeUserEmailDs): Promise { const { verificationString } = inputData; const newEmail = inputData.newEmail.toLowerCase(); const hashedToken = Encryptor.hashVerificationToken(verificationString); @@ -62,6 +62,19 @@ export class VerifyChangeUserEmailUseCase foundUser.email = newEmail; await this._dbContext.userRepository.saveUserEntity(foundUser); await this._dbContext.emailChangeRepository.removeEmailChangeEntity(verificationEntity); + + // Trigger inversion (plan 15 Phase 2): the bridge caller sends the "email changed" notice + // itself — hand back the notice context instead of sending. + if (inputData.suppressEmail) { + return { + message: Messages.EMAIL_CHANGED, + emailPayload: { + type: 'email_changed', + to: newEmail, + }, + }; + } + await this.emailService.sendEmailChanged(newEmail); return { message: Messages.EMAIL_CHANGED }; } diff --git a/backend/src/entities/user/user.controller.ts b/backend/src/entities/user/user.controller.ts index bce619c3a..da092f69a 100644 --- a/backend/src/entities/user/user.controller.ts +++ b/backend/src/entities/user/user.controller.ts @@ -306,7 +306,16 @@ export class UserController { @Throttle({ default: { limit: isTest() ? 200 : 5, ttl: 60000 } }) @Post('user/password/reset/request/') async askResetUserPassword(@Body() emailData: RequestRestUserPasswordDto): Promise { - return await this.requestResetUserPasswordUseCase.execute(emailData, InTransactionEnum.ON); + // Fields are copied explicitly: `suppressEmail` (plan 15 Phase 2) is bridge-only and the global + // ValidationPipe does not strip unknown body properties — never forward the raw body here. + return await this.requestResetUserPasswordUseCase.execute( + { + email: emailData.email, + companyId: emailData.companyId, + verificationLinkBase: emailData.verificationLinkBase, + }, + InTransactionEnum.ON, + ); } @ApiOperation({ summary: 'Request user email change' }) diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 4d8fdb087..e88e7ac02 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -331,7 +331,6 @@ export const Messages = { EMAIL_CHANGE_REQUESTED: `Email change request was requested`, EMAIL_CHANGE_FAILED: `Email change request failed. Incorrect link`, EMAIL_CHANGED: 'Email changed', - EMAIL_SEND_FAILED: (email: string) => `Email sending to ${email} failed`, EMAIL_VERIFICATION_REQUESTED: 'Email verification requested', FILTERS_MISSING: 'Filters are missing', USER_ADDED_IN_GROUP: (email: string) => `User ${email} was added in group successfully`, diff --git a/backend/src/microservices/agents-microservice/agents.controller.ts b/backend/src/microservices/agents-microservice/agents.controller.ts index 290e3a5ae..fe6708321 100644 --- a/backend/src/microservices/agents-microservice/agents.controller.ts +++ b/backend/src/microservices/agents-microservice/agents.controller.ts @@ -87,7 +87,7 @@ export class AgentsController { @ApiBody({ type: ValidateUserTokenDto }) @Post('/auth/validate-user-token') public async validateUserToken(@Body() body: ValidateUserTokenDto): Promise { - return await this.validateUserTokenUseCase.execute(body.token, InTransactionEnum.OFF); + return await this.validateUserTokenUseCase.execute({ token: body.token }, InTransactionEnum.OFF); } @ApiOperation({ summary: 'Check Cedar permission for an AI request on a table' }) diff --git a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts index d4ad5ba4a..9fffad5cd 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts @@ -1,5 +1,13 @@ import { Response } from 'express'; +export class ValidateUserTokenDs { + token: string; + // Scopes the caller is willing to accept on the token (plan 15 Phase 5). When it includes + // '2fa_enable', validation mirrors the core's NonScopedAuthMiddleware instead of AuthMiddleware. + // Absent/empty = current strict behavior (backward compatible). + allowScopes?: Array; +} + export class ValidateTableAiRequestDs { userId: string; connectionId: string; diff --git a/backend/src/microservices/agents-microservice/dto/agents-auth.dtos.ts b/backend/src/microservices/agents-microservice/dto/agents-auth.dtos.ts index 8825ef566..0c4d63e50 100644 --- a/backend/src/microservices/agents-microservice/dto/agents-auth.dtos.ts +++ b/backend/src/microservices/agents-microservice/dto/agents-auth.dtos.ts @@ -1,11 +1,25 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { JwtScopesEnum } from '../../../entities/user/enums/jwt-scopes.enum.js'; export class ValidateUserTokenDto { @ApiProperty() @IsString() @IsNotEmpty() token: string; + + @ApiPropertyOptional({ + isArray: true, + enum: JwtScopesEnum, + description: + "Scopes the caller is willing to accept on the token (plan 15 Phase 5). When it includes '2fa_enable', " + + 'validation mirrors NonScopedAuthMiddleware (no 2fa-scope rejection, no suspension check). ' + + 'Absent = current strict behavior.', + }) + @IsOptional() + @IsArray() + @IsIn(Object.values(JwtScopesEnum), { each: true }) + allowScopes?: Array; } export class ValidateTableAiRequestDto { diff --git a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts index 98cb8efbd..d2203083b 100644 --- a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts +++ b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts @@ -11,6 +11,7 @@ import { SetSiteRuntimePolicyDs, ValidateConnectionEditDs, ValidateTableAiRequestDs, + ValidateUserTokenDs, } from '../data-structures/agents.ds.js'; import { AiConnectionContextRO, @@ -25,7 +26,7 @@ import { } from '../data-structures/agents-responses.ds.js'; export interface IValidateUserToken { - execute(token: string, inTransaction: InTransactionEnum): Promise; + execute(inputData: ValidateUserTokenDs, inTransaction: InTransactionEnum): Promise; } export interface IValidateTableAiRequest { diff --git a/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts index d977f0246..346d7bdd0 100644 --- a/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts +++ b/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts @@ -8,12 +8,13 @@ import { JwtScopesEnum } from '../../../entities/user/enums/jwt-scopes.enum.js'; import { TwoFaRequiredException } from '../../../exceptions/custom-exceptions/two-fa-required-exception.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { appConfig } from '../../../shared/config/app-config.js'; +import { ValidateUserTokenDs } from '../data-structures/agents.ds.js'; import { ValidatedUserTokenRO } from '../data-structures/agents-responses.ds.js'; import { IValidateUserToken } from './agents-use-cases.interface.js'; @Injectable({ scope: Scope.REQUEST }) export class ValidateUserTokenUseCase - extends AbstractUseCase + extends AbstractUseCase implements IValidateUserToken { constructor( @@ -23,7 +24,15 @@ export class ValidateUserTokenUseCase super(); } - protected async implementation(token: string): Promise { + protected async implementation(inputData: ValidateUserTokenDs): Promise { + const { token, allowScopes } = inputData; + // Plan 15 Phase 5: when the caller explicitly accepts the '2fa_enable' scope, validation + // deliberately MATCHES the core's NonScopedAuthMiddleware semantics EXACTLY (used by the + // core's own OTP-enrolment routes): the 2fa-scope rejection is skipped AND the suspension + // check is skipped too — NonScopedAuthMiddleware only verifies signature + logout blacklist, + // without loading the user at all. Tighten both together if this ever changes. + const allow2faEnableScope = allowScopes?.includes(JwtScopesEnum.TWO_FA_ENABLE) === true; + if (!token) { throw new UnauthorizedException('Token is missing'); } @@ -50,13 +59,13 @@ export class ValidateUserTokenUseCase throw new UnauthorizedException('JWT verification failed'); } - if (foundUser.suspended) { + if (foundUser.suspended && !allow2faEnableScope) { throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED); } const addedScope: Array = data.scope; if (addedScope && addedScope.length > 0) { - if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) { + if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE) && !allow2faEnableScope) { throw new TwoFaRequiredException(); } } diff --git a/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts b/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts new file mode 100644 index 000000000..2c17acb6d --- /dev/null +++ b/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { WinstonLogger } from '../../../entities/logging/winston-logger.js'; +import { isSaaS } from '../../../helpers/app/is-saas.js'; +import { getErrorMessage } from '../../../helpers/get-error-message.js'; +import { appConfig } from '../../../shared/config/app-config.js'; +import { generateSaaSJwt } from './utils/generate-saas-jwt.js'; + +export type SentEmailWebhookResultDs = { + messageId?: string; + accepted?: Array; + rejected?: Array; +}; + +// Plan 15 Phase 3: the core no longer composes or transports letters — it fires the +// saas-side composer webhook (POST /webhook/email/send) with the letter type and params. +@Injectable() +export class SaasEmailGatewayService { + private static readonly REQUEST_TIMEOUT_MS = 4000; + private readonly baseSaaSUrl = appConfig.thirdParty.saasUrl; + + constructor(private readonly logger: WinstonLogger) {} + + // An email failure must never fail the parent operation: ANY failure (non-2xx status, + // network error, timeout) is logged as a warning and swallowed — the caller gets null. + public async sendEmail( + type: string, + to: string, + params: Record, + ): Promise { + if (!isSaaS()) { + return null; + } + try { + const jwtToken = generateSaaSJwt(); + const res = await fetch(`${this.baseSaaSUrl}/webhook/email/send`, { + method: 'POST', + body: JSON.stringify({ type, to, params }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + }, + signal: AbortSignal.timeout(SaasEmailGatewayService.REQUEST_TIMEOUT_MS), + }); + if (res.status > 299) { + this.logger.warn(`Email webhook rejected "${type}" letter to "${to}": status ${res.status}`); + return null; + } + const body = await this.bodyToJSON(res); + return { + messageId: typeof body.messageId === 'string' ? body.messageId : undefined, + accepted: Array.isArray(body.accepted) ? (body.accepted as Array) : [], + rejected: Array.isArray(body.rejected) ? (body.rejected as Array) : [], + }; + } catch (error) { + this.logger.warn(`Email webhook dispatch of "${type}" letter to "${to}" failed: ${getErrorMessage(error)}`); + return null; + } + } + + private async bodyToJSON(res: Response): Promise> { + if (!res.body) { + return {}; + } + try { + return await res.json(); + } catch (_error) { + return {}; + } + } +} diff --git a/backend/src/microservices/gateways/saas-gateway.ts/saas-gateway.module.ts b/backend/src/microservices/gateways/saas-gateway.ts/saas-gateway.module.ts index f5e808232..ccb26439e 100644 --- a/backend/src/microservices/gateways/saas-gateway.ts/saas-gateway.module.ts +++ b/backend/src/microservices/gateways/saas-gateway.ts/saas-gateway.module.ts @@ -1,12 +1,13 @@ import { Global, Module } from '@nestjs/common'; import { BaseSaasGatewayService } from './base-saas-gateway.service.js'; import { SaasCompanyGatewayService } from './saas-company-gateway.service.js'; +import { SaasEmailGatewayService } from './saas-email-gateway.service.js'; @Global() @Module({ imports: [], controllers: [], - providers: [BaseSaasGatewayService, SaasCompanyGatewayService], - exports: [BaseSaasGatewayService, SaasCompanyGatewayService], + providers: [BaseSaasGatewayService, SaasCompanyGatewayService, SaasEmailGatewayService], + exports: [BaseSaasGatewayService, SaasCompanyGatewayService, SaasEmailGatewayService], }) export class SaaSGatewayModule {} diff --git a/backend/src/microservices/saas-microservice/data-structures/saas-email-flows.dtos.ts b/backend/src/microservices/saas-microservice/data-structures/saas-email-flows.dtos.ts index d722c61ba..6e9204a92 100644 --- a/backend/src/microservices/saas-microservice/data-structures/saas-email-flows.dtos.ts +++ b/backend/src/microservices/saas-microservice/data-structures/saas-email-flows.dtos.ts @@ -1,11 +1,18 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { OutgoingEmailPayloadDs } from '../../../entities/email/application/data-structures/outgoing-email-payload.ds.js'; +import { EmailDto } from '../../../entities/user/dto/email.dto.js'; +import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js'; +import { RequestRestUserPasswordDto } from '../../../entities/user/dto/request-rest-user-password.dto.js'; import { UserRoleEnum } from '../../../entities/user/enums/user-role.enum.js'; // Bodies of the internal (microservice-JWT) email-flow bridges the SaaS control plane calls. // The `verificationLinkBase` fields are URL prefixes the token is appended to; they are validated // against the SaaS domain allowlist (ValidationHelper.resolveEmailVerificationLinkBase) and fall // back to the legacy frontend links when absent or not allowed. +// `suppressEmail` (plan 15 Phase 2) inverts the trigger: the bridge skips the send and returns an +// `emailPayload` (raw token + context) instead, so the SaaS caller composes and sends in-process. +// The flag exists ONLY on these bridge DTOs — the core's public routes never honor it. export class SaasUserIdWithLinkBaseDto { @ApiProperty() @@ -18,6 +25,32 @@ export class SaasUserIdWithLinkBaseDto { @IsOptional() @IsString() verificationLinkBase?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + suppressEmail?: boolean; +} + +export class SaasRequestPasswordResetDto extends RequestRestUserPasswordDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + suppressEmail?: boolean; +} + +export class SaasVerifyEmailChangeDto extends EmailDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + suppressEmail?: boolean; +} + +// Register-bridge response: FoundUserDto plus the suppressed-email payload (present only when the +// caller set `suppressEmail: true`). +export class SaasRegisteredUserRO extends FoundUserDto { + @ApiProperty({ required: false, type: OutgoingEmailPayloadDs }) + emailPayload?: OutgoingEmailPayloadDs; } export class SaasInviteUserInCompanyDto { @@ -53,4 +86,9 @@ export class SaasInviteUserInCompanyDto { @IsOptional() @IsString() emailVerificationLinkBase?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + suppressEmail?: boolean; } diff --git a/backend/src/microservices/saas-microservice/data-structures/saas-otp-login.ds.ts b/backend/src/microservices/saas-microservice/data-structures/saas-otp-login.ds.ts new file mode 100644 index 000000000..aa3a16fd4 --- /dev/null +++ b/backend/src/microservices/saas-microservice/data-structures/saas-otp-login.ds.ts @@ -0,0 +1,6 @@ +export class SaasOtpLoginDs { + temporaryToken: string; + otpCode: string; + ipAddress?: string; + userAgent?: string; +} diff --git a/backend/src/microservices/saas-microservice/data-structures/saas-user-account.dtos.ts b/backend/src/microservices/saas-microservice/data-structures/saas-user-account.dtos.ts new file mode 100644 index 000000000..c0bd4f40b --- /dev/null +++ b/backend/src/microservices/saas-microservice/data-structures/saas-user-account.dtos.ts @@ -0,0 +1,151 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsIn, + IsJSON, + IsNotEmpty, + IsOptional, + IsString, + IsStrongPassword, + IsUUID, + MaxLength, +} from 'class-validator'; + +// Bodies of the internal (microservice-JWT) user-account bridges the SaaS control plane calls +// (plan 15 Phases 4/5). End-user authorization happens on the SaaS side (cookie + guards) — +// these bridges only trust the microservice JWT, exactly like `saas/user/register`. + +export class SaasUserPasswordChangeDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsUUID() + userId: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsEmail() + email: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + oldPassword: string; + + // Same strength policy as the core's public route (ChangeUsualUserPasswordDto). + @ApiProperty() + @IsNotEmpty() + @IsString() + @MaxLength(255) + @IsStrongPassword({ + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 0, + }) + newPassword: string; +} + +export class SaasChangeUserNameDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsUUID() + userId: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + name: string; +} + +export class SaasDeleteUserAccountDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsUUID() + userId: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + message?: string; +} + +export class SaasSaveUserSettingsDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsUUID() + userId: string; + + // Mirrors the core's UserSettingsDataRequestDto (`userSettings` is a JSON string). + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsJSON() + userSettings: string; +} + +export class SaasToggleTestConnectionsDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsUUID() + userId: string; + + // Mirrors the core's public route query param (`?displayMode=on|off`). + @ApiProperty({ enum: ['on', 'off'] }) + @IsNotEmpty() + @IsString() + @IsIn(['on', 'off']) + displayMode: 'on' | 'off'; +} + +export class SaasUserIdDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsUUID() + userId: string; +} + +export class SaasOtpCodeDto extends SaasUserIdDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + @MaxLength(12) + otpCode: string; +} + +export class SaasOtpLoginDto { + // The temporary (TEMPORARY_JWT_SECRET-signed, 4-min TTL) token issued at the password step of + // a 2FA login. Read server-side from the cookie by the SaaS caller — never accepted from JS. + @ApiProperty() + @IsNotEmpty() + @IsString() + temporaryToken: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + @MaxLength(12) + otpCode: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + ipAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + userAgent?: string; +} diff --git a/backend/src/microservices/saas-microservice/saas.controller.ts b/backend/src/microservices/saas-microservice/saas.controller.ts index 6e0b96081..cfa083fa5 100644 --- a/backend/src/microservices/saas-microservice/saas.controller.ts +++ b/backend/src/microservices/saas-microservice/saas.controller.ts @@ -29,26 +29,47 @@ import { IVerifyInviteUserInCompanyAndConnectionGroup, } from '../../entities/company-info/use-cases/company-info-use-cases.interface.js'; import { CreatedConnectionDTO } from '../../entities/connection/application/dto/created-connection.dto.js'; -import { OperationResultMessageDs } from '../../entities/user/application/data-structures/operation-result-message.ds.js'; +import { ChangeUsualUserPasswordDs } from '../../entities/user/application/data-structures/change-usual-user-password.ds.js'; +import { + OperationResultMessageDs, + OperationResultMessageWithEmailPayloadDs, +} from '../../entities/user/application/data-structures/operation-result-message.ds.js'; +import { OtpSecretDS } from '../../entities/user/application/data-structures/otp-secret.ds.js'; +import { + OtpDisablingResultDS, + OtpValidationResultDS, +} from '../../entities/user/application/data-structures/otp-validation-result.ds.js'; import { RegisteredUserDs } from '../../entities/user/application/data-structures/registered-user.ds.js'; +import { SaveUserSettingsDs } from '../../entities/user/application/data-structures/save-user-settings.ds.js'; import { SaasUsualUserRegisterDS } from '../../entities/user/application/data-structures/usual-register-user.ds.js'; -import { EmailDto } from '../../entities/user/dto/email.dto.js'; import { FoundUserDto } from '../../entities/user/dto/found-user.dto.js'; import { PasswordDto } from '../../entities/user/dto/password.dto.js'; -import { RequestRestUserPasswordDto } from '../../entities/user/dto/request-rest-user-password.dto.js'; +import { UserSettingsDataRequestDto } from '../../entities/user/dto/user-settings-data-request.dto.js'; import { ExternalRegistrationProviderEnum } from '../../entities/user/enums/external-registration-provider.enum.js'; import { + IChangeUserName, + IDeleteUserAccount, + IDisableOTP, + IFindUserUseCase, + IGenerateOTP, + IGetUserSettings, ILogOut, IRequestEmailChange, IRequestEmailVerification, IRequestPasswordReset, + ISaveUserSettings, + IToggleTestConnectionsMode, + IUsualPasswordChange, IVerifyEmail, IVerifyEmailChange, + IVerifyOTP, IVerifyPasswordReset, } from '../../entities/user/use-cases/user-use-cases.interfaces.js'; import { UserEntity } from '../../entities/user/user.entity.js'; +import { IToken } from '../../entities/user/utils/generate-gwt-token.js'; import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { Messages } from '../../exceptions/text/messages.js'; +import { slackPostMessage } from '../../helpers/slack/slack-post-message.js'; import { ValidationHelper } from '../../helpers/validators/validation-helper.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; import { ValidatedUserTokenRO } from '../agents-microservice/data-structures/agents-responses.ds.js'; @@ -64,9 +85,26 @@ import { GetHostedConnectionCredentialsDto } from './data-structures/get-hosted- import { HostedConnectionCredentialsRO } from './data-structures/hosted-connection-credentials.ro.js'; import { RegisterCompanyWebhookDS } from './data-structures/register-company.ds.js'; import { RegisteredCompanyDS } from './data-structures/registered-company.ds.js'; -import { SaasInviteUserInCompanyDto, SaasUserIdWithLinkBaseDto } from './data-structures/saas-email-flows.dtos.js'; +import { + SaasInviteUserInCompanyDto, + SaasRegisteredUserRO, + SaasRequestPasswordResetDto, + SaasUserIdWithLinkBaseDto, + SaasVerifyEmailChangeDto, +} from './data-structures/saas-email-flows.dtos.js'; +import { SaasOtpLoginDs } from './data-structures/saas-otp-login.ds.js'; import { SaasRegisterUserWithGithub } from './data-structures/saas-register-user-with-github.js'; import { SaasSAMLUserRegisterDS } from './data-structures/saas-saml-user-register.ds.js'; +import { + SaasChangeUserNameDto, + SaasDeleteUserAccountDto, + SaasOtpCodeDto, + SaasOtpLoginDto, + SaasSaveUserSettingsDto, + SaasToggleTestConnectionsDto, + SaasUserIdDto, + SaasUserPasswordChangeDto, +} from './data-structures/saas-user-account.dtos.js'; import { SaasRegisterUserWithGoogleDS } from './data-structures/sass-register-user-with-google.js'; import { UpdateHostedConnectionPasswordDto } from './data-structures/update-hosted-connection-password.dto.js'; import { @@ -84,6 +122,7 @@ import { ISaasDemoRegisterUser, ISaasGetUserEmailCompanies, ISaasGetUsersInfosByEmail, + ISaasOtpLogin, ISaasRegisterUser, ISaasSAMLRegisterUser, ISaasUsualLoginUser, @@ -163,6 +202,28 @@ export class SaasController { private readonly inviteUserInCompanyUseCase: IInviteUserInCompanyAndConnectionGroup, @Inject(UseCaseType.VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP) private readonly verifyInviteUserInCompanyUseCase: IVerifyInviteUserInCompanyAndConnectionGroup, + @Inject(UseCaseType.FIND_USER) + private readonly findUserUseCase: IFindUserUseCase, + @Inject(UseCaseType.CHANGE_USUAL_PASSWORD) + private readonly changeUsualPasswordUseCase: IUsualPasswordChange, + @Inject(UseCaseType.CHANGE_USER_NAME) + private readonly changeUserNameUseCase: IChangeUserName, + @Inject(UseCaseType.DELETE_USER_ACCOUNT) + private readonly deleteUserAccountUseCase: IDeleteUserAccount, + @Inject(UseCaseType.SAVE_USER_SESSION_SETTINGS) + private readonly saveUserSessionSettingsUseCase: ISaveUserSettings, + @Inject(UseCaseType.GET_USER_SESSION_SETTINGS) + private readonly getUserSessionSettingsUseCase: IGetUserSettings, + @Inject(UseCaseType.TOGGLE_TEST_CONNECTIONS_DISPLAY_MODE) + private readonly toggleTestConnectionsDisplayModeUseCase: IToggleTestConnectionsMode, + @Inject(UseCaseType.GENERATE_OTP) + private readonly generateOtpUseCase: IGenerateOTP, + @Inject(UseCaseType.VERIFY_OTP) + private readonly verifyOtpUseCase: IVerifyOTP, + @Inject(UseCaseType.DISABLE_OTP) + private readonly disableOtpUseCase: IDisableOTP, + @Inject(UseCaseType.SAAS_OTP_LOGIN) + private readonly saasOtpLoginUseCase: ISaasOtpLogin, ) {} @ApiOperation({ summary: 'Company registered webhook' }) @@ -210,7 +271,7 @@ export class SaasController { @ApiResponse({ status: 201, description: 'User has been successfully registered.', - type: FoundUserDto, + type: SaasRegisteredUserRO, }) @Post('user/register') async usualUserRegister( @@ -221,7 +282,8 @@ export class SaasController { @Body('companyId') companyId: string, @Body('companyName') companyName: string, @Body('emailVerificationLinkBase') emailVerificationLinkBase: string, - ): Promise { + @Body('suppressEmail') suppressEmail: boolean, + ): Promise { if (!companyId) { throw new BadRequestException(Messages.COMPANY_ID_MISSING); } @@ -233,6 +295,7 @@ export class SaasController { companyId, companyName, emailVerificationLinkBase, + suppressEmail: suppressEmail === true, }); } @@ -240,11 +303,17 @@ export class SaasController { // segment is not captured as a verification token. @ApiOperation({ summary: 'Re-send the email-confirmation letter on behalf of the SaaS service' }) @ApiBody({ type: SaasUserIdWithLinkBaseDto }) - @ApiResponse({ status: 201, type: OperationResultMessageDs }) + @ApiResponse({ status: 201, type: OperationResultMessageWithEmailPayloadDs }) @Post('user/email/verify/request') - async requestSaasUserEmailVerification(@Body() body: SaasUserIdWithLinkBaseDto): Promise { + async requestSaasUserEmailVerification( + @Body() body: SaasUserIdWithLinkBaseDto, + ): Promise { return await this.requestEmailVerificationUseCase.execute( - { userId: body.userId, verificationLinkBase: body.verificationLinkBase }, + { + userId: body.userId, + verificationLinkBase: body.verificationLinkBase, + suppressEmail: body.suppressEmail === true, + }, InTransactionEnum.ON, ); } @@ -263,11 +332,21 @@ export class SaasController { } @ApiOperation({ summary: 'Request a password-reset email on behalf of the SaaS service' }) - @ApiBody({ type: RequestRestUserPasswordDto }) - @ApiResponse({ status: 201, type: OperationResultMessageDs }) + @ApiBody({ type: SaasRequestPasswordResetDto }) + @ApiResponse({ status: 201, type: OperationResultMessageWithEmailPayloadDs }) @Post('user/password/reset/request') - async requestSaasUserPasswordReset(@Body() body: RequestRestUserPasswordDto): Promise { - return await this.requestResetUserPasswordUseCase.execute(body, InTransactionEnum.ON); + async requestSaasUserPasswordReset( + @Body() body: SaasRequestPasswordResetDto, + ): Promise { + return await this.requestResetUserPasswordUseCase.execute( + { + email: body.email, + companyId: body.companyId, + verificationLinkBase: body.verificationLinkBase, + suppressEmail: body.suppressEmail === true, + }, + InTransactionEnum.ON, + ); } @ApiOperation({ summary: 'Consume a password-reset token on behalf of the SaaS service' }) @@ -292,25 +371,31 @@ export class SaasController { @ApiOperation({ summary: 'Request an email-change letter on behalf of the SaaS service' }) @ApiBody({ type: SaasUserIdWithLinkBaseDto }) - @ApiResponse({ status: 201, type: OperationResultMessageDs }) + @ApiResponse({ status: 201, type: OperationResultMessageWithEmailPayloadDs }) @Post('user/email/change/request') - async requestSaasUserEmailChange(@Body() body: SaasUserIdWithLinkBaseDto): Promise { + async requestSaasUserEmailChange( + @Body() body: SaasUserIdWithLinkBaseDto, + ): Promise { return await this.requestChangeUserEmailUseCase.execute( - { userId: body.userId, verificationLinkBase: body.verificationLinkBase }, + { + userId: body.userId, + verificationLinkBase: body.verificationLinkBase, + suppressEmail: body.suppressEmail === true, + }, InTransactionEnum.ON, ); } @ApiOperation({ summary: 'Consume an email-change token on behalf of the SaaS service' }) - @ApiBody({ type: EmailDto }) - @ApiResponse({ status: 201, type: OperationResultMessageDs }) + @ApiBody({ type: SaasVerifyEmailChangeDto }) + @ApiResponse({ status: 201, type: OperationResultMessageWithEmailPayloadDs }) @Post('user/email/change/verify/:verificationString') async verifySaasUserEmailChange( @VerificationString('verificationString') verificationString: string, - @Body() emailData: EmailDto, - ): Promise { + @Body() emailData: SaasVerifyEmailChangeDto, + ): Promise { return await this.verifyChangeUserEmailUseCase.execute( - { verificationString, newEmail: emailData.email }, + { verificationString, newEmail: emailData.email, suppressEmail: emailData.suppressEmail === true }, InTransactionEnum.OFF, ); } @@ -336,6 +421,7 @@ export class SaasController { invitedUserCompanyRole: body.role, inviteLinkBase: body.inviteLinkBase, emailVerificationLinkBase: body.emailVerificationLinkBase, + suppressEmail: body.suppressEmail === true, }); } @@ -369,7 +455,10 @@ export class SaasController { @ApiBody({ type: ValidateUserTokenDto }) @Post('user/validate-token') async validateUserToken(@Body() body: ValidateUserTokenDto): Promise { - return await this.validateUserTokenUseCase.execute(body.token, InTransactionEnum.OFF); + return await this.validateUserTokenUseCase.execute( + { token: body.token, allowScopes: body.allowScopes }, + InTransactionEnum.OFF, + ); } @ApiOperation({ summary: 'User logout webhook — blacklist an end-user JWT issued by the SaaS service' }) @@ -408,6 +497,144 @@ export class SaasController { ); } + // --------------------------------------------------------------------------------------------- + // Account-management bridges (plan 15 Phase 4). `userId` ownership is enforced by the SaaS + // caller (cookie auth) — these bridges only trust the microservice JWT, like `saas/user/login`. + // --------------------------------------------------------------------------------------------- + + @ApiOperation({ summary: 'Get the full user profile on behalf of the SaaS service (findMe parity)' }) + @ApiResponse({ status: 200, type: FoundUserDto }) + @Get('user/:userId/profile') + async getSaasUserProfile(@Param('userId') userId: string): Promise { + if (!ValidationHelper.isValidUUID(userId)) { + throw new BadRequestException(Messages.USER_ID_MISSING); + } + // findMe passes the GCLID cookie value here; there is no cookie on this internal hop. + return await this.findUserUseCase.execute({ id: userId, gclidValue: undefined }, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Change a user password on behalf of the SaaS service' }) + @ApiBody({ type: SaasUserPasswordChangeDto }) + @ApiResponse({ + status: 201, + description: + 'Password changed. The response carries a core-signed token (what the reused use case returns) — ' + + 'the SaaS caller ignores it and re-signs its own cookie, as with the login bridge.', + }) + @Post('user/password/change') + async changeSaasUserPassword(@Body() body: SaasUserPasswordChangeDto): Promise { + const inputData: ChangeUsualUserPasswordDs = { + userId: body.userId, + email: body.email, + oldPassword: body.oldPassword, + newPassword: body.newPassword, + }; + return await this.changeUsualPasswordUseCase.execute(inputData, InTransactionEnum.ON); + } + + @ApiOperation({ summary: 'Change a user name on behalf of the SaaS service' }) + @ApiBody({ type: SaasChangeUserNameDto }) + @ApiResponse({ status: 200, type: FoundUserDto }) + @Put('user/name') + async changeSaasUserName(@Body() body: SaasChangeUserNameDto): Promise { + return await this.changeUserNameUseCase.execute({ id: body.userId, name: body.name }, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Delete a user account on behalf of the SaaS service' }) + @ApiBody({ type: SaasDeleteUserAccountDto }) + @ApiResponse({ status: 200, type: RegisteredUserDs }) + @Put('user/delete') + async deleteSaasUserAccount(@Body() body: SaasDeleteUserAccountDto): Promise> { + // When the deleted user is the last one in the company, the use case calls back into the SaaS + // service (`saasCompanyGatewayService.deleteCompany`) — plain sequential HTTP, no deadlock. + const deleteResult = await this.deleteUserAccountUseCase.execute(body.userId, InTransactionEnum.ON); + const slackMessage = Messages.USER_DELETED_ACCOUNT(deleteResult.email, body.reason ?? '', body.message ?? ''); + await slackPostMessage(slackMessage); + return deleteResult; + } + + @ApiOperation({ summary: 'Save user session settings on behalf of the SaaS service' }) + @ApiBody({ type: SaasSaveUserSettingsDto }) + @ApiResponse({ status: 201, type: UserSettingsDataRequestDto }) + @Post('user/settings') + async saveSaasUserSettings(@Body() body: SaasSaveUserSettingsDto): Promise { + return await this.saveUserSessionSettingsUseCase.execute( + { userId: body.userId, userSettings: body.userSettings }, + InTransactionEnum.OFF, + ); + } + + @ApiOperation({ summary: 'Get user session settings on behalf of the SaaS service' }) + @ApiResponse({ status: 200, type: UserSettingsDataRequestDto }) + @Get('user/:userId/settings') + async getSaasUserSettings(@Param('userId') userId: string): Promise { + if (!ValidationHelper.isValidUUID(userId)) { + throw new BadRequestException(Messages.USER_ID_MISSING); + } + return await this.getUserSessionSettingsUseCase.execute(userId, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Toggle display mode of test connections on behalf of the SaaS service' }) + @ApiBody({ type: SaasToggleTestConnectionsDto }) + @ApiResponse({ status: 200, type: SuccessResponse }) + @Put('user/test-connections') + async toggleSaasTestConnectionsDisplayMode(@Body() body: SaasToggleTestConnectionsDto): Promise { + return await this.toggleTestConnectionsDisplayModeUseCase.execute( + { userId: body.userId, displayMode: body.displayMode === 'on' }, + InTransactionEnum.OFF, + ); + } + + // --------------------------------------------------------------------------------------------- + // 2FA/OTP bridges (plan 15 Phase 5). + // --------------------------------------------------------------------------------------------- + + @ApiOperation({ summary: 'Generate an OTP secret and QR code on behalf of the SaaS service' }) + @ApiBody({ type: SaasUserIdDto }) + @ApiResponse({ status: 201, type: OtpSecretDS }) + @Post('user/otp/generate') + async generateSaasUserOtp(@Body() body: SaasUserIdDto): Promise { + return await this.generateOtpUseCase.execute(body.userId, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Verify an OTP code (finish 2FA enrolment) on behalf of the SaaS service' }) + @ApiBody({ type: SaasOtpCodeDto }) + @ApiResponse({ status: 201, type: OtpValidationResultDS }) + @Post('user/otp/verify') + async verifySaasUserOtp(@Body() body: SaasOtpCodeDto): Promise { + return await this.verifyOtpUseCase.execute({ userId: body.userId, otpToken: body.otpCode }, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Disable 2FA on behalf of the SaaS service' }) + @ApiBody({ type: SaasOtpCodeDto }) + @ApiResponse({ status: 201, type: OtpDisablingResultDS }) + @Post('user/otp/disable') + async disableSaasUserOtp(@Body() body: SaasOtpCodeDto): Promise { + return await this.disableOtpUseCase.execute({ userId: body.userId, otpToken: body.otpCode }, InTransactionEnum.OFF); + } + + @ApiOperation({ + summary: + 'Complete a 2FA login on behalf of the SaaS service: validates the temporary token ' + + '(blacklist + TEMPORARY_JWT_SECRET) and verifies the OTP code.', + }) + @ApiBody({ type: SaasOtpLoginDto }) + @ApiResponse({ + status: 201, + description: 'OTP accepted; returns the user identity so the SaaS caller can sign its own full-session cookie.', + type: FoundUserDto, + }) + @Post('user/otp/login') + async saasUserOtpLogin(@Body() body: SaasOtpLoginDto): Promise { + const inputData: SaasOtpLoginDs = { + temporaryToken: body.temporaryToken, + otpCode: body.otpCode, + ipAddress: body.ipAddress, + userAgent: body.userAgent, + }; + return await this.saasOtpLoginUseCase.execute(inputData, InTransactionEnum.OFF); + } + @ApiOperation({ summary: 'Get companies where a user with this email is registered' }) @ApiResponse({ status: 200, diff --git a/backend/src/microservices/saas-microservice/saas.module.ts b/backend/src/microservices/saas-microservice/saas.module.ts index 209d5a525..ba4ab2531 100644 --- a/backend/src/microservices/saas-microservice/saas.module.ts +++ b/backend/src/microservices/saas-microservice/saas.module.ts @@ -6,14 +6,26 @@ import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; import { CompanyInfoHelperService } from '../../entities/company-info/company-info-helper.service.js'; import { InviteUserInCompanyAndConnectionGroupUseCase } from '../../entities/company-info/use-cases/invite-user-in-company.use.case.js'; import { VerifyInviteUserInCompanyAndConnectionGroupUseCase } from '../../entities/company-info/use-cases/verify-invite-user-in-company.use.case.js'; +import { ChangeUserNameUseCase } from '../../entities/user/use-cases/change-user-name-use.case.js'; +import { ChangeUsualPasswordUseCase } from '../../entities/user/use-cases/change-usual-password-use.case.js'; +import { DeleteUserAccountUseCase } from '../../entities/user/use-cases/delete-user-account-use-case.js'; +import { DisableOtpUseCase } from '../../entities/user/use-cases/disable-otp.use.case.js'; +import { FindUserUseCase } from '../../entities/user/use-cases/find-user-use.case.js'; +import { GenerateOtpUseCase } from '../../entities/user/use-cases/generate-otp-use.case.js'; +import { GetUserSessionSettingsUseCase } from '../../entities/user/use-cases/get-user-session-settings.use.case.js'; import { LogOutUseCase } from '../../entities/user/use-cases/log-out.use.case.js'; +import { OtpLoginUseCase } from '../../entities/user/use-cases/otp-login-use.case.js'; import { RequestChangeUserEmailUseCase } from '../../entities/user/use-cases/request-change-user-email.use.case.js'; import { RequestEmailVerificationUseCase } from '../../entities/user/use-cases/request-email-verification.use.case.js'; import { RequestResetUserPasswordUseCase } from '../../entities/user/use-cases/request-reset-user-password.use.case.js'; +import { SaveUserSettingsUseCase } from '../../entities/user/use-cases/save-user-session-settings.use.case.js'; +import { ToggleTestConnectionsDisplayModeUseCase } from '../../entities/user/use-cases/toggle-test-connections-display-mode.use.case.js'; import { VerifyChangeUserEmailUseCase } from '../../entities/user/use-cases/verify-change-user-email.use.case.js'; +import { VerifyOtpUseCase } from '../../entities/user/use-cases/verify-otp-use.case.js'; import { VerifyResetUserPasswordUseCase } from '../../entities/user/use-cases/verify-reset-user-password.use.case.js'; import { VerifyUserEmailUseCase } from '../../entities/user/use-cases/verify-user-email.use.case.js'; import { UserEntity } from '../../entities/user/user.entity.js'; +import { UserHelperService } from '../../entities/user/user-helper.service.js'; import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-audit.entity.js'; import { SignInAuditService } from '../../entities/user-sign-in-audit/sign-in-audit.service.js'; import { ValidateUserTokenUseCase } from '../agents-microservice/use-cases/validate-user-token.use.case.js'; @@ -33,6 +45,7 @@ import { RegisteredCompanyWebhookUseCase } from './use-cases/register-company-we import { SaasRegisterDemoUserAccountUseCase } from './use-cases/register-demo-user-account.use.case.js'; import { SaaSRegisterUserWIthSamlUseCase } from './use-cases/register-user-with-saml-use.case.js'; import { SaasGetUserEmailCompaniesUseCase } from './use-cases/saas-get-user-email-companies.use.case.js'; +import { SaasOtpLoginUseCase } from './use-cases/saas-otp-login.use.case.js'; import { SaasUsualLoginUseCase } from './use-cases/saas-usual-login.use.case.js'; import { SaasUsualRegisterUseCase } from './use-cases/saas-usual-register-user.use.case.js'; import { SuspendUsersUseCase } from './use-cases/suspend-users.use.case.js'; @@ -171,8 +184,59 @@ import { UpdateHostedConnectionPasswordUseCase } from './use-cases/update-hosted provide: UseCaseType.VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP, useClass: VerifyInviteUserInCompanyAndConnectionGroupUseCase, }, + // Account-management bridges (plan 15 Phase 4) — reuse the user-entity use cases. + { + provide: UseCaseType.FIND_USER, + useClass: FindUserUseCase, + }, + { + provide: UseCaseType.CHANGE_USUAL_PASSWORD, + useClass: ChangeUsualPasswordUseCase, + }, + { + provide: UseCaseType.CHANGE_USER_NAME, + useClass: ChangeUserNameUseCase, + }, + { + provide: UseCaseType.DELETE_USER_ACCOUNT, + useClass: DeleteUserAccountUseCase, + }, + { + provide: UseCaseType.SAVE_USER_SESSION_SETTINGS, + useClass: SaveUserSettingsUseCase, + }, + { + provide: UseCaseType.GET_USER_SESSION_SETTINGS, + useClass: GetUserSessionSettingsUseCase, + }, + { + provide: UseCaseType.TOGGLE_TEST_CONNECTIONS_DISPLAY_MODE, + useClass: ToggleTestConnectionsDisplayModeUseCase, + }, + // 2FA/OTP bridges (plan 15 Phase 5). + { + provide: UseCaseType.GENERATE_OTP, + useClass: GenerateOtpUseCase, + }, + { + provide: UseCaseType.VERIFY_OTP, + useClass: VerifyOtpUseCase, + }, + { + provide: UseCaseType.DISABLE_OTP, + useClass: DisableOtpUseCase, + }, + { + provide: UseCaseType.OTP_LOGIN, + useClass: OtpLoginUseCase, + }, + { + provide: UseCaseType.SAAS_OTP_LOGIN, + useClass: SaasOtpLoginUseCase, + }, CompanyInfoHelperService, SignInAuditService, + UserHelperService, ], controllers: [SaasController], exports: [], @@ -213,6 +277,17 @@ export class SaasModule { { path: 'saas/connection/hosted/password', method: RequestMethod.POST }, { path: 'saas/connection/hosted/credentials', method: RequestMethod.POST }, { path: 'saas/connections/info', method: RequestMethod.POST }, + { path: 'saas/user/:userId/profile', method: RequestMethod.GET }, + { path: 'saas/user/password/change', method: RequestMethod.POST }, + { path: 'saas/user/name', method: RequestMethod.PUT }, + { path: 'saas/user/delete', method: RequestMethod.PUT }, + { path: 'saas/user/settings', method: RequestMethod.POST }, + { path: 'saas/user/:userId/settings', method: RequestMethod.GET }, + { path: 'saas/user/test-connections', method: RequestMethod.PUT }, + { path: 'saas/user/otp/generate', method: RequestMethod.POST }, + { path: 'saas/user/otp/verify', method: RequestMethod.POST }, + { path: 'saas/user/otp/disable', method: RequestMethod.POST }, + { path: 'saas/user/otp/login', method: RequestMethod.POST }, ); } } diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-otp-login.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/saas-otp-login.use.case.ts new file mode 100644 index 000000000..9bc6179a6 --- /dev/null +++ b/backend/src/microservices/saas-microservice/use-cases/saas-otp-login.use.case.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Scope, UnauthorizedException } from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType, UseCaseType } from '../../../common/data-injection.tokens.js'; +import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js'; +import { IOtpLogin } from '../../../entities/user/use-cases/user-use-cases.interfaces.js'; +import { UserHelperService } from '../../../entities/user/user-helper.service.js'; +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { appConfig } from '../../../shared/config/app-config.js'; +import { SaasOtpLoginDs } from '../data-structures/saas-otp-login.ds.js'; +import { ISaasOtpLogin } from './saas-use-cases.interface.js'; + +/** + * Completes a 2FA login on behalf of the SaaS control plane (plan 15 Phase 5). + * + * The SaaS service cannot verify the temporary token locally (the logout blacklist lives in the + * core's database), so this bridge replicates the core's TemporaryAuthMiddleware — blacklist check + * plus jwt.verify against TEMPORARY_JWT_SECRET — and then delegates the OTP verification + + * sign-in-audit recording to the existing OtpLoginUseCase (its core-signed token is discarded: + * the SaaS caller signs its own cookie from the returned user, exactly as with the login bridge). + */ +@Injectable({ scope: Scope.REQUEST }) +export class SaasOtpLoginUseCase extends AbstractUseCase implements ISaasOtpLogin { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + @Inject(UseCaseType.OTP_LOGIN) + private readonly otpLoginUseCase: IOtpLogin, + private readonly userHelperService: UserHelperService, + ) { + super(); + } + + protected async implementation(inputData: SaasOtpLoginDs): Promise { + const { temporaryToken, otpCode, ipAddress, userAgent } = inputData; + if (!temporaryToken) { + throw new UnauthorizedException('Token is missing'); + } + + // Mirror TemporaryAuthMiddleware: blacklist first, then verify against the temporary secret. + const isLoggedOut = await this._dbContext.logOutRepository.isLoggedOut(temporaryToken); + if (isLoggedOut) { + throw new UnauthorizedException('JWT verification failed'); + } + + const jwtSecret = appConfig.auth.temporaryJwtSecret; + if (!jwtSecret) { + throw new UnauthorizedException('JWT verification failed'); + } + + let userId: string | undefined; + try { + const data = jwt.verify(temporaryToken, jwtSecret) as jwt.JwtPayload; + userId = data.id; + } catch (_e) { + throw new UnauthorizedException('JWT verification failed'); + } + if (!userId) { + throw new UnauthorizedException('JWT verification failed'); + } + + // Reuse the existing OTP-login use case (OTP verification + sign-in audit); it throws on an + // invalid code and returns a core-signed token we deliberately discard. + await this.otpLoginUseCase.execute({ userId, otpToken: otpCode, ipAddress, userAgent }, InTransactionEnum.OFF); + + const foundUser = await this._dbContext.userRepository.findOneUserById(userId); + if (!foundUser) { + throw new UnauthorizedException('JWT verification failed'); + } + return await this.userHelperService.buildFoundUserDs(foundUser); + } +} diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts index f47378687..ded2f2632 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts @@ -20,6 +20,8 @@ import { GetUsersInfosByEmailDS } from '../data-structures/get-users-infos-by-em import { HostedConnectionCredentialsRO } from '../data-structures/hosted-connection-credentials.ro.js'; import { RegisterCompanyWebhookDS } from '../data-structures/register-company.ds.js'; import { RegisteredCompanyDS } from '../data-structures/registered-company.ds.js'; +import { SaasRegisteredUserRO } from '../data-structures/saas-email-flows.dtos.js'; +import { SaasOtpLoginDs } from '../data-structures/saas-otp-login.ds.js'; import { SaasRegisterUserWithGithub } from '../data-structures/saas-register-user-with-github.js'; import { SaasSAMLUserRegisterDS } from '../data-structures/saas-saml-user-register.ds.js'; import { SaasRegisterUserWithGoogleDS } from '../data-structures/sass-register-user-with-google.js'; @@ -39,7 +41,7 @@ export interface ISaasGetUsersInfosByEmail { } export interface ISaasRegisterUser { - execute(userData: SaasUsualUserRegisterDS): Promise; + execute(userData: SaasUsualUserRegisterDS): Promise; } export interface ISaasUsualLoginUser { @@ -105,3 +107,7 @@ export interface IGetConnectionsInfoByIds { export interface IGetHostedConnectionCredentials { execute(inputData: GetHostedConnectionCredentialsDto): Promise; } + +export interface ISaasOtpLogin { + execute(inputData: SaasOtpLoginDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts index c65c984f4..8c5fd3147 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts @@ -8,17 +8,17 @@ import { DemoDataService } from '../../../entities/demo-data/demo-data.service.j import { EmailService } from '../../../entities/email/email/email.service.js'; import { RegisterUserDs } from '../../../entities/user/application/data-structures/register-user-ds.js'; import { SaasUsualUserRegisterDS } from '../../../entities/user/application/data-structures/usual-register-user.ds.js'; -import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js'; import { UserRoleEnum } from '../../../entities/user/enums/user-role.enum.js'; import { UserEntity } from '../../../entities/user/user.entity.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; import { SaasCompanyGatewayService } from '../../gateways/saas-gateway.ts/saas-company-gateway.service.js'; +import { SaasRegisteredUserRO } from '../data-structures/saas-email-flows.dtos.js'; import { ISaasRegisterUser } from './saas-use-cases.interface.js'; @Injectable() export class SaasUsualRegisterUseCase - extends AbstractUseCase + extends AbstractUseCase implements ISaasRegisterUser { constructor( @@ -31,8 +31,9 @@ export class SaasUsualRegisterUseCase super(); } - protected async implementation(userData: SaasUsualUserRegisterDS): Promise { - const { email, password, gclidValue, name, companyId, companyName, emailVerificationLinkBase } = userData; + protected async implementation(userData: SaasUsualUserRegisterDS): Promise { + const { email, password, gclidValue, name, companyId, companyName, emailVerificationLinkBase, suppressEmail } = + userData; const foundUser = await this._dbContext.userRepository.findOneUserByEmailAndCompanyId(email, companyId); const userCompany = await this._dbContext.companyInfoRepository.findCompanyInfoWithUsersById(companyId); @@ -65,15 +66,8 @@ export class SaasUsualRegisterUseCase } const { rawToken } = await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(savedUser); - const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); - - // The satellite may route the confirmation link through itself (SiteNova). A disallowed or - // malformed base silently falls back to the legacy link — never fail the registration over it. - const verificationLinkBase = ValidationHelper.resolveEmailVerificationLinkBase(emailVerificationLinkBase); - await this.emailService.sendEmailConfirmation(savedUser.email, rawToken, companyCustomDomain, verificationLinkBase); - - return { + const registeredUserRO: SaasRegisteredUserRO = { id: savedUser.id, createdAt: savedUser.createdAt, isActive: savedUser.isActive, @@ -86,6 +80,27 @@ export class SaasUsualRegisterUseCase externalRegistrationProvider: savedUser.externalRegistrationProvider, show_test_connections: savedUser.showTestConnections, }; + + // Trigger inversion (plan 15 Phase 2): the SaaS caller builds the link and sends the + // confirmation itself — hand back the raw token instead of sending (and never log it). + if (suppressEmail) { + registeredUserRO.emailPayload = { + type: 'email_confirmation', + to: savedUser.email, + rawToken, + }; + return registeredUserRO; + } + + const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId); + + // The satellite may route the confirmation link through itself (SiteNova). A disallowed or + // malformed base silently falls back to the legacy link — never fail the registration over it. + const verificationLinkBase = ValidationHelper.resolveEmailVerificationLinkBase(emailVerificationLinkBase); + + await this.emailService.sendEmailConfirmation(savedUser.email, rawToken, companyCustomDomain, verificationLinkBase); + + return registeredUserRO; } private async registerEmptyCompany( diff --git a/backend/src/selfhosted-operations/application/data-structures/update-user-as-admin.ds.ts b/backend/src/selfhosted-operations/application/data-structures/update-user-as-admin.ds.ts new file mode 100644 index 000000000..ed000c4aa --- /dev/null +++ b/backend/src/selfhosted-operations/application/data-structures/update-user-as-admin.ds.ts @@ -0,0 +1,11 @@ +export class UpdateUserPasswordAsAdminDs { + callerUserId: string; + targetUserId: string; + newPassword: string; +} + +export class UpdateUserEmailAsAdminDs { + callerUserId: string; + targetUserId: string; + newEmail: string; +} diff --git a/backend/src/selfhosted-operations/application/dto/update-user-email-as-admin.dto.ts b/backend/src/selfhosted-operations/application/dto/update-user-email-as-admin.dto.ts new file mode 100644 index 000000000..baa559f65 --- /dev/null +++ b/backend/src/selfhosted-operations/application/dto/update-user-email-as-admin.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class UpdateUserEmailAsAdminDto { + @ApiProperty({ description: 'New email for the target user' }) + @IsNotEmpty() + @IsString() + @IsEmail() + readonly newEmail: string; +} diff --git a/backend/src/selfhosted-operations/application/dto/update-user-password-as-admin.dto.ts b/backend/src/selfhosted-operations/application/dto/update-user-password-as-admin.dto.ts new file mode 100644 index 000000000..2c06f85dc --- /dev/null +++ b/backend/src/selfhosted-operations/application/dto/update-user-password-as-admin.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateUserPasswordAsAdminDto { + @ApiProperty({ description: 'New password for the target user' }) + @IsNotEmpty() + @IsString() + @MinLength(8) + @MaxLength(255) + readonly newPassword: string; +} diff --git a/backend/src/selfhosted-operations/application/use-cases/selfhosted-use-cases.interfaces.ts b/backend/src/selfhosted-operations/application/use-cases/selfhosted-use-cases.interfaces.ts index 2c9ec5d11..0391ae4c3 100644 --- a/backend/src/selfhosted-operations/application/use-cases/selfhosted-use-cases.interfaces.ts +++ b/backend/src/selfhosted-operations/application/use-cases/selfhosted-use-cases.interfaces.ts @@ -1,6 +1,8 @@ import { SimpleFoundUserInfoDs } from '../../../entities/user/dto/found-user.dto.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { SuccessResponse } from '../../../microservices/saas-microservice/data-structures/common-responce.ds.js'; import { CreateInitialUserDs } from '../data-structures/create-initial-user.ds.js'; +import { UpdateUserEmailAsAdminDs, UpdateUserPasswordAsAdminDs } from '../data-structures/update-user-as-admin.ds.js'; import { IsConfiguredRo } from '../responce-objects/is-configured.ro.js'; export interface IIsConfiguredUseCase { @@ -10,3 +12,11 @@ export interface IIsConfiguredUseCase { export interface ICreateInitialUserUseCase { execute(inputData: CreateInitialUserDs, inTransaction: InTransactionEnum): Promise; } + +export interface IUpdateUserPasswordAsAdminUseCase { + execute(inputData: UpdateUserPasswordAsAdminDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IUpdateUserEmailAsAdminUseCase { + execute(inputData: UpdateUserEmailAsAdminDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/selfhosted-operations/application/use-cases/update-user-email-as-admin.use.case.ts b/backend/src/selfhosted-operations/application/use-cases/update-user-email-as-admin.use.case.ts new file mode 100644 index 000000000..f99f494f1 --- /dev/null +++ b/backend/src/selfhosted-operations/application/use-cases/update-user-email-as-admin.use.case.ts @@ -0,0 +1,65 @@ +import { BadRequestException, HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { isSaaS } from '../../../helpers/app/is-saas.js'; +import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; +import { SuccessResponse } from '../../../microservices/saas-microservice/data-structures/common-responce.ds.js'; +import { UpdateUserEmailAsAdminDs } from '../data-structures/update-user-as-admin.ds.js'; +import { IUpdateUserEmailAsAdminUseCase } from './selfhosted-use-cases.interfaces.js'; + +// Plan 15 Phase 6 (rev 5): self-hosted has no email-change verification flow — a company +// admin updates the address directly. Uniqueness rule matches VerifyChangeUserEmailUseCase. +@Injectable() +export class UpdateUserEmailAsAdminUseCase + extends AbstractUseCase + implements IUpdateUserEmailAsAdminUseCase +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: UpdateUserEmailAsAdminDs): Promise { + if (isSaaS()) { + throw new BadRequestException(Messages.ENDPOINT_NOT_AVAILABLE_IN_THIS_MODE); + } + const { callerUserId, targetUserId } = inputData; + const newEmail = inputData.newEmail.toLowerCase(); + ValidationHelper.validateOrThrowHttpExceptionEmail(newEmail); + + const foundExistingUsersWithThisEmail = await this._dbContext.userRepository.find({ + where: { email: newEmail }, + }); + if (foundExistingUsersWithThisEmail.length > 0) { + throw new HttpException( + { + message: Messages.CANNOT_SET_THIS_EMAIL, + }, + HttpStatus.BAD_REQUEST, + ); + } + + const targetUser = await this.findTargetUserInCallerCompany(this._dbContext, callerUserId, targetUserId); + targetUser.email = newEmail; + await this._dbContext.userRepository.saveUserEntity(targetUser); + return { success: true }; + } + + private async findTargetUserInCallerCompany( + dbContext: IGlobalDatabaseContext, + callerUserId: string, + targetUserId: string, + ) { + const callerCompany = await dbContext.companyInfoRepository.findCompanyInfoByUserId(callerUserId); + const targetUser = await dbContext.userRepository.findOneUserById(targetUserId); + // The target must belong to the same company as the acting admin. + if (!targetUser || !callerCompany || targetUser.company?.id !== callerCompany.id) { + throw new NotFoundException(Messages.USER_NOT_FOUND); + } + return targetUser; + } +} diff --git a/backend/src/selfhosted-operations/application/use-cases/update-user-password-as-admin.use.case.ts b/backend/src/selfhosted-operations/application/use-cases/update-user-password-as-admin.use.case.ts new file mode 100644 index 000000000..221bc2905 --- /dev/null +++ b/backend/src/selfhosted-operations/application/use-cases/update-user-password-as-admin.use.case.ts @@ -0,0 +1,54 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { isSaaS } from '../../../helpers/app/is-saas.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; +import { SuccessResponse } from '../../../microservices/saas-microservice/data-structures/common-responce.ds.js'; +import { UpdateUserPasswordAsAdminDs } from '../data-structures/update-user-as-admin.ds.js'; +import { IUpdateUserPasswordAsAdminUseCase } from './selfhosted-use-cases.interfaces.js'; + +// Plan 15 Phase 6 (rev 5): self-hosted has no email-based password recovery — a company +// admin sets a new password directly. Same strength validation and hashing as the public +// reset flow (VerifyResetUserPasswordUseCase); admin authority replaces the old-password check. +@Injectable() +export class UpdateUserPasswordAsAdminUseCase + extends AbstractUseCase + implements IUpdateUserPasswordAsAdminUseCase +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: UpdateUserPasswordAsAdminDs): Promise { + if (isSaaS()) { + throw new BadRequestException(Messages.ENDPOINT_NOT_AVAILABLE_IN_THIS_MODE); + } + const { callerUserId, targetUserId, newPassword } = inputData; + ValidationHelper.isPasswordStrongOrThrowError(newPassword); + + const targetUser = await this.findTargetUserInCallerCompany(this._dbContext, callerUserId, targetUserId); + targetUser.password = await Encryptor.hashUserPassword(newPassword); + await this._dbContext.userRepository.saveUserEntity(targetUser); + return { success: true }; + } + + private async findTargetUserInCallerCompany( + dbContext: IGlobalDatabaseContext, + callerUserId: string, + targetUserId: string, + ) { + const callerCompany = await dbContext.companyInfoRepository.findCompanyInfoByUserId(callerUserId); + const targetUser = await dbContext.userRepository.findOneUserById(targetUserId); + // The target must belong to the same company as the acting admin. + if (!targetUser || !callerCompany || targetUser.company?.id !== callerCompany.id) { + throw new NotFoundException(Messages.USER_NOT_FOUND); + } + return targetUser; + } +} diff --git a/backend/src/selfhosted-operations/selfhosted-operations.controller.ts b/backend/src/selfhosted-operations/selfhosted-operations.controller.ts index 08c304f5f..4db4368a9 100644 --- a/backend/src/selfhosted-operations/selfhosted-operations.controller.ts +++ b/backend/src/selfhosted-operations/selfhosted-operations.controller.ts @@ -1,14 +1,22 @@ -import { Body, Controller, Get, HttpStatus, Inject, Post, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Get, HttpStatus, Inject, Post, Put, UseGuards, UseInterceptors } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UseCaseType } from '../common/data-injection.tokens.js'; +import { SlugUuid } from '../decorators/slug-uuid.decorator.js'; +import { UserId } from '../decorators/user-id.decorator.js'; import { SimpleFoundUserInfoDs } from '../entities/user/dto/found-user.dto.js'; import { InTransactionEnum } from '../enums/in-transaction.enum.js'; +import { CompanyAdminGuard } from '../guards/company-admin.guard.js'; import { SentryInterceptor } from '../interceptors/sentry.interceptor.js'; +import { SuccessResponse } from '../microservices/saas-microservice/data-structures/common-responce.ds.js'; import { CreateInitialUserDto } from './application/dto/create-initial-admin-user.dto.js'; +import { UpdateUserEmailAsAdminDto } from './application/dto/update-user-email-as-admin.dto.js'; +import { UpdateUserPasswordAsAdminDto } from './application/dto/update-user-password-as-admin.dto.js'; import { IsConfiguredRo } from './application/responce-objects/is-configured.ro.js'; import { ICreateInitialUserUseCase, IIsConfiguredUseCase, + IUpdateUserEmailAsAdminUseCase, + IUpdateUserPasswordAsAdminUseCase, } from './application/use-cases/selfhosted-use-cases.interfaces.js'; @UseInterceptors(SentryInterceptor) @@ -20,6 +28,10 @@ export class SelfHostedOperationsController { private readonly isConfiguredUseCase: IIsConfiguredUseCase, @Inject(UseCaseType.CREATE_INITIAL_USER) private readonly createInitialUserUseCase: ICreateInitialUserUseCase, + @Inject(UseCaseType.SELFHOSTED_UPDATE_USER_PASSWORD) + private readonly updateUserPasswordAsAdminUseCase: IUpdateUserPasswordAsAdminUseCase, + @Inject(UseCaseType.SELFHOSTED_UPDATE_USER_EMAIL) + private readonly updateUserEmailAsAdminUseCase: IUpdateUserEmailAsAdminUseCase, ) {} @Get('/is-configured') @@ -48,4 +60,48 @@ export class SelfHostedOperationsController { public async createInitialUser(@Body() createInitialUserDto: CreateInitialUserDto): Promise { return await this.createInitialUserUseCase.execute(createInitialUserDto, InTransactionEnum.OFF); } + + // Plan 15 Phase 6 (rev 5): admin-managed replacement for the email-based password + // recovery flow, which does not exist self-hosted. + @UseGuards(CompanyAdminGuard) + @Put('/users/:userId/password') + @ApiOperation({ summary: 'Set a new password for a user of the admin`s company (self-hosted only)' }) + @ApiBody({ type: UpdateUserPasswordAsAdminDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Password updated successfully', + type: SuccessResponse, + }) + public async updateUserPassword( + @UserId() callerUserId: string, + @SlugUuid('userId') targetUserId: string, + @Body() dto: UpdateUserPasswordAsAdminDto, + ): Promise { + return await this.updateUserPasswordAsAdminUseCase.execute( + { callerUserId, targetUserId, newPassword: dto.newPassword }, + InTransactionEnum.OFF, + ); + } + + // Plan 15 Phase 6 (rev 5): admin-managed replacement for the email-change verification + // flow, which does not exist self-hosted. + @UseGuards(CompanyAdminGuard) + @Put('/users/:userId/email') + @ApiOperation({ summary: 'Set a new email for a user of the admin`s company (self-hosted only)' }) + @ApiBody({ type: UpdateUserEmailAsAdminDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Email updated successfully', + type: SuccessResponse, + }) + public async updateUserEmail( + @UserId() callerUserId: string, + @SlugUuid('userId') targetUserId: string, + @Body() dto: UpdateUserEmailAsAdminDto, + ): Promise { + return await this.updateUserEmailAsAdminUseCase.execute( + { callerUserId, targetUserId, newEmail: dto.newEmail }, + InTransactionEnum.OFF, + ); + } } diff --git a/backend/src/selfhosted-operations/selhosted-operations.module.ts b/backend/src/selfhosted-operations/selhosted-operations.module.ts index bc8ffcf22..2b80b030b 100644 --- a/backend/src/selfhosted-operations/selhosted-operations.module.ts +++ b/backend/src/selfhosted-operations/selhosted-operations.module.ts @@ -1,19 +1,32 @@ -import { DynamicModule, Module } from '@nestjs/common'; +import { DynamicModule, MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthMiddleware } from '../authorization/auth.middleware.js'; import { GlobalDatabaseContext } from '../common/application/global-database-context.js'; import { BaseType, UseCaseType } from '../common/data-injection.tokens.js'; import { CompanyInfoEntity } from '../entities/company-info/company-info.entity.js'; +import { LogOutEntity } from '../entities/log-out/log-out.entity.js'; import { UserEntity } from '../entities/user/user.entity.js'; import { isSaaS } from '../helpers/app/is-saas.js'; import { CreateInitialUserUseCase } from './application/use-cases/create-initial-user.use.case.js'; import { IsConfiguredUseCase } from './application/use-cases/is-configured.use.case.js'; +import { UpdateUserEmailAsAdminUseCase } from './application/use-cases/update-user-email-as-admin.use.case.js'; +import { UpdateUserPasswordAsAdminUseCase } from './application/use-cases/update-user-password-as-admin.use.case.js'; import { SelfHostedOperationsController } from './selfhosted-operations.controller.js'; @Module({}) export class SelfHostedOperationsModule { + // Whether register() built the full (self-hosted) module. configure() must + // follow THIS decision, not re-read isSaaS(): register() runs at import + // time and configure() at app init — a process that flips IS_SAAS in + // between (the in-process test apps do) would otherwise apply + // AuthMiddleware inside the empty module variant, whose TypeORM + // repositories were never imported, and crash the boot on DI resolution. + private static registeredSelfHosted = false; + static register(): DynamicModule { if (isSaaS()) { // Return empty module in SaaS mode + SelfHostedOperationsModule.registeredSelfHosted = false; return { module: SelfHostedOperationsModule, imports: [], @@ -21,10 +34,11 @@ export class SelfHostedOperationsModule { providers: [], }; } + SelfHostedOperationsModule.registeredSelfHosted = true; return { module: SelfHostedOperationsModule, - imports: [TypeOrmModule.forFeature([UserEntity, CompanyInfoEntity])], + imports: [TypeOrmModule.forFeature([UserEntity, CompanyInfoEntity, LogOutEntity])], controllers: [SelfHostedOperationsController], providers: [ { @@ -39,7 +53,31 @@ export class SelfHostedOperationsModule { provide: UseCaseType.CREATE_INITIAL_USER, useClass: CreateInitialUserUseCase, }, + { + provide: UseCaseType.SELFHOSTED_UPDATE_USER_PASSWORD, + useClass: UpdateUserPasswordAsAdminUseCase, + }, + { + provide: UseCaseType.SELFHOSTED_UPDATE_USER_EMAIL, + useClass: UpdateUserEmailAsAdminUseCase, + }, ], }; } + + // Plan 15 Phase 6: unlike the public bootstrap routes (`/is-configured`, `/initial-user`), + // the admin user-management routes require cookie auth (+ CompanyAdminGuard on the + // controller). Skipped in SaaS mode: the module is empty there (no controller, and the + // middleware's repositories are not imported), so the routes 404. + public configure(consumer: MiddlewareConsumer): void { + if (!SelfHostedOperationsModule.registeredSelfHosted) { + return; + } + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: '/selfhosted/users/:userId/password', method: RequestMethod.PUT }, + { path: '/selfhosted/users/:userId/email', method: RequestMethod.PUT }, + ); + } } diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-selfhosted-user-admin-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-selfhosted-user-admin-e2e.test.ts new file mode 100644 index 000000000..1acebf055 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-selfhosted-user-admin-e2e.test.ts @@ -0,0 +1,360 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { Constants } from '../../../src/helpers/constants/constants.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +let app: INestApplication; +let currentTest: string; + +const testPassword = `#r@dY^e&7R4b5Ib@31iE4xbn`; + +// SelfHostedOperationsModule.register() decides at IMPORT time (when +// app.module loads) whether to build the full self-hosted variant — flipping +// IS_SAAS afterwards cannot bring the routes back. In SaaS-mode environments +// (e.g. the sitenova full stack, IS_SAAS=true in the container env) this file +// can only self-skip; it runs for real where the env is self-hosted at import +// (rocketadmin CI), or via `exec -e IS_SAAS= … npx ava `. +const SELFHOSTED_MODULE_UNAVAILABLE = !!process.env.IS_SAAS; + +function skipUnavailable(t: { log: (msg: string) => void; pass: () => void }): boolean { + if (SELFHOSTED_MODULE_UNAVAILABLE) { + t.log('skipped: selfhosted module unavailable (IS_SAAS was set when the app module was imported)'); + t.pass(); + return true; + } + return false; +} + +test.beforeEach(async () => { + if (SELFHOSTED_MODULE_UNAVAILABLE) { + return; + } + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService], + }).compile(); + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.afterEach(async () => { + setSaasEnvVariable(); + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +async function getUserProfile(userToken: string): Promise<{ id: string; email: string; company: { id: string } }> { + const foundUser = await request(app.getHttpServer()) + .get('/user/') + .set('Cookie', userToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + return JSON.parse(foundUser.text); +} + +// Self-hosted invite flow (plan 15 Phase 6): no email transporter exists or is reachable — +// the invitation must still succeed (dispatchEmail no-ops), the raw verification string is +// exposed under isTest(), and the invited user can complete signup and log in. +async function inviteAndActivateUser( + adminToken: string, + role: 'ADMIN' | 'USER' = 'USER', +): Promise<{ userId: string; email: string; password: string; token: string }> { + const adminProfile = await getUserProfile(adminToken); + const email = `${faker.lorem.words(1)}_${faker.internet.email()}`.toLowerCase(); + + const invitationResult = await request(app.getHttpServer()) + .put(`/company/user/${adminProfile.company.id}`) + .send({ companyId: adminProfile.company.id, email, role, groupId: undefined }) + .set('Cookie', adminToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + if (invitationResult.status > 201) { + throw new Error(`Invitation failed: ${invitationResult.text}`); + } + const invitationRO = JSON.parse(invitationResult.text); + + const verificationResult = await request(app.getHttpServer()) + .post(`/company/invite/verify/${invitationRO.verificationString}`) + .send({ password: testPassword, userName: email }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + if (verificationResult.status > 201) { + throw new Error(`Invite verification failed: ${verificationResult.text}`); + } + const token = `${Constants.JWT_COOKIE_KEY_NAME}=${TestUtils.getJwtTokenFromResponse(verificationResult)}`; + const profile = await getUserProfile(token); + return { userId: profile.id, email, password: testPassword, token }; +} + +async function loginUser(email: string, password: string): Promise { + return await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); +} + +currentTest = 'self-hosted invite flow without email'; + +test.serial(`${currentTest} invite succeeds with no email transporter and invited user can log in`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + + // The whole flow must work although no SMTP transporter is configured or reachable: + // plan 15 Phase 3 dispatchEmail never touches a transporter, and Phase 6 removed the + // fatal EMAIL_SEND_FAILED check from the invite use case. + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + t.truthy(invitedUser.userId); + + const loginResult = await loginUser(invitedUser.email, invitedUser.password); + t.is(loginResult.status, 201); +}); + +test.serial(`${currentTest} re-inviting an inactive user does not fail with email send error`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const adminProfile = await getUserProfile(adminInfo.token); + const email = `${faker.lorem.words(1)}_${faker.internet.email()}`.toLowerCase(); + + const firstInvite = await request(app.getHttpServer()) + .put(`/company/user/${adminProfile.company.id}`) + .send({ companyId: adminProfile.company.id, email, role: 'USER', groupId: undefined }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.true(firstInvite.status <= 201); + + // Second invite of the same not-yet-active email: before plan 15 Phase 6 this path + // attempted an email send and threw a fatal 500 EMAIL_SEND_FAILED self-hosted. + // Now it must answer with the domain-level 400 (user already added but not active). + const secondInvite = await request(app.getHttpServer()) + .put(`/company/user/${adminProfile.company.id}`) + .send({ companyId: adminProfile.company.id, email, role: 'USER', groupId: undefined }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.not(secondInvite.status, 500); +}); + +currentTest = 'PUT /selfhosted/users/:userId/password'; + +test.serial(`${currentTest} admin sets a new password: old rejected, new accepted`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + const newPassword = `New_${faker.internet.password({ length: 16 })}1A`; + + const setPasswordResult = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/password`) + .send({ newPassword }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(setPasswordResult.status, 200); + t.is(JSON.parse(setPasswordResult.text).success, true); + + const oldPasswordLogin = await loginUser(invitedUser.email, invitedUser.password); + t.true(oldPasswordLogin.status >= 400); + + const newPasswordLogin = await loginUser(invitedUser.email, newPassword); + t.is(newPasswordLogin.status, 201); +}); + +test.serial(`${currentTest} non-admin user receives 403`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const adminProfile = await getUserProfile(adminInfo.token); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${adminProfile.id}/password`) + .send({ newPassword: `New_${faker.internet.password({ length: 16 })}1A` }) + .set('Cookie', invitedUser.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 403); +}); + +test.serial(`${currentTest} request without cookie receives 401`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${faker.string.uuid()}/password`) + .send({ newPassword: `New_${faker.internet.password({ length: 16 })}1A` }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 401); +}); + +test.serial(`${currentTest} weak (too short) password receives 400`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + + // isPasswordStrongOrThrowError is bypassed in test mode — the DTO MinLength(8) + // still enforces the lower bound, so a short password must be rejected. + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/password`) + .send({ newPassword: 'short' }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 400); +}); + +test.serial(`${currentTest} admin of another company receives 404`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + const foreignAdminInfo = await registerUserAndReturnUserInfo(app); + + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/password`) + .send({ newPassword: `New_${faker.internet.password({ length: 16 })}1A` }) + .set('Cookie', foreignAdminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 404); +}); + +currentTest = 'PUT /selfhosted/users/:userId/email'; + +test.serial(`${currentTest} admin updates user email and user can log in with it`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + const newEmail = `${faker.lorem.words(1)}_${faker.internet.email()}`.toLowerCase(); + + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/email`) + .send({ newEmail }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 200); + t.is(JSON.parse(result.text).success, true); + + const oldEmailLogin = await loginUser(invitedUser.email, invitedUser.password); + t.true(oldEmailLogin.status >= 400); + + const newEmailLogin = await loginUser(newEmail, invitedUser.password); + t.is(newEmailLogin.status, 201); +}); + +test.serial(`${currentTest} taken email receives 400 with uniqueness message`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/email`) + .send({ newEmail: adminInfo.email }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 400); + t.is(JSON.parse(result.text).message, Messages.CANNOT_SET_THIS_EMAIL); +}); + +test.serial(`${currentTest} malformed email receives 400`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + + const result = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/email`) + .send({ newEmail: 'not-an-email' }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(result.status, 400); +}); + +currentTest = 'SaaS mode gating'; + +// The real SaaS deployment registers an EMPTY SelfHostedOperationsModule (register() runs +// with IS_SAAS set), so the routes plainly 404 there — that branch cannot be exercised +// in-process because register() already ran self-hosted at module import. What CAN be +// verified is the dynamic belt-and-braces gate inside the use cases: with IS_SAAS flipped +// at request time the endpoints answer 400 ENDPOINT_NOT_AVAILABLE_IN_THIS_MODE. +test.serial(`${currentTest} user-admin routes answer 400 when IS_SAAS is set`, async (t) => { + if (skipUnavailable(t)) { + return; + } + const adminInfo = await registerUserAndReturnUserInfo(app); + const invitedUser = await inviteAndActivateUser(adminInfo.token, 'USER'); + + setSaasEnvVariable(true); + try { + const passwordResult = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/password`) + .send({ newPassword: `New_${faker.internet.password({ length: 16 })}1A` }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(passwordResult.status, 400); + t.is(JSON.parse(passwordResult.text).message, Messages.ENDPOINT_NOT_AVAILABLE_IN_THIS_MODE); + + const emailResult = await request(app.getHttpServer()) + .put(`/selfhosted/users/${invitedUser.userId}/email`) + .send({ newEmail: `${faker.lorem.words(1)}_${faker.internet.email()}`.toLowerCase() }) + .set('Cookie', adminInfo.token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(emailResult.status, 400); + t.is(JSON.parse(emailResult.text).message, Messages.ENDPOINT_NOT_AVAILABLE_IN_THIS_MODE); + } finally { + setSaasEnvVariable(); + } +}); diff --git a/backend/test/ava-tests/saas-tests/saas-email-trigger-inversion-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-email-trigger-inversion-e2e.test.ts new file mode 100644 index 000000000..fa5239e40 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-email-trigger-inversion-e2e.test.ts @@ -0,0 +1,409 @@ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { appConfig } from '../../../src/shared/config/app-config.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +// Plan 15 Phase 2 — trigger inversion on the /saas/* email-flow bridges (microservice JWT): +// with `suppressEmail: true` the bridge skips the send and returns an `emailPayload` +// (raw token + context) instead; without the flag the responses stay exactly as before +// (no `emailPayload` field anywhere — backward compatible with old SaaS deployments). + +let app: INestApplication; +let currentTest: string; +let _testUtils: TestUtils; + +const STRONG_PASSWORD = `#r@dY^e&7R4b5Ib@31iE4xbn`; + +function microserviceAuthHeader(): string { + const token = jwt.sign({ request_id: faker.string.uuid() }, appConfig.auth.microserviceJwtSecret); + return `Bearer ${token}`; +} + +function randomEmail(): string { + return `${faker.lorem.word()}_${faker.string.alphanumeric(6)}_${faker.internet.email()}`.toLowerCase(); +} + +async function registerUser(suppressEmail = false): Promise<{ + userId: string; + email: string; + companyId: string; + responseBody: Record; +}> { + const body: Record = { + email: randomEmail(), + password: STRONG_PASSWORD, + name: faker.person.firstName(), + companyId: faker.string.uuid(), + companyName: faker.company.name(), + gclidValue: null, + }; + if (suppressEmail) { + body.suppressEmail = true; + } + const result = await request(app.getHttpServer()) + .post('/saas/user/register') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body); + if (result.status !== 201) { + throw new Error(`Test user registration failed: ${result.status} ${result.text}`); + } + const ro = JSON.parse(result.text); + return { userId: ro.id, email: ro.email, companyId: body.companyId as string, responseBody: ro }; +} + +test.before(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'POST /saas/user/register (suppressEmail)'; + +test.serial(`${currentTest} returns an email_confirmation payload whose raw token verifies`, async (t) => { + const { email, responseBody } = await registerUser(true); + + t.truthy(responseBody.emailPayload); + t.is(responseBody.emailPayload.type, 'email_confirmation'); + t.is(responseBody.emailPayload.to, email); + t.is(typeof responseBody.emailPayload.rawToken, 'string'); + t.true(responseBody.emailPayload.rawToken.length > 0); + + // the returned raw token must actually work against the verify bridge + const verifyResult = await request(app.getHttpServer()) + .post(`/saas/user/email/verify/${responseBody.emailPayload.rawToken}`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({}); + t.is(verifyResult.status, 201); + t.pass(); +}); + +test.serial(`${currentTest} without the flag keeps today's response (no emailPayload field)`, async (t) => { + const { responseBody } = await registerUser(false); + t.false('emailPayload' in responseBody); + t.pass(); +}); + +currentTest = 'POST /saas/user/email/verify/request (suppressEmail)'; + +test.serial(`${currentTest} returns the payload with the flag and none without`, async (t) => { + const { userId, email } = await registerUser(); + + const suppressed = await request(app.getHttpServer()) + .post('/saas/user/email/verify/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, suppressEmail: true }); + t.is(suppressed.status, 201); + const suppressedRO = JSON.parse(suppressed.text); + t.is(typeof suppressedRO.message, 'string'); + t.truthy(suppressedRO.emailPayload); + t.is(suppressedRO.emailPayload.type, 'email_confirmation'); + t.is(suppressedRO.emailPayload.to, email); + t.is(typeof suppressedRO.emailPayload.rawToken, 'string'); + + // the raw token from the re-send payload must verify the email + const verifyResult = await request(app.getHttpServer()) + .post(`/saas/user/email/verify/${suppressedRO.emailPayload.rawToken}`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({}); + t.is(verifyResult.status, 201); + + // backward-compat check needs a fresh inactive user (the previous one is now active) + const { userId: legacyUserId } = await registerUser(); + const legacy = await request(app.getHttpServer()) + .post('/saas/user/email/verify/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId: legacyUserId }); + t.is(legacy.status, 201); + t.false('emailPayload' in JSON.parse(legacy.text)); + t.pass(); +}); + +currentTest = 'POST /saas/user/password/reset/request (suppressEmail)'; + +test.serial( + `${currentTest} existing user gets a payload; unknown user gets the same message, no payload`, + async (t) => { + const { email, companyId } = await registerUser(); + + const existing = await request(app.getHttpServer()) + .post('/saas/user/password/reset/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email, companyId, suppressEmail: true }); + t.is(existing.status, 201); + const existingRO = JSON.parse(existing.text); + t.truthy(existingRO.emailPayload); + t.is(existingRO.emailPayload.type, 'password_reset_request'); + t.is(existingRO.emailPayload.to, email); + t.is(typeof existingRO.emailPayload.rawToken, 'string'); + + const missing = await request(app.getHttpServer()) + .post('/saas/user/password/reset/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email: `nobody_${faker.string.alphanumeric(8)}@example.com`, companyId, suppressEmail: true }); + t.is(missing.status, 201); + const missingRO = JSON.parse(missing.text); + // user-enumeration guard: identical message, no payload, no error + t.is(missingRO.message, existingRO.message); + t.false('emailPayload' in missingRO); + + // the payload's raw token must consume via the reset-verify bridge + const verifyResult = await request(app.getHttpServer()) + .post(`/saas/user/password/reset/verify/${existingRO.emailPayload.rawToken}`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ password: `N3w!${STRONG_PASSWORD}` }); + t.is(verifyResult.status, 201); + t.pass(); + }, +); + +test.serial(`${currentTest} without the flag keeps today's behavior (403 for unknown user, no payload)`, async (t) => { + const { email, companyId } = await registerUser(); + + const legacy = await request(app.getHttpServer()) + .post('/saas/user/password/reset/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email, companyId }); + t.is(legacy.status, 201); + t.false('emailPayload' in JSON.parse(legacy.text)); + + const legacyMissing = await request(app.getHttpServer()) + .post('/saas/user/password/reset/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email: `nobody_${faker.string.alphanumeric(8)}@example.com`, companyId: faker.string.uuid() }); + t.is(legacyMissing.status, 403); + t.pass(); +}); + +currentTest = 'POST /saas/user/email/change/request + verify (suppressEmail)'; + +test.serial(`${currentTest} returns payloads on both legs and the raw token works`, async (t) => { + const { userId, email } = await registerUser(true); + + // activate through the suppressed-registration raw token (email + token state live in the core) + const registerPayloadToken = ( + await request(app.getHttpServer()) + .post('/saas/user/email/verify/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, suppressEmail: true }) + ).body.emailPayload.rawToken; + await request(app.getHttpServer()) + .post(`/saas/user/email/verify/${registerPayloadToken}`) + .set('Authorization', microserviceAuthHeader()) + .send({}); + + const changeRequest = await request(app.getHttpServer()) + .post('/saas/user/email/change/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, suppressEmail: true }); + t.is(changeRequest.status, 201); + const changeRequestRO = JSON.parse(changeRequest.text); + t.truthy(changeRequestRO.emailPayload); + t.is(changeRequestRO.emailPayload.type, 'email_change_request'); + t.is(changeRequestRO.emailPayload.to, email); + t.is(typeof changeRequestRO.emailPayload.rawToken, 'string'); + + const newEmail = `changed_${faker.string.alphanumeric(8)}@example.com`.toLowerCase(); + const verify = await request(app.getHttpServer()) + .post(`/saas/user/email/change/verify/${changeRequestRO.emailPayload.rawToken}`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email: newEmail, suppressEmail: true }); + t.is(verify.status, 201); + const verifyRO = JSON.parse(verify.text); + t.truthy(verifyRO.emailPayload); + t.is(verifyRO.emailPayload.type, 'email_changed'); + t.is(verifyRO.emailPayload.to, newEmail); + t.false('rawToken' in verifyRO.emailPayload); + t.pass(); +}); + +test.serial(`${currentTest} without the flag returns no payloads`, async (t) => { + const { userId } = await registerUser(true); + const activateToken = ( + await request(app.getHttpServer()) + .post('/saas/user/email/verify/request') + .set('Authorization', microserviceAuthHeader()) + .send({ userId, suppressEmail: true }) + ).body.emailPayload.rawToken; + await request(app.getHttpServer()) + .post(`/saas/user/email/verify/${activateToken}`) + .set('Authorization', microserviceAuthHeader()) + .send({}); + + const changeRequest = await request(app.getHttpServer()) + .post('/saas/user/email/change/request') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId }); + t.is(changeRequest.status, 201); + t.false('emailPayload' in JSON.parse(changeRequest.text)); + t.pass(); +}); + +currentTest = 'POST /saas/company/:companyId/invite (suppressEmail)'; + +test.serial(`${currentTest} returns a company_invite payload with company context`, async (t) => { + const { userId, companyId } = await registerUser(); + const invitedEmail = `invited_${faker.string.alphanumeric(8)}@example.com`.toLowerCase(); + + const result = await request(app.getHttpServer()) + .post(`/saas/company/${companyId}/invite`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + inviterId: userId, + email: invitedEmail, + role: 'USER', + suppressEmail: true, + }); + + t.is(result.status, 201); + const ro = JSON.parse(result.text); + t.is(ro.email, invitedEmail); + t.is(ro.companyId, companyId); + t.truthy(ro.emailPayload); + t.is(ro.emailPayload.type, 'company_invite'); + t.is(ro.emailPayload.to, invitedEmail); + t.is(ro.emailPayload.companyId, companyId); + t.is(typeof ro.emailPayload.rawToken, 'string'); + t.is(typeof ro.emailPayload.companyName, 'string'); + // the raw token in the payload is the invitation token (same one surfaced under test env) + t.is(ro.emailPayload.rawToken, ro.verificationString); + t.pass(); +}); + +test.serial(`${currentTest} existing-inactive user branch returns a marked success with the payload`, async (t) => { + const { userId, companyId } = await registerUser(); + // second (still inactive) user in the same company + const inactive = await request(app.getHttpServer()) + .post('/saas/user/register') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + email: randomEmail(), + password: STRONG_PASSWORD, + name: faker.person.firstName(), + companyId, + companyName: faker.company.name(), + gclidValue: null, + suppressEmail: true, + }); + t.is(inactive.status, 201); + const inactiveRO = JSON.parse(inactive.text); + + const result = await request(app.getHttpServer()) + .post(`/saas/company/${companyId}/invite`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + inviterId: userId, + email: inactiveRO.email, + role: 'USER', + suppressEmail: true, + }); + + // Marked success, NOT a 400: the global exception filter serializes a fixed error shape and + // would strip the payload, so the bridge returns `userAlreadyAddedInactive` and the SaaS + // caller surfaces the user-facing 400 itself. + t.is(result.status, 201); + const ro = JSON.parse(result.text); + t.is(ro.userAlreadyAddedInactive, true); + t.truthy(ro.emailPayload); + t.is(ro.emailPayload.type, 'email_confirmation'); + t.is(ro.emailPayload.to, inactiveRO.email); + t.is(typeof ro.emailPayload.rawToken, 'string'); + + // the re-confirmation token must verify the inactive user's email + const verifyResult = await request(app.getHttpServer()) + .post(`/saas/user/email/verify/${ro.emailPayload.rawToken}`) + .set('Authorization', microserviceAuthHeader()) + .send({}); + t.is(verifyResult.status, 201); + t.pass(); +}); + +test.serial(`${currentTest} without the flag returns no payload (backward compat)`, async (t) => { + const { userId, companyId } = await registerUser(); + const invitedEmail = `invited_${faker.string.alphanumeric(8)}@example.com`.toLowerCase(); + + const result = await request(app.getHttpServer()) + .post(`/saas/company/${companyId}/invite`) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + inviterId: userId, + email: invitedEmail, + role: 'USER', + }); + + t.is(result.status, 201); + const ro = JSON.parse(result.text); + t.false('emailPayload' in ro); + t.pass(); +}); + +currentTest = 'public routes never honor suppressEmail'; + +test.serial(`${currentTest} POST /user/password/reset/request/ ignores a smuggled flag`, async (t) => { + const { email, companyId } = await registerUser(); + + // The public (non-bridge) route must never return a raw token, whatever the body says. + const result = await request(app.getHttpServer()) + .post('/user/password/reset/request/') + .set('Content-Type', 'application/json') + .send({ email, companyId, suppressEmail: true }); + + t.is(result.status, 201); + t.false('emailPayload' in JSON.parse(result.text)); + t.pass(); +}); diff --git a/backend/test/ava-tests/saas-tests/saas-user-account-bridges-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-user-account-bridges-e2e.test.ts new file mode 100644 index 000000000..0123aa8b0 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-user-account-bridges-e2e.test.ts @@ -0,0 +1,521 @@ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import jwt from 'jsonwebtoken'; +import { generateSync } from 'otplib'; +import request from 'supertest'; +import { DataSource } from 'typeorm'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { BaseType } from '../../../src/common/data-injection.tokens.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { UserEntity } from '../../../src/entities/user/user.entity.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { appConfig } from '../../../src/shared/config/app-config.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +// Tests for the plan-15 Phase 4/5 internal user-account bridges rocketadmin-saas calls +// (microservice JWT): +// GET /saas/user/:userId/profile POST /saas/user/password/change +// PUT /saas/user/name PUT /saas/user/delete +// POST /saas/user/settings GET /saas/user/:userId/settings +// PUT /saas/user/test-connections +// POST /saas/user/otp/generate|verify|disable|login +// POST /saas/user/validate-token (allowScopes extension) +// The OTP codes are computed with the same otplib the core verifies with; the temporary token for +// otp/login is minted exactly the way the saas service signs it (TEMPORARY_JWT_SECRET, `id` claim). + +let app: INestApplication; +let currentTest: string; +let _testUtils: TestUtils; + +const STRONG_PASSWORD = `#r@dY^e&7R4b5Ib@31iE4xbn`; + +function microserviceAuthHeader(): string { + const token = jwt.sign({ request_id: faker.string.uuid() }, appConfig.auth.microserviceJwtSecret); + return `Bearer ${token}`; +} + +async function registerUser(): Promise<{ userId: string; email: string; companyId: string }> { + const body = { + email: `${faker.lorem.word()}_${faker.string.alphanumeric(6)}_${faker.internet.email()}`.toLowerCase(), + password: STRONG_PASSWORD, + name: faker.person.firstName(), + companyId: faker.string.uuid(), + companyName: faker.company.name(), + gclidValue: null, + }; + const result = await request(app.getHttpServer()) + .post('/saas/user/register') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body); + if (result.status !== 201) { + throw new Error(`Test user registration failed: ${result.status} ${result.text}`); + } + const ro = JSON.parse(result.text); + return { userId: ro.id, email: ro.email, companyId: body.companyId }; +} + +async function activateUser(userId: string): Promise { + const dataSource = app.get(BaseType.DATA_SOURCE); + const userRepository = dataSource.getRepository(UserEntity); + const user = await userRepository.findOne({ where: { id: userId } }); + user.isActive = true; + await userRepository.save(user); +} + +async function getProfile(userId: string): Promise { + return await request(app.getHttpServer()) + .get(`/saas/user/${userId}/profile`) + .set('Authorization', microserviceAuthHeader()) + .set('Accept', 'application/json'); +} + +// Enrols the user into 2FA through the bridges and returns the shared TOTP secret. +async function enrollUserIntoOtp(userId: string): Promise { + await activateUser(userId); + const generateResult = await request(app.getHttpServer()) + .post('/saas/user/otp/generate') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId }); + if (generateResult.status !== 201) { + throw new Error(`OTP generation failed: ${generateResult.status} ${generateResult.text}`); + } + const { otpauth_url } = JSON.parse(generateResult.text); + const secret = new URL(otpauth_url).searchParams.get('secret'); + if (!secret) { + throw new Error(`No secret in otpauth url: ${otpauth_url}`); + } + const verifyResult = await request(app.getHttpServer()) + .post('/saas/user/otp/verify') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, otpCode: generateSync({ secret }) }); + if (verifyResult.status !== 201) { + throw new Error(`OTP verification failed: ${verifyResult.status} ${verifyResult.text}`); + } + return secret; +} + +// Mints the 4-minute temporary token exactly the way the saas service does at the password step +// of a 2FA login (generateTemporaryJwtToken: `id` + `email` claims, TEMPORARY_JWT_SECRET). +function mintTemporaryToken(userId: string, email: string): string { + const exp = Math.floor(Date.now() / 1000) + 60 * 4; + return jwt.sign({ id: userId, email, exp }, appConfig.auth.temporaryJwtSecret); +} + +test.before(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'GET /saas/user/:userId/profile'; + +test.serial(`${currentTest} returns the full FoundUserDto shape (findMe parity)`, async (t) => { + const { userId, email, companyId } = await registerUser(); + + const result = await getProfile(userId); + t.is(result.status, 200); + const ro = JSON.parse(result.text); + t.is(ro.id, userId); + t.is(ro.email, email); + t.is(ro.isActive, false); + t.is(ro.suspended, false); + t.is(ro.is_2fa_enabled, false); + t.is(ro.show_test_connections, true); + t.is(typeof ro.role, 'string'); + t.is(Object.hasOwn(ro, 'createdAt'), true); + t.is(Object.hasOwn(ro, 'externalRegistrationProvider'), true); + t.is(ro.company.id, companyId); + t.pass(); +}); + +test.serial(`${currentTest} rejects an unknown user`, async (t) => { + const result = await getProfile(faker.string.uuid()); + t.is(result.status, 404); + t.pass(); +}); + +currentTest = 'POST /saas/user/password/change'; + +test.serial(`${currentTest} rejects a wrong old password and accepts the right one`, async (t) => { + const { userId, email, companyId } = await registerUser(); + const newPassword = `N3w!${STRONG_PASSWORD}`; + + const wrongResult = await request(app.getHttpServer()) + .post('/saas/user/password/change') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, email, oldPassword: `wrong_${STRONG_PASSWORD}`, newPassword }); + t.is(wrongResult.status, 400); + + const result = await request(app.getHttpServer()) + .post('/saas/user/password/change') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, email, oldPassword: STRONG_PASSWORD, newPassword }); + t.is(result.status, 201); + + // the login bridge accepts the new password and rejects the old one + const newPasswordLogin = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email, password: newPassword, companyId, request_domain: '127.0.0.1' }); + t.is(newPasswordLogin.status, 201); + + const oldPasswordLogin = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ email, password: STRONG_PASSWORD, companyId, request_domain: '127.0.0.1' }); + t.is(oldPasswordLogin.status, 400); + t.pass(); +}); + +currentTest = 'PUT /saas/user/name'; + +test.serial(`${currentTest} changes the name (reflected in the profile)`, async (t) => { + const { userId } = await registerUser(); + const newName = `Renamed_${faker.string.alphanumeric(6)}`; + + const result = await request(app.getHttpServer()) + .put('/saas/user/name') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, name: newName }); + t.is(result.status, 200); + t.is(JSON.parse(result.text).name, newName); + + const profileResult = await getProfile(userId); + t.is(JSON.parse(profileResult.text).name, newName); + t.pass(); +}); + +currentTest = 'POST /saas/user/settings + GET /saas/user/:userId/settings'; + +test.serial(`${currentTest} roundtrips the settings`, async (t) => { + const { userId } = await registerUser(); + const userSettings = JSON.stringify({ theme: 'dark', tableWidth: 42 }); + + const saveResult = await request(app.getHttpServer()) + .post('/saas/user/settings') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, userSettings }); + t.is(saveResult.status, 201); + const savedRO = JSON.parse(saveResult.text); + t.is(savedRO.userId, userId); + + const getResult = await request(app.getHttpServer()) + .get(`/saas/user/${userId}/settings`) + .set('Authorization', microserviceAuthHeader()) + .set('Accept', 'application/json'); + t.is(getResult.status, 200); + const gotRO = JSON.parse(getResult.text); + t.is(gotRO.userId, userId); + t.deepEqual(JSON.parse(gotRO.userSettings), JSON.parse(userSettings)); + t.pass(); +}); + +test.serial(`${currentTest} rejects a non-JSON settings string`, async (t) => { + const { userId } = await registerUser(); + const result = await request(app.getHttpServer()) + .post('/saas/user/settings') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, userSettings: 'not json at all' }); + t.is(result.status, 400); + t.pass(); +}); + +currentTest = 'PUT /saas/user/test-connections'; + +test.serial(`${currentTest} toggles the display mode (reflected in the profile)`, async (t) => { + const { userId } = await registerUser(); + + const offResult = await request(app.getHttpServer()) + .put('/saas/user/test-connections') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, displayMode: 'off' }); + t.is(offResult.status, 200); + t.is(JSON.parse(offResult.text).success, true); + t.is(JSON.parse((await getProfile(userId)).text).show_test_connections, false); + + const onResult = await request(app.getHttpServer()) + .put('/saas/user/test-connections') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, displayMode: 'on' }); + t.is(onResult.status, 200); + t.is(JSON.parse((await getProfile(userId)).text).show_test_connections, true); + t.pass(); +}); + +test.serial(`${currentTest} rejects an invalid display mode`, async (t) => { + const { userId } = await registerUser(); + const result = await request(app.getHttpServer()) + .put('/saas/user/test-connections') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, displayMode: 'maybe' }); + t.is(result.status, 400); + t.pass(); +}); + +currentTest = 'POST /saas/user/otp/generate + /saas/user/otp/verify'; + +test.serial(`${currentTest} enrols with a computed TOTP code`, async (t) => { + const { userId } = await registerUser(); + await activateUser(userId); + + const generateResult = await request(app.getHttpServer()) + .post('/saas/user/otp/generate') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId }); + t.is(generateResult.status, 201); + const generateRO = JSON.parse(generateResult.text); + t.is(typeof generateRO.otpauth_url, 'string'); + t.is(typeof generateRO.qrCode, 'string'); + const secret = new URL(generateRO.otpauth_url).searchParams.get('secret'); + t.truthy(secret); + + const wrongResult = await request(app.getHttpServer()) + .post('/saas/user/otp/verify') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, otpCode: '000000' }); + t.true(wrongResult.status >= 400); + t.is(JSON.parse((await getProfile(userId)).text).is_2fa_enabled, false); + + const verifyResult = await request(app.getHttpServer()) + .post('/saas/user/otp/verify') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, otpCode: generateSync({ secret }) }); + t.is(verifyResult.status, 201); + t.is(JSON.parse(verifyResult.text).validated, true); + t.is(JSON.parse((await getProfile(userId)).text).is_2fa_enabled, true); + t.pass(); +}); + +test.serial(`${currentTest} rejects generation for an inactive user`, async (t) => { + const { userId } = await registerUser(); + const result = await request(app.getHttpServer()) + .post('/saas/user/otp/generate') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId }); + t.is(result.status, 400); + t.pass(); +}); + +currentTest = 'POST /saas/user/otp/login'; + +test.serial(`${currentTest} completes a 2FA login with a temporary token + TOTP code`, async (t) => { + const { userId, email } = await registerUser(); + const secret = await enrollUserIntoOtp(userId); + const temporaryToken = mintTemporaryToken(userId, email); + + const result = await request(app.getHttpServer()) + .post('/saas/user/otp/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ temporaryToken, otpCode: generateSync({ secret }) }); + t.is(result.status, 201); + const ro = JSON.parse(result.text); + t.is(ro.id, userId); + t.is(ro.email, email); + t.is(ro.is_2fa_enabled, true); + t.pass(); +}); + +test.serial(`${currentTest} rejects a wrong OTP code`, async (t) => { + const { userId, email } = await registerUser(); + await enrollUserIntoOtp(userId); + const temporaryToken = mintTemporaryToken(userId, email); + + const result = await request(app.getHttpServer()) + .post('/saas/user/otp/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ temporaryToken, otpCode: '000000' }); + t.is(result.status, 400); + t.pass(); +}); + +test.serial(`${currentTest} rejects a token signed with the wrong secret`, async (t) => { + const { userId, email } = await registerUser(); + const secret = await enrollUserIntoOtp(userId); + // A FULL session token (JWT_SECRET) must not pass the temporary-token check. + const exp = Math.floor(Date.now() / 1000) + 60 * 4; + const wrongToken = jwt.sign({ id: userId, email, exp }, appConfig.auth.jwtSecret); + + const result = await request(app.getHttpServer()) + .post('/saas/user/otp/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ temporaryToken: wrongToken, otpCode: generateSync({ secret }) }); + t.is(result.status, 401); + t.pass(); +}); + +currentTest = 'POST /saas/user/validate-token with allowScopes'; + +test.serial(`${currentTest} accepts a 2fa_enable-scoped token only when the scope is allowed`, async (t) => { + const { userId, email } = await registerUser(); + const exp = Math.floor(Date.now() / 1000) + 60 * 60; + const scopedToken = jwt.sign({ id: userId, email, exp, scope: ['2fa_enable'] }, appConfig.auth.jwtSecret); + + // current strict behavior without the new field: 2FA-required rejection + const strictResult = await request(app.getHttpServer()) + .post('/saas/user/validate-token') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ token: scopedToken }); + t.is(strictResult.status, 400); + + const allowedResult = await request(app.getHttpServer()) + .post('/saas/user/validate-token') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ token: scopedToken, allowScopes: ['2fa_enable'] }); + t.is(allowedResult.status, 201); + const ro = JSON.parse(allowedResult.text); + t.is(ro.sub, userId); + t.is(ro.email, email); + + // an unscoped token still validates with the field present + const plainToken = jwt.sign({ id: userId, email, exp }, appConfig.auth.jwtSecret); + const plainResult = await request(app.getHttpServer()) + .post('/saas/user/validate-token') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ token: plainToken, allowScopes: ['2fa_enable'] }); + t.is(plainResult.status, 201); + t.pass(); +}); + +currentTest = 'POST /saas/user/otp/disable'; + +test.serial(`${currentTest} disables 2FA with a valid code`, async (t) => { + const { userId } = await registerUser(); + const secret = await enrollUserIntoOtp(userId); + + const wrongResult = await request(app.getHttpServer()) + .post('/saas/user/otp/disable') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, otpCode: '000000' }); + t.true(wrongResult.status >= 400); + + const result = await request(app.getHttpServer()) + .post('/saas/user/otp/disable') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId, otpCode: generateSync({ secret }) }); + t.is(result.status, 201); + t.is(JSON.parse(result.text).disabled, true); + t.is(JSON.parse((await getProfile(userId)).text).is_2fa_enabled, false); + t.pass(); +}); + +currentTest = 'PUT /saas/user/delete'; + +test.serial(`${currentTest} deletes the account (profile 404 afterwards)`, async (t) => { + // Delete a NON-last company member: deleting the last user triggers the + // core→saas deleteCompany webhook, and bridge-registered test users have no + // saas-side CompanyEntity, so that call can only fail in this stack. A + // second bridge registration into the same companyId gives us a deletable + // non-last member without touching any saas webhook. + const { userId: firstUserId, companyId } = await registerUser(); + await activateUser(firstUserId); + + const secondBody = { + email: `${faker.lorem.word()}_${faker.string.alphanumeric(6)}_${faker.internet.email()}`.toLowerCase(), + password: STRONG_PASSWORD, + name: faker.person.firstName(), + companyId, + companyName: faker.company.name(), + gclidValue: null, + }; + const secondRegister = await request(app.getHttpServer()) + .post('/saas/user/register') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send(secondBody); + t.is(secondRegister.status, 201); + const secondUserId = JSON.parse(secondRegister.text).id; + + const result = await request(app.getHttpServer()) + .put('/saas/user/delete') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .send({ userId: secondUserId, reason: 'other', message: 'e2e cleanup' }); + t.is(result.status, 200); + t.is(JSON.parse(result.text).email, secondBody.email); + + const profileResult = await getProfile(secondUserId); + t.is(profileResult.status, 404); + t.pass(); +}); + +currentTest = 'internal user-account endpoints auth'; + +test.serial(`${currentTest} reject requests without a microservice JWT`, async (t) => { + const userId = faker.string.uuid(); + const cases: Array<{ method: 'get' | 'post' | 'put'; path: string }> = [ + { method: 'get', path: `/saas/user/${userId}/profile` }, + { method: 'post', path: '/saas/user/password/change' }, + { method: 'put', path: '/saas/user/name' }, + { method: 'put', path: '/saas/user/delete' }, + { method: 'post', path: '/saas/user/settings' }, + { method: 'get', path: `/saas/user/${userId}/settings` }, + { method: 'put', path: '/saas/user/test-connections' }, + { method: 'post', path: '/saas/user/otp/generate' }, + { method: 'post', path: '/saas/user/otp/verify' }, + { method: 'post', path: '/saas/user/otp/disable' }, + { method: 'post', path: '/saas/user/otp/login' }, + ]; + for (const { method, path } of cases) { + const result = await request(app.getHttpServer())[method](path).set('Content-Type', 'application/json').send({}); + t.is(result.status, 401, `expected 401 for ${method.toUpperCase()} ${path}`); + } + t.pass(); +});