diff --git a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts index b0d29ae6e5a..704dbb3bd5a 100644 --- a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts @@ -159,13 +159,71 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { if (!newMsg.pwd) { throw new Error('password unexpectedly missing'); } + + // Check if we should use the new pre-signed S3 URL flow + if (this.view.clientConfiguration.shouldUseFesPresignedUrls()) { + // New flow: allocation -> S3 upload -> create message + return await this.prepareAndUploadPwdEncryptedMsgWithPresignedUrl(newMsg, signingKey); + } else { + // Legacy flow: get token, then upload directly to FES + return await this.prepareAndUploadPwdEncryptedMsgLegacy(newMsg, signingKey); + } + }; + + private prepareAndUploadPwdEncryptedMsgWithPresignedUrl = async ( + newMsg: NewMsgData, + signingKey?: ParsedKeyInfo + ): Promise => { + // Step 1: Allocate storage and get pre-signed URL + reply token + const allocation = await this.view.acctServer.messageAllocation(); + const { storageFileName, replyToken, uploadUrl } = allocation; + + // Step 2: Prepare body with reply token and encrypt the message + const { bodyWithReplyToken } = await this.getPwdMsgSendableBodyWithReplyToken(newMsg, replyToken); + const pgpMimeWithAttachments = await Mime.encode( + bodyWithReplyToken, + { Subject: newMsg.subject }, // eslint-disable-line @typescript-eslint/naming-convention + await this.view.attachmentsModule.attachment.collectAttachments() + ); + const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor( + Buf.fromUtfStr(pgpMimeWithAttachments), + newMsg.pwd, + [], + signingKey?.key + ); + + // Step 3: Upload encrypted content to S3 + await this.view.acctServer.uploadToS3( + uploadUrl, + pwdEncryptedWithAttachments, + p => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF') + ); + + // Step 4: Create message record in FES + return await this.view.acctServer.messageCreate( + storageFileName, + replyToken, + newMsg.from.email, + newMsg.recipients + ); + }; + + private prepareAndUploadPwdEncryptedMsgLegacy = async ( + newMsg: NewMsgData, + signingKey?: ParsedKeyInfo + ): Promise => { const { bodyWithReplyToken, replyToken } = await this.getPwdMsgSendableBodyWithOnlineReplyMsgToken(newMsg); const pgpMimeWithAttachments = await Mime.encode( bodyWithReplyToken, { Subject: newMsg.subject }, // eslint-disable-line @typescript-eslint/naming-convention await this.view.attachmentsModule.attachment.collectAttachments() ); - const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeWithAttachments), newMsg.pwd, [], signingKey?.key); + const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor( + Buf.fromUtfStr(pgpMimeWithAttachments), + newMsg.pwd, + [], + signingKey?.key + ); return await this.view.acctServer.messageUpload( pwdEncryptedWithAttachments, replyToken, @@ -250,29 +308,28 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { }); }; + /** + * Prepares the message body with a reply token that was provided (used by pre-signed URL flow). + */ + private getPwdMsgSendableBodyWithReplyToken = async ( + newMsgData: NewMsgData, + replyToken: string + ): Promise<{ bodyWithReplyToken: SendableMsgBody }> => { + return { + bodyWithReplyToken: this.buildSendableBodyWithReplyToken(newMsgData, replyToken), + }; + }; + + /** + * Gets a reply token from the server and prepares message body (used by legacy flow). + */ private getPwdMsgSendableBodyWithOnlineReplyMsgToken = async ( newMsgData: NewMsgData ): Promise<{ bodyWithReplyToken: SendableMsgBody; replyToken: string }> => { - const recipientsWithoutBcc = { ...newMsgData.recipients, bcc: [] }; - const recipients = getUniqueRecipientEmails(recipientsWithoutBcc); try { const response = await this.view.acctServer.messageToken(); - const replyInfoRaw: ReplyInfoRaw = { - sender: newMsgData.from.email, - recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), - subject: newMsgData.subject, - token: response.replyToken, - }; - const replyInfoDiv = Ui.e('div', { - style: 'display: none;', - class: 'cryptup_reply', - 'cryptup-data': Str.htmlAttrEncode(replyInfoRaw), - }); return { - bodyWithReplyToken: { - 'text/plain': newMsgData.plaintext + '\n\n' + replyInfoDiv, - 'text/html': newMsgData.plainhtml + '

' + replyInfoDiv, - }, + bodyWithReplyToken: this.buildSendableBodyWithReplyToken(newMsgData, response.replyToken), replyToken: response.replyToken, }; } catch (msgTokenErr) { @@ -293,6 +350,29 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { } }; + /** + * Builds the sendable message body with embedded reply token information. + */ + private buildSendableBodyWithReplyToken = (newMsgData: NewMsgData, replyToken: string): SendableMsgBody => { + const recipientsWithoutBcc = { ...newMsgData.recipients, bcc: [] }; + const recipients = getUniqueRecipientEmails(recipientsWithoutBcc); + const replyInfoRaw: ReplyInfoRaw = { + sender: newMsgData.from.email, + recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), + subject: newMsgData.subject, + token: replyToken, + }; + const replyInfoDiv = Ui.e('div', { + style: 'display: none;', + class: 'cryptup_reply', + 'cryptup-data': Str.htmlAttrEncode(replyInfoRaw), + }); + return { + 'text/plain': newMsgData.plaintext + '\n\n' + replyInfoDiv, + 'text/html': newMsgData.plainhtml + '

' + replyInfoDiv, + }; + }; + private encryptMsgAsOfDateIfSomeAreExpiredAndUserConfirmedModal = async (pubs: PubkeyResult[]): Promise => { if (!pubs.length) { return undefined; diff --git a/extension/js/common/api/account-server.ts b/extension/js/common/api/account-server.ts index 910b70d1d05..1e4d0d8e9f0 100644 --- a/extension/js/common/api/account-server.ts +++ b/extension/js/common/api/account-server.ts @@ -3,7 +3,7 @@ 'use strict'; import { isCustomerUrlFesUsed } from '../helpers.js'; -import { ExternalService } from './account-servers/external-service.js'; +import { ExternalService, FesRes } from './account-servers/external-service.js'; import { ParsedRecipients } from './email-provider/email-provider-api.js'; import { Api, ProgressCb } from './shared/api.js'; import { ClientConfigurationJson } from '../client-configuration.js'; @@ -61,7 +61,37 @@ export class AccountServer extends Api { await this.externalService.messageGatewayUpdate(externalId, emailGatewayMessageId); }; + /** + * Gets a reply token for password-protected messages (legacy flow). + */ public messageToken = async (): Promise<{ replyToken: string }> => { return await this.externalService.webPortalMessageNewReplyToken(); }; + + /** + * Allocates storage for a password-protected message using pre-signed S3 URL (new flow). + * Returns storage file name, reply token, and pre-signed upload URL. + */ + public messageAllocation = async (): Promise => { + return await this.externalService.webPortalMessageAllocation(); + }; + + /** + * Uploads encrypted content directly to S3 using a pre-signed URL (new flow). + */ + public uploadToS3 = async (uploadUrl: string, data: Uint8Array, progressCb: ProgressCb): Promise => { + await this.externalService.uploadToS3(uploadUrl, data, progressCb); + }; + + /** + * Creates a password-protected message record in FES after uploading content to S3 (new flow). + */ + public messageCreate = async ( + storageFileName: string, + associateReplyToken: string, + from: string, + recipients: ParsedRecipients + ): Promise => { + return await this.externalService.webPortalMessageCreate(storageFileName, associateReplyToken, from, recipients); + }; } diff --git a/extension/js/common/api/account-servers/external-service.ts b/extension/js/common/api/account-servers/external-service.ts index cc7b5fb3127..619ed6096e2 100644 --- a/extension/js/common/api/account-servers/external-service.ts +++ b/extension/js/common/api/account-servers/external-service.ts @@ -26,6 +26,11 @@ export namespace FesRes { externalId: string; // LEGACY emailToExternalIdAndUrl?: { [email: string]: { url: string; externalId: string } }; }; + export type MessageAllocation = { + storageFileName: string; + replyToken: string; + uploadUrl: string; + }; export type ServiceInfo = { vendor: string; service: string; orgId: string; version: string; apiVersion: string }; export type ClientConfiguration = { clientConfiguration: ClientConfigurationJson }; } @@ -159,9 +164,7 @@ export class ExternalService extends Api { JSON.stringify({ associateReplyToken, from, - to: (recipients.to || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars), - cc: (recipients.cc || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars), - bcc: (recipients.bcc || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars), + ...this.prepareRecipientsForFes(recipients), }) ), }); @@ -178,6 +181,63 @@ export class ExternalService extends Api { }); }; + /** + * Allocates storage for a password-protected message using pre-signed S3 URL. + * Returns storage file name, reply token, and pre-signed upload URL. + */ + public webPortalMessageAllocation = async (): Promise => { + return await this.request(`/api/${this.apiVersion}/messages/allocation`, { fmt: 'JSON', data: {} }); + }; + + /** + * Uploads encrypted message content directly to S3 using a pre-signed URL. + */ + public uploadToS3 = async (uploadUrl: string, data: Uint8Array, progressCb: ProgressCb): Promise => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', uploadUrl, true); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + progressCb(percent, e.loaded, e.total); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`S3 upload failed with status ${xhr.status}: ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + reject(new Error('S3 upload failed due to network error')); + }; + xhr.send(new Blob([data.buffer as ArrayBuffer])); + }); + }; + + /** + * Creates a password-protected message record in FES after uploading content to S3. + * Uses storageFileName from allocation instead of uploading encrypted content directly. + */ + public webPortalMessageCreate = async ( + storageFileName: string, + associateReplyToken: string, + from: string, + recipients: ParsedRecipients + ): Promise => { + return await this.request(`/api/${this.apiVersion}/messages`, { + fmt: 'JSON', + data: { + storageFileName, + associateReplyToken, + from, + ...this.prepareRecipientsForFes(recipients), + }, + }); + }; + private request = async ( path: string, vals?: @@ -211,4 +271,12 @@ export class ExternalService extends Api { 'json' ); }; + private prepareRecipientsForFes = (recipients: ParsedRecipients) => { + const process = (list: ParsedRecipients['to']) => (list || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars); + return { + to: process(recipients.to), + cc: process(recipients.cc), + bcc: process(recipients.bcc), + }; + } } diff --git a/extension/js/common/client-configuration.ts b/extension/js/common/client-configuration.ts index 76f8d84fb29..c03a470f2c4 100644 --- a/extension/js/common/client-configuration.ts +++ b/extension/js/common/client-configuration.ts @@ -18,7 +18,8 @@ type ClientConfiguration$flag = | 'DEFAULT_REMEMBER_PASS_PHRASE' | 'HIDE_ARMOR_META' | 'FORBID_STORING_PASS_PHRASE' - | 'DISABLE_FLOWCRYPT_HOSTED_PASSWORD_MESSAGES'; + | 'DISABLE_FLOWCRYPT_HOSTED_PASSWORD_MESSAGES' + | 'DISABLE_FES_PRESIGNED_URLS'; /* eslint-disable @typescript-eslint/naming-convention */ export type ClientConfigurationJson = { @@ -285,4 +286,13 @@ export class ClientConfiguration { public getPublicKeyForPrivateKeyBackupToDesignatedMailbox = (): string | undefined => { return this.clientConfigurationJson.prv_backup_to_designated_mailbox; }; + + /** + * When sending password-protected messages, by default pre-signed S3 URLs are used for uploading + * the encrypted message content. This allows for larger attachments (beyond ~5MB). + * If this flag is set, the legacy flow (direct upload to FES) will be used instead. + */ + public shouldUseFesPresignedUrls = (): boolean => { + return !(this.clientConfigurationJson.flags || []).includes('DISABLE_FES_PRESIGNED_URLS'); + }; } diff --git a/test/source/mock/fes/customer-url-fes-endpoints.ts b/test/source/mock/fes/customer-url-fes-endpoints.ts index a81ce15006c..4766ec12c08 100644 --- a/test/source/mock/fes/customer-url-fes-endpoints.ts +++ b/test/source/mock/fes/customer-url-fes-endpoints.ts @@ -6,7 +6,8 @@ import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; import { MockJwt } from '../lib/oauth'; -import { FesConfig } from './shared-tenant-fes-endpoints'; +import { FesConfig, MessageCreateBody, createCombinedBodyForValidator } from './shared-tenant-fes-endpoints'; +import { getStoredS3Content } from '../s3/s3-endpoints'; const standardFesUrl = (port: string) => { return `fes.standardsubdomainfes.localhost:${port}`; @@ -64,66 +65,92 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message': async ({ body }, req) => { + // New pre-signed S3 URL flow endpoints + '/api/v1/messages/allocation': async ({}, req) => { const port = parsePort(req); - const fesUrl = standardFesUrl(port); - // body is a mime-multipart string, we're doing a few smoke checks here without parsing it - if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') { + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { authenticate(req, isCustomIDPUsed); - if (config?.messagePostValidator) { - return await config.messagePostValidator(body, fesUrl); - } - throw new HttpClientErr('Not Allowed', 405); + const storageFileName = 'mock-storage-file-name-' + Date.now(); + return { + storageFileName, + replyToken: 'mock-fes-reply-token', + uploadUrl: `https://localhost:${port}/mock-s3-upload/${storageFileName}`, + }; } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-ID/gateway': async ({ body }, req) => { + '/api/v1/messages': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + const fesUrl = standardFesUrl(port); + // New endpoint that receives storageFileName instead of encrypted content + if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'object') { authenticate(req, isCustomIDPUsed); - expect(body).to.match(messageIdRegex(port)); - return {}; + const bodyObj = body as MessageCreateBody; + // Retrieve the PGP content uploaded to S3 and combine with metadata for validation + const s3Content = getStoredS3Content(bodyObj.storageFileName); + const combinedBody = createCombinedBodyForValidator(s3Content, bodyObj); + if (config?.messagePostValidator) { + return await config.messagePostValidator(combinedBody, fesUrl); + } + throw new HttpClientErr('Not Allowed', 405); } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID/gateway': async ({ body }, req) => { + // Wildcard handler for /api/v1/messages/* sub-paths (gateway endpoints for new flow) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/api/v1/messages/?': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + const gatewayMatch = /\/api\/v1\/messages\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, isCustomIDPUsed); - expect(body).to.match(messageIdRegex(port)); + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + expect(bodyStr).to.match(messageIdRegex(port)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { + // Legacy endpoint - body is a mime-multipart string + '/api/v1/message': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` - // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` - // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + const fesUrl = standardFesUrl(port); + // body is a mime-multipart string, we're doing a few smoke checks here without parsing it + if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') { authenticate(req, isCustomIDPUsed); - expect(body).to.match(messageIdRegex(port)); - return {}; + if (config?.messagePostValidator) { + return await config.messagePostValidator(body, fesUrl); + } + throw new HttpClientErr('Not Allowed', 405); } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { + // Legacy wildcard handler for /api/v1/message/* sub-paths (gateway endpoints) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/api/v1/message/?': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + const gatewayMatch = /\/api\/v1\/message\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, isCustomIDPUsed); expect(body).to.match(messageIdRegex(port)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID/gateway': async () => { - // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` - throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); - }, }; }; diff --git a/test/source/mock/fes/shared-tenant-fes-endpoints.ts b/test/source/mock/fes/shared-tenant-fes-endpoints.ts index 0d41016adf6..896cdf580d2 100644 --- a/test/source/mock/fes/shared-tenant-fes-endpoints.ts +++ b/test/source/mock/fes/shared-tenant-fes-endpoints.ts @@ -6,6 +6,7 @@ import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; import { MockJwt } from '../lib/oauth'; import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; +import { getStoredS3Content } from '../s3/s3-endpoints'; export interface ReportedError { name: string; @@ -34,6 +35,7 @@ type FesClientConfigurationFlag = | 'HIDE_ARMOR_META' | 'FORBID_STORING_PASS_PHRASE' | 'DISABLE_FES_ACCESS_TOKEN' + | 'DISABLE_FES_PRESIGNED_URLS' | 'SETUP_ENSURE_IMPORTED_PRV_MATCH_LDAP_PUB'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -69,6 +71,33 @@ export interface FesMessageReturnType { externalId: string; emailToExternalIdAndUrl: { [email: string]: { url: string; externalId: string } }; } +export interface MessageCreateBody { + storageFileName: string; + associateReplyToken: string; + from: string; + to: string[]; + cc: string[]; + bcc: string[]; +} + +/** + * Creates a combined mock body string from S3 content and message metadata, + * matching the format expected by messagePostValidator functions. + */ +export const createCombinedBodyForValidator = (s3Content: string, bodyObj: MessageCreateBody): string => { + return ( + s3Content + + '\n' + + JSON.stringify({ + associateReplyToken: bodyObj.associateReplyToken, + from: bodyObj.from, + to: bodyObj.to, + cc: bodyObj.cc, + bcc: bodyObj.bcc, + }) + ); +}; + export interface FesConfig { returnError?: HttpClientErr; apiEndpointReturnError?: HttpClientErr; @@ -139,13 +168,32 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message': async ({ body }, req) => { - // body is a mime-multipart string, we're doing a few smoke checks here without parsing it - if (req.method === 'POST' && typeof body === 'string') { - expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); - expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); - if (body.includes('NameWithEmoji')) { - expect(body).to.not.include('⭐'); + // New pre-signed S3 URL flow endpoints + '/shared-tenant-fes/api/v1/messages/allocation': async ({}, req) => { + if (req.method === 'POST') { + authenticate(req, 'oidc'); + const port = parsePort(req); + const storageFileName = 'mock-storage-file-name-' + Date.now(); + return { + storageFileName, + replyToken: 'mock-fes-reply-token', + uploadUrl: `https://localhost:${port}/mock-s3-upload/${storageFileName}`, + }; + } + throw new HttpClientErr('Not Found', 404); + }, + '/shared-tenant-fes/api/v1/messages': async ({ body }, req) => { + // New endpoint that receives storageFileName instead of encrypted content + if (req.method === 'POST' && typeof body === 'object') { + authenticate(req, 'oidc'); + const bodyObj = body as MessageCreateBody; + // Retrieve the PGP content uploaded to S3 and combine with metadata for validation + const s3Content = getStoredS3Content(bodyObj.storageFileName); + const combinedBody = createCombinedBodyForValidator(s3Content, bodyObj); + expect(combinedBody).to.contain('-----BEGIN PGP MESSAGE-----'); + expect(combinedBody).to.contain('"associateReplyToken":"mock-fes-reply-token"'); + if (combinedBody.includes('NameWithEmoji')) { + expect(combinedBody).to.not.include('⭐'); } const response = { // this url is required for pubkey encrypted message @@ -157,49 +205,64 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); - return {}; - } - throw new HttpClientErr('Not Found', 404); - }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // Wildcard handler for /shared-tenant-fes/api/v1/messages/* sub-paths (gateway endpoints for new flow) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/shared-tenant-fes/api/v1/messages/?': async ({ body }, req) => { + const gatewayMatch = /\/shared-tenant-fes\/api\/v1\/messages\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + expect(bodyStr).to.match(messageIdRegexForRequest(req)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` - // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` - // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` - authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); - return {}; + // Legacy endpoint - body is a mime-multipart string + '/shared-tenant-fes/api/v1/message': async ({ body }, req) => { + // body is a mime-multipart string, we're doing a few smoke checks here without parsing it + if (req.method === 'POST' && typeof body === 'string') { + expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); + expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); + if (body.includes('NameWithEmoji')) { + expect(body).to.not.include('⭐'); + } + const response = { + // this url is required for pubkey encrypted message + url: `https://flowcrypt.com/shared-tenant-fes/message/6da5ea3c-d2d6-4714-b15e-f29c805e5c6a`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, + }; + return response; } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // Legacy wildcard handler for /shared-tenant-fes/api/v1/message/* sub-paths (gateway endpoints) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/shared-tenant-fes/api/v1/message/?': async ({ body }, req) => { + const gatewayMatch = /\/shared-tenant-fes\/api\/v1\/message\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, 'oidc'); expect(body).to.match(messageIdRegexForRequest(req)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID/gateway': async () => { - // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` - throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); - }, }; }; diff --git a/test/source/mock/lib/api.ts b/test/source/mock/lib/api.ts index 79201e87882..f2b4df5e227 100644 --- a/test/source/mock/lib/api.ts +++ b/test/source/mock/lib/api.ts @@ -13,6 +13,7 @@ import { FesConfig, getMockSharedTenantFesEndpoints } from '../fes/shared-tenant import { WkdConfig, getMockWkdEndpoints } from '../wkd/wkd-endpoints'; import { SksConfig, getMockSksEndpoints } from '../sks/sks-endpoints'; import { getMockCustomerUrlFesEndpoints } from '../fes/customer-url-fes-endpoints'; +import { getMockS3Endpoints } from '../s3/s3-endpoints'; export class HttpAuthErr extends Error {} export class HttpClientErr extends Error { @@ -76,6 +77,7 @@ export class ConfigurationProvider implements ConfigurationProviderInterface { if ( req.url.startsWith('/upload/') || // gmail message send (req.url.startsWith('/attester/pub/') && req.method === 'POST') || // attester submit - req.url.startsWith('/api/v1/message') || // FES pwd msg - req.url.startsWith('/shared-tenant-fes/api/v1/message') // Shared TENANT FES pwd msg + (req.url.startsWith('/api/v1/message') && !req.url.startsWith('/api/v1/messages')) || // FES pwd msg (legacy only) + (req.url.startsWith('/shared-tenant-fes/api/v1/message') && !req.url.startsWith('/shared-tenant-fes/api/v1/messages')) || // Shared TENANT FES pwd msg (legacy only) + req.url.startsWith('/mock-s3-upload') // Mock S3 upload ) { parsedBody = body.toString(); } else { diff --git a/test/source/mock/s3/s3-endpoints.ts b/test/source/mock/s3/s3-endpoints.ts new file mode 100644 index 00000000000..e5118d79295 --- /dev/null +++ b/test/source/mock/s3/s3-endpoints.ts @@ -0,0 +1,37 @@ +/* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ + +import { expect } from 'chai'; +import { HandlersDefinition } from '../all-apis-mock'; + +/** + * In-memory storage for S3 uploaded content, keyed by storageFileName. + */ +const s3Storage = new Map(); + +/** + * Retrieves stored S3 content for a given storageFileName. + */ +export const getStoredS3Content = (storageFileName: string): string => { + const content = s3Storage.get(storageFileName); + if (!content) { + throw new Error(`S3 content not found for storageFileName: ${storageFileName}`); + } + return content; +}; + +export const getMockS3Endpoints = (): HandlersDefinition => { + return { + '/mock-s3-upload/?': async ({ body }, req) => { + if (req.method === 'PUT') { + // Extract storage file name from URL path: /mock-s3-upload/ + const storageFileName = req.url.split('/mock-s3-upload/')[1]?.split('?')[0]; + expect(storageFileName).to.be.a('string').and.not.be.empty; + expect(body).to.exist; + // Store the uploaded content (PGP encrypted message as string) + s3Storage.set(storageFileName, body as string); + return ''; // S3 returns empty body on successful PUT + } + throw new Error(`Unexpected method ${req.method} for /mock-s3-upload`); + }, + }; +};