From 9b820bde134000bb79e63e0a484a126447f7355e Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Wed, 27 Mar 2024 13:23:46 -0700 Subject: [PATCH] Revert "task(auth): Have auth-server use cloud-tasks nx lib " --- .circleci/config.yml | 6 - .../cloud-tasks/src/lib/account-tasks.ts | 6 +- .../src/lib/account-tasks.types.ts | 4 +- .../shared/cloud-tasks/src/lib/cloud-tasks.ts | 18 - packages/fxa-auth-server/bin/key_server.js | 9 +- .../fxa-auth-server/lib/account-delete.ts | 129 +- .../fxa-auth-server/lib/routes/account.ts | 24 +- .../fxa-auth-server/lib/routes/cloud-tasks.ts | 20 +- .../fxa-auth-server/lib/routes/validators.js | 4 - .../clean-up-partial-account-customer.ts | 35 +- .../cleanup-partial-firestore-customers.ts | 42 +- .../fxa-auth-server/scripts/delete-account.ts | 11 +- .../scripts/delete-unverified-accounts.ts | 46 +- .../test/local/account-delete.js | 136 +- .../test/local/routes/account.js | 78 +- .../test/local/routes/cloud-tasks.js | 9 +- .../test/local/routes/validators.js | 24 - .../test/remote/account_create_tests.js | 1845 +++++++++-------- 18 files changed, 1310 insertions(+), 1136 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 54e3438dc7..efe511305e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -121,14 +121,11 @@ executors: - image: jdlk7/firestore-emulator - image: memcached - image: redis - - image: ghcr.io/aertje/cloud-tasks-emulator:1.2.0 - command: -queue "projects/test/locations/test/queues/delete-accounts-queue" environment: NODE_ENV: development FIRESTORE_EMULATOR_HOST: localhost:9090 CUSTOMS_SERVER_URL: none HUSKY_SKIP_INSTALL: 1 - AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true # For anything that needs a full stack to run and needs browsers available for # ui test automation. This image requires a restored workspace state. @@ -146,8 +143,6 @@ executors: - image: cimg/mysql:8.0 command: --default-authentication-plugin=mysql_native_password - image: jdlk7/firestore-emulator - - image: ghcr.io/aertje/cloud-tasks-emulator:1.2.0 - command: -queue "projects/test/locations/test/queues/delete-accounts-queue" environment: NODE_ENV: development FXA_EMAIL_ENV: development @@ -169,7 +164,6 @@ executors: REACT_CONVERSION_POST_VERIFY_CAD_VIA_QR_ROUTES: true CUSTOMS_SERVER_URL: none HUSKY_SKIP_INSTALL: 1 - AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true # Contains a pre-installed fxa stack and browsers for doing ui test # automation. Perfect for running smoke tests against remote targets. diff --git a/libs/shared/cloud-tasks/src/lib/account-tasks.ts b/libs/shared/cloud-tasks/src/lib/account-tasks.ts index 55e64ec007..0fb9e325a7 100644 --- a/libs/shared/cloud-tasks/src/lib/account-tasks.ts +++ b/libs/shared/cloud-tasks/src/lib/account-tasks.ts @@ -20,11 +20,7 @@ export class AccountTasks extends CloudTasks { super(config, cloudTaskClient); } - /** - * Adds an account to the delete account task queue. - * @param deleteTask The info necessary to queue an account deletion. - * @returns A taskName - */ + /** Add an account to the task queue. */ public async deleteAccount(deleteTask: DeleteAccountTask) { try { const result = await this.enqueueTask({ 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 65e94d0fb1..b03dbd731f 100644 --- a/libs/shared/cloud-tasks/src/lib/account-tasks.types.ts +++ b/libs/shared/cloud-tasks/src/lib/account-tasks.types.ts @@ -12,6 +12,8 @@ export type DeleteAccountCloudTaskConfig = CloudTasksConfig & { queueName: string; }; }; + publicUrl: string; + apiVersion: string; }; /** Reasons an account can be deleted. */ @@ -26,7 +28,7 @@ export type DeleteAccountTask = { /** The account id */ uid: string; /** The customer id, i.e. a stripe customer id if applicable */ - customerId: string | undefined; + customerId?: string; /** Reason for deletion */ reason: ReasonForDeletion; }; diff --git a/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts b/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts index 455a532102..23b5dbfc89 100644 --- a/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts +++ b/libs/shared/cloud-tasks/src/lib/cloud-tasks.ts @@ -6,24 +6,6 @@ import { CloudTasksConfig } from './cloud-tasks.types'; /** Base class for encapsulating common cloud task operations */ export class CloudTasks { - /** - * Indicates if a queue has been configured and is enabled. - */ - public get queueEnabled() { - // If a keyFilename was supplied, the cloud task queue can be considered enabled. - if (this.config.cloudTasks.credentials.keyFilename) { - return true; - } - - // If we specify a local emulator is being used, then no keyFilename is required, - // and the task queue can be considered enabled. - if (this.config.cloudTasks.useLocalEmulator) { - return true; - } - - return false; - } - protected constructor( protected readonly config: CloudTasksConfig, protected readonly client: Pick diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index bcf73efd99..1e236e5f81 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -37,10 +37,6 @@ const { AccountDeleteManager } = require('../lib/account-delete'); const { gleanMetrics } = require('../lib/metrics/glean'); const Customs = require('../lib/customs'); const Profile = require('../lib/profile/client'); -const { - AccountTasks, - AccountTasksFactory, -} = require('@fxa/shared/cloud-tasks'); async function run(config) { Container.set(AppConfig, config); @@ -180,15 +176,12 @@ async function run(config) { // The AccountDeleteManager is dependent on some of the object set into // Container above. - const accountTasks = AccountTasksFactory(config, statsd); - Container.set(AccountTasks, accountTasks); - const accountDeleteManager = new AccountDeleteManager({ fxaDb: database, oauthDb, - config, push, pushbox, + statsd, }); Container.set(AccountDeleteManager, accountDeleteManager); diff --git a/packages/fxa-auth-server/lib/account-delete.ts b/packages/fxa-auth-server/lib/account-delete.ts index e13f1057f7..a6784ae1f7 100644 --- a/packages/fxa-auth-server/lib/account-delete.ts +++ b/packages/fxa-auth-server/lib/account-delete.ts @@ -4,10 +4,13 @@ import { deleteAllPayPalBAs, + getAccountCustomerByUid, getAllPayPalBAByUid, } from 'fxa-shared/db/models/auth'; +import { StatsD } from 'hot-shots'; import { Container } from 'typedi'; +import { CloudTasksClient } from '@google-cloud/tasks'; import * as Sentry from '@sentry/node'; import { ConfigType } from '../config'; @@ -18,10 +21,10 @@ import { AppleIAP } from './payments/iap/apple-app-store/apple-iap'; import { PlayBilling } from './payments/iap/google-play/play-billing'; import { PayPalHelper } from './payments/paypal/helper'; import { StripeHelper } from './payments/stripe'; -import { ReasonForDeletion } from '@fxa/shared/cloud-tasks'; import { StripeFirestoreMultiError } from './payments/stripe-firestore'; import pushBuilder from './push'; import pushboxApi from './pushbox'; +import { accountDeleteCloudTaskPath } from './routes/cloud-tasks'; import { AppConfig, AuthLogger, AuthRequest } from './types'; type FxaDbDeleteAccount = Pick< @@ -39,17 +42,54 @@ type PushForDeleteAccount = Pick< >; type Log = AuthLogger & { activityEvent: (data: Record) => void }; +export const ReasonForDeletionOptions = { + UserRequested: 'fxa_user_requested_account_delete', + Unverified: 'fxa_unverified_account_delete', + Cleanup: 'fxa_cleanup_account_delete', +} as const; +export type ReasonForDeletion = + (typeof ReasonForDeletionOptions)[keyof typeof ReasonForDeletionOptions]; +export const ReasonForDeletionValues = Object.values(ReasonForDeletionOptions); + +type DeleteTask = { + uid: string; + customerId?: string; + reason: ReasonForDeletion; +}; +type EnqueueByUidParam = { + uid: string; + reason: ReasonForDeletion; +}; +type EnqueueByEmailParam = { + email: string; + reason: ReasonForDeletion; +}; + +const isEnqueueByUidParam = ( + x: EnqueueByUidParam | EnqueueByEmailParam +): x is EnqueueByUidParam => (x as EnqueueByUidParam).uid !== undefined; +const isEnqueueByEmailParam = ( + x: EnqueueByUidParam | EnqueueByEmailParam +): x is EnqueueByEmailParam => (x as EnqueueByEmailParam).email !== undefined; + export class AccountDeleteManager { private fxaDb: FxaDbDeleteAccount; private oauthDb: OAuthDbDeleteAccount; private push: PushForDeleteAccount; private pushbox: PushboxDeleteAccount; + private statsd: StatsD; private stripeHelper?: StripeHelper; private paypalHelper?: PayPalHelper; private appleIap?: AppleIAP; private playBilling?: PlayBilling; private log: Log; private config: ConfigType; + private tasksEnabled = false; + private tasksRequired = false; + + private cloudTasksClient: CloudTasksClient; + private queueName; + private taskUrl; constructor({ fxaDb, @@ -57,18 +97,21 @@ export class AccountDeleteManager { config, push, pushbox, + statsd, }: { fxaDb: FxaDbDeleteAccount; oauthDb: OAuthDbDeleteAccount; config: ConfigType; push: PushForDeleteAccount; pushbox: PushboxDeleteAccount; + statsd: StatsD; }) { this.fxaDb = fxaDb; this.oauthDb = oauthDb; this.config = config; this.push = push; this.pushbox = pushbox; + this.statsd = statsd; if (Container.has(StripeHelper)) { this.stripeHelper = Container.get(StripeHelper); @@ -82,11 +125,81 @@ export class AccountDeleteManager { if (Container.has(PlayBilling)) { this.playBilling = Container.get(PlayBilling); } - this.log = Container.get(AuthLogger) as Log; - - // Is this intentional? Config is passed in the constructor this.config = Container.get(AppConfig); + const tasksConfig = this.config.cloudTasks; + + this.cloudTasksClient = new CloudTasksClient({ + projectId: tasksConfig.projectId, + keyFilename: tasksConfig.credentials.keyFilename ?? undefined, + }); + this.queueName = `projects/${tasksConfig.projectId}/locations/${tasksConfig.locationId}/queues/${tasksConfig.deleteAccounts.queueName}`; + this.taskUrl = `${this.config.publicUrl}/v${this.config.apiVersion}${accountDeleteCloudTaskPath}`; + this.tasksEnabled = !!tasksConfig.credentials.keyFilename; + this.tasksRequired = ['stage', 'prod'].includes(this.config.env); + } + + public async enqueue(options: EnqueueByUidParam | EnqueueByEmailParam) { + if (isEnqueueByUidParam(options)) { + return this.enqueueByUid(options); + } + + if (isEnqueueByEmailParam(options)) { + return this.enqueueByEmail(options); + } + + throw new Error( + `Failed to enqueue account delete cloud task with ${options}.` + ); + } + + private async enqueueByUid(options: EnqueueByUidParam) { + const { stripeCustomerId } = + (await getAccountCustomerByUid(options.uid)) || {}; + const task: DeleteTask = { + uid: options.uid, + customerId: stripeCustomerId, + reason: options.reason, + }; + return this.enqueueTask(task); + } + + private async enqueueByEmail(options: EnqueueByEmailParam) { + const account = await this.fxaDb.accountRecord(options.email); + const { stripeCustomerId } = + (await getAccountCustomerByUid(account.uid)) || {}; + const task: DeleteTask = { + uid: account.uid, + customerId: stripeCustomerId, + reason: options.reason, + }; + return this.enqueueTask(task); + } + + private async enqueueTask(task: DeleteTask) { + try { + const taskResult = await this.cloudTasksClient.createTask({ + parent: this.queueName, + task: { + httpRequest: { + url: this.taskUrl, + httpMethod: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: Buffer.from(JSON.stringify(task)).toString('base64'), + oidcToken: { + audience: this.config.cloudTasks.oidc.aud, + serviceAccountEmail: + this.config.cloudTasks.oidc.serviceAccountEmail, + }, + }, + }, + }); + this.statsd.increment('cloud-tasks.account-delete.enqueue.success'); + return taskResult[0].name; + } catch (err) { + this.statsd.increment('cloud-tasks.account-delete.enqueue.failure'); + throw err; + } } /** @@ -127,7 +240,7 @@ export class AccountDeleteManager { * deletion. */ public async quickDelete(uid: string, reason: ReasonForDeletion) { - if (reason !== ReasonForDeletion.UserRequested) { + if (reason !== ReasonForDeletionOptions.UserRequested) { throw new Error('quickDelete only supports user requested deletions'); } @@ -139,6 +252,10 @@ export class AccountDeleteManager { // still queue the account for cleanup. this.log.error('quickDelete', { uid, error }); } + // Only queue if enabled or is in stage/prod. + if (this.tasksEnabled || this.tasksRequired) { + await this.enqueueByUid({ uid, reason }); + } } /** @@ -212,7 +329,7 @@ export class AccountDeleteManager { }); // Currently only support auto refund of invoices for unverified accounts - if (deleteReason !== ReasonForDeletion.Unverified || !customerId) { + if (deleteReason !== ReasonForDeletionOptions.Unverified || !customerId) { return; } diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 2c9d9031d4..f2e0594413 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -1,7 +1,7 @@ /* 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 { Account, getAccountCustomerByUid } from 'fxa-shared/db/models/auth'; +import { Account } from 'fxa-shared/db/models/auth'; import { AppStoreSubscription, PlayStoreSubscription, @@ -42,9 +42,11 @@ import requestHelper from './utils/request_helper'; import validators from './validators'; import { AccountEventsManager } from '../account-events'; import { gleanMetrics } from '../metrics/glean'; -import { AccountDeleteManager } from '../account-delete'; +import { + AccountDeleteManager, + ReasonForDeletionOptions, +} from '../account-delete'; import { uuidTransformer } from 'fxa-shared/db/transformers'; -import { AccountTasks, ReasonForDeletion } from '@fxa/shared/cloud-tasks'; const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema; @@ -65,7 +67,6 @@ export class AccountHandler { private capabilityService: CapabilityService; private accountEventsManager: AccountEventsManager; private accountDeleteManager: AccountDeleteManager; - private accountTasks: AccountTasks; constructor( private log: AuthLogger, @@ -81,6 +82,7 @@ export class AccountHandler { private subscriptionAccountReminders: any, private oauth: any, private stripeHelper: StripeHelper, + private pushbox: any, private glean: ReturnType ) { this.otpUtils = require('./utils/otp')(log, config, db); @@ -102,7 +104,6 @@ export class AccountHandler { this.capabilityService = Container.get(CapabilityService); this.accountEventsManager = Container.get(AccountEventsManager); this.accountDeleteManager = Container.get(AccountDeleteManager); - this.accountTasks = Container.get(AccountTasks); } private async generateRandomValues() { @@ -1845,21 +1846,11 @@ export class AccountHandler { await this.accountDeleteManager.quickDelete( accountRecord.uid, - ReasonForDeletion.UserRequested + ReasonForDeletionOptions.UserRequested ); await request.emitMetricsEvent('account.deleted', { uid: accountRecord.uid, }); - - if (this.accountTasks.queueEnabled) { - const result = await getAccountCustomerByUid(accountRecord.uid); - await this.accountTasks.deleteAccount({ - uid: accountRecord.uid, - customerId: result?.stripeCustomerId, - reason: ReasonForDeletion.UserRequested, - }); - } - return {}; } @@ -1944,6 +1935,7 @@ export const accountRoutes = ( subscriptionAccountReminders, oauth, stripeHelper, + pushbox, glean ); const routes = [ diff --git a/packages/fxa-auth-server/lib/routes/cloud-tasks.ts b/packages/fxa-auth-server/lib/routes/cloud-tasks.ts index a50ebc619f..3a504792da 100644 --- a/packages/fxa-auth-server/lib/routes/cloud-tasks.ts +++ b/packages/fxa-auth-server/lib/routes/cloud-tasks.ts @@ -7,11 +7,19 @@ import { Container } from 'typedi'; import { ConfigType } from '../../config'; import DESCRIPTION from '../../docs/swagger/shared/descriptions'; -import { AccountDeleteManager } from '../account-delete'; +import { + AccountDeleteManager, + ReasonForDeletion, + ReasonForDeletionValues, +} from '../account-delete'; import { AuthLogger, AuthRequest } from '../types'; import validators from './validators'; -import { DeleteAccountTask } from '@fxa/shared/cloud-tasks'; +export type DeleteAccountTaskPayload = { + uid: string; + customerId?: string; + reason: ReasonForDeletion; +}; export class CloudTaskHandler { private accountDeleteManager: AccountDeleteManager; @@ -20,7 +28,7 @@ export class CloudTaskHandler { this.accountDeleteManager = Container.get(AccountDeleteManager); } - async deleteAccount(taskPayload: DeleteAccountTask) { + async deleteAccount(taskPayload: DeleteAccountTaskPayload) { this.log.debug('Received delete account task', taskPayload); await this.accountDeleteManager.deleteAccount( taskPayload.uid, @@ -56,12 +64,14 @@ export const cloudTaskRoutes = (log: AuthLogger, config: ConfigType) => { .string() .optional() .description(DESCRIPTION.customerId), - reason: validators.reasonForAccountDeletion, + reason: isA.string().valid(...ReasonForDeletionValues), }), }, }, handler: (request: AuthRequest) => - cloudTaskHandler.deleteAccount(request.payload as DeleteAccountTask), + cloudTaskHandler.deleteAccount( + request.payload as DeleteAccountTaskPayload + ), }, ]; diff --git a/packages/fxa-auth-server/lib/routes/validators.js b/packages/fxa-auth-server/lib/routes/validators.js index 03e0be8b56..0b64d79f4d 100644 --- a/packages/fxa-auth-server/lib/routes/validators.js +++ b/packages/fxa-auth-server/lib/routes/validators.js @@ -35,7 +35,6 @@ const { const { VX_REGEX: CLIENT_SALT_STRING, } = require('../../lib/routes/utils/client-key-stretch'); -const { ReasonForDeletion } = require('@fxa/shared/cloud-tasks'); // Match any non-empty hex-encoded string. const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/; @@ -122,9 +121,6 @@ module.exports.uid = module.exports.hexString.length(32); module.exports.clientId = module.exports.hexString.length(16); module.exports.clientSecret = module.exports.hexString; module.exports.idToken = module.exports.jwt; -module.exports.reasonForAccountDeletion = isA - .string() - .valid(...Object.values(ReasonForDeletion)); module.exports.refreshToken = module.exports.hexString.length(64); module.exports.sessionToken = module.exports.hexString.length(64); module.exports.sessionTokenId = module.exports.hexString.length(64); diff --git a/packages/fxa-auth-server/scripts/clean-up-partial-account-customer.ts b/packages/fxa-auth-server/scripts/clean-up-partial-account-customer.ts index 7c6bc85a00..a3d79e6de3 100644 --- a/packages/fxa-auth-server/scripts/clean-up-partial-account-customer.ts +++ b/packages/fxa-auth-server/scripts/clean-up-partial-account-customer.ts @@ -3,28 +3,27 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Command } from 'commander'; -import { - AccountCustomers, - getAccountCustomerByUid, -} from 'fxa-shared/db/models/auth'; +import { AccountCustomers } from 'fxa-shared/db/models/auth'; import { StatsD } from 'hot-shots'; import { Container } from 'typedi'; import appConfig from '../config'; +import { + AccountDeleteManager, + ReasonForDeletionOptions, +} from '../lib/account-delete'; import * as random from '../lib/crypto/random'; import DB from '../lib/db'; import { setupFirestore } from '../lib/firestore-db'; import initLog from '../lib/log'; +import oauthDb from '../lib/oauth/db'; import { CurrencyHelper } from '../lib/payments/currencies'; import { createStripeHelper, StripeHelper } from '../lib/payments/stripe'; +import { pushboxApi } from '../lib/pushbox'; import initRedis from '../lib/redis'; import Token from '../lib/tokens'; import { AppConfig, AuthFirestore, AuthLogger } from '../lib/types'; import { parseDryRun } from './lib/args'; -import { - AccountTasksFactory, - ReasonForDeletion, -} from '@fxa/shared/cloud-tasks'; const dryRun = async (program: Command, limit: number) => { const countQuery = AccountCustomers.query() @@ -67,7 +66,7 @@ const init = async () => { program.parse(process.argv); const isDryRun = parseDryRun(program.dryRun); const limit = program.limit ? parseInt(program.limit) : Infinity; - const reason = ReasonForDeletion.Cleanup; + const reason = ReasonForDeletionOptions.Cleanup; if (limit <= 0) { throw new Error('The limit should be a positive integer.'); @@ -89,7 +88,7 @@ const init = async () => { random.base32(config.signinUnblock.codeLength) as any // TS type inference is failing pretty hard with this ); // connect to db here so the dry run could get a row count - await db.connect(config, redis); + const fxaDb = await db.connect(config, redis); if (isDryRun) { console.log( @@ -99,6 +98,7 @@ const init = async () => { return dryRun(program, limit); } + const pushbox = pushboxApi(log, config, statsd); Container.set(AppConfig, config); Container.set(AuthLogger, log); const authFirestore = setupFirestore(config); @@ -108,7 +108,14 @@ const init = async () => { const stripeHelper = createStripeHelper(log, config, statsd); Container.set(StripeHelper, stripeHelper); - const accountTasks = AccountTasksFactory(config, statsd); + const accountDeleteManager = new AccountDeleteManager({ + fxaDb, + oauthDb, + config, + push: {} as any, // Push isn't needed for enqueuing + pushbox, + statsd, + }); const query = AccountCustomers.query() .select({ uid: 'accountCustomers.uid' }) @@ -124,11 +131,7 @@ const init = async () => { const rows = await query; for (const x of rows) { - const result = await accountTasks.deleteAccount({ - uid: x.uid, - customerId: (await getAccountCustomerByUid(x.uid))?.stripeCustomerId, - reason, - }); + const result = await accountDeleteManager.enqueue({ uid: x.uid, reason }); console.log(`Created cloud task ${result} for uid ${x.uid}`); } diff --git a/packages/fxa-auth-server/scripts/cleanup-partial-firestore-customers.ts b/packages/fxa-auth-server/scripts/cleanup-partial-firestore-customers.ts index 6b10488f7a..2cf078fa6b 100644 --- a/packages/fxa-auth-server/scripts/cleanup-partial-firestore-customers.ts +++ b/packages/fxa-auth-server/scripts/cleanup-partial-firestore-customers.ts @@ -8,31 +8,31 @@ import { parseDryRun } from './lib/args'; import { FieldPath, Firestore } from '@google-cloud/firestore'; import { AppConfig, AuthFirestore, AuthLogger } from '../lib/types'; import Container from 'typedi'; +import { + AccountDeleteManager, + ReasonForDeletionOptions, +} from '../lib/account-delete'; import { ConfigType } from '../config'; +import oauthDb from '../lib/oauth/db'; import { StatsD } from 'hot-shots'; +import pushboxApi from '../lib/pushbox'; import { setupAccountDatabase } from '@fxa/shared/db/mysql/account'; import { AccountManager } from '@fxa/shared/account/account'; import { uuidTransformer } from 'packages/fxa-shared/db/transformers'; import * as pckg from '../package.json'; -import { - AccountTasks, - ReasonForDeletion, - AccountTasksFactory, -} from '@fxa/shared/cloud-tasks'; -import { getAccountCustomerByUid } from 'fxa-shared/db/models/auth'; class CleanupFirestoreHelper { private firestore: Firestore; private log: AuthLogger; private config: ConfigType; - private accountTasks: AccountTasks; + private accountDeleteManager: AccountDeleteManager; private accountManager: AccountManager; constructor(private batchSize: number, private dryRun: boolean) { this.firestore = Container.get(AuthFirestore); this.log = Container.get(AuthLogger); this.config = Container.get(AppConfig); - this.accountTasks = Container.get(AccountTasks); + this.accountDeleteManager = Container.get(AccountDeleteManager); this.accountManager = Container.get(AccountManager); } @@ -104,11 +104,10 @@ class CleanupFirestoreHelper { } await Promise.all( - uids.map(async (uid) => - this.accountTasks.deleteAccount({ + uids.map((uid) => + this.accountDeleteManager.enqueue({ uid, - customerId: (await getAccountCustomerByUid(uid))?.stripeCustomerId, - reason: ReasonForDeletion.Cleanup, + reason: ReasonForDeletionOptions.Cleanup, }) ) ); @@ -154,14 +153,25 @@ export async function init() { const batchSize = parseInt(options.batchSize); const isDryRun = parseDryRun(options.dryRun); - // TBD, do we still need this? fxaDb is no longer referenced... - await setupProcessingTaskObjects('cleanup-delete-partial-firestore'); + const { database: fxaDb } = await setupProcessingTaskObjects( + 'cleanup-delete-partial-firestore' + ); const config = Container.get(AppConfig); const statsd = Container.get(StatsD); + const log = Container.get(AuthLogger); + const pushbox = pushboxApi(log, config, statsd); - const accountTasks = AccountTasksFactory(config, statsd); - Container.set(AccountTasks, accountTasks); + const accountDeleteManager = new AccountDeleteManager({ + fxaDb, + oauthDb, + config, + push: {} as any, + pushbox, + statsd, + }); + + Container.set(AccountDeleteManager, accountDeleteManager); const accountDb = await setupAccountDatabase(config.database.mysql.auth); const accountManager = new AccountManager(accountDb); diff --git a/packages/fxa-auth-server/scripts/delete-account.ts b/packages/fxa-auth-server/scripts/delete-account.ts index a558cf03a3..c861e851dc 100755 --- a/packages/fxa-auth-server/scripts/delete-account.ts +++ b/packages/fxa-auth-server/scripts/delete-account.ts @@ -37,10 +37,6 @@ import { AuthLogger, ProfileClient, } from '../lib/types'; -import { - AccountTasks, - AccountTasksFactory, -} from '../../../libs/shared/cloud-tasks/src'; const config = configProperties.getProperties(); const mailer = null; @@ -125,16 +121,13 @@ DB.connect(config).then(async (db: any) => { Container.set(PayPalHelper, paypalHelper); } - const accountTasks = AccountTasksFactory(config, statsd); - Container.set(AccountTasks, accountTasks); - const accountDeleteManager = new AccountDeleteManager({ fxaDb: db, oauthDb: oauthDB, - config, push, pushbox, - }); + statsd, + } as any); Container.set(AccountDeleteManager, accountDeleteManager); // Load the account-deletion route, so we can use its logic directly. diff --git a/packages/fxa-auth-server/scripts/delete-unverified-accounts.ts b/packages/fxa-auth-server/scripts/delete-unverified-accounts.ts index 94b31c6149..866786c907 100644 --- a/packages/fxa-auth-server/scripts/delete-unverified-accounts.ts +++ b/packages/fxa-auth-server/scripts/delete-unverified-accounts.ts @@ -8,23 +8,25 @@ import PQueue from 'p-queue'; import { Container } from 'typedi'; import { setupAccountDatabase } from '@fxa/shared/db/mysql/account'; +import { CloudTasksClient } from '@google-cloud/tasks'; import appConfig from '../config'; +import { + AccountDeleteManager, + ReasonForDeletionOptions, +} from '../lib/account-delete'; import * as random from '../lib/crypto/random'; import DB from '../lib/db'; import { setupFirestore } from '../lib/firestore-db'; import initLog from '../lib/log'; +import oauthDb from '../lib/oauth/db'; import { CurrencyHelper } from '../lib/payments/currencies'; import { createStripeHelper, StripeHelper } from '../lib/payments/stripe'; +import { pushboxApi } from '../lib/pushbox'; import initRedis from '../lib/redis'; import Token from '../lib/tokens'; import { AppConfig, AuthFirestore, AuthLogger } from '../lib/types'; import { parseDryRun } from './lib/args'; -import { - AccountTasksFactory, - ReasonForDeletion, -} from '@fxa/shared/cloud-tasks'; -import { getAccountCustomerByUid } from 'fxa-shared/db/models/auth'; const collect = () => (val: string, xs: string[]) => { xs.push(val); @@ -127,7 +129,7 @@ const init = async () => { const hasEmail = program.email.length > 0; const hasDateRange = program.startDate && program.endDate && program.endDate > program.startDate; - const reason = ReasonForDeletion.Unverified; + const reason = ReasonForDeletionOptions.Unverified; const taskLimit = program.taskEnqueueLimit ? parseInt(program.taskEnqueueLimit) : 200; @@ -173,6 +175,7 @@ const init = async () => { random.base32(config.signinUnblock.codeLength) as any // TS type inference is failing pretty hard with this ); const fxaDb = await db.connect(config, redis); + const pushbox = pushboxApi(log, config, statsd); Container.set(AppConfig, config); Container.set(AuthLogger, log); @@ -184,7 +187,22 @@ const init = async () => { const stripeHelper = createStripeHelper(log, config, statsd); Container.set(StripeHelper, stripeHelper); - const accountTasks = AccountTasksFactory(config, statsd); + const accountDeleteManager = new AccountDeleteManager({ + fxaDb, + oauthDb, + config, + push: {} as any, // Not needed when enqueuing + pushbox, + statsd, + }); + + // Replace the client with one that uses the fallback to HTTP for higher concurrency + const tasksConfig = config.cloudTasks; + accountDeleteManager['cloudTasksClient'] = new CloudTasksClient({ + projectId: tasksConfig.projectId, + keyFilename: tasksConfig.credentials.keyFilename ?? undefined, + fallback: true, + }); if (useSpecifiedAccounts) { const { uids, emails } = limitSpecifiedAccounts(program, limit); @@ -195,11 +213,7 @@ const init = async () => { console.error(`Account with uid ${x} is verified. Skipping.`); continue; } - const result = await accountTasks.deleteAccount({ - uid: x, - customerId: (await getAccountCustomerByUid(x))?.stripeCustomerId, - reason, - }); + const result = await accountDeleteManager.enqueue({ uid: x, reason }); console.log(`Created cloud task ${result} for uid ${x}`); } @@ -209,11 +223,7 @@ const init = async () => { console.error(`Account with email ${x} is verified. Skipping.`); continue; } - const result = await accountTasks.deleteAccount({ - uid: acct.uid, - customerId: (await getAccountCustomerByUid(acct.uid))?.stripeCustomerId, - reason, - }); + const result = await accountDeleteManager.enqueue({ email: x, reason }); console.log(`Created cloud task ${result} for uid ${x}`); } } @@ -262,7 +272,7 @@ const init = async () => { queue.add(async () => { try { - const result = await accountTasks.deleteAccount({ + const result = await accountDeleteManager['enqueueTask']({ uid: row.uid.toString('hex'), customerId: row.stripeCustomerId || undefined, reason, diff --git a/packages/fxa-auth-server/test/local/account-delete.js b/packages/fxa-auth-server/test/local/account-delete.js index 2db67c1094..3c8df27f90 100644 --- a/packages/fxa-auth-server/test/local/account-delete.js +++ b/packages/fxa-auth-server/test/local/account-delete.js @@ -44,12 +44,14 @@ describe('AccountDeleteManager', function () { let mockConfig; let accountDeleteManager; let mockAuthModels; + let createTaskStub; beforeEach(() => { const { PayPalHelper } = require('../../lib/payments/paypal/helper'); const { StripeHelper } = require('../../lib/payments/stripe'); sandbox.reset(); + createTaskStub = sandbox.stub(); mockFxaDb = { ...mocks.mockDB({ email: email, uid: uid }), fetchAccountSubscriptions: sinon.spy( @@ -105,7 +107,7 @@ describe('AccountDeleteManager', function () { return [{ status: 'Active', billingAgreementId: 'B-test' }]; }); mockAuthModels.deleteAllPayPalBAs = sinon.spy(async () => {}); - mockAuthModels.getAccountCustomerByUid = sinon.spy(async (...args) => { + mockAuthModels.getAccountCustomerByUid = sinon.spy(async () => { return { stripeCustomerId: 'cus_993' }; }); @@ -122,6 +124,14 @@ describe('AccountDeleteManager', function () { Container.set(PlayBilling, mockPlayBilling); const { AccountDeleteManager } = proxyquire('../../lib/account-delete', { + '@google-cloud/tasks': { + CloudTasksClient: class CloudTasksClient { + constructor() {} + createTask(...args) { + return createTaskStub.apply(null, args); + } + }, + }, 'fxa-shared/db/models/auth': mockAuthModels, }); @@ -143,6 +153,104 @@ describe('AccountDeleteManager', function () { assert.ok(accountDeleteManager); }); + describe('create tasks', function () { + it('creates a delete account task by uid', async () => { + const taskId = 'proj/testo/loc/us-n/q/del0/tasks/123'; + createTaskStub = sandbox.stub().resolves([{ name: taskId }]); + const result = await accountDeleteManager.enqueue({ + uid, + reason: 'fxa_unverified_account_delete', + }); + sinon.assert.calledOnceWithExactly( + mockAuthModels.getAccountCustomerByUid, + uid + ); + sinon.assert.calledOnceWithExactly( + mockStatsd.increment, + 'cloud-tasks.account-delete.enqueue.success' + ); + sinon.assert.calledOnceWithExactly(createTaskStub, { + parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.deleteAccounts.queueName}`, + task: { + httpRequest: { + url: `${mockConfig.publicUrl}/v${mockConfig.apiVersion}/cloud-tasks/accounts/delete`, + httpMethod: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: Buffer.from( + JSON.stringify({ + uid, + customerId: 'cus_993', + reason: 'fxa_unverified_account_delete', + }) + ).toString('base64'), + oidcToken: { + audience: mockConfig.cloudTasks.oidc.aud, + serviceAccountEmail: + mockConfig.cloudTasks.oidc.serviceAccountEmail, + }, + }, + }, + }); + assert.equal(result, taskId); + }); + + it('creates a delete account task by email', async () => { + const taskId = 'proj/testo/loc/us-n/q/del0/tasks/134'; + createTaskStub = sandbox.stub().resolves([{ name: taskId }]); + const result = await accountDeleteManager.enqueue({ + email, + reason: 'fxa_unverified_account_delete', + }); + sinon.assert.calledOnceWithExactly( + mockAuthModels.getAccountCustomerByUid, + uid + ); + sinon.assert.calledOnceWithExactly(createTaskStub, { + parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.deleteAccounts.queueName}`, + task: { + httpRequest: { + url: `${mockConfig.publicUrl}/v${mockConfig.apiVersion}/cloud-tasks/accounts/delete`, + httpMethod: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: Buffer.from( + JSON.stringify({ + uid, + customerId: 'cus_993', + + reason: 'fxa_unverified_account_delete', + }) + ).toString('base64'), + oidcToken: { + audience: mockConfig.cloudTasks.oidc.aud, + serviceAccountEmail: + mockConfig.cloudTasks.oidc.serviceAccountEmail, + }, + }, + }, + }); + assert.equal(result, taskId); + }); + + it('throws when task creation fails', async () => { + const fetchCustomerStub = sandbox.stub().resolves({ id: 'cus_997' }); + mockStripeHelper['fetchCustomer'] = fetchCustomerStub; + createTaskStub = sandbox.stub().throws(); + try { + await accountDeleteManager.enqueue({ + uid, + reason: 'fxa_unverified_account_delete', + }); + assert.fail('An error should have been thrown.'); + } catch (err) { + sinon.assert.calledOnceWithExactly( + mockStatsd.increment, + 'cloud-tasks.account-delete.enqueue.failure' + ); + assert.instanceOf(err, Error); + } + }); + }); + describe('delete account', function () { it('should delete the account', async () => { mockPush.notifyAccountDestroyed = sinon.fake.resolves(); @@ -238,12 +346,17 @@ describe('AccountDeleteManager', function () { }); describe('quickDelete', () => { - it('should delete the account', async () => { + it('should delete the account and queue', async () => { + createTaskStub = sandbox.stub().resolves([{ name: 'test' }]); await accountDeleteManager.quickDelete(uid, deleteReason); sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, { uid, }); + sinon.assert.calledOnceWithExactly( + mockAuthModels.getAccountCustomerByUid, + uid + ); sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid); }); @@ -255,6 +368,25 @@ describe('AccountDeleteManager', function () { assert.match(err.message, /^quickDelete only supports user/); } }); + + it('should enqueue if an error happens during delete', async () => { + createTaskStub = sandbox.stub().resolves([{ name: 'test' }]); + mockFxaDb.deleteAccount = sandbox.stub().throws(); + await accountDeleteManager.quickDelete(uid, deleteReason); + + sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, { + uid, + }); + sinon.assert.calledOnceWithExactly( + mockAuthModels.getAccountCustomerByUid, + uid + ); + sinon.assert.callCount(mockOAuthDb.removeTokensAndCodes, 0); + sinon.assert.calledOnceWithExactly( + mockStatsd.increment, + 'cloud-tasks.account-delete.enqueue.success' + ); + }); }); describe('refundSubscriptions', () => { diff --git a/packages/fxa-auth-server/test/local/routes/account.js b/packages/fxa-auth-server/test/local/routes/account.js index 36ad26a4c3..f91ca9a13c 100644 --- a/packages/fxa-auth-server/test/local/routes/account.js +++ b/packages/fxa-auth-server/test/local/routes/account.js @@ -10,7 +10,6 @@ const assert = { ...sinon.assert, ...require('chai').assert }; const mocks = require('../../mocks'); const getRoute = require('../../routes_helpers').getRoute; const proxyquire = require('proxyquire'); -const { AccountTasks, ReasonForDeletion } = require('@fxa/shared/cloud-tasks'); const uuid = require('uuid'); const crypto = require('crypto'); @@ -20,7 +19,10 @@ const otplib = require('otplib'); const { Container } = require('typedi'); const { CapabilityService } = require('../../../lib/payments/capability'); const { AccountEventsManager } = require('../../../lib/account-events'); -const { AccountDeleteManager } = require('../../../lib/account-delete'); +const { + AccountDeleteManager, + ReasonForDeletionOptions, +} = require('../../../lib/account-delete'); const { normalizeEmail } = require('fxa-shared').email.helpers; const { MozillaSubscriptionTypes } = require('fxa-shared/subscriptions/types'); const { @@ -42,11 +44,7 @@ function hexString(bytes) { return crypto.randomBytes(bytes).toString('hex'); } -let mockAccountQuickDelete = sinon.fake.resolves(); -let mockAccountTasksDeleteAccount = sinon.fake(async (...args) => {}); -const mockGetAccountCustomerByUid = sinon.fake.resolves({ - stripeCustomerId: 'customer123', -}); +const mockAccountQuickDelete = sinon.fake.resolves(); const makeRoutes = function (options = {}, requireMocks = {}) { Container.set(CapabilityService, options.capabilityService || sinon.fake); @@ -97,12 +95,10 @@ const makeRoutes = function (options = {}, requireMocks = {}) { options.verificationReminders || mocks.mockVerificationReminders(); const subscriptionAccountReminders = options.subscriptionAccountReminders || mocks.mockVerificationReminders(); - const { accountRoutes } = proxyquire('../../../lib/routes/account', { - ...(requireMocks || {}), - 'fxa-shared/db/models/auth': { - getAccountCustomerByUid: mockGetAccountCustomerByUid, - }, - }); + const { accountRoutes } = proxyquire( + '../../../lib/routes/account', + requireMocks || {} + ); const signupUtils = options.signupUtils || require('../../../lib/routes/utils/signup')( @@ -120,20 +116,11 @@ const makeRoutes = function (options = {}, requireMocks = {}) { ...(options.oauth || {}), }; - mockAccountTasksDeleteAccount = sinon.fake.resolves(); - const accountTasks = { - deleteAccount: mockAccountTasksDeleteAccount, - queueEnabled: true, - }; - Container.set(AccountTasks, accountTasks); - // We have to do some redirection with proxyquire because dependency // injection changes the class const AccountDeleteManagerMock = proxyquire('../../../lib/account-delete', { - 'fxa-shared/db/models/auth': { - ...(requireMocks['fxa-shared/db/models/auth'] || {}), - getAccountCustomerByUid: mockGetAccountCustomerByUid, - }, + 'fxa-shared/db/models/auth': + requireMocks['fxa-shared/db/models/auth'] || {}, }); const accountManagerMock = new AccountDeleteManagerMock.AccountDeleteManager({ fxaDb: db, @@ -141,11 +128,10 @@ const makeRoutes = function (options = {}, requireMocks = {}) { config, push, pushbox, - }); - mockAccountQuickDelete = sinon.fake(async (...args) => { - return {}; + statsd: { increment: sinon.fake.returns({}) }, }); accountManagerMock.quickDelete = mockAccountQuickDelete; + mockAccountQuickDelete.resetHistory(); Container.set(AccountDeleteManager, accountManagerMock); return accountRoutes( @@ -3824,7 +3810,7 @@ describe('/account/destroy', () => { return getRoute(accountRoutes, '/account/destroy'); } - it('should delete the account and enqueue account task', () => { + it('should delete the account', () => { const route = buildRoute(); return runTest(route, mockRequest, () => { @@ -3837,43 +3823,11 @@ describe('/account/destroy', () => { userAgent: 'test user-agent', uid: uid, }); - sinon.assert.calledOnceWithExactly( mockAccountQuickDelete, uid, - ReasonForDeletion.UserRequested + ReasonForDeletionOptions.UserRequested ); - - sinon.assert.calledOnceWithExactly(mockGetAccountCustomerByUid, uid); - sinon.assert.calledOnceWithExactly(mockAccountTasksDeleteAccount, { - uid, - customerId: 'customer123', - reason: ReasonForDeletion.UserRequested, - }); - }); - }); - - it('should delete the account and enqueue account task on error', () => { - const route = buildRoute(); - - // Here we act like there's an error when calling accountDeleteManager.quickDelete(...) - mockAccountQuickDelete = sinon.fake.rejects(); - - return runTest(route, mockRequest, () => { - sinon.assert.calledOnceWithExactly(mockDB.accountRecord, email); - sinon.assert.calledOnceWithExactly(mockLog.activityEvent, { - country: 'United States', - event: 'account.deleted', - region: 'California', - service: undefined, - userAgent: 'test user-agent', - uid: uid, - }); - sinon.assert.calledOnceWithExactly(mockAccountTasksDeleteAccount, { - uid, - customerId: 'customer123', - reason: ReasonForDeletion.UserRequested, - }); }); }); @@ -3892,7 +3846,7 @@ describe('/account/destroy', () => { sinon.assert.calledOnceWithExactly( mockAccountQuickDelete, uid, - ReasonForDeletion.UserRequested + ReasonForDeletionOptions.UserRequested ); }); }); 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 186ce744e6..c00ae0d459 100644 --- a/packages/fxa-auth-server/test/local/routes/cloud-tasks.js +++ b/packages/fxa-auth-server/test/local/routes/cloud-tasks.js @@ -8,8 +8,11 @@ const sinon = require('sinon'); const mocks = require('../../mocks'); 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 { + AccountDeleteManager, + ReasonForDeletionOptions, +} = require('../../../lib/account-delete'); + const mockConfig = { cloudTasks: { deleteAccounts: { queueName: 'del-accts' }, @@ -42,7 +45,7 @@ describe('/cloud-tasks/accounts/delete', () => { it('should delete the account', async () => { try { const req = { - payload: { uid, reason: ReasonForDeletion.Unverified }, + payload: { uid, reason: ReasonForDeletionOptions.Unverified }, }; await route.handler(req); diff --git a/packages/fxa-auth-server/test/local/routes/validators.js b/packages/fxa-auth-server/test/local/routes/validators.js index 8a020f83bc..b648758942 100644 --- a/packages/fxa-auth-server/test/local/routes/validators.js +++ b/packages/fxa-auth-server/test/local/routes/validators.js @@ -11,7 +11,6 @@ const plan1 = require('../payments/fixtures/stripe/plan1.json'); const validProductMetadata = plan1.product.metadata; const { MozillaSubscriptionTypes } = require('fxa-shared/subscriptions/types'); const { deepCopy } = require('../payments/util'); -const { ReasonForDeletion } = require('@fxa/shared/cloud-tasks'); describe('lib/routes/validators:', () => { it('isValidEmailAddress returns true for valid email addresses', () => { @@ -1139,27 +1138,4 @@ describe('lib/routes/validators:', () => { assert.exists(validators.recoveryCode(11).validate('123456').error); }); }); - - describe('reason for account deletion', () => { - it('validates valid reason', () => { - assert.notExists( - validators.reasonForAccountDeletion.validate(ReasonForDeletion.Cleanup) - .error - ); - assert.notExists( - validators.reasonForAccountDeletion.validate( - ReasonForDeletion.UserRequested - ).error - ); - assert.notExists( - validators.reasonForAccountDeletion.validate( - ReasonForDeletion.Unverified - ).error - ); - }); - - it('requires valid reason', () => { - assert.exists(validators.reasonForAccountDeletion.validate('blah').error); - }); - }); }); diff --git a/packages/fxa-auth-server/test/remote/account_create_tests.js b/packages/fxa-auth-server/test/remote/account_create_tests.js index 96b2437fe9..fdffa682cc 100644 --- a/packages/fxa-auth-server/test/remote/account_create_tests.js +++ b/packages/fxa-auth-server/test/remote/account_create_tests.js @@ -19,987 +19,998 @@ const { } = require('../../lib/payments/iap/apple-app-store/subscriptions'); // Note, intentionally not indenting for code review. -[{ version: '' }, { version: 'V2' }].forEach((testOptions) => { - describe(`#integration${testOptions.version} - remote account create`, function () { - this.timeout(50000); - let server; - before(async () => { - config.subscriptions = { - enabled: true, - stripeApiKey: 'fake_key', - paypalNvpSigCredentials: { - enabled: false, - }, - paymentsServer: { - url: 'http://fakeurl.com', - }, - productConfigsFirestore: { enabled: true }, - }; - const mockStripeHelper = {}; - mockStripeHelper.hasActiveSubscription = async () => - Promise.resolve(false); - mockStripeHelper.removeCustomer = async () => Promise.resolve(); +[{version:""},{version:"V2"}].forEach((testOptions) => { - Container.set(PlaySubscriptions, {}); - Container.set(AppStoreSubscriptions, {}); - server = await TestServer.start(config, false, { - authServerMockDependencies: { - '../lib/payments/stripe': { - StripeHelper: mockStripeHelper, - createStripeHelper: () => mockStripeHelper, - }, +describe(`#integration${testOptions.version} - remote account create`, function () { + this.timeout(50000); + let server; + before(async () => { + config.subscriptions = { + enabled: true, + stripeApiKey: 'fake_key', + paypalNvpSigCredentials: { + enabled: false, + }, + paymentsServer: { + url: 'http://fakeurl.com', + }, + productConfigsFirestore: { enabled: true }, + }; + const mockStripeHelper = {}; + mockStripeHelper.hasActiveSubscription = async () => + Promise.resolve(false); + mockStripeHelper.removeCustomer = async () => Promise.resolve(); + + Container.set(PlaySubscriptions, {}); + Container.set(AppStoreSubscriptions, {}); + + server = await TestServer.start(config, false, { + authServerMockDependencies: { + '../lib/payments/stripe': { + StripeHelper: mockStripeHelper, + createStripeHelper: () => mockStripeHelper, }, - }); - return server; + }, }); + return server; + }); - it('unverified account fail when getting keys', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - let client = null; - return Client.create(config.publicUrl, email, password, testOptions) - .then((x) => { - client = x; - assert.ok(client.authAt, 'authAt was set'); - }) - .then(() => { - return client.keys(); - }) - .then( - (keys) => { - assert(false, 'got keys before verifying email'); - }, - (err) => { - assert.equal(err.errno, 104, 'Unverified account error code'); - assert.equal( - err.message, - 'Unconfirmed account', - 'Unverified account error message' - ); - } + it('unverified account fail when getting keys', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + let client = null; + return Client.create(config.publicUrl, email, password, testOptions) + .then((x) => { + client = x; + assert.ok(client.authAt, 'authAt was set'); + }) + .then(() => { + return client.keys(); + }) + .then( + (keys) => { + assert(false, 'got keys before verifying email'); + }, + (err) => { + assert.equal(err.errno, 104, 'Unverified account error code'); + assert.equal( + err.message, + 'Unconfirmed account', + 'Unverified account error message' + ); + } + ); + }); + + it('create and verify sync account', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + let client = null; + return Client.create(config.publicUrl, email, password, { + ...testOptions, + service: 'sync', + }) + .then((x) => { + client = x; + assert.ok(client.authAt, 'authAt was set'); + }) + .then(() => { + return client.emailStatus(); + }) + .then((status) => { + assert.equal(status.verified, false); + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal(emailData.headers['x-mailer'], undefined); + assert.equal(emailData.headers['x-template-name'], 'verify'); + return emailData.headers['x-verify-code']; + }) + .then((verifyCode) => { + return client.verifyEmail(verifyCode, { service: 'sync' }); + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal( + emailData.headers['x-link'].indexOf(config.smtp.syncUrl), + 0, + 'sync url present' ); - }); - - it('create and verify sync account', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - let client = null; - return Client.create(config.publicUrl, email, password, { - ...testOptions, - service: 'sync', }) - .then((x) => { - client = x; - assert.ok(client.authAt, 'authAt was set'); - }) - .then(() => { - return client.emailStatus(); - }) - .then((status) => { - assert.equal(status.verified, false); - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal(emailData.headers['x-mailer'], undefined); - assert.equal(emailData.headers['x-template-name'], 'verify'); - return emailData.headers['x-verify-code']; - }) - .then((verifyCode) => { - return client.verifyEmail(verifyCode, { service: 'sync' }); - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal( - emailData.headers['x-link'].indexOf(config.smtp.syncUrl), - 0, - 'sync url present' - ); - }) - .then(() => { - return client.emailStatus(); - }) - .then((status) => { - assert.equal(status.verified, true); - }); - }); - - it('create account with service identifier and resume', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - return Client.create(config.publicUrl, email, password, { - ...testOptions, - service: 'abcdef', - resume: 'foo', + .then(() => { + return client.emailStatus(); }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal(emailData.headers['x-service-id'], 'abcdef'); - assert.ok(emailData.headers['x-link'].indexOf('resume=foo') > -1); + .then((status) => { + assert.equal(status.verified, true); + }); + }); + + it('create account with service identifier and resume', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + return Client.create(config.publicUrl, email, password, { + ...testOptions, + service: 'abcdef', + resume: 'foo', + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal(emailData.headers['x-service-id'], 'abcdef'); + assert.ok(emailData.headers['x-link'].indexOf('resume=foo') > -1); + }); + }); + + it('create account allows localization of emails', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + let client = null; + return Client.create(config.publicUrl, email, password, testOptions) + .then((x) => { + client = x; + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.include(emailData.text, 'Confirm account', 'en-US'); + // TODO: reinstate after translations catch up + //assert.notInclude(emailData.text, 'Ativar agora', 'not pt-BR'); + return client.destroyAccount(); + }) + .then(() => { + return Client.create(config.publicUrl, email, password, { + ...testOptions, + lang: 'pt-br', }); - }); + }) + .then((x) => { + client = x; + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.notInclude(emailData.text, 'Confirm email', 'not en-US'); + // TODO: reinstate after translations catch up + //assert.include(emailData.text, 'Ativar agora', 'is pt-BR'); + return client.destroyAccount(); + }); + }); - it('create account allows localization of emails', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - let client = null; - return Client.create(config.publicUrl, email, password, testOptions) - .then((x) => { - client = x; - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.include(emailData.text, 'Confirm account', 'en-US'); - // TODO: reinstate after translations catch up - //assert.notInclude(emailData.text, 'Ativar agora', 'not pt-BR'); - return client.destroyAccount(); - }) - .then(() => { - return Client.create(config.publicUrl, email, password, { - ...testOptions, - lang: 'pt-br', - }); - }) - .then((x) => { - client = x; - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.notInclude(emailData.text, 'Confirm email', 'not en-US'); - // TODO: reinstate after translations catch up - //assert.include(emailData.text, 'Ativar agora', 'is pt-BR'); - return client.destroyAccount(); - }); - }); - - it('Unknown account should not exist', () => { - const client = new Client(config.publicUrl, testOptions); - client.email = server.uniqueEmail(); - client.authPW = crypto.randomBytes(32); - client.authPWVersion2 = crypto.randomBytes(32); - return client.auth().then( - () => { - assert(false, 'account should not exist'); - }, - (err) => { - assert.equal(err.errno, 102, 'account does not exist'); - } - ); - }); - - it('stubs account and finishes setup', async () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; - const client = new Client(config.publicUrl, testOptions); - await client.setupCredentials(email, password); - - if (testOptions.version === 'V2') { - await client.setupCredentialsV2(email, password); + it('Unknown account should not exist', () => { + const client = new Client(config.publicUrl, testOptions); + client.email = server.uniqueEmail(); + client.authPW = crypto.randomBytes(32); + client.authPWVersion2 = crypto.randomBytes(32); + return client.auth().then( + () => { + assert(false, 'account should not exist'); + }, + (err) => { + assert.equal(err.errno, 102, 'account does not exist'); } + ); + }); - // Stub account for 123Done - const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); - assert.exists(stubResponse.setup_token); + it('stubs account and finishes setup', async () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + const client = new Client(config.publicUrl, testOptions); + await client.setupCredentials(email, password); - // Finish the setup. - const response = await client.finishAccountSetup( - stubResponse.setup_token - ); - assert.exists(response.uid); - assert.exists(response.sessionToken); - assert.exists(response.verified); - assert.isFalse(response.verified); + if (testOptions.version === "V2") { + await client.setupCredentialsV2(email, password); + } - // Now a client should be able login - const client2 = await Client.login( - config.publicUrl, - email, - password, - testOptions - ); - assert.exists(client2.sessionToken); - }); + // Stub account for 123Done + const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); + assert.exists(stubResponse.setup_token); - it('cannot stub the same account twice', async () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; - const stub = async () => { - const client = new Client(config.publicUrl, testOptions); - await client.setupCredentials(email, password); + // Finish the setup. + const response = await client.finishAccountSetup(stubResponse.setup_token) + assert.exists(response.uid); + assert.exists(response.sessionToken); + assert.exists(response.verified); + assert.isFalse(response.verified); - if (testOptions.version === 'V2') { - await client.setupCredentialsV2(email, password); - } + // Now a client should be able login + const client2 = await Client.login(config.publicUrl, email, password, testOptions); + assert.exists(client2.sessionToken) + }) - const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); - assert.exists(stubResponse.setup_token); - }; - - // The second attempt to stub should fail, because the email has already been - // stubbed - await stub(); - assert.isRejected(stub()); - }); - - it('fails to create account with a corrupt setup token', async () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; + it('cannot stub the same account twice', async () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + const stub = async () => { const client = new Client(config.publicUrl, testOptions); await client.setupCredentials(email, password); - if (testOptions.version === 'V2') { + if (testOptions.version === "V2") { await client.setupCredentialsV2(email, password); } const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); assert.exists(stubResponse.setup_token); + }; - // modify the setup token and make sure it fails - stubResponse.setup_token = stubResponse.setup_token - .toString() - .substring(2); + // The second attempt to stub should fail, because the email has already been + // stubbed + await stub(); + assert.isRejected(stub()); + }) - // Finish the setup. Should fail because the setup token is bad - assert.isRejected(client.finishAccountSetup(stubResponse.setup_token)); + it('fails to create account with a corrupt setup token', async () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + const client = new Client(config.publicUrl, testOptions); + await client.setupCredentials(email, password); + + if (testOptions.version === "V2") { + await client.setupCredentialsV2(email, password); + } + + const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); + assert.exists(stubResponse.setup_token); + + // modify the setup token and make sure it fails + stubResponse.setup_token = stubResponse.setup_token.toString().substring(2); + + // Finish the setup. Should fail because the setup token is bad + assert.isRejected(client.finishAccountSetup(stubResponse.setup_token)) + }) + + it('fails to call finish setup again', async () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + const client = new Client(config.publicUrl, testOptions); + await client.setupCredentials(email, password); + + if (testOptions.version === "V2") { + await client.setupCredentialsV2(email, password); + } + + const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); + await client.finishAccountSetup(stubResponse.setup_token); + + //Should fail because finish account setup was already called + assert.isRejected(client.finishAccountSetup(stubResponse.setup_token)); + }) + + it('/account/create works with proper data', () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + let client; + return Client.createAndVerify( + config.publicUrl, + email, + password, + server.mailbox, + testOptions + ) + .then((x) => { + client = x; + assert.ok(client.uid, 'account created'); + }) + .then(() => { + return client.login(); + }) + .then(() => { + assert.ok(client.sessionToken, 'client can login'); + }); + }); + + it('/account/create returns a sessionToken', () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + const client = new Client(config.publicUrl, testOptions); + return client.setupCredentials(email, password).then((c) => { + return c.api.accountCreate(c.email, c.authPW).then((response) => { + assert.ok(response.sessionToken, 'has a sessionToken'); + assert.equal( + response.keyFetchToken, + undefined, + 'no keyFetchToken without keys=true' + ); + }); }); + }); - it('fails to call finish setup again', async () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; - const client = new Client(config.publicUrl, testOptions); - await client.setupCredentials(email, password); - - if (testOptions.version === 'V2') { - await client.setupCredentialsV2(email, password); - } - - const stubResponse = await client.stubAccount('dcdb5ae7add825d2'); - await client.finishAccountSetup(stubResponse.setup_token); - - //Should fail because finish account setup was already called - assert.isRejected(client.finishAccountSetup(stubResponse.setup_token)); - }); - - it('/account/create works with proper data', () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; - let client; - return Client.createAndVerify( - config.publicUrl, - email, - password, - server.mailbox, - testOptions - ) - .then((x) => { - client = x; - assert.ok(client.uid, 'account created'); - }) - .then(() => { - return client.login(); - }) - .then(() => { - assert.ok(client.sessionToken, 'client can login'); - }); - }); - - it('/account/create returns a sessionToken', () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; - const client = new Client(config.publicUrl, testOptions); - return client.setupCredentials(email, password).then((c) => { - return c.api.accountCreate(c.email, c.authPW).then((response) => { + it('/account/create returns a keyFetchToken when keys=true', () => { + const email = server.uniqueEmail(); + const password = 'ilikepancakes'; + const client = new Client(config.publicUrl, testOptions); + return client.setupCredentials(email, password).then((c) => { + return c.api + .accountCreate(c.email, c.authPW, { keys: true }) + .then((response) => { assert.ok(response.sessionToken, 'has a sessionToken'); - assert.equal( - response.keyFetchToken, - undefined, - 'no keyFetchToken without keys=true' - ); - }); - }); - }); - - it('/account/create returns a keyFetchToken when keys=true', () => { - const email = server.uniqueEmail(); - const password = 'ilikepancakes'; - const client = new Client(config.publicUrl, testOptions); - return client.setupCredentials(email, password).then((c) => { - return c.api - .accountCreate(c.email, c.authPW, { keys: true }) - .then((response) => { - assert.ok(response.sessionToken, 'has a sessionToken'); - assert.ok(response.keyFetchToken, 'keyFetchToken with keys=true'); - }); - }); - }); - - it('signup with same email, different case', () => { - const email = server.uniqueEmail(); - const email2 = email.toUpperCase(); - const password = 'abcdef'; - return Client.createAndVerify( - config.publicUrl, - email, - password, - server.mailbox, - testOptions - ) - .then((c) => { - return Client.create(config.publicUrl, email2, password, testOptions); - }) - .then(assert.fail, (err) => { - assert.equal(err.code, 400); - assert.equal(err.errno, 101, 'Account already exists'); - assert.equal( - err.email, - email, - 'The existing email address is returned' - ); + assert.ok(response.keyFetchToken, 'keyFetchToken with keys=true'); }); }); + }); - it('re-signup against an unverified email', () => { - const email = server.uniqueEmail(); - const password = 'abcdef'; - return Client.create(config.publicUrl, email, password, testOptions) - .then(() => { - // delete the first verification email - return server.mailbox.waitForEmail(email); - }) - .then(() => { - return Client.createAndVerify( - config.publicUrl, - email, - password, - server.mailbox, - testOptions - ); - }) - .then((client) => { - assert.ok(client.uid, 'account created'); - }); - }); - - it('invalid redirectTo', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - redirectTo: 'http://accounts.firefox.com.evil.us', - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => { - assert.equal(err.errno, 107, 'bad redirectTo rejected'); - }) - .then(() => { - return api.passwordForgotSendCode(email, options); - }) - .then(assert.fail, (err) => { - assert.equal(err.errno, 107, 'bad redirectTo rejected'); - }); - }); - - it('another invalid redirectTo', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - redirectTo: 'https://www.fake.com/.firefox.com', - }; - - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => { - assert.equal(err.errno, 107, 'bad redirectTo rejected'); - }) - .then(() => { - return api.passwordForgotSendCode(email, { - redirectTo: 'https://fakefirefox.com', - }); - }) - .then(assert.fail, (err) => { - assert.equal(err.errno, 107, 'bad redirectTo rejected'); - }); - }); - - it('valid metricsContext', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: 'bar', - utmContent: 'baz', - utmMedium: 'qux', - utmSource: 'wibble', - utmTerm: 'blee', - }, - }; - return api.accountCreate(email, authPW, options); - }); - - it('empty metricsContext', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: {}, - }; - return api.accountCreate(email, authPW, options); - }); - - it('invalid entrypoint', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: ';', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: 'foo', - utmContent: 'bar', - utmMedium: 'baz', - utmSource: 'qux', - utmTerm: 'wibble', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid entrypointExperiment', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: ';', - entrypointVariation: 'var', - utmCampaign: 'bar', - utmContent: 'baz', - utmMedium: 'qux', - utmSource: 'wibble', - utmTerm: 'blee', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid entrypointVariation', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: ';', - utmCampaign: 'bar', - utmContent: 'baz', - utmMedium: 'qux', - utmSource: 'wibble', - utmTerm: 'blee', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid utmCampaign', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: ';', - utmContent: 'bar', - utmMedium: 'baz', - utmSource: 'qux', - utmTerm: 'wibble', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid utmContent', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: 'bar', - utmContent: ';', - utmMedium: 'baz', - utmSource: 'qux', - utmTerm: 'wibble', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid utmMedium', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: 'bar', - utmContent: 'baz', - utmMedium: ';', - utmSource: 'qux', - utmTerm: 'wibble', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid utmSource', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: 'bar', - utmContent: 'baz', - utmMedium: 'qux', - utmSource: ';', - utmTerm: 'wibble', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('invalid utmTerm', () => { - const api = new Client.Api(config.publicUrl, testOptions); - const email = server.uniqueEmail(); - const authPW = - '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; - const options = { - ...testOptions, - metricsContext: { - entrypoint: 'foo', - entrypointExperiment: 'exp', - entrypointVariation: 'var', - utmCampaign: 'bar', - utmContent: 'baz', - utmMedium: 'qux', - utmSource: 'wibble', - utmTerm: ';', - }, - }; - return api - .accountCreate(email, authPW, options) - .then(assert.fail, (err) => assert.equal(err.errno, 107)); - }); - - it('create account with service query parameter', () => { - const email = server.uniqueEmail(); - return Client.create(config.publicUrl, email, 'foo', { - ...testOptions, - serviceQuery: 'bar', + it('signup with same email, different case', () => { + const email = server.uniqueEmail(); + const email2 = email.toUpperCase(); + const password = 'abcdef'; + return Client.createAndVerify( + config.publicUrl, + email, + password, + server.mailbox, + testOptions + ) + .then((c) => { + return Client.create(config.publicUrl, email2, password, testOptions); }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal( - emailData.headers['x-service-id'], - 'bar', - 'service query parameter was propagated' - ); + .then(assert.fail, (err) => { + assert.equal(err.code, 400); + assert.equal(err.errno, 101, 'Account already exists'); + assert.equal( + err.email, + email, + 'The existing email address is returned' + ); + }); + }); + + it('re-signup against an unverified email', () => { + const email = server.uniqueEmail(); + const password = 'abcdef'; + return Client.create(config.publicUrl, email, password, testOptions) + .then(() => { + // delete the first verification email + return server.mailbox.waitForEmail(email); + }) + .then(() => { + return Client.createAndVerify( + config.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + }) + .then((client) => { + assert.ok(client.uid, 'account created'); + }); + }); + + it('invalid redirectTo', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + redirectTo: 'http://accounts.firefox.com.evil.us', + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => { + assert.equal(err.errno, 107, 'bad redirectTo rejected'); + }) + .then(() => { + return api.passwordForgotSendCode(email, options); + }) + .then(assert.fail, (err) => { + assert.equal(err.errno, 107, 'bad redirectTo rejected'); + }); + }); + + it('another invalid redirectTo', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + redirectTo: 'https://www.fake.com/.firefox.com', + }; + + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => { + assert.equal(err.errno, 107, 'bad redirectTo rejected'); + }) + .then(() => { + return api.passwordForgotSendCode(email, { + redirectTo: 'https://fakefirefox.com', }); - }); + }) + .then(assert.fail, (err) => { + assert.equal(err.errno, 107, 'bad redirectTo rejected'); + }); + }); - it('account creation works with unicode email address', () => { - const email = server.uniqueUnicodeEmail(); - return Client.create(config.publicUrl, email, 'foo', testOptions) - .then((client) => { - assert.ok(client, 'created account'); - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.ok(emailData, 'received email'); - }); - }); + it('valid metricsContext', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: 'bar', + utmContent: 'baz', + utmMedium: 'qux', + utmSource: 'wibble', + utmTerm: 'blee', + }, + }; + return api.accountCreate(email, authPW, options); + }); - it('account creation fails with invalid metricsContext flowId', () => { - const email = server.uniqueEmail(); - return Client.create(config.publicUrl, email, 'foo', { - ...testOptions, - metricsContext: { - flowId: 'deadbeefbaadf00ddeadbeefbaadf00d', - flowBeginTime: 1, - }, - }).then( - () => { - assert(false, 'account creation should have failed'); - }, - (err) => { - assert.ok(err, 'account creation failed'); - } - ); - }); + it('empty metricsContext', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: {}, + }; + return api.accountCreate(email, authPW, options); + }); - it('account creation fails with invalid metricsContext flowBeginTime', () => { - const email = server.uniqueEmail(); - return Client.create(config.publicUrl, email, 'foo', { - ...testOptions, - metricsContext: { - flowId: - 'deadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00d', - flowBeginTime: 0, - }, - }).then( - () => { - assert(false, 'account creation should have failed'); - }, - (err) => { - assert.ok(err, 'account creation failed'); - } - ); - }); + it('invalid entrypoint', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: ';', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: 'foo', + utmContent: 'bar', + utmMedium: 'baz', + utmSource: 'qux', + utmTerm: 'wibble', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); - it('account creation works with maximal metricsContext metadata', () => { - const email = server.uniqueEmail(); - const options = { - ...testOptions, - metricsContext: mocks.generateMetricsContext(), - }; - return Client.create(config.publicUrl, email, 'foo', options) - .then((client) => { - assert.ok(client, 'created account'); - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal( - emailData.headers['x-flow-begin-time'], - options.metricsContext.flowBeginTime, - 'flow begin time set' - ); - assert.equal( - emailData.headers['x-flow-id'], - options.metricsContext.flowId, - 'flow id set' - ); - }); - }); + it('invalid entrypointExperiment', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: ';', + entrypointVariation: 'var', + utmCampaign: 'bar', + utmContent: 'baz', + utmMedium: 'qux', + utmSource: 'wibble', + utmTerm: 'blee', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); - it('account creation works with empty metricsContext metadata', () => { - const email = server.uniqueEmail(); - return Client.create(config.publicUrl, email, 'foo', { - ...testOptions, - metricsContext: {}, - }).then((client) => { + it('invalid entrypointVariation', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: ';', + utmCampaign: 'bar', + utmContent: 'baz', + utmMedium: 'qux', + utmSource: 'wibble', + utmTerm: 'blee', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); + + it('invalid utmCampaign', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: ';', + utmContent: 'bar', + utmMedium: 'baz', + utmSource: 'qux', + utmTerm: 'wibble', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); + + it('invalid utmContent', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: 'bar', + utmContent: ';', + utmMedium: 'baz', + utmSource: 'qux', + utmTerm: 'wibble', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); + + it('invalid utmMedium', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: 'bar', + utmContent: 'baz', + utmMedium: ';', + utmSource: 'qux', + utmTerm: 'wibble', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); + + it('invalid utmSource', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: 'bar', + utmContent: 'baz', + utmMedium: 'qux', + utmSource: ';', + utmTerm: 'wibble', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); + + it('invalid utmTerm', () => { + const api = new Client.Api(config.publicUrl, testOptions); + const email = server.uniqueEmail(); + const authPW = + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'; + const options = { + ...testOptions, + metricsContext: { + entrypoint: 'foo', + entrypointExperiment: 'exp', + entrypointVariation: 'var', + utmCampaign: 'bar', + utmContent: 'baz', + utmMedium: 'qux', + utmSource: 'wibble', + utmTerm: ';', + }, + }; + return api + .accountCreate(email, authPW, options) + .then(assert.fail, (err) => assert.equal(err.errno, 107)); + }); + + it('create account with service query parameter', () => { + const email = server.uniqueEmail(); + return Client.create(config.publicUrl, email, 'foo', { + ...testOptions, + serviceQuery: 'bar', + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal( + emailData.headers['x-service-id'], + 'bar', + 'service query parameter was propagated' + ); + }); + }); + + it('account creation works with unicode email address', () => { + const email = server.uniqueUnicodeEmail(); + return Client.create(config.publicUrl, email, 'foo', testOptions) + .then((client) => { assert.ok(client, 'created account'); - }); - }); - - it('account creation fails with missing flowBeginTime', () => { - const email = server.uniqueEmail(); - return Client.create(config.publicUrl, email, 'foo', { - ...testOptions, - metricsContext: { - flowId: - 'deadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00d', - }, - }).then( - () => { - assert(false, 'account creation should have failed'); - }, - (err) => { - assert.ok(err, 'account creation failed'); - } - ); - }); - - it('account creation fails with missing flowId', () => { - const email = server.uniqueEmail(); - return Client.create(config.publicUrl, email, 'foo', { - ...testOptions, - metricsContext: { - flowBeginTime: Date.now(), - }, - }).then( - () => { - assert(false, 'account creation should have failed'); - }, - (err) => { - assert.ok(err, 'account creation failed'); - } - ); - }); - - it('create account for non-sync service, gets generic sign-up email and does not get post-verify email', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - let client = null; - return Client.create(config.publicUrl, email, password, testOptions) - .then((x) => { - client = x; - assert.ok('account was created'); - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal(emailData.headers['x-template-name'], 'verify'); - return emailData.headers['x-verify-code']; - }) - .then((verifyCode) => { - return client.verifyEmail(verifyCode, { service: 'testpilot' }); - }) - .then(() => { - return client.emailStatus(); - }) - .then((status) => { - assert.equal(status.verified, true); - }) - .then(() => { - // It's hard to test for "an email didn't arrive. - // Instead trigger sending of another email and test - // that there wasn't anything in the queue before it. - return client.forgotPassword(); - }) - .then(() => { - return server.mailbox.waitForCode(email); - }) - .then((code) => { - assert.ok(code, 'the next email was reset-password, not post-verify'); - }); - }); - - it('create account for unspecified service does not get create sync email and no post-verify email', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - let client = null; - return Client.create(config.publicUrl, email, password, testOptions) - .then((x) => { - client = x; - assert.ok('account was created'); - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal(emailData.headers['x-template-name'], 'verify'); - return emailData.headers['x-verify-code']; - }) - .then((verifyCode) => { - return client.verifyEmail(verifyCode, {}); // no 'service' param - }) - .then(() => { - return client.emailStatus(); - }) - .then((status) => { - assert.equal(status.verified, true); - }) - .then(() => { - // It's hard to test for "an email didn't arrive. - // Instead trigger sending of another email and test - // that there wasn't anything in the queue before it. - return client.forgotPassword(); - }) - .then(() => { - return server.mailbox.waitForCode(email); - }) - .then((code) => { - assert.ok(code, 'the next email was reset-password, not post-verify'); - }); - }); - - it('create account and subscribe to newsletters', () => { - const email = server.uniqueEmail(); - const password = 'allyourbasearebelongtous'; - let client = null; - return Client.create(config.publicUrl, email, password, { - ...testOptions, - service: 'sync', + return server.mailbox.waitForEmail(email); }) - .then((x) => { - client = x; - assert.ok('account was created'); - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - return emailData.headers['x-verify-code']; - }) - .then((verifyCode) => { - return client.verifyEmail(verifyCode, { - service: 'sync', - newsletters: ['test-pilot'], - }); - }) - .then(() => { - return client.emailStatus(); - }) - .then((status) => { - assert.equal(status.verified, true); - }) - .then(() => { - return server.mailbox.waitForEmail(email); - }) - .then((emailData) => { - assert.equal(emailData.headers['x-template-name'], 'postVerify'); + .then((emailData) => { + assert.ok(emailData, 'received email'); + }); + }); + + it('account creation fails with invalid metricsContext flowId', () => { + const email = server.uniqueEmail(); + return Client.create(config.publicUrl, email, 'foo', { + ...testOptions, + metricsContext: { + flowId: 'deadbeefbaadf00ddeadbeefbaadf00d', + flowBeginTime: 1, + }, + }).then( + () => { + assert(false, 'account creation should have failed'); + }, + (err) => { + assert.ok(err, 'account creation failed'); + } + ); + }); + + it('account creation fails with invalid metricsContext flowBeginTime', () => { + const email = server.uniqueEmail(); + return Client.create(config.publicUrl, email, 'foo', { + ...testOptions, + metricsContext: { + flowId: + 'deadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00d', + flowBeginTime: 0, + }, + }).then( + () => { + assert(false, 'account creation should have failed'); + }, + (err) => { + assert.ok(err, 'account creation failed'); + } + ); + }); + + it('account creation works with maximal metricsContext metadata', () => { + const email = server.uniqueEmail(); + const options = { + ...testOptions, + metricsContext: mocks.generateMetricsContext(), + }; + return Client.create(config.publicUrl, email, 'foo', options) + .then((client) => { + assert.ok(client, 'created account'); + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal( + emailData.headers['x-flow-begin-time'], + options.metricsContext.flowBeginTime, + 'flow begin time set' + ); + assert.equal( + emailData.headers['x-flow-id'], + options.metricsContext.flowId, + 'flow id set' + ); + }); + }); + + it('account creation works with empty metricsContext metadata', () => { + const email = server.uniqueEmail(); + return Client.create(config.publicUrl, email, 'foo', { + ...testOptions, + metricsContext: {}, + }).then((client) => { + assert.ok(client, 'created account'); + }); + }); + + it('account creation fails with missing flowBeginTime', () => { + const email = server.uniqueEmail(); + return Client.create(config.publicUrl, email, 'foo', { + ...testOptions, + metricsContext: { + flowId: + 'deadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00ddeadbeefbaadf00d', + }, + }).then( + () => { + assert(false, 'account creation should have failed'); + }, + (err) => { + assert.ok(err, 'account creation failed'); + } + ); + }); + + it('account creation fails with missing flowId', () => { + const email = server.uniqueEmail(); + return Client.create(config.publicUrl, email, 'foo', { + ...testOptions, + metricsContext: { + flowBeginTime: Date.now(), + }, + }).then( + () => { + assert(false, 'account creation should have failed'); + }, + (err) => { + assert.ok(err, 'account creation failed'); + } + ); + }); + + it('create account for non-sync service, gets generic sign-up email and does not get post-verify email', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + let client = null; + return Client.create(config.publicUrl, email, password, testOptions) + .then((x) => { + client = x; + assert.ok('account was created'); + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal(emailData.headers['x-template-name'], 'verify'); + return emailData.headers['x-verify-code']; + }) + .then((verifyCode) => { + return client.verifyEmail(verifyCode, { service: 'testpilot' }); + }) + .then(() => { + return client.emailStatus(); + }) + .then((status) => { + assert.equal(status.verified, true); + }) + .then(() => { + // It's hard to test for "an email didn't arrive. + // Instead trigger sending of another email and test + // that there wasn't anything in the queue before it. + return client.forgotPassword(); + }) + .then(() => { + return server.mailbox.waitForCode(email); + }) + .then((code) => { + assert.ok( + code, + 'the next email was reset-password, not post-verify' + ); + }); + }); + + it('create account for unspecified service does not get create sync email and no post-verify email', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + let client = null; + return Client.create(config.publicUrl, email, password, testOptions) + .then((x) => { + client = x; + assert.ok('account was created'); + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal(emailData.headers['x-template-name'], 'verify'); + return emailData.headers['x-verify-code']; + }) + .then((verifyCode) => { + return client.verifyEmail(verifyCode, {}); // no 'service' param + }) + .then(() => { + return client.emailStatus(); + }) + .then((status) => { + assert.equal(status.verified, true); + }) + .then(() => { + // It's hard to test for "an email didn't arrive. + // Instead trigger sending of another email and test + // that there wasn't anything in the queue before it. + return client.forgotPassword(); + }) + .then(() => { + return server.mailbox.waitForCode(email); + }) + .then((code) => { + assert.ok( + code, + 'the next email was reset-password, not post-verify' + ); + }); + }); + + it('create account and subscribe to newsletters', () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + let client = null; + return Client.create(config.publicUrl, email, password, { + ...testOptions, + service: 'sync', + }) + .then((x) => { + client = x; + assert.ok('account was created'); + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + return emailData.headers['x-verify-code']; + }) + .then((verifyCode) => { + return client.verifyEmail(verifyCode, { + service: 'sync', + newsletters: ['test-pilot'], }); - }); + }) + .then(() => { + return client.emailStatus(); + }) + .then((status) => { + assert.equal(status.verified, true); + }) + .then(() => { + return server.mailbox.waitForEmail(email); + }) + .then((emailData) => { + assert.equal(emailData.headers['x-template-name'], 'postVerify'); + }); + }); - it('maintains single kB value for account create with V1 & V2 credentials', async function () { - if (testOptions.version !== 'V2') { - return this.skip(); + + it('maintains single kB value for account create with V1 & V2 credentials', async function () { + + if (testOptions.version !== "V2") { + return this.skip(); + } + + const email = server.uniqueEmail(); + const password = 'F00BAR'; + + const client = await Client.createAndVerify( + config.publicUrl, + email, + password, + server.mailbox, + { + ...testOptions, + keys: true, + service: 'sync', } + ); - const email = server.uniqueEmail(); - const password = 'F00BAR'; + await client.getKeysV1(); + await client.getKeysV2(); + const originalKb = client.kB; + const clientSalt = await client.getClientSalt(); - const client = await Client.createAndVerify( - config.publicUrl, - email, - password, - server.mailbox, - { - ...testOptions, - keys: true, - service: 'sync', - } - ); + // Log in with new clients and grab kbs + const clientV1 = await login(email, password); + await clientV1.getKeysV1(); + const kB1 = clientV1.kB; - await client.getKeysV1(); - await client.getKeysV2(); - const originalKb = client.kB; - const clientSalt = await client.getClientSalt(); + const clientV2 = await login(email, password, "V2"); + await clientV2.getKeysV2(); + const kB2 = clientV2.kB; - // Log in with new clients and grab kbs - const clientV1 = await login(email, password); - await clientV1.getKeysV1(); - const kB1 = clientV1.kB; + assert.exists(originalKb); + assert.isTrue( + clientSalt.startsWith( + 'identity.mozilla.com/picl/v1/quickStretchV2:' + ) + ); + assert.equal(kB1, originalKb); + assert.equal(kB2, originalKb); + }); - const clientV2 = await login(email, password, 'V2'); - await clientV2.getKeysV2(); - const kB2 = clientV2.kB; + it('maintains single kB value after account password upgrade from V1 to V2', async function () { - assert.exists(originalKb); - assert.isTrue( - clientSalt.startsWith('identity.mozilla.com/picl/v1/quickStretchV2:') - ); - assert.equal(kB1, originalKb); - assert.equal(kB2, originalKb); - }); + if (testOptions.version !== "V2") { + return this.skip(); + } - it('maintains single kB value after account password upgrade from V1 to V2', async function () { - if (testOptions.version !== 'V2') { - return this.skip(); + const email = server.uniqueEmail(); + const password = 'F00BAR'; + + const client = await Client.createAndVerify( + config.publicUrl, + email, + password, + server.mailbox, + { + ...testOptions, + keys: true, + service: 'sync', } + ); - const email = server.uniqueEmail(); - const password = 'F00BAR'; + await client.keys(); - const client = await Client.createAndVerify( - config.publicUrl, - email, - password, - server.mailbox, - { - ...testOptions, - keys: true, - service: 'sync', - } - ); + const originalKb = client.getState().kB; + await client.upgradeCredentials(password); - await client.keys(); + // Login with two different client versions and check the kB values + const clientV1 = await login(email, password); + await clientV1.getKeysV1(); - const originalKb = client.getState().kB; - await client.upgradeCredentials(password); + const kB1 = clientV1.kB; - // Login with two different client versions and check the kB values - const clientV1 = await login(email, password); - await clientV1.getKeysV1(); + const clientV2 = await login(email, password, "V2"); + await clientV2.getKeysV2(); + const kB2 = clientV2.kB; - const kB1 = clientV1.kB; + assert.exists(originalKb); + assert.equal(kB1, originalKb); + assert.equal(kB2, originalKb); + }); - const clientV2 = await login(email, password, 'V2'); - await clientV2.getKeysV2(); - const kB2 = clientV2.kB; - assert.exists(originalKb); - assert.equal(kB1, originalKb); - assert.equal(kB2, originalKb); - }); + after(async () => { + await TestServer.stop(server); + }); - after(async () => { - await TestServer.stop(server); - }); - - async function login(email, password, version = '') { - return await Client.login(config.publicUrl, email, password, { + async function login(email, password, version="") { + return await Client.login( + config.publicUrl, + email, + password, + { ...testOptions, version, keys: true, service: 'sync', - }); - } - }); + } + ); + } +}); + });