diff --git a/libs/shared/cloud-tasks/src/lib/account-tasks.types.ts b/libs/shared/cloud-tasks/src/lib/account-tasks.types.ts index e4dd6e162f8..06f6f5bbcde 100644 --- a/libs/shared/cloud-tasks/src/lib/account-tasks.types.ts +++ b/libs/shared/cloud-tasks/src/lib/account-tasks.types.ts @@ -3,6 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { CloudTasksConfig } from './cloud-tasks.types'; +import { CloudTaskEmailType } from './send-email-tasks'; + +export type FxACloudTaskHeaders = { + 'fxa-cloud-task-delivery-time'?: string; +}; /** Represents config specific for running cloud tasks */ export type DeleteAccountCloudTaskConfig = CloudTasksConfig & { @@ -42,5 +47,5 @@ export type SendEmailCloudTaskConfig = CloudTasksConfig & { export type SendEmailTaskPayload = { uid: string; - emailType: string; // @TODO define type + emailType: CloudTaskEmailType; }; diff --git a/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts b/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts index ae15631cfb5..417c5607ad8 100644 --- a/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts +++ b/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { CloudTasksClient } from '@google-cloud/tasks'; import { CloudTaskOptions, CloudTasksConfig } from './cloud-tasks.types'; +import { FxACloudTaskHeaders } from './account-tasks.types'; /** Base class for encapsulating common cloud task operations */ export class CloudTasks { @@ -25,6 +26,7 @@ export class CloudTasks { protected async enqueueTask( opts: { taskPayload: unknown; + taskHeaders?: FxACloudTaskHeaders; taskUrl: string; queueName: string; }, @@ -43,7 +45,10 @@ export class CloudTasks { httpRequest: { url: opts.taskUrl, httpMethod: 1, // HttpMethod.POST - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(opts.taskHeaders ?? {}), + }, body: Buffer.from(JSON.stringify(opts.taskPayload)).toString( 'base64' ), diff --git a/libs/shared/cloud-tasks/src/lib/send-email-tasks.spec.ts b/libs/shared/cloud-tasks/src/lib/send-email-tasks.spec.ts new file mode 100644 index 00000000000..6af66656bf2 --- /dev/null +++ b/libs/shared/cloud-tasks/src/lib/send-email-tasks.spec.ts @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EmailTypes, SendEmailTasks } from './send-email-tasks'; +import { SendEmailTasksFactory } from './account-tasks.factories'; + +const now = 1736500000000; +jest.useFakeTimers({ now }); + +describe('send-email-tasks', () => { + const mockStatsd = { + increment: jest.fn(), + }; + + const mockCloudClient = { + getTask: jest.fn(), + createTask: jest.fn(), + }; + + const mockConfig = { + cloudTasks: { + useLocalEmulator: true, + projectId: 'pid123', + locationId: 'lid123', + credentials: { + keyFilename: 'foo.cred', + }, + oidc: { + aud: 'foo', + serviceAccountEmail: 'foo@mozilla.com', + }, + sendEmails: { + taskUrl: 'http://localhost:9000/v1/cloud-tasks/emails/notify-inactive', + queueName: 'notification-emails', + }, + }, + publicUrl: 'http://localhost:9000', + apiVersion: '1', + }; + + describe('factories', () => { + it('produces SendEmailTasks', () => { + const sendEmailTasks = SendEmailTasksFactory(mockConfig, mockStatsd); + expect(sendEmailTasks).toBeDefined(); + }); + }); + + describe('send email tasks', () => { + const mockSendEmailPayload = { + uid: 'act0123456789', + emailType: EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION, + }; + const mockTaskOptions = { + taskId: 'act0123456789-inactive-delete-notification', + }; + + let sendEmailTasks: SendEmailTasks; + + beforeEach(() => { + sendEmailTasks = new SendEmailTasks( + mockConfig, + mockCloudClient, + mockStatsd + ); + }); + + it('creates email task with same delivery and schedule time', async () => { + mockCloudClient.createTask.mockImplementation(() => { + return [{ name: 'task123' }]; + }); + + const taskName = await sendEmailTasks.sendEmail({ + payload: mockSendEmailPayload, + taskOptions: mockTaskOptions, + }); + expect(taskName).toEqual('task123'); + expect(mockStatsd.increment).toBeCalledWith( + 'cloud-tasks.send-email.enqueue.success', + ['inactiveDeleteFirstNotification'] + ); + expect(mockCloudClient.createTask).toBeCalledWith({ + parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.sendEmails.queueName}`, + task: { + httpRequest: { + url: mockConfig.cloudTasks.sendEmails.taskUrl, + httpMethod: 1, // POST + headers: { + 'Content-Type': 'application/json', + 'fxa-cloud-task-delivery-time': now.toString(), + }, + body: Buffer.from(JSON.stringify(mockSendEmailPayload)).toString( + 'base64' + ), + oidcToken: { + audience: mockConfig.cloudTasks.oidc.aud, + serviceAccountEmail: + mockConfig.cloudTasks.oidc.serviceAccountEmail, + }, + }, + name: 'projects/pid123/locations/lid123/queues/notification-emails/tasks/act0123456789-inactive-delete-notification', + scheduleTime: { + seconds: now / 1000, + }, + }, + }); + }); + + it('creates email task with delivery beyond schedule time', async () => { + mockCloudClient.createTask.mockImplementation(() => { + return [{ name: 'task123' }]; + }); + + await sendEmailTasks.sendEmail({ + payload: mockSendEmailPayload, + emailOptions: { deliveryTime: now + 60 * 24 * 60 * 60 * 1000 }, + taskOptions: mockTaskOptions, + }); + expect(mockStatsd.increment).toBeCalledWith( + 'cloud-tasks.send-email.enqueue.success', + ['inactiveDeleteFirstNotification'] + ); + expect(mockCloudClient.createTask).toBeCalledWith({ + parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.sendEmails.queueName}`, + task: { + httpRequest: { + url: mockConfig.cloudTasks.sendEmails.taskUrl, + httpMethod: 1, // POST + headers: { + 'Content-Type': 'application/json', + 'fxa-cloud-task-delivery-time': '1741684000000', + }, + body: Buffer.from(JSON.stringify(mockSendEmailPayload)).toString( + 'base64' + ), + oidcToken: { + audience: mockConfig.cloudTasks.oidc.aud, + serviceAccountEmail: + mockConfig.cloudTasks.oidc.serviceAccountEmail, + }, + }, + name: 'projects/pid123/locations/lid123/queues/notification-emails/tasks/act0123456789-inactive-delete-notification', + scheduleTime: { + seconds: 1739092000, + }, + }, + }); + }); + + it('reports send email task failure', async () => { + mockCloudClient.createTask.mockImplementation(() => { + throw new Error('BOOM'); + }); + await expect( + sendEmailTasks.sendEmail({ + payload: mockSendEmailPayload, + taskOptions: mockTaskOptions, + }) + ).rejects.toThrow('BOOM'); + expect(mockStatsd.increment).toHaveBeenLastCalledWith( + 'cloud-tasks.send-email.enqueue.failure', + ['inactiveDeleteFirstNotification'] + ); + }); + }); +}); diff --git a/libs/shared/cloud-tasks/src/lib/send-email-tasks.ts b/libs/shared/cloud-tasks/src/lib/send-email-tasks.ts index 02198677a7f..b7e35f5fdd4 100644 --- a/libs/shared/cloud-tasks/src/lib/send-email-tasks.ts +++ b/libs/shared/cloud-tasks/src/lib/send-email-tasks.ts @@ -6,11 +6,19 @@ import { StatsD } from 'hot-shots'; import { CloudTasks } from './cloud-tasks'; import { CloudTasksClient } from '@google-cloud/tasks'; import { + FxACloudTaskHeaders, SendEmailCloudTaskConfig, SendEmailTaskPayload, } from './account-tasks.types'; import { CloudTaskOptions } from './cloud-tasks.types'; +export enum EmailTypes { + INACTIVE_DELETE_FIRST_NOTIFICATION = 'inactiveDeleteFirstNotification', +} +export type CloudTaskEmailType = (typeof EmailTypes)[keyof typeof EmailTypes]; + +const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000; + export class SendEmailTasks extends CloudTasks { constructor( protected override config: SendEmailCloudTaskConfig, @@ -25,28 +33,49 @@ export class SendEmailTasks extends CloudTasks { * * @returns A taskName */ - public async sendEmail( - sendEmailTask: SendEmailTaskPayload, - taskOptions?: CloudTaskOptions - ) { + public async sendEmail(task: { + payload: SendEmailTaskPayload; + emailOptions?: { deliveryTime: number }; + taskOptions?: CloudTaskOptions; + }) { + // schedule time is when the task is dispatched and there's a limit of + // 30 days. delivery time is when we want to send the email by + // handling the task. + const now = Date.now(); + const inThirtyDays = now + thirtyDaysInMs; + const deliveryTime = task.emailOptions?.deliveryTime || now; + const scheduleTime = Math.min(deliveryTime, inThirtyDays); + + const taskHeaders: FxACloudTaskHeaders = { + 'fxa-cloud-task-delivery-time': deliveryTime.toString(), + }; + + const taskOptions: CloudTaskOptions = { + ...task.taskOptions, + scheduleTime: { + seconds: scheduleTime / 1000, + }, + }; + try { const result = await this.enqueueTask( { queueName: this.config.cloudTasks.sendEmails.queueName, taskUrl: this.config.cloudTasks.sendEmails.taskUrl, - taskPayload: sendEmailTask, + taskPayload: task.payload, + taskHeaders, }, taskOptions ); const taskName = result[0].name; this.statsd.increment('cloud-tasks.send-email.enqueue.success', [ - sendEmailTask.emailType, + task.payload.emailType, ]); return taskName; } catch (err) { this.statsd.increment('cloud-tasks.send-email.enqueue.failure', [ - sendEmailTask.emailType, + task.payload.emailType, ]); throw err; } diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 22ceb673918..0b50e6a964f 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -52,6 +52,7 @@ const { TwilioFactory, } = require('@fxa/accounts/recovery-phone'); const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account'); +const { EmailCloudTaskManager } = require('../lib/email-cloud-tasks'); async function run(config) { Container.set(AppConfig, config); @@ -257,6 +258,9 @@ async function run(config) { }); Container.set(AccountDeleteManager, accountDeleteManager); + const emailCloudTaskManager = new EmailCloudTaskManager({ config, statsd }); + Container.set(EmailCloudTaskManager, emailCloudTaskManager); + const profile = new ProfileClient(log, { ...config.profileServer, serviceName: 'subhub', diff --git a/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts b/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts index abeaca645c2..ca00667cffc 100644 --- a/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts +++ b/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts @@ -46,6 +46,7 @@ const DESCRIPTIONS = { 'The salt used when creating authPW. If not provided, it will be assumed that version one of the password encryption scheme was used.', clientSecret: 'The OAuth client secret for the requesting client application. Required for confidential clients, forbidden for public clients.', + cloudTaskEmailType: 'An email type that can be sent with cloud tasks.', code: 'Time based code to verify secondary email', codeOauth: 'A string that the client will trade with the [token][] endpoint. Codes have a configurable expiration value, default is 15 minutes. Codes are single use only.', diff --git a/packages/fxa-auth-server/lib/email-cloud-tasks.ts b/packages/fxa-auth-server/lib/email-cloud-tasks.ts new file mode 100644 index 00000000000..e960cf6e632 --- /dev/null +++ b/packages/fxa-auth-server/lib/email-cloud-tasks.ts @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { StatsD } from 'hot-shots'; + +import { + SendEmailTaskPayload, + SendEmailTasks, + SendEmailTasksFactory, +} from '@fxa/shared/cloud-tasks'; + +import { ConfigType } from '../config'; +import { AuthRequest } from './types'; +import { IncomingHttpHeaders } from 'http'; + +const fxaCloudTaskDeliveryTimeHeaderName = 'fxa-cloud-task-delivery-time'; + +// If the task has the optional delivery time, and it's in the future, we +// reschedule the task. +const mustReschedule = (headers: IncomingHttpHeaders) => + headers[fxaCloudTaskDeliveryTimeHeaderName] && + Date.now() < parseInt(headers[fxaCloudTaskDeliveryTimeHeaderName] as string); + +// The task id needs to be unique. We'll create a new one if there was one so +// there won't a conflict. +const maybeNewTaskId = (request: AuthRequest) => { + if ( + request.raw.req.headers['x-cloudtasks-taskname'] && + (request.raw.req.headers['x-cloudtasks-taskname'] as string).startsWith( + (request.payload as SendEmailTaskPayload).uid + ) + ) { + if ( + request.raw.req.headers['x-cloudtasks-taskname'].includes('reschedule') + ) { + const parts = ( + request.raw.req.headers['x-cloudtasks-taskname'] as string + ).split('-'); + let prevVer = parseInt(parts.pop() as string); + return `${parts.join('-')}-${++prevVer}`; + } + + return `${request.raw.req.headers['x-cloudtasks-taskname']}-reschedule-1`; + } + + return; +}; + +export class EmailCloudTaskManager { + private config: ConfigType; + private statsd: StatsD; + private emailCloudTasks: SendEmailTasks; + + constructor({ config, statsd }) { + this.config = config; + this.statsd = statsd; + + this.emailCloudTasks = SendEmailTasksFactory(config, statsd); + } + + async handleInactiveAccountNotification(request: AuthRequest) { + // in the request handler, request.headers only contains the very first + // 'x-' header, not sure why, so we need to use request.raw.req.headers + // instead + if (mustReschedule(request.raw.req.headers)) { + const maybeUpdateTaskId = maybeNewTaskId(request); + + await this.emailCloudTasks.sendEmail({ + payload: request.payload as SendEmailTaskPayload, + emailOptions: { + deliveryTime: parseInt( + request.raw.req.headers[ + fxaCloudTaskDeliveryTimeHeaderName + ] as string + ), + }, + taskOptions: { + ...(maybeUpdateTaskId ? { taskId: maybeUpdateTaskId } : {}), + }, + }); + + this.statsd.increment('cloud-tasks.send-email.rescheduled', { + email_type: (request.payload as SendEmailTaskPayload).emailType, + }); + + return; + } + + // @TODO FXA-10573, FXA-10574, FXA-10942 + } +} diff --git a/packages/fxa-auth-server/lib/routes/cloud-tasks.ts b/packages/fxa-auth-server/lib/routes/cloud-tasks.ts index 7d7544448ae..9bd621bd09f 100644 --- a/packages/fxa-auth-server/lib/routes/cloud-tasks.ts +++ b/packages/fxa-auth-server/lib/routes/cloud-tasks.ts @@ -4,6 +4,7 @@ import isA from 'joi'; import { Container } from 'typedi'; +import { StatsD } from 'hot-shots'; import { ConfigType } from '../../config'; import DESCRIPTION from '../../docs/swagger/shared/descriptions'; @@ -11,16 +12,19 @@ import { AccountDeleteManager } from '../account-delete'; import { AuthLogger, AuthRequest } from '../types'; import validators from './validators'; -import { DeleteAccountTask } from '@fxa/shared/cloud-tasks'; +import { DeleteAccountTask, EmailTypes } from '@fxa/shared/cloud-tasks'; +import { EmailCloudTaskManager } from '../email-cloud-tasks'; /** Work around for path module resolution in validator.js which is still using cjs. */ export { ReasonForDeletion } from '@fxa/shared/cloud-tasks'; export class CloudTaskHandler { private accountDeleteManager: AccountDeleteManager; + private emailCloudTaskManager: EmailCloudTaskManager; - constructor(private log: AuthLogger) { + constructor(private log: AuthLogger, config: ConfigType, statsd: StatsD) { this.accountDeleteManager = Container.get(AccountDeleteManager); + this.emailCloudTaskManager = Container.get(EmailCloudTaskManager); } async deleteAccount(taskPayload: DeleteAccountTask) { @@ -32,11 +36,24 @@ export class CloudTaskHandler { ); return {}; } + + async sendInactiveAccountNotification(request: AuthRequest) { + this.log.debug('Received inactive account notification task', request); + await this.emailCloudTaskManager.handleInactiveAccountNotification(request); + return {}; + } } + export const accountDeleteCloudTaskPath = '/cloud-tasks/accounts/delete'; +export const inactiveNotificationsCloudTaskPath = + '/cloud-tasks/emails/notify-inactive'; -export const cloudTaskRoutes = (log: AuthLogger, config: ConfigType) => { - const cloudTaskHandler = new CloudTaskHandler(log); +export const cloudTaskRoutes = ( + log: AuthLogger, + config: ConfigType, + statsd: StatsD +) => { + const cloudTaskHandler = new CloudTaskHandler(log, config, statsd); const routes = [ { method: 'POST', @@ -66,6 +83,34 @@ export const cloudTaskRoutes = (log: AuthLogger, config: ConfigType) => { handler: (request: AuthRequest) => cloudTaskHandler.deleteAccount(request.payload as DeleteAccountTask), }, + + { + method: 'POST', + path: inactiveNotificationsCloudTaskPath, + options: { + auth: { + mode: config.cloudTasks.useLocalEmulator ? 'try' : 'required', + payload: false, + strategy: 'cloudTasksOIDC', + }, + validate: { + headers: isA.object({ + 'x-cloudtasks-queuename': isA + .string() + .equal(config.cloudTasks.sendEmails.queueName), + }), + payload: isA.object({ + uid: validators.uid.required().description(DESCRIPTION.uid), + emailType: isA + .string() + .valid(...Object.values(EmailTypes)) + .description(DESCRIPTION.cloudTaskEmailType), + }), + }, + }, + handler: (request: AuthRequest) => + cloudTaskHandler.sendInactiveAccountNotification(request), + }, ]; return routes; diff --git a/packages/fxa-auth-server/lib/routes/index.js b/packages/fxa-auth-server/lib/routes/index.js index 117f995f66d..231608cc114 100644 --- a/packages/fxa-auth-server/lib/routes/index.js +++ b/packages/fxa-auth-server/lib/routes/index.js @@ -224,7 +224,7 @@ module.exports = function ( ); const { cloudTaskRoutes } = require('./cloud-tasks'); - const cloudTasks = cloudTaskRoutes(log, config); + const cloudTasks = cloudTaskRoutes(log, config, statsd); const { cloudSchedulerRoutes } = require('./cloud-scheduler'); const cloudScheduler = cloudSchedulerRoutes(log, config, statsd); diff --git a/packages/fxa-auth-server/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.ts b/packages/fxa-auth-server/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.ts index 5ad4915edf8..6facfa2c3ac 100755 --- a/packages/fxa-auth-server/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.ts +++ b/packages/fxa-auth-server/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.ts @@ -40,6 +40,7 @@ import { BigQuery } from '@google-cloud/bigquery'; import { CloudTaskOptions, + EmailTypes, SendEmailTaskPayload, SendEmailTasksFactory, } from '@fxa/shared/cloud-tasks'; @@ -75,7 +76,7 @@ import { // {{{ constants and defaults -const emailType = 'inactiveDeleteFirstNotification'; +const emailType = EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION; const defaultDaysTilFirstEmail = 0; const defaultResultsLImit = 500000; const defaultConcurrency = 100; @@ -560,17 +561,20 @@ const init = async () => { emailType, }; const taskId = `${uid}-inactive-delete-first-email`; - const scheduleTime = { - seconds: (Date.now() + msTilFirstEmail) / 1000, + const taskOptions: CloudTaskOptions = { + taskId, }; - const taskOptions: CloudTaskOptions = { taskId, scheduleTime }; try { glean.inactiveAccountDeletion.firstEmailTaskRequest(requestForGlean, { uid, }); - await emailCloudTasks.sendEmail(taskPayload, taskOptions); + await emailCloudTasks.sendEmail({ + payload: taskPayload, + emailOptions: { deliveryTime: Date.now() + msTilFirstEmail }, + taskOptions: taskOptions, + }); emailsQueued++; glean.inactiveAccountDeletion.firstEmailTaskEnqueued( diff --git a/packages/fxa-auth-server/test/local/email-cloud-tasks.js b/packages/fxa-auth-server/test/local/email-cloud-tasks.js new file mode 100644 index 00000000000..a61b690a0a5 --- /dev/null +++ b/packages/fxa-auth-server/test/local/email-cloud-tasks.js @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const sandbox = sinon.createSandbox(); + +const sendEmailTaskStub = sandbox.stub(); +const { EmailCloudTaskManager } = proxyquire('../../lib/email-cloud-tasks', { + ...require('../../lib/email-cloud-tasks'), + '@fxa/shared/cloud-tasks': { + SendEmailTasksFactory: () => ({ + sendEmail: sendEmailTaskStub, + }), + }, +}); + +describe('EmailCloudTaskManager', () => { + const mockConfig = {}; + const mockStatsd = { increment: sandbox.stub() }; + const emailCloudTaskManager = new EmailCloudTaskManager({ + config: mockConfig, + statsd: mockStatsd, + }); + const mockTaskPayload = { + emailType: 'inactiveNotification', + uid: '5adfe2a2a4c34dd6b77a16efcafedc44', + }; + const deliveryTime = Date.now() + 60 * 24 * 60 * 60 * 1000; + + beforeEach(() => { + sandbox.stub(Date, 'now').returns(1736500000000); + }); + + afterEach(() => { + Date.now.restore(); + sandbox.reset(); + }); + + describe('reschedule', () => { + it('should reschedule a task', async () => { + await emailCloudTaskManager.handleInactiveAccountNotification({ + payload: mockTaskPayload, + raw: { + req: { + headers: { + 'fxa-cloud-task-delivery-time': deliveryTime.toString(), + 'x-cloudtasks-taskname': `${mockTaskPayload.uid}-inactive-notification`, + }, + }, + }, + }); + sinon.assert.calledOnceWithExactly(sendEmailTaskStub, { + payload: mockTaskPayload, + emailOptions: { + deliveryTime, + }, + taskOptions: { + taskId: `${mockTaskPayload.uid}-inactive-notification-reschedule-1`, + }, + }); + sinon.assert.calledOnceWithExactly( + mockStatsd.increment, + 'cloud-tasks.send-email.rescheduled', + { email_type: 'inactiveNotification' } + ); + }); + + it('should increment the reschedule task id', async () => { + await emailCloudTaskManager.handleInactiveAccountNotification({ + payload: mockTaskPayload, + raw: { + req: { + headers: { + 'fxa-cloud-task-delivery-time': deliveryTime.toString(), + 'x-cloudtasks-taskname': `${mockTaskPayload.uid}-inactive-notification-reschedule-1`, + }, + }, + }, + }); + sinon.assert.calledOnceWithExactly(sendEmailTaskStub, { + payload: mockTaskPayload, + emailOptions: { + deliveryTime, + }, + taskOptions: { + taskId: `${mockTaskPayload.uid}-inactive-notification-reschedule-2`, + }, + }); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/local/routes/cloud-tasks.js b/packages/fxa-auth-server/test/local/routes/cloud-tasks.js index 186ce744e67..a6825067b29 100644 --- a/packages/fxa-auth-server/test/local/routes/cloud-tasks.js +++ b/packages/fxa-auth-server/test/local/routes/cloud-tasks.js @@ -6,34 +6,41 @@ const { Container } = require('typedi'); const { assert } = require('chai'); const sinon = require('sinon'); const mocks = require('../../mocks'); + +const { ReasonForDeletion, EmailTypes } = require('@fxa/shared/cloud-tasks'); + const getRoute = require('../../routes_helpers').getRoute; const { cloudTaskRoutes } = require('../../../lib/routes/cloud-tasks'); const { AccountDeleteManager } = require('../../../lib/account-delete'); -const { ReasonForDeletion } = require('@fxa/shared/cloud-tasks'); +const { EmailCloudTaskManager } = require('../../../lib/email-cloud-tasks'); const mockConfig = { cloudTasks: { deleteAccounts: { queueName: 'del-accts' }, + sendEmails: { queueName: 'send-emails' }, }, }; const sandbox = sinon.createSandbox(); +const deleteAccountStub = sandbox + .stub() + .callsFake((uid, reason, customerId) => {}); +const inactiveNotificationStub = sandbox.stub(); describe('/cloud-tasks/accounts/delete', () => { const uid = '0f0f0f9001'; let mockLog; let route, routes; - let deleteAccountStub; beforeEach(() => { mockLog = mocks.mockLog(); sandbox.reset(); - deleteAccountStub = sandbox - .stub() - .callsFake((uid, reason, customerId) => {}); Container.set(AccountDeleteManager, { deleteAccount: deleteAccountStub, }); + Container.set(EmailCloudTaskManager, { + handleInactiveAccountNotification: inactiveNotificationStub, + }); routes = cloudTaskRoutes(mockLog, mockConfig); route = getRoute(routes, '/cloud-tasks/accounts/delete'); @@ -55,3 +62,46 @@ describe('/cloud-tasks/accounts/delete', () => { } }); }); + +describe('/cloud-tasks/emails/notify-inactive', () => { + let mockLog; + let routes, route; + + beforeEach(() => { + sandbox.reset(); + mockLog = mocks.mockLog(); + + Container.set(AccountDeleteManager, { + deleteAccount: deleteAccountStub, + }); + Container.set(EmailCloudTaskManager, { + handleInactiveAccountNotification: inactiveNotificationStub, + }); + + routes = cloudTaskRoutes(mockLog, mockConfig); + route = getRoute(routes, '/cloud-tasks/emails/notify-inactive'); + }); + + it('should handle the inactive notification email task', async () => { + const req = { + payload: { + uid: 'act0123456789', + emailType: EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION, + }, + raw: { + req: { + headers: { + 'fxa-cloud-task-delivery-time': '17365000000', + 'x-cloudtasks-taskname': 'act0123456789-inactive-notification', + }, + }, + }, + }; + try { + await route.handler(req); + sinon.assert.calledOnceWithExactly(inactiveNotificationStub, req); + } catch (err) { + assert.fail('An error should not have been thrown.'); + } + }); +}); diff --git a/packages/fxa-content-server/app/scripts/views/mixins/password-mixin.js b/packages/fxa-content-server/app/scripts/views/mixins/password-mixin.js index d32ae2d423e..3a52f5d8703 100644 --- a/packages/fxa-content-server/app/scripts/views/mixins/password-mixin.js +++ b/packages/fxa-content-server/app/scripts/views/mixins/password-mixin.js @@ -120,9 +120,13 @@ export default { }, getAffectedPasswordInputs(button) { - let $passwordEl = this.$(button).siblings('[id*="password"]:not([id^="show-"])'); + let $passwordEl = this.$(button).siblings( + '[id*="password"]:not([id^="show-"])' + ); if (this.$(button).data('synchronizeShow')) { - $passwordEl = this.$('[id*="password"]:not([id^="show-"])[data-synchronize-show]'); + $passwordEl = this.$( + '[id*="password"]:not([id^="show-"])[data-synchronize-show]' + ); } return $passwordEl; }, @@ -209,9 +213,11 @@ export default { */ hideVisiblePasswords() { const active = document.activeElement; - this.$el.find('[id*="password"][type=text]:not([id^="show-"])').each((index, el) => { - this.hidePassword(el); - }); + this.$el + .find('[id*="password"][type=text]:not([id^="show-"])') + .each((index, el) => { + this.hidePassword(el); + }); active.focus(); },